feat(config): AutoConf with "auto" placeholders (#10883)
https://github.com/ipfs/kubo/pull/10883 https://github.com/ipshipyard/config.ipfs-mainnet.org/issues/3 --------- Co-authored-by: gammazero <gammazero@users.noreply.github.com>
Marcin Rataj committed
Aug 20, 2025 at 05:59 UTC
ccb49de8524e77bbb72733c73c9bec606cf1d005
99 files changed
+10267
-412
.gitattributes
+20
@@ -15,3 +15,23 @@ LICENSE text eol=auto
15
# Binary assets
16
assets/init-doc/* binary
17
core/coreunix/test_data/** binary
18
+test/cli/migrations/testdata/** binary
19
+
20
+# Generated test data
21
+test/cli/migrations/testdata/** linguist-generated=true
22
+test/cli/autoconf/testdata/** linguist-generated=true
23
+test/cli/fixtures/** linguist-generated=true
24
+test/sharness/t0054-dag-car-import-export-data/** linguist-generated=true
25
+test/sharness/t0109-gateway-web-_redirects-data/** linguist-generated=true
26
+test/sharness/t0114-gateway-subdomains/** linguist-generated=true
27
+test/sharness/t0115-gateway-dir-listing/** linguist-generated=true
28
+test/sharness/t0116-gateway-cache/** linguist-generated=true
29
+test/sharness/t0119-prometheus-data/** linguist-generated=true
30
+test/sharness/t0165-keystore-data/** linguist-generated=true
31
+test/sharness/t0275-cid-security-data/** linguist-generated=true
32
+test/sharness/t0280-plugin-dag-jose-data/** linguist-generated=true
33
+test/sharness/t0280-plugin-data/** linguist-generated=true
34
+test/sharness/t0280-plugin-git-data/** linguist-generated=true
35
+test/sharness/t0400-api-no-gateway/** linguist-generated=true
36
+test/sharness/t0701-delegated-routing-reframe/** linguist-generated=true
37
+test/sharness/t0702-delegated-routing-http/** linguist-generated=true
cmd/ipfs/kubo/daemon.go
+74
-62
@@ -34,7 +34,6 @@ import (
34
nodeMount "github.com/ipfs/kubo/fuse/node"
35
fsrepo "github.com/ipfs/kubo/repo/fsrepo"
36
"github.com/ipfs/kubo/repo/fsrepo/migrations"
37
- "github.com/ipfs/kubo/repo/fsrepo/migrations/ipfsfetcher"
37
p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
38
pnet "github.com/libp2p/go-libp2p/core/pnet"
39
"github.com/libp2p/go-libp2p/core/protocol"
@@ -65,6 +64,7 @@ const (
64
routingOptionDHTServerKwd = "dhtserver"
65
routingOptionNoneKwd = "none"
66
routingOptionCustomKwd = "custom"
67
+ routingOptionDelegatedKwd = "delegated"
68
routingOptionDefaultKwd = "default"
69
routingOptionAutoKwd = "auto"
70
routingOptionAutoClientKwd = "autoclient"
@@ -275,7 +275,7 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
275
}
276
277
var cacheMigrations, pinMigrations bool
278
- var fetcher migrations.Fetcher
278
+ var externalMigrationFetcher migrations.Fetcher
279
280
// acquire the repo lock _before_ constructing a node. we need to make
281
// sure we are permitted to access the resources (datastore, etc.)
@@ -285,74 +285,39 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
285
return err
286
case fsrepo.ErrNeedMigration:
287
domigrate, found := req.Options[migrateKwd].(bool)
288
- fmt.Println("Found outdated fs-repo, migrations need to be run.")
288
+
289
+ // Get current repo version for more informative message
290
+ currentVersion, verErr := migrations.RepoVersion(cctx.ConfigRoot)
291
+ if verErr != nil {
292
+ // Fallback to generic message if we can't read version
293
+ fmt.Printf("Kubo repository at %s requires migration.\n", cctx.ConfigRoot)
294
+ } else {
295
+ fmt.Printf("Kubo repository at %s has version %d and needs to be migrated to version %d.\n",
296
+ cctx.ConfigRoot, currentVersion, version.RepoVersion)
297
+ }
298
299
if !found {
300
domigrate = YesNoPrompt("Run migrations now? [y/N]")
301
}
302
303
if !domigrate {
295
- fmt.Println("Not running migrations of fs-repo now.")
296
- fmt.Println("Please get fs-repo-migrations from https://dist.ipfs.tech")
304
+ fmt.Printf("Not running migrations on repository at %s. Re-run daemon with --migrate or see 'ipfs repo migrate --help'\n", cctx.ConfigRoot)
305
return errors.New("fs-repo requires migration")
306
}
307
300
- // Read Migration section of IPFS config
301
- configFileOpt, _ := req.Options[commands.ConfigFileOption].(string)
302
- migrationCfg, err := migrations.ReadMigrationConfig(cctx.ConfigRoot, configFileOpt)
303
- if err != nil {
304
- return err
305
- }
306
-
307
- // Define function to create IPFS fetcher. Do not supply an
308
- // already-constructed IPFS fetcher, because this may be expensive and
309
- // not needed according to migration config. Instead, supply a function
310
- // to construct the particular IPFS fetcher implementation used here,
311
- // which is called only if an IPFS fetcher is needed.
312
- newIpfsFetcher := func(distPath string) migrations.Fetcher {
313
- return ipfsfetcher.NewIpfsFetcher(distPath, 0, &cctx.ConfigRoot, configFileOpt)
314
- }
315
-
316
- // Fetch migrations from current distribution, or location from environ
317
- fetchDistPath := migrations.GetDistPathEnv(migrations.CurrentIpfsDist)
318
-
319
- // Create fetchers according to migrationCfg.DownloadSources
320
- fetcher, err = migrations.GetMigrationFetcher(migrationCfg.DownloadSources, fetchDistPath, newIpfsFetcher)
321
- if err != nil {
322
- return err
323
- }
324
- defer fetcher.Close()
325
-
326
- if migrationCfg.Keep == "cache" {
327
- cacheMigrations = true
328
- } else if migrationCfg.Keep == "pin" {
329
- pinMigrations = true
330
- }
331
-
332
- if cacheMigrations || pinMigrations {
333
- // Create temp directory to store downloaded migration archives
334
- migrations.DownloadDirectory, err = os.MkdirTemp("", "migrations")
335
- if err != nil {
336
- return err
337
- }
338
- // Defer cleanup of download directory so that it gets cleaned up
339
- // if daemon returns early due to error
340
- defer func() {
341
- if migrations.DownloadDirectory != "" {
342
- os.RemoveAll(migrations.DownloadDirectory)
343
- }
344
- }()
345
- }
346
-
347
- err = migrations.RunMigration(cctx.Context(), fetcher, fsrepo.RepoVersion, "", false)
308
+ // Use hybrid migration strategy that intelligently combines external and embedded migrations
309
+ err = migrations.RunHybridMigrations(cctx.Context(), version.RepoVersion, cctx.ConfigRoot, false)
310
if err != nil {
349
- fmt.Println("The migrations of fs-repo failed:")
311
+ fmt.Println("Repository migration failed:")
312
fmt.Printf(" %s\n", err)
313
fmt.Println("If you think this is a bug, please file an issue and include this whole log output.")
352
- fmt.Println(" https://github.com/ipfs/fs-repo-migrations")
314
+ fmt.Println(" https://github.com/ipfs/kubo")
315
return err
316
}
317
318
+ // Note: Migration caching/pinning functionality has been deprecated
319
+ // The hybrid migration system handles legacy migrations more efficiently
320
+
321
repo, err = fsrepo.Open(cctx.ConfigRoot)
322
if err != nil {
323
return err
@@ -379,6 +344,27 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
344
return err
345
}
346
347
+ // Validate autoconf setup - check for private network conflict
348
+ swarmKey, _ := repo.SwarmKey()
349
+ isPrivateNetwork := swarmKey != nil || pnet.ForcePrivateNetwork
350
+ if err := config.ValidateAutoConfWithRepo(cfg, isPrivateNetwork); err != nil {
351
+ return err
352
+ }
353
+
354
+ // Start background AutoConf updater if enabled
355
+ if cfg.AutoConf.Enabled.WithDefault(config.DefaultAutoConfEnabled) {
356
+ // Start autoconf client for background updates
357
+ client, err := config.GetAutoConfClient(cfg)
358
+ if err != nil {
359
+ log.Errorf("failed to create autoconf client: %v", err)
360
+ } else {
361
+ // Start primes cache and starts background updater
362
+ if _, err := client.Start(cctx.Context()); err != nil {
363
+ log.Errorf("failed to start autoconf updater: %v", err)
364
+ }
365
+ }
366
+ }
367
+
368
fmt.Printf("PeerID: %s\n", cfg.Identity.PeerID)
369
370
if !psSet {
@@ -402,8 +388,8 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
388
}
389
390
routingOption, _ := req.Options[routingOptionKwd].(string)
405
- if routingOption == routingOptionDefaultKwd {
406
- routingOption = cfg.Routing.Type.WithDefault(routingOptionAutoKwd)
391
+ if routingOption == routingOptionDefaultKwd || routingOption == "" {
392
+ routingOption = cfg.Routing.Type.WithDefault(config.DefaultRoutingType)
393
if routingOption == "" {
394
routingOption = routingOptionAutoKwd
395
}
@@ -433,6 +419,8 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
419
}
420
}
421
422
+ // Use config for routing construction
423
+
424
switch routingOption {
425
case routingOptionSupernodeKwd:
426
return errors.New("supernode routing was never fully implemented and has been removed")
@@ -448,6 +436,8 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
436
ncfg.Routing = libp2p.DHTServerOption
437
case routingOptionNoneKwd:
438
ncfg.Routing = libp2p.NilRouterOption
439
+ case routingOptionDelegatedKwd:
440
+ ncfg.Routing = libp2p.ConstructDelegatedOnlyRouting(cfg)
441
case routingOptionCustomKwd:
442
if cfg.Routing.AcceleratedDHTClient.WithDefault(config.DefaultAcceleratedDHTClient) {
443
return errors.New("Routing.AcceleratedDHTClient option is set even tho Routing.Type is custom, using custom .AcceleratedDHTClient needs to be set on DHT routers individually")
@@ -494,6 +484,15 @@ func daemonFunc(req *cmds.Request, re cmds.ResponseEmitter, env cmds.Environment
484
cfg.Experimental.StrategicProviding = false
485
cfg.Provider.Enabled = config.False
486
}
487
+ if routingOption == routingOptionDelegatedKwd {
488
+ // Delegated routing is read-only mode - content providing must be disabled
489
+ if cfg.Provider.Enabled.WithDefault(config.DefaultProviderEnabled) {
490
+ log.Fatal("Routing.Type=delegated does not support content providing. Set Provider.Enabled=false in your config.")
491
+ }
492
+ if cfg.Reprovider.Interval.WithDefault(config.DefaultReproviderInterval) != 0 {
493
+ log.Fatal("Routing.Type=delegated does not support content providing. Set Reprovider.Interval='0' in your config.")
494
+ }
495
+ }
496
497
printLibp2pPorts(node)
498
@@ -525,6 +524,9 @@ take effect.
524
}
525
}()
526
527
+ // Clear any cached offline node and set the online daemon node
528
+ // This ensures HTTP RPC server uses the online node, not any cached offline node
529
+ cctx.ClearCachedNode()
530
cctx.ConstructNode = func() (*core.IpfsNode, error) {
531
return node, nil
532
}
@@ -578,9 +580,9 @@ take effect.
580
return err
581
}
582
581
- // Add any files downloaded by migration.
582
- if cacheMigrations || pinMigrations {
583
- err = addMigrations(cctx.Context(), node, fetcher, pinMigrations)
583
+ // Add any files downloaded by external migrations (embedded migrations don't download files)
584
+ if externalMigrationFetcher != nil && (cacheMigrations || pinMigrations) {
585
+ err = addMigrations(cctx.Context(), node, externalMigrationFetcher, pinMigrations)
586
if err != nil {
587
fmt.Fprintln(os.Stderr, "Could not add migration to IPFS:", err)
588
}
@@ -589,10 +591,10 @@ take effect.
591
os.RemoveAll(migrations.DownloadDirectory)
592
migrations.DownloadDirectory = ""
593
}
592
- if fetcher != nil {
594
+ if externalMigrationFetcher != nil {
595
// If there is an error closing the IpfsFetcher, then print error, but
596
// do not fail because of it.
595
- err = fetcher.Close()
597
+ err = externalMigrationFetcher.Close()
598
if err != nil {
599
log.Errorf("error closing IPFS fetcher: %s", err)
600
}
@@ -884,6 +886,12 @@ func printLibp2pPorts(node *core.IpfsNode) {
886
return
887
}
888
889
+ if node.PeerHost == nil {
890
+ log.Error("PeerHost is nil - this should not happen and likely indicates an FX dependency injection issue or race condition")
891
+ fmt.Println("Swarm not properly initialized - node PeerHost is nil.")
892
+ return
893
+ }
894
+
895
ifaceAddrs, err := node.PeerHost.Network().InterfaceListenAddresses()
896
if err != nil {
897
log.Errorf("failed to read listening addresses: %s", err)
@@ -1065,6 +1073,10 @@ func serveTrustlessGatewayOverLibp2p(cctx *oldcmds.Context) (<-chan error, error
1073
return nil, err
1074
}
1075
1076
+ if node.PeerHost == nil {
1077
+ return nil, fmt.Errorf("cannot create libp2p gateway: node PeerHost is nil (this should not happen and likely indicates an FX dependency injection issue or race condition)")
1078
+ }
1079
+
1080
h := p2phttp.Host{
1081
StreamHost: node.PeerHost,
1082
}
commands/context.go
+17
@@ -53,6 +53,23 @@ func (c *Context) GetNode() (*core.IpfsNode, error) {
53
return c.node, err
54
}
55
56
+// ClearCachedNode clears any cached node, forcing GetNode to construct a new one.
57
+//
58
+// This method is critical for mitigating racy FX dependency injection behavior
59
+// that can occur during daemon startup. The daemon may create multiple IpfsNode
60
+// instances during initialization - first an offline node during early init, then
61
+// the proper online daemon node. Without clearing the cache, HTTP RPC handlers may
62
+// end up using the first (offline) cached node instead of the intended online daemon node.
63
+//
64
+// This behavior was likely present forever in go-ipfs, but recent changes made it more
65
+// prominent and forced us to proactively mitigate FX shortcomings. The daemon calls
66
+// this method immediately before setting its ConstructNode function to ensure that
67
+// subsequent GetNode() calls use the correct online daemon node rather than any
68
+// stale cached offline node from initialization.
69
+func (c *Context) ClearCachedNode() {
70
+ c.node = nil
71
+}
72
+
73
// GetAPI returns CoreAPI instance backed by ipfs node.
74
// It may construct the node with the provided function.
75
func (c *Context) GetAPI() (coreiface.CoreAPI, error) {
config/autoconf.go
new
+319
@@ -0,0 +1,319 @@
1
+package config
2
+
3
+import (
4
+ "maps"
5
+ "math/rand"
6
+ "strings"
7
+
8
+ "github.com/ipfs/boxo/autoconf"
9
+ logging "github.com/ipfs/go-log/v2"
10
+ peer "github.com/libp2p/go-libp2p/core/peer"
11
+)
12
+
13
+var log = logging.Logger("config")
14
+
15
+// AutoConf contains the configuration for the autoconf subsystem
16
+type AutoConf struct {
17
+ // URL is the HTTP(S) URL to fetch the autoconf.json from
18
+ // Default: see boxo/autoconf.MainnetAutoConfURL
19
+ URL *OptionalString `json:",omitempty"`
20
+
21
+ // Enabled determines whether to use autoconf
22
+ // Default: true
23
+ Enabled Flag `json:",omitempty"`
24
+
25
+ // RefreshInterval is how often to refresh autoconf data
26
+ // Default: 24h
27
+ RefreshInterval *OptionalDuration `json:",omitempty"`
28
+
29
+ // TLSInsecureSkipVerify allows skipping TLS verification (for testing only)
30
+ // Default: false
31
+ TLSInsecureSkipVerify Flag `json:",omitempty"`
32
+}
33
+
34
+const (
35
+ // AutoPlaceholder is the string used as a placeholder for autoconf values
36
+ AutoPlaceholder = "auto"
37
+
38
+ // DefaultAutoConfEnabled is the default value for AutoConf.Enabled
39
+ DefaultAutoConfEnabled = true
40
+
41
+ // DefaultAutoConfURL is the default URL for fetching autoconf
42
+ DefaultAutoConfURL = autoconf.MainnetAutoConfURL
43
+
44
+ // DefaultAutoConfRefreshInterval is the default interval for refreshing autoconf data
45
+ DefaultAutoConfRefreshInterval = autoconf.DefaultRefreshInterval
46
+
47
+ // AutoConf client configuration constants
48
+ DefaultAutoConfCacheSize = autoconf.DefaultCacheSize
49
+ DefaultAutoConfTimeout = autoconf.DefaultTimeout
50
+)
51
+
52
+// getNativeSystems returns the list of systems that should be used natively based on routing type
53
+func getNativeSystems(routingType string) []string {
54
+ switch routingType {
55
+ case "dht", "dhtclient", "dhtserver":
56
+ return []string{autoconf.SystemAminoDHT} // Only native DHT
57
+ case "auto", "autoclient":
58
+ return []string{autoconf.SystemAminoDHT} // Native DHT, delegated others
59
+ case "delegated":
60
+ return []string{} // Everything delegated
61
+ case "none":
62
+ return []string{} // No native systems
63
+ default:
64
+ return []string{} // Custom mode
65
+ }
66
+}
67
+
68
+// selectRandomResolver picks a random resolver from a list for load balancing
69
+func selectRandomResolver(resolvers []string) string {
70
+ if len(resolvers) == 0 {
71
+ return ""
72
+ }
73
+ return resolvers[rand.Intn(len(resolvers))]
74
+}
75
+
76
+// DNSResolversWithAutoConf returns DNS resolvers with "auto" values replaced by autoconf values
77
+func (c *Config) DNSResolversWithAutoConf() map[string]string {
78
+ if c.DNS.Resolvers == nil {
79
+ return nil
80
+ }
81
+
82
+ resolved := make(map[string]string)
83
+ autoConf := c.getAutoConf()
84
+ autoExpanded := 0
85
+
86
+ // Process each configured resolver
87
+ for domain, resolver := range c.DNS.Resolvers {
88
+ if resolver == AutoPlaceholder {
89
+ // Try to resolve from autoconf
90
+ if autoConf != nil && autoConf.DNSResolvers != nil {
91
+ if resolvers, exists := autoConf.DNSResolvers[domain]; exists && len(resolvers) > 0 {
92
+ resolved[domain] = selectRandomResolver(resolvers)
93
+ autoExpanded++
94
+ }
95
+ }
96
+ // If autoConf is disabled or domain not found, skip this "auto" resolver
97
+ } else {
98
+ // Keep custom resolver as-is
99
+ resolved[domain] = resolver
100
+ }
101
+ }
102
+
103
+ // Add default resolvers from autoconf that aren't already configured
104
+ if autoConf != nil && autoConf.DNSResolvers != nil {
105
+ for domain, resolvers := range autoConf.DNSResolvers {
106
+ if _, exists := resolved[domain]; !exists && len(resolvers) > 0 {
107
+ resolved[domain] = selectRandomResolver(resolvers)
108
+ }
109
+ }
110
+ }
111
+
112
+ // Log expansion statistics
113
+ if autoExpanded > 0 {
114
+ log.Debugf("expanded %d 'auto' DNS.Resolvers from autoconf", autoExpanded)
115
+ }
116
+
117
+ return resolved
118
+}
119
+
120
+// expandAutoConfSlice is a generic helper for expanding "auto" placeholders in string slices
121
+// It handles the common pattern of: iterate through slice, expand "auto" once, keep custom values
122
+func expandAutoConfSlice(sourceSlice []string, autoConfData []string) []string {
123
+ var resolved []string
124
+ autoExpanded := false
125
+
126
+ for _, item := range sourceSlice {
127
+ if item == AutoPlaceholder {
128
+ // Replace with autoconf data (only once)
129
+ if autoConfData != nil && !autoExpanded {
130
+ resolved = append(resolved, autoConfData...)
131
+ autoExpanded = true
132
+ }
133
+ // If autoConfData is nil or already expanded, skip redundant "auto" entries silently
134
+ } else {
135
+ // Keep custom item
136
+ resolved = append(resolved, item)
137
+ }
138
+ }
139
+
140
+ return resolved
141
+}
142
+
143
+// BootstrapWithAutoConf returns bootstrap config with "auto" values replaced by autoconf values
144
+func (c *Config) BootstrapWithAutoConf() []string {
145
+ autoConf := c.getAutoConf()
146
+ var autoConfData []string
147
+
148
+ if autoConf != nil {
149
+ routingType := c.Routing.Type.WithDefault(DefaultRoutingType)
150
+ nativeSystems := getNativeSystems(routingType)
151
+ autoConfData = autoConf.GetBootstrapPeers(nativeSystems...)
152
+ log.Debugf("BootstrapWithAutoConf: processing with routing type: %s", routingType)
153
+ } else {
154
+ log.Debugf("BootstrapWithAutoConf: autoConf disabled, using original config")
155
+ }
156
+
157
+ result := expandAutoConfSlice(c.Bootstrap, autoConfData)
158
+ log.Debugf("BootstrapWithAutoConf: final result contains %d peers", len(result))
159
+ return result
160
+}
161
+
162
+// getAutoConf is a helper to get autoconf data with fallbacks
163
+func (c *Config) getAutoConf() *autoconf.Config {
164
+ if !c.AutoConf.Enabled.WithDefault(DefaultAutoConfEnabled) {
165
+ log.Debugf("getAutoConf: AutoConf disabled, returning nil")
166
+ return nil
167
+ }
168
+
169
+ // Create or get cached client with config
170
+ client, err := GetAutoConfClient(c)
171
+ if err != nil {
172
+ log.Debugf("getAutoConf: client creation failed - %v", err)
173
+ return nil
174
+ }
175
+
176
+ // Use GetCached to avoid network I/O during config operations
177
+ // This ensures config retrieval doesn't block on network operations
178
+ result := client.GetCached()
179
+
180
+ log.Debugf("getAutoConf: returning autoconf data")
181
+ return result
182
+}
183
+
184
+// BootstrapPeersWithAutoConf returns bootstrap peers with "auto" values replaced by autoconf values
185
+// and parsed into peer.AddrInfo structures
186
+func (c *Config) BootstrapPeersWithAutoConf() ([]peer.AddrInfo, error) {
187
+ bootstrapStrings := c.BootstrapWithAutoConf()
188
+ return ParseBootstrapPeers(bootstrapStrings)
189
+}
190
+
191
+// DelegatedRoutersWithAutoConf returns delegated router URLs without trailing slashes
192
+func (c *Config) DelegatedRoutersWithAutoConf() []string {
193
+ autoConf := c.getAutoConf()
194
+
195
+ // Use autoconf to expand the endpoints with supported paths for read operations
196
+ routingType := c.Routing.Type.WithDefault(DefaultRoutingType)
197
+ nativeSystems := getNativeSystems(routingType)
198
+ return autoconf.ExpandDelegatedEndpoints(
199
+ c.Routing.DelegatedRouters,
200
+ autoConf,
201
+ nativeSystems,
202
+ // Kubo supports all read paths
203
+ autoconf.RoutingV1ProvidersPath,
204
+ autoconf.RoutingV1PeersPath,
205
+ autoconf.RoutingV1IPNSPath,
206
+ )
207
+}
208
+
209
+// DelegatedPublishersWithAutoConf returns delegated publisher URLs without trailing slashes
210
+func (c *Config) DelegatedPublishersWithAutoConf() []string {
211
+ autoConf := c.getAutoConf()
212
+
213
+ // Use autoconf to expand the endpoints with IPNS write path
214
+ routingType := c.Routing.Type.WithDefault(DefaultRoutingType)
215
+ nativeSystems := getNativeSystems(routingType)
216
+ return autoconf.ExpandDelegatedEndpoints(
217
+ c.Ipns.DelegatedPublishers,
218
+ autoConf,
219
+ nativeSystems,
220
+ autoconf.RoutingV1IPNSPath, // Only IPNS operations (for write)
221
+ )
222
+}
223
+
224
+// expandConfigField expands a specific config field with autoconf values
225
+// Handles both top-level fields ("Bootstrap") and nested fields ("DNS.Resolvers")
226
+func (c *Config) expandConfigField(expandedCfg map[string]any, fieldPath string) {
227
+ // Check if this field supports autoconf expansion
228
+ expandFunc, supported := supportedAutoConfFields[fieldPath]
229
+ if !supported {
230
+ return
231
+ }
232
+
233
+ // Handle top-level fields (no dot in path)
234
+ if !strings.Contains(fieldPath, ".") {
235
+ if _, exists := expandedCfg[fieldPath]; exists {
236
+ expandedCfg[fieldPath] = expandFunc(c)
237
+ }
238
+ return
239
+ }
240
+
241
+ // Handle nested fields (section.field format)
242
+ parts := strings.SplitN(fieldPath, ".", 2)
243
+ if len(parts) != 2 {
244
+ return
245
+ }
246
+
247
+ sectionName, fieldName := parts[0], parts[1]
248
+ if section, exists := expandedCfg[sectionName]; exists {
249
+ if sectionMap, ok := section.(map[string]any); ok {
250
+ if _, exists := sectionMap[fieldName]; exists {
251
+ sectionMap[fieldName] = expandFunc(c)
252
+ expandedCfg[sectionName] = sectionMap
253
+ }
254
+ }
255
+ }
256
+}
257
+
258
+// ExpandAutoConfValues expands "auto" placeholders in config with their actual values using the same methods as the daemon
259
+func (c *Config) ExpandAutoConfValues(cfg map[string]any) (map[string]any, error) {
260
+ // Create a deep copy of the config map to avoid modifying the original
261
+ expandedCfg := maps.Clone(cfg)
262
+
263
+ // Use the same expansion methods that the daemon uses - ensures runtime consistency
264
+ // Unified expansion for all supported autoconf fields
265
+ c.expandConfigField(expandedCfg, "Bootstrap")
266
+ c.expandConfigField(expandedCfg, "DNS.Resolvers")
267
+ c.expandConfigField(expandedCfg, "Routing.DelegatedRouters")
268
+ c.expandConfigField(expandedCfg, "Ipns.DelegatedPublishers")
269
+
270
+ return expandedCfg, nil
271
+}
272
+
273
+// supportedAutoConfFields maps field keys to their expansion functions
274
+var supportedAutoConfFields = map[string]func(*Config) any{
275
+ "Bootstrap": func(c *Config) any {
276
+ expanded := c.BootstrapWithAutoConf()
277
+ return stringSliceToInterfaceSlice(expanded)
278
+ },
279
+ "DNS.Resolvers": func(c *Config) any {
280
+ expanded := c.DNSResolversWithAutoConf()
281
+ return stringMapToInterfaceMap(expanded)
282
+ },
283
+ "Routing.DelegatedRouters": func(c *Config) any {
284
+ expanded := c.DelegatedRoutersWithAutoConf()
285
+ return stringSliceToInterfaceSlice(expanded)
286
+ },
287
+ "Ipns.DelegatedPublishers": func(c *Config) any {
288
+ expanded := c.DelegatedPublishersWithAutoConf()
289
+ return stringSliceToInterfaceSlice(expanded)
290
+ },
291
+}
292
+
293
+// ExpandConfigField expands auto values for a specific config field using the same methods as the daemon
294
+func (c *Config) ExpandConfigField(key string, value any) any {
295
+ if expandFunc, supported := supportedAutoConfFields[key]; supported {
296
+ return expandFunc(c)
297
+ }
298
+
299
+ // Return original value if no expansion needed (not a field that supports auto values)
300
+ return value
301
+}
302
+
303
+// Helper functions for type conversion between string types and any types for JSON compatibility
304
+
305
+func stringSliceToInterfaceSlice(slice []string) []any {
306
+ result := make([]any, len(slice))
307
+ for i, v := range slice {
308
+ result[i] = v
309
+ }
310
+ return result
311
+}
312
+
313
+func stringMapToInterfaceMap(m map[string]string) map[string]any {
314
+ result := make(map[string]any)
315
+ for k, v := range m {
316
+ result[k] = v
317
+ }
318
+ return result
319
+}
config/autoconf_client.go
new
+136
@@ -0,0 +1,136 @@
1
+package config
2
+
3
+import (
4
+ "fmt"
5
+ "path/filepath"
6
+ "sync"
7
+
8
+ "github.com/ipfs/boxo/autoconf"
9
+ logging "github.com/ipfs/go-log/v2"
10
+ version "github.com/ipfs/kubo"
11
+)
12
+
13
+var autoconfLog = logging.Logger("autoconf")
14
+
15
+// Singleton state for autoconf client
16
+var (
17
+ clientOnce sync.Once
18
+ clientCache *autoconf.Client
19
+ clientErr error
20
+)
21
+
22
+// GetAutoConfClient returns a cached autoconf client or creates a new one.
23
+// This is thread-safe and uses a singleton pattern.
24
+func GetAutoConfClient(cfg *Config) (*autoconf.Client, error) {
25
+ clientOnce.Do(func() {
26
+ clientCache, clientErr = newAutoConfClient(cfg)
27
+ })
28
+ return clientCache, clientErr
29
+}
30
+
31
+// newAutoConfClient creates a new autoconf client with the given config
32
+func newAutoConfClient(cfg *Config) (*autoconf.Client, error) {
33
+ // Get repo path for cache directory
34
+ repoPath, err := PathRoot()
35
+ if err != nil {
36
+ return nil, fmt.Errorf("failed to get repo path: %w", err)
37
+ }
38
+
39
+ // Prepare refresh interval with nil check
40
+ refreshInterval := cfg.AutoConf.RefreshInterval
41
+ if refreshInterval == nil {
42
+ refreshInterval = &OptionalDuration{}
43
+ }
44
+
45
+ // Use default URL if not specified
46
+ url := cfg.AutoConf.URL.WithDefault(DefaultAutoConfURL)
47
+
48
+ // Build client options
49
+ options := []autoconf.Option{
50
+ autoconf.WithCacheDir(filepath.Join(repoPath, "autoconf")),
51
+ autoconf.WithUserAgent(version.GetUserAgentVersion()),
52
+ autoconf.WithCacheSize(DefaultAutoConfCacheSize),
53
+ autoconf.WithTimeout(DefaultAutoConfTimeout),
54
+ autoconf.WithRefreshInterval(refreshInterval.WithDefault(DefaultAutoConfRefreshInterval)),
55
+ autoconf.WithFallback(autoconf.GetMainnetFallbackConfig),
56
+ autoconf.WithURL(url),
57
+ }
58
+
59
+ return autoconf.NewClient(options...)
60
+}
61
+
62
+// ValidateAutoConfWithRepo validates that autoconf setup is correct at daemon startup with repo access
63
+func ValidateAutoConfWithRepo(cfg *Config, swarmKeyExists bool) error {
64
+ if !cfg.AutoConf.Enabled.WithDefault(DefaultAutoConfEnabled) {
65
+ // AutoConf is disabled, check for "auto" values and warn
66
+ return validateAutoConfDisabled(cfg)
67
+ }
68
+
69
+ // Check for private network with default mainnet URL
70
+ url := cfg.AutoConf.URL.WithDefault(DefaultAutoConfURL)
71
+ if swarmKeyExists && url == DefaultAutoConfURL {
72
+ return fmt.Errorf("AutoConf cannot use the default mainnet URL (%s) on a private network (swarm.key or LIBP2P_FORCE_PNET detected). Either disable AutoConf by setting AutoConf.Enabled=false, or configure AutoConf.URL to point to a configuration service specific to your private swarm", DefaultAutoConfURL)
73
+ }
74
+
75
+ // Further validation will happen lazily when config is accessed
76
+ return nil
77
+}
78
+
79
+// validateAutoConfDisabled checks for "auto" values when AutoConf is disabled and logs errors
80
+func validateAutoConfDisabled(cfg *Config) error {
81
+ hasAutoValues := false
82
+ var errors []string
83
+
84
+ // Check Bootstrap
85
+ for _, peer := range cfg.Bootstrap {
86
+ if peer == AutoPlaceholder {
87
+ hasAutoValues = true
88
+ errors = append(errors, "Bootstrap contains 'auto' but AutoConf.Enabled=false")
89
+ break
90
+ }
91
+ }
92
+
93
+ // Check DNS.Resolvers
94
+ if cfg.DNS.Resolvers != nil {
95
+ for _, resolver := range cfg.DNS.Resolvers {
96
+ if resolver == AutoPlaceholder {
97
+ hasAutoValues = true
98
+ errors = append(errors, "DNS.Resolvers contains 'auto' but AutoConf.Enabled=false")
99
+ break
100
+ }
101
+ }
102
+ }
103
+
104
+ // Check Routing.DelegatedRouters
105
+ for _, router := range cfg.Routing.DelegatedRouters {
106
+ if router == AutoPlaceholder {
107
+ hasAutoValues = true
108
+ errors = append(errors, "Routing.DelegatedRouters contains 'auto' but AutoConf.Enabled=false")
109
+ break
110
+ }
111
+ }
112
+
113
+ // Check Ipns.DelegatedPublishers
114
+ for _, publisher := range cfg.Ipns.DelegatedPublishers {
115
+ if publisher == AutoPlaceholder {
116
+ hasAutoValues = true
117
+ errors = append(errors, "Ipns.DelegatedPublishers contains 'auto' but AutoConf.Enabled=false")
118
+ break
119
+ }
120
+ }
121
+
122
+ // Log all errors
123
+ for _, errMsg := range errors {
124
+ autoconfLog.Error(errMsg)
125
+ }
126
+
127
+ // If only auto values exist and no static ones, fail to start
128
+ if hasAutoValues {
129
+ if len(cfg.Bootstrap) == 1 && cfg.Bootstrap[0] == AutoPlaceholder {
130
+ autoconfLog.Error("Kubo cannot start with only 'auto' Bootstrap values when AutoConf.Enabled=false")
131
+ return fmt.Errorf("no usable bootstrap peers: AutoConf is disabled (AutoConf.Enabled=false) but 'auto' placeholder is used in Bootstrap config. Either set AutoConf.Enabled=true to enable automatic configuration, or replace 'auto' with specific Bootstrap peer addresses")
132
+ }
133
+ }
134
+
135
+ return nil
136
+}
config/autoconf_test.go
new
+92
@@ -0,0 +1,92 @@
1
+package config
2
+
3
+import (
4
+ "testing"
5
+
6
+ "github.com/stretchr/testify/assert"
7
+ "github.com/stretchr/testify/require"
8
+)
9
+
10
+func TestAutoConfDefaults(t *testing.T) {
11
+ // Test that AutoConf has the correct default values
12
+ cfg := &Config{
13
+ AutoConf: AutoConf{
14
+ URL: NewOptionalString(DefaultAutoConfURL),
15
+ Enabled: True,
16
+ },
17
+ }
18
+
19
+ assert.Equal(t, DefaultAutoConfURL, cfg.AutoConf.URL.WithDefault(DefaultAutoConfURL))
20
+ assert.True(t, cfg.AutoConf.Enabled.WithDefault(DefaultAutoConfEnabled))
21
+
22
+ // Test default refresh interval
23
+ if cfg.AutoConf.RefreshInterval == nil {
24
+ // This is expected - nil means use default
25
+ duration := (*OptionalDuration)(nil).WithDefault(DefaultAutoConfRefreshInterval)
26
+ assert.Equal(t, DefaultAutoConfRefreshInterval, duration)
27
+ }
28
+}
29
+
30
+func TestAutoConfProfile(t *testing.T) {
31
+ cfg := &Config{
32
+ Bootstrap: []string{"some", "existing", "peers"},
33
+ DNS: DNS{
34
+ Resolvers: map[string]string{
35
+ "eth.": "https://example.com",
36
+ },
37
+ },
38
+ Routing: Routing{
39
+ DelegatedRouters: []string{"https://existing.router"},
40
+ },
41
+ Ipns: Ipns{
42
+ DelegatedPublishers: []string{"https://existing.publisher"},
43
+ },
44
+ AutoConf: AutoConf{
45
+ Enabled: False,
46
+ },
47
+ }
48
+
49
+ // Apply autoconf profile
50
+ profile, ok := Profiles["autoconf-on"]
51
+ require.True(t, ok, "autoconf-on profile not found")
52
+
53
+ err := profile.Transform(cfg)
54
+ require.NoError(t, err)
55
+
56
+ // Check that values were set to "auto"
57
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Bootstrap)
58
+ assert.Equal(t, AutoPlaceholder, cfg.DNS.Resolvers["."])
59
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Routing.DelegatedRouters)
60
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Ipns.DelegatedPublishers)
61
+
62
+ // Check that AutoConf was enabled
63
+ assert.True(t, cfg.AutoConf.Enabled.WithDefault(DefaultAutoConfEnabled))
64
+
65
+ // Check that URL was set
66
+ assert.Equal(t, DefaultAutoConfURL, cfg.AutoConf.URL.WithDefault(DefaultAutoConfURL))
67
+}
68
+
69
+func TestInitWithAutoValues(t *testing.T) {
70
+ identity := Identity{
71
+ PeerID: "QmTest",
72
+ }
73
+
74
+ cfg, err := InitWithIdentity(identity)
75
+ require.NoError(t, err)
76
+
77
+ // Check that Bootstrap is set to "auto"
78
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Bootstrap)
79
+
80
+ // Check that DNS resolver is set to "auto"
81
+ assert.Equal(t, AutoPlaceholder, cfg.DNS.Resolvers["."])
82
+
83
+ // Check that DelegatedRouters is set to "auto"
84
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Routing.DelegatedRouters)
85
+
86
+ // Check that DelegatedPublishers is set to "auto"
87
+ assert.Equal(t, []string{AutoPlaceholder}, cfg.Ipns.DelegatedPublishers)
88
+
89
+ // Check that AutoConf is enabled with correct URL
90
+ assert.True(t, cfg.AutoConf.Enabled.WithDefault(DefaultAutoConfEnabled))
91
+ assert.Equal(t, DefaultAutoConfURL, cfg.AutoConf.URL.WithDefault(DefaultAutoConfURL))
92
+}
config/bootstrap_peers.go
-29
@@ -2,28 +2,11 @@ package config
2
3
import (
4
"errors"
5
- "fmt"
5
6
peer "github.com/libp2p/go-libp2p/core/peer"
7
ma "github.com/multiformats/go-multiaddr"
8
)
9
11
-// DefaultBootstrapAddresses are the hardcoded bootstrap addresses
12
-// for IPFS. they are nodes run by the IPFS team. docs on these later.
13
-// As with all p2p networks, bootstrap is an important security concern.
14
-//
15
-// NOTE: This is here -- and not inside cmd/ipfs/init.go -- because of an
16
-// import dependency issue. TODO: move this into a config/default/ package.
17
-var DefaultBootstrapAddresses = []string{
18
- "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
19
- "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", // rust-libp2p-server
20
- "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
21
- "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
22
- "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8", // js-libp2p-amino-dht-bootstrapper
23
- "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", // mars.i.ipfs.io
24
- "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", // mars.i.ipfs.io
25
-}
26
-
10
// ErrInvalidPeerAddr signals an address is not a valid peer address.
11
var ErrInvalidPeerAddr = errors.New("invalid peer address")
12
@@ -31,18 +14,6 @@ func (c *Config) BootstrapPeers() ([]peer.AddrInfo, error) {
14
return ParseBootstrapPeers(c.Bootstrap)
15
}
16
34
-// DefaultBootstrapPeers returns the (parsed) set of default bootstrap peers.
35
-// if it fails, it returns a meaningful error for the user.
36
-// This is here (and not inside cmd/ipfs/init) because of module dependency problems.
37
-func DefaultBootstrapPeers() ([]peer.AddrInfo, error) {
38
- ps, err := ParseBootstrapPeers(DefaultBootstrapAddresses)
39
- if err != nil {
40
- return nil, fmt.Errorf(`failed to parse hardcoded bootstrap peers: %w
41
-This is a problem with the ipfs codebase. Please report it to the dev team`, err)
42
- }
43
- return ps, nil
44
-}
45
-
17
func (c *Config) SetBootstrapPeers(bps []peer.AddrInfo) {
18
c.Bootstrap = BootstrapPeerStrings(bps)
19
}
config/bootstrap_peers_test.go
+18
-14
@@ -1,24 +1,28 @@
1
package config
2
3
import (
4
- "sort"
4
"testing"
5
+
6
+ "github.com/ipfs/boxo/autoconf"
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
)
10
11
func TestBootstrapPeerStrings(t *testing.T) {
9
- parsed, err := ParseBootstrapPeers(DefaultBootstrapAddresses)
10
- if err != nil {
11
- t.Fatal(err)
12
- }
12
+ // Test round-trip: string -> parse -> format -> string
13
+ // This ensures that parsing and formatting are inverse operations
14
+
15
+ // Start with the default bootstrap peer multiaddr strings
16
+ originalStrings := autoconf.FallbackBootstrapPeers
17
+
18
+ // Parse multiaddr strings into structured peer data
19
+ parsed, err := ParseBootstrapPeers(originalStrings)
20
+ require.NoError(t, err, "parsing bootstrap peers should succeed")
21
14
- formatted := BootstrapPeerStrings(parsed)
15
- sort.Strings(formatted)
16
- expected := append([]string{}, DefaultBootstrapAddresses...)
17
- sort.Strings(expected)
22
+ // Format the parsed data back into multiaddr strings
23
+ formattedStrings := BootstrapPeerStrings(parsed)
24
19
- for i, s := range formatted {
20
- if expected[i] != s {
21
- t.Fatalf("expected %s, %s", expected[i], s)
22
- }
23
- }
25
+ // Verify round-trip: we should get back exactly what we started with
26
+ assert.ElementsMatch(t, originalStrings, formattedStrings,
27
+ "round-trip through parse/format should preserve all bootstrap peers")
28
}
config/config.go
+2
@@ -31,7 +31,9 @@ type Config struct {
31
Pubsub PubsubConfig
32
Peering Peering
33
DNS DNS
34
+
35
Migration Migration
36
+ AutoConf AutoConf
37
38
Provider Provider
39
Reprovider Reprovider
config/dns.go
+1
-1
@@ -10,7 +10,7 @@ type DNS struct {
10
//
11
// Example:
12
// - Custom resolver for ENS: `eth.` → `https://dns.eth.limo/dns-query`
13
- // - Override the default OS resolver: `.` → `https://doh.applied-privacy.net/query`
13
+ // - Override the default OS resolver: `.` → `https://1.1.1.1/dns-query`
14
Resolvers map[string]string
15
// MaxCacheTTL is the maximum duration DNS entries are valid in the cache.
16
MaxCacheTTL *OptionalDuration `json:",omitempty"`
config/init.go
+8
-11
@@ -23,11 +23,6 @@ func Init(out io.Writer, nBitsForKeypair int) (*Config, error) {
23
}
24
25
func InitWithIdentity(identity Identity) (*Config, error) {
26
- bootstrapPeers, err := DefaultBootstrapPeers()
27
- if err != nil {
28
- return nil, err
29
- }
30
-
26
datastore := DefaultDatastoreConfig()
27
28
conf := &Config{
@@ -40,7 +35,7 @@ func InitWithIdentity(identity Identity) (*Config, error) {
35
Addresses: addressesConfig(),
36
37
Datastore: datastore,
43
- Bootstrap: BootstrapPeerStrings(bootstrapPeers),
38
+ Bootstrap: []string{AutoPlaceholder},
39
Identity: identity,
40
Discovery: Discovery{
41
MDNS: MDNS{
@@ -56,7 +51,8 @@ func InitWithIdentity(identity Identity) (*Config, error) {
51
},
52
53
Ipns: Ipns{
59
- ResolveCacheSize: 128,
54
+ ResolveCacheSize: 128,
55
+ DelegatedPublishers: []string{AutoPlaceholder},
56
},
57
58
Gateway: Gateway{
@@ -72,11 +68,12 @@ func InitWithIdentity(identity Identity) (*Config, error) {
68
RemoteServices: map[string]RemotePinningService{},
69
},
70
DNS: DNS{
75
- Resolvers: map[string]string{},
71
+ Resolvers: map[string]string{
72
+ ".": AutoPlaceholder,
73
+ },
74
},
77
- Migration: Migration{
78
- DownloadSources: []string{},
79
- Keep: "",
75
+ Routing: Routing{
76
+ DelegatedRouters: []string{AutoPlaceholder},
77
},
78
}
79
config/ipns.go
+3
@@ -20,4 +20,7 @@ type Ipns struct {
20
21
// Enable namesys pubsub (--enable-namesys-pubsub)
22
UsePubsub Flag `json:",omitempty"`
23
+
24
+ // Simplified configuration for delegated IPNS publishers
25
+ DelegatedPublishers []string
26
}
config/migration.go
+12
-10
@@ -2,16 +2,18 @@ package config
2
3
const DefaultMigrationKeep = "cache"
4
5
-var DefaultMigrationDownloadSources = []string{"HTTPS", "IPFS"}
5
+// DefaultMigrationDownloadSources defines the default download sources for legacy migrations (repo versions <16).
6
+// Only HTTPS is supported for legacy migrations. IPFS downloads are not supported.
7
+var DefaultMigrationDownloadSources = []string{"HTTPS"}
8
7
-// Migration configures how migrations are downloaded and if the downloads are
8
-// added to IPFS locally.
9
+// Migration configures how legacy migrations are downloaded (repo versions <16).
10
+//
11
+// DEPRECATED: This configuration only applies to legacy external migrations for repository
12
+// versions below 16. Modern repositories (v16+) use embedded migrations that do not require
13
+// external downloads. These settings will be ignored for modern repository versions.
14
type Migration struct {
10
- // Sources in order of preference, where "IPFS" means use IPFS and "HTTPS"
11
- // means use default gateways. Any other values are interpreted as
12
- // hostnames for custom gateways. Empty list means "use default sources".
13
- DownloadSources []string
14
- // Whether or not to keep the migration after downloading it.
15
- // Options are "discard", "cache", "pin". Empty string for default.
16
- Keep string
15
+ // DEPRECATED: This field is deprecated and ignored for modern repositories (repo versions ≥16).
16
+ DownloadSources []string `json:",omitempty"`
17
+ // DEPRECATED: This field is deprecated and ignored for modern repositories (repo versions ≥16).
18
+ Keep string `json:",omitempty"`
19
}
config/profile.go
+43
-5
@@ -87,6 +87,12 @@ is useful when using the daemon in test environments.`,
87
c.Bootstrap = []string{}
88
c.Discovery.MDNS.Enabled = false
89
c.AutoTLS.Enabled = False
90
+ c.AutoConf.Enabled = False
91
+
92
+ // Explicitly set autoconf-controlled fields to empty when autoconf is disabled
93
+ c.DNS.Resolvers = map[string]string{}
94
+ c.Routing.DelegatedRouters = []string{}
95
+ c.Ipns.DelegatedPublishers = []string{}
96
return nil
97
},
98
},
@@ -97,11 +103,10 @@ Inverse profile of the test profile.`,
103
Transform: func(c *Config) error {
104
c.Addresses = addressesConfig()
105
100
- bootstrapPeers, err := DefaultBootstrapPeers()
101
- if err != nil {
102
- return err
103
- }
104
- c.Bootstrap = appendSingle(c.Bootstrap, BootstrapPeerStrings(bootstrapPeers))
106
+ // Use AutoConf system for bootstrap peers
107
+ c.Bootstrap = []string{AutoPlaceholder}
108
+ c.AutoConf.Enabled = Default
109
+ c.AutoConf.URL = nil // Clear URL to use implicit default
110
111
c.Swarm.DisableNatPortMap = false
112
c.Discovery.MDNS.Enabled = true
@@ -349,6 +354,39 @@ fetching may be degraded.
354
return nil
355
},
356
},
357
+ "autoconf-on": {
358
+ Description: `Sets configuration to use implicit defaults from remote autoconf service.
359
+Bootstrap peers, DNS resolvers, delegated routers, and IPNS delegated publishers are set to "auto".
360
+This profile requires AutoConf to be enabled and configured.`,
361
+
362
+ Transform: func(c *Config) error {
363
+ c.Bootstrap = []string{AutoPlaceholder}
364
+ c.DNS.Resolvers = map[string]string{
365
+ ".": AutoPlaceholder,
366
+ }
367
+ c.Routing.DelegatedRouters = []string{AutoPlaceholder}
368
+ c.Ipns.DelegatedPublishers = []string{AutoPlaceholder}
369
+ c.AutoConf.Enabled = True
370
+ if c.AutoConf.URL == nil {
371
+ c.AutoConf.URL = NewOptionalString(DefaultAutoConfURL)
372
+ }
373
+ return nil
374
+ },
375
+ },
376
+ "autoconf-off": {
377
+ Description: `Disables AutoConf and sets networking fields to empty for manual configuration.
378
+Bootstrap peers, DNS resolvers, delegated routers, and IPNS delegated publishers are set to empty.
379
+Use this when you want normal networking but prefer manual control over all endpoints.`,
380
+
381
+ Transform: func(c *Config) error {
382
+ c.Bootstrap = nil
383
+ c.DNS.Resolvers = nil
384
+ c.Routing.DelegatedRouters = nil
385
+ c.Ipns.DelegatedPublishers = nil
386
+ c.AutoConf.Enabled = False
387
+ return nil
388
+ },
389
+ },
390
}
391
392
func getAvailablePort() (port int, err error) {
config/routing.go
+4
-7
@@ -11,6 +11,7 @@ import (
11
const (
12
DefaultAcceleratedDHTClient = false
13
DefaultLoopbackAddressesOnLanDHT = false
14
+ DefaultRoutingType = "auto"
15
CidContactRoutingURL = "https://cid.contact"
16
PublicGoodDelegatedRoutingURL = "https://delegated-ipfs.dev" // cid.contact + amino dht (incl. IPNS PUTs)
17
EnvHTTPRouters = "IPFS_HTTP_ROUTERS"
@@ -18,11 +19,6 @@ const (
19
)
20
21
var (
21
- // Default HTTP routers used in parallel to DHT when Routing.Type = "auto"
22
- DefaultHTTPRouters = getEnvOrDefault(EnvHTTPRouters, []string{
23
- CidContactRoutingURL, // https://github.com/ipfs/kubo/issues/9422#issuecomment-1338142084
24
- })
25
-
22
// Default filter-protocols to pass along with delegated routing requests (as defined in IPIP-484)
23
// and also filter out locally
24
DefaultHTTPRoutersFilterProtocols = getEnvOrDefault(EnvHTTPRoutersFilterProtocols, []string{
@@ -37,8 +33,9 @@ var (
33
type Routing struct {
34
// Type sets default daemon routing mode.
35
//
40
- // Can be one of "auto", "autoclient", "dht", "dhtclient", "dhtserver", "none", or "custom".
36
+ // Can be one of "auto", "autoclient", "dht", "dhtclient", "dhtserver", "none", "delegated", or "custom".
37
// When unset or set to "auto", DHT and implicit routers are used.
38
+ // When "delegated" is set, only HTTP delegated routers and IPNS publishers are used (no DHT).
39
// When "custom" is set, user-provided Routing.Routers is used.
40
Type *OptionalString `json:",omitempty"`
41
@@ -49,7 +46,7 @@ type Routing struct {
46
IgnoreProviders []string `json:",omitempty"`
47
48
// Simplified configuration used by default when Routing.Type=auto|autoclient
52
- DelegatedRouters []string `json:",omitempty"`
49
+ DelegatedRouters []string
50
51
// Advanced configuration used when Routing.Type=custom
52
Routers Routers `json:",omitempty"`
core/commands/bootstrap.go
+61
-65
@@ -41,15 +41,15 @@ Running 'ipfs bootstrap' with no arguments will run 'ipfs bootstrap list'.
41
},
42
}
43
44
-const (
45
- defaultOptionName = "default"
46
-)
47
-
44
var bootstrapAddCmd = &cmds.Command{
45
Helptext: cmds.HelpText{
46
Tagline: "Add peers to the bootstrap list.",
47
ShortDescription: `Outputs a list of peers that were added (that weren't already
48
in the bootstrap list).
49
+
50
+The special values 'default' and 'auto' can be used to add the default
51
+bootstrap peers. Both are equivalent and will add the 'auto' placeholder to
52
+the bootstrap list, which gets resolved using the AutoConf system.
53
` + bootstrapSecurityWarning,
54
},
55
@@ -57,66 +57,23 @@ in the bootstrap list).
57
cmds.StringArg("peer", false, true, peerOptionDesc).EnableStdin(),
58
},
59
60
- Options: []cmds.Option{
61
- cmds.BoolOption(defaultOptionName, "Add default bootstrap nodes. (Deprecated, use 'default' subcommand instead)"),
62
- },
63
- Subcommands: map[string]*cmds.Command{
64
- "default": bootstrapAddDefaultCmd,
65
- },
66
-
60
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
68
- deflt, _ := req.Options[defaultOptionName].(bool)
69
-
70
- inputPeers := config.DefaultBootstrapAddresses
71
- if !deflt {
72
- if err := req.ParseBodyArgs(); err != nil {
73
- return err
74
- }
75
-
76
- inputPeers = req.Arguments
61
+ if err := req.ParseBodyArgs(); err != nil {
62
+ return err
63
}
64
+ inputPeers := req.Arguments
65
66
if len(inputPeers) == 0 {
67
return errors.New("no bootstrap peers to add")
68
}
69
83
- cfgRoot, err := cmdenv.GetConfigRoot(env)
84
- if err != nil {
85
- return err
86
- }
87
-
88
- r, err := fsrepo.Open(cfgRoot)
89
- if err != nil {
90
- return err
91
- }
92
- defer r.Close()
93
- cfg, err := r.Config()
94
- if err != nil {
95
- return err
96
- }
97
-
98
- added, err := bootstrapAdd(r, cfg, inputPeers)
99
- if err != nil {
100
- return err
70
+ // Convert "default" to "auto" for backward compatibility
71
+ for i, peer := range inputPeers {
72
+ if peer == "default" {
73
+ inputPeers[i] = "auto"
74
+ }
75
}
76
103
- return cmds.EmitOnce(res, &BootstrapOutput{added})
104
- },
105
- Type: BootstrapOutput{},
106
- Encoders: cmds.EncoderMap{
107
- cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *BootstrapOutput) error {
108
- return bootstrapWritePeers(w, "added ", out.Peers)
109
- }),
110
- },
111
-}
112
-
113
-var bootstrapAddDefaultCmd = &cmds.Command{
114
- Helptext: cmds.HelpText{
115
- Tagline: "Add default peers to the bootstrap list.",
116
- ShortDescription: `Outputs a list of peers that were added (that weren't already
117
-in the bootstrap list).`,
118
- },
119
- Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
77
cfgRoot, err := cmdenv.GetConfigRoot(env)
78
if err != nil {
79
return err
@@ -126,14 +83,20 @@ in the bootstrap list).`,
83
if err != nil {
84
return err
85
}
129
-
86
defer r.Close()
87
cfg, err := r.Config()
88
if err != nil {
89
return err
90
}
91
136
- added, err := bootstrapAdd(r, cfg, config.DefaultBootstrapAddresses)
92
+ // Check if trying to add "auto" when AutoConf is disabled
93
+ for _, peer := range inputPeers {
94
+ if peer == config.AutoPlaceholder && !cfg.AutoConf.Enabled.WithDefault(config.DefaultAutoConfEnabled) {
95
+ return errors.New("cannot add default bootstrap peers: AutoConf is disabled (AutoConf.Enabled=false). Enable AutoConf by setting AutoConf.Enabled=true in your config, or add specific peer addresses instead")
96
+ }
97
+ }
98
+
99
+ added, err := bootstrapAdd(r, cfg, inputPeers)
100
if err != nil {
101
return err
102
}
@@ -251,6 +214,9 @@ var bootstrapListCmd = &cmds.Command{
214
Tagline: "Show peers in the bootstrap list.",
215
ShortDescription: "Peers are output in the format '<multiaddr>/<peerID>'.",
216
},
217
+ Options: []cmds.Option{
218
+ cmds.BoolOption(configExpandAutoName, "Expand 'auto' placeholders from AutoConf service."),
219
+ },
220
221
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
222
cfgRoot, err := cmdenv.GetConfigRoot(env)
@@ -268,12 +234,16 @@ var bootstrapListCmd = &cmds.Command{
234
return err
235
}
236
271
- peers, err := cfg.BootstrapPeers()
272
- if err != nil {
273
- return err
237
+ // Check if user wants to expand auto values
238
+ expandAuto, _ := req.Options[configExpandAutoName].(bool)
239
+ if expandAuto {
240
+ // Use the same expansion method as the daemon
241
+ expandedBootstrap := cfg.BootstrapWithAutoConf()
242
+ return cmds.EmitOnce(res, &BootstrapOutput{expandedBootstrap})
243
}
244
276
- return cmds.EmitOnce(res, &BootstrapOutput{config.BootstrapPeerStrings(peers)})
245
+ // Simply return the bootstrap config as-is, including any "auto" values
246
+ return cmds.EmitOnce(res, &BootstrapOutput{cfg.Bootstrap})
247
},
248
Type: BootstrapOutput{},
249
Encoders: cmds.EncoderMap{
@@ -297,7 +267,11 @@ func bootstrapWritePeers(w io.Writer, prefix string, peers []string) error {
267
}
268
269
func bootstrapAdd(r repo.Repo, cfg *config.Config, peers []string) ([]string, error) {
270
+ // Validate peers - skip validation for "auto" placeholder
271
for _, p := range peers {
272
+ if p == config.AutoPlaceholder {
273
+ continue // Skip validation for "auto" placeholder
274
+ }
275
m, err := ma.NewMultiaddr(p)
276
if err != nil {
277
return nil, err
@@ -347,6 +321,16 @@ func bootstrapAdd(r repo.Repo, cfg *config.Config, peers []string) ([]string, er
321
}
322
323
func bootstrapRemove(r repo.Repo, cfg *config.Config, toRemove []string) ([]string, error) {
324
+ // Check if bootstrap contains "auto"
325
+ hasAuto := slices.Contains(cfg.Bootstrap, config.AutoPlaceholder)
326
+
327
+ if hasAuto && cfg.AutoConf.Enabled.WithDefault(config.DefaultAutoConfEnabled) {
328
+ // Cannot selectively remove peers when using "auto" bootstrap
329
+ // Users should either disable AutoConf or replace "auto" with specific peers
330
+ return nil, fmt.Errorf("cannot remove individual bootstrap peers when using 'auto' placeholder: the 'auto' value is managed by AutoConf. Either disable AutoConf by setting AutoConf.Enabled=false and replace 'auto' with specific peer addresses, or use 'ipfs bootstrap rm --all' to remove all peers")
331
+ }
332
+
333
+ // Original logic for non-auto bootstrap
334
removed := make([]peer.AddrInfo, 0, len(toRemove))
335
keep := make([]peer.AddrInfo, 0, len(cfg.Bootstrap))
336
@@ -406,16 +390,28 @@ func bootstrapRemove(r repo.Repo, cfg *config.Config, toRemove []string) ([]stri
390
}
391
392
func bootstrapRemoveAll(r repo.Repo, cfg *config.Config) ([]string, error) {
409
- removed, err := cfg.BootstrapPeers()
410
- if err != nil {
411
- return nil, err
393
+ // Check if bootstrap contains "auto" - if so, we need special handling
394
+ hasAuto := slices.Contains(cfg.Bootstrap, config.AutoPlaceholder)
395
+
396
+ var removed []string
397
+ if hasAuto {
398
+ // When "auto" is present, we can't parse it as peer.AddrInfo
399
+ // Just return the raw bootstrap list as strings for display
400
+ removed = slices.Clone(cfg.Bootstrap)
401
+ } else {
402
+ // Original logic for configs without "auto"
403
+ removedPeers, err := cfg.BootstrapPeers()
404
+ if err != nil {
405
+ return nil, err
406
+ }
407
+ removed = config.BootstrapPeerStrings(removedPeers)
408
}
409
410
cfg.Bootstrap = nil
411
if err := r.SetConfig(cfg); err != nil {
412
return nil, err
413
}
418
- return config.BootstrapPeerStrings(removed), nil
414
+ return removed, nil
415
}
416
417
const bootstrapSecurityWarning = `
core/commands/commands_test.go
-1
@@ -30,7 +30,6 @@ func TestCommands(t *testing.T) {
30
"/block/stat",
31
"/bootstrap",
32
"/bootstrap/add",
33
- "/bootstrap/add/default",
33
"/bootstrap/list",
34
"/bootstrap/rm",
35
"/bootstrap/rm/all",
core/commands/config.go
+57
-2
@@ -5,8 +5,10 @@ import (
5
"errors"
6
"fmt"
7
"io"
8
+ "maps"
9
"os"
10
"os/exec"
11
+ "slices"
12
"strings"
13
14
"github.com/anmitsu/go-shlex"
@@ -33,6 +35,7 @@ const (
35
configBoolOptionName = "bool"
36
configJSONOptionName = "json"
37
configDryRunOptionName = "dry-run"
38
+ configExpandAutoName = "expand-auto"
39
)
40
41
var ConfigCmd = &cmds.Command{
@@ -75,6 +78,7 @@ Set multiple values in the 'Addresses.AppendAnnounce' array:
78
Options: []cmds.Option{
79
cmds.BoolOption(configBoolOptionName, "Set a boolean value."),
80
cmds.BoolOption(configJSONOptionName, "Parse stringified JSON."),
81
+ cmds.BoolOption(configExpandAutoName, "Expand 'auto' placeholders to their expanded values from AutoConf service."),
82
},
83
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
84
args := req.Arguments
@@ -105,6 +109,11 @@ Set multiple values in the 'Addresses.AppendAnnounce' array:
109
}
110
defer r.Close()
111
if len(args) == 2 {
112
+ // Check if user is trying to write config with expand flag
113
+ if expandAuto, _ := req.Options[configExpandAutoName].(bool); expandAuto {
114
+ return fmt.Errorf("--expand-auto can only be used for reading config values, not for setting them")
115
+ }
116
+
117
value := args[1]
118
119
if parseJSON, _ := req.Options[configJSONOptionName].(bool); parseJSON {
@@ -121,7 +130,13 @@ Set multiple values in the 'Addresses.AppendAnnounce' array:
130
output, err = setConfig(r, key, value)
131
}
132
} else {
124
- output, err = getConfig(r, key)
133
+ // Check if user wants to expand auto values for getter
134
+ expandAuto, _ := req.Options[configExpandAutoName].(bool)
135
+ if expandAuto {
136
+ output, err = getConfigWithAutoExpand(r, key)
137
+ } else {
138
+ output, err = getConfig(r, key)
139
+ }
140
}
141
142
if err != nil {
@@ -208,6 +223,23 @@ NOTE: For security reasons, this command will omit your private key and remote s
223
return err
224
}
225
226
+ // Check if user wants to expand auto values
227
+ expandAuto, _ := req.Options[configExpandAutoName].(bool)
228
+ if expandAuto {
229
+ // Load full config to use resolution methods
230
+ var fullCfg config.Config
231
+ err = json.Unmarshal(data, &fullCfg)
232
+ if err != nil {
233
+ return err
234
+ }
235
+
236
+ // Expand auto values and update the map
237
+ cfg, err = fullCfg.ExpandAutoConfValues(cfg)
238
+ if err != nil {
239
+ return err
240
+ }
241
+ }
242
+
243
cfg, err = scrubValue(cfg, []string{config.IdentityTag, config.PrivKeyTag})
244
if err != nil {
245
return err
@@ -417,7 +449,8 @@ var configProfileApplyCmd = &cmds.Command{
449
func buildProfileHelp() string {
450
var out string
451
420
- for name, profile := range config.Profiles {
452
+ for _, name := range slices.Sorted(maps.Keys(config.Profiles)) {
453
+ profile := config.Profiles[name]
454
dlines := strings.Split(profile.Description, "\n")
455
for i := range dlines {
456
dlines[i] = " " + dlines[i]
@@ -498,6 +531,28 @@ func getConfig(r repo.Repo, key string) (*ConfigField, error) {
531
}, nil
532
}
533
534
+func getConfigWithAutoExpand(r repo.Repo, key string) (*ConfigField, error) {
535
+ // First get the current value
536
+ value, err := r.GetConfigKey(key)
537
+ if err != nil {
538
+ return nil, fmt.Errorf("failed to get config value: %q", err)
539
+ }
540
+
541
+ // Load full config for resolution
542
+ fullCfg, err := r.Config()
543
+ if err != nil {
544
+ return nil, fmt.Errorf("failed to load config: %q", err)
545
+ }
546
+
547
+ // Expand auto values based on the key
548
+ expandedValue := fullCfg.ExpandConfigField(key, value)
549
+
550
+ return &ConfigField{
551
+ Key: key,
552
+ Value: expandedValue,
553
+ }, nil
554
+}
555
+
556
func setConfig(r repo.Repo, key string, value interface{}) (*ConfigField, error) {
557
err := r.SetConfigKey(key, value)
558
if err != nil {
core/commands/name/publish.go
+33
-18
@@ -16,18 +16,19 @@ import (
16
options "github.com/ipfs/kubo/core/coreiface/options"
17
)
18
19
-var errAllowOffline = errors.New("can't publish while offline: pass `--allow-offline` to override")
19
+var errAllowOffline = errors.New("can't publish while offline: pass `--allow-offline` to override or `--allow-delegated` if Ipns.DelegatedPublishers are set up")
20
21
const (
22
- ipfsPathOptionName = "ipfs-path"
23
- resolveOptionName = "resolve"
24
- allowOfflineOptionName = "allow-offline"
25
- lifeTimeOptionName = "lifetime"
26
- ttlOptionName = "ttl"
27
- keyOptionName = "key"
28
- quieterOptionName = "quieter"
29
- v1compatOptionName = "v1compat"
30
- sequenceOptionName = "sequence"
22
+ ipfsPathOptionName = "ipfs-path"
23
+ resolveOptionName = "resolve"
24
+ allowOfflineOptionName = "allow-offline"
25
+ allowDelegatedOptionName = "allow-delegated"
26
+ lifeTimeOptionName = "lifetime"
27
+ ttlOptionName = "ttl"
28
+ keyOptionName = "key"
29
+ quieterOptionName = "quieter"
30
+ v1compatOptionName = "v1compat"
31
+ sequenceOptionName = "sequence"
32
)
33
34
var PublishCmd = &cmds.Command{
@@ -48,6 +49,14 @@ which is the hash of its public key.
49
You can use the 'ipfs key' commands to list and generate more names and their
50
respective keys.
51
52
+Publishing Modes:
53
+
54
+By default, IPNS records are published to both the DHT and any configured
55
+HTTP delegated publishers. You can control this behavior with the following flags:
56
+
57
+ --allow-offline Allow publishing when offline (publishes to local datastore, network operations are optional)
58
+ --allow-delegated Allow publishing without DHT connectivity (local + HTTP delegated publishers only)
59
+
60
Examples:
61
62
Publish an <ipfs-path> with your default name:
@@ -55,16 +64,14 @@ Publish an <ipfs-path> with your default name:
64
> ipfs name publish /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
65
Published to QmbCMUZw6JFeZ7Wp9jkzbye3Fzp2GGcPgC3nmeUjfVF87n: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
66
58
-Publish an <ipfs-path> with another name, added by an 'ipfs key' command:
67
+Publish without DHT (HTTP delegated publishers only):
68
60
- > ipfs key gen --type=rsa --size=2048 mykey
61
- > ipfs name publish --key=mykey /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
62
- Published to QmSrPmbaUKA3ZodhzPWZnpFgcPMFWF4QsxXbkWfEptTBJd: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
69
+ > ipfs name publish --allow-delegated /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
70
+ Published to QmbCMUZw6JFeZ7Wp9jkzbye3Fzp2GGcPgC3nmeUjfVF87n: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
71
64
-Alternatively, publish an <ipfs-path> using a valid PeerID (as listed by
65
-'ipfs key list -l'):
72
+Publish when offline (local publish, network optional):
73
67
- > ipfs name publish --key=QmbCMUZw6JFeZ7Wp9jkzbye3Fzp2GGcPgC3nmeUjfVF87n /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
74
+ > ipfs name publish --allow-offline /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
75
Published to QmbCMUZw6JFeZ7Wp9jkzbye3Fzp2GGcPgC3nmeUjfVF87n: /ipfs/QmatmE9msSfkKxoffpHwNLNKgwZG8eT9Bud6YoPab52vpy
76
77
Notes:
@@ -97,7 +104,8 @@ For faster IPNS updates, consider:
104
cmds.StringOption(ttlOptionName, "Time duration hint, akin to --lifetime, indicating how long to cache this record before checking for updates.").WithDefault(ipns.DefaultRecordTTL.String()),
105
cmds.BoolOption(quieterOptionName, "Q", "Write only final IPNS Name encoded as CIDv1 (for use in /ipns content paths)."),
106
cmds.BoolOption(v1compatOptionName, "Produce a backward-compatible IPNS Record by including fields for both V1 and V2 signatures.").WithDefault(true),
100
- cmds.BoolOption(allowOfflineOptionName, "When --offline, save the IPNS record to the local datastore without broadcasting to the network (instead of failing)."),
107
+ cmds.BoolOption(allowOfflineOptionName, "Allow publishing when offline - publishes to local datastore without requiring network connectivity."),
108
+ cmds.BoolOption(allowDelegatedOptionName, "Allow publishing without DHT connectivity - uses local datastore and HTTP delegated publishers only."),
109
cmds.Uint64Option(sequenceOptionName, "Set a custom sequence number for the IPNS record (must be higher than current)."),
110
ke.OptionIPNSBase,
111
},
@@ -108,9 +116,15 @@ For faster IPNS updates, consider:
116
}
117
118
allowOffline, _ := req.Options[allowOfflineOptionName].(bool)
119
+ allowDelegated, _ := req.Options[allowDelegatedOptionName].(bool)
120
compatibleWithV1, _ := req.Options[v1compatOptionName].(bool)
121
kname, _ := req.Options[keyOptionName].(string)
122
123
+ // Validate flag combinations
124
+ if allowOffline && allowDelegated {
125
+ return errors.New("cannot use both --allow-offline and --allow-delegated flags")
126
+ }
127
+
128
validTimeOpt, _ := req.Options[lifeTimeOptionName].(string)
129
validTime, err := time.ParseDuration(validTimeOpt)
130
if err != nil {
@@ -119,6 +133,7 @@ For faster IPNS updates, consider:
133
134
opts := []options.NamePublishOption{
135
options.Name.AllowOffline(allowOffline),
136
+ options.Name.AllowDelegated(allowDelegated),
137
options.Name.Key(kname),
138
options.Name.ValidTime(validTime),
139
options.Name.CompatibleWithV1(compatibleWithV1),
core/commands/repo.go
+51
-33
@@ -16,7 +16,6 @@ import (
16
corerepo "github.com/ipfs/kubo/core/corerepo"
17
fsrepo "github.com/ipfs/kubo/repo/fsrepo"
18
"github.com/ipfs/kubo/repo/fsrepo/migrations"
19
- "github.com/ipfs/kubo/repo/fsrepo/migrations/ipfsfetcher"
19
20
humanize "github.com/dustin/go-humanize"
21
bstore "github.com/ipfs/boxo/blockstore"
@@ -57,6 +56,7 @@ const (
56
repoQuietOptionName = "quiet"
57
repoSilentOptionName = "silent"
58
repoAllowDowngradeOptionName = "allow-downgrade"
59
+ repoToVersionOptionName = "to"
60
)
61
62
var repoGcCmd = &cmds.Command{
@@ -373,63 +373,81 @@ var repoVersionCmd = &cmds.Command{
373
374
var repoMigrateCmd = &cmds.Command{
375
Helptext: cmds.HelpText{
376
- Tagline: "Apply any outstanding migrations to the repo.",
376
+ Tagline: "Apply repository migrations to a specific version.",
377
+ ShortDescription: `
378
+'ipfs repo migrate' applies repository migrations to bring the repository
379
+to a specific version. By default, migrates to the latest version supported
380
+by this IPFS binary.
381
+
382
+Examples:
383
+ ipfs repo migrate # Migrate to latest version
384
+ ipfs repo migrate --to=17 # Migrate to version 17
385
+ ipfs repo migrate --to=16 --allow-downgrade # Downgrade to version 16
386
+
387
+WARNING: Downgrading a repository may cause data loss and requires using
388
+an older IPFS binary that supports the target version. After downgrading,
389
+you must use an IPFS implementation compatible with that repository version.
390
+
391
+Repository versions 16+ use embedded migrations for faster, more reliable
392
+migration. Versions below 16 require external migration tools.
393
+`,
394
},
395
Options: []cmds.Option{
396
+ cmds.IntOption(repoToVersionOptionName, "Target repository version").WithDefault(fsrepo.RepoVersion),
397
cmds.BoolOption(repoAllowDowngradeOptionName, "Allow downgrading to a lower repo version"),
398
},
399
NoRemote: true,
400
+ // SetDoesNotUseRepo(true) might seem counter-intuitive since migrations
401
+ // do access the repo, but it's correct - we need direct filesystem access
402
+ // without going through the daemon. Migrations handle their own locking.
403
+ Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
404
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
405
cctx := env.(*oldcmds.Context)
406
allowDowngrade, _ := req.Options[repoAllowDowngradeOptionName].(bool)
407
+ targetVersion, _ := req.Options[repoToVersionOptionName].(int)
408
386
- _, err := fsrepo.Open(cctx.ConfigRoot)
409
+ // Get current repo version
410
+ currentVersion, err := migrations.RepoVersion(cctx.ConfigRoot)
411
+ if err != nil {
412
+ return fmt.Errorf("could not get current repo version: %w", err)
413
+ }
414
388
- if err == nil {
389
- fmt.Println("Repo does not require migration.")
415
+ // Check if migration is needed
416
+ if currentVersion == targetVersion {
417
+ fmt.Printf("Repository is already at version %d.\n", targetVersion)
418
return nil
391
- } else if err != fsrepo.ErrNeedMigration {
392
- return err
419
}
420
395
- fmt.Println("Found outdated fs-repo, starting migration.")
421
+ // Validate downgrade request
422
+ if targetVersion < currentVersion && !allowDowngrade {
423
+ return fmt.Errorf("downgrade from version %d to %d requires --allow-downgrade flag", currentVersion, targetVersion)
424
+ }
425
397
- // Read Migration section of IPFS config
398
- configFileOpt, _ := req.Options[ConfigFileOption].(string)
399
- migrationCfg, err := migrations.ReadMigrationConfig(cctx.ConfigRoot, configFileOpt)
426
+ // Check if repo is locked by daemon before running migration
427
+ locked, err := fsrepo.LockedByOtherProcess(cctx.ConfigRoot)
428
if err != nil {
401
- return err
429
+ return fmt.Errorf("could not check repo lock: %w", err)
430
}
403
-
404
- // Define function to create IPFS fetcher. Do not supply an
405
- // already-constructed IPFS fetcher, because this may be expensive and
406
- // not needed according to migration config. Instead, supply a function
407
- // to construct the particular IPFS fetcher implementation used here,
408
- // which is called only if an IPFS fetcher is needed.
409
- newIpfsFetcher := func(distPath string) migrations.Fetcher {
410
- return ipfsfetcher.NewIpfsFetcher(distPath, 0, &cctx.ConfigRoot, configFileOpt)
431
+ if locked {
432
+ return fmt.Errorf("cannot run migration while daemon is running (repo.lock exists)")
433
}
434
413
- // Fetch migrations from current distribution, or location from environ
414
- fetchDistPath := migrations.GetDistPathEnv(migrations.CurrentIpfsDist)
415
-
416
- // Create fetchers according to migrationCfg.DownloadSources
417
- fetcher, err := migrations.GetMigrationFetcher(migrationCfg.DownloadSources, fetchDistPath, newIpfsFetcher)
418
- if err != nil {
419
- return err
420
- }
421
- defer fetcher.Close()
435
+ fmt.Printf("Migrating repository from version %d to %d...\n", currentVersion, targetVersion)
436
423
- err = migrations.RunMigration(cctx.Context(), fetcher, fsrepo.RepoVersion, "", allowDowngrade)
437
+ // Use hybrid migration strategy that intelligently combines external and embedded migrations
438
+ err = migrations.RunHybridMigrations(cctx.Context(), targetVersion, cctx.ConfigRoot, allowDowngrade)
439
if err != nil {
425
- fmt.Println("The migrations of fs-repo failed:")
440
+ fmt.Println("Repository migration failed:")
441
fmt.Printf(" %s\n", err)
442
fmt.Println("If you think this is a bug, please file an issue and include this whole log output.")
428
- fmt.Println(" https://github.com/ipfs/fs-repo-migrations")
443
+ fmt.Println(" https://github.com/ipfs/kubo")
444
return err
445
}
446
432
- fmt.Printf("Success: fs-repo has been migrated to version %d.\n", fsrepo.RepoVersion)
447
+ fmt.Printf("Repository successfully migrated to version %d.\n", targetVersion)
448
+ if targetVersion < fsrepo.RepoVersion {
449
+ fmt.Println("WARNING: After downgrading, you must use an IPFS binary compatible with this repository version.")
450
+ }
451
return nil
452
},
453
}
core/core.go
+2
-1
@@ -213,7 +213,8 @@ func (n *IpfsNode) loadBootstrapPeers() ([]peer.AddrInfo, error) {
213
return nil, err
214
}
215
216
- return cfg.BootstrapPeers()
216
+ // Use auto-config resolution for actual bootstrap connectivity
217
+ return cfg.BootstrapPeersWithAutoConf()
218
}
219
220
func (n *IpfsNode) saveTempBootstrapPeers(ctx context.Context, peerList []peer.AddrInfo) error {
core/coreapi/name.go
+19
-3
@@ -45,9 +45,25 @@ func (api *NameAPI) Publish(ctx context.Context, p path.Path, opts ...caopts.Nam
45
span.SetAttributes(attribute.Float64("ttl", options.TTL.Seconds()))
46
}
47
48
- err = api.checkOnline(options.AllowOffline)
49
- if err != nil {
50
- return ipns.Name{}, err
48
+ // Handle different publishing modes
49
+ if options.AllowDelegated {
50
+ // AllowDelegated mode: check if delegated publishers are configured
51
+ cfg, err := api.repo.Config()
52
+ if err != nil {
53
+ return ipns.Name{}, fmt.Errorf("failed to read config: %w", err)
54
+ }
55
+ delegatedPublishers := cfg.DelegatedPublishersWithAutoConf()
56
+ if len(delegatedPublishers) == 0 {
57
+ return ipns.Name{}, errors.New("no delegated publishers configured: add Ipns.DelegatedPublishers or use --allow-offline for local-only publishing")
58
+ }
59
+ // For allow-delegated mode, we only require that we have delegated publishers configured
60
+ // The node doesn't need P2P connectivity since we're using HTTP publishing
61
+ } else {
62
+ // Normal mode: check online status with allow-offline flag
63
+ err = api.checkOnline(options.AllowOffline)
64
+ if err != nil {
65
+ return ipns.Name{}, err
66
+ }
67
}
68
69
k, err := keylookup(api.privateKey, api.repo.Keystore(), options.Key)
core/coreiface/options/name.go
+13
-1
@@ -16,6 +16,7 @@ type NamePublishSettings struct {
16
TTL *time.Duration
17
CompatibleWithV1 bool
18
AllowOffline bool
19
+ AllowDelegated bool
20
Sequence *uint64
21
}
22
@@ -35,7 +36,8 @@ func NamePublishOptions(opts ...NamePublishOption) (*NamePublishSettings, error)
36
ValidTime: DefaultNameValidTime,
37
Key: "self",
38
38
- AllowOffline: false,
39
+ AllowOffline: false,
40
+ AllowDelegated: false,
41
}
42
43
for _, opt := range opts {
@@ -97,6 +99,16 @@ func (nameOpts) AllowOffline(allow bool) NamePublishOption {
99
}
100
}
101
102
+// AllowDelegated is an option for Name.Publish which allows publishing without
103
+// DHT connectivity, using local datastore and HTTP delegated publishers only.
104
+// Default value is false
105
+func (nameOpts) AllowDelegated(allowDelegated bool) NamePublishOption {
106
+ return func(settings *NamePublishSettings) error {
107
+ settings.AllowDelegated = allowDelegated
108
+ return nil
109
+ }
110
+}
111
+
112
// TTL is an option for Name.Publish which specifies the time duration the
113
// published record should be cached for (caution: experimental).
114
func (nameOpts) TTL(ttl time.Duration) NamePublishOption {
core/coreiface/tests/name.go
+14
-5
@@ -142,8 +142,6 @@ func (tp *TestSuite) TestBasicPublishResolveKey(t *testing.T) {
142
}
143
144
func (tp *TestSuite) TestBasicPublishResolveTimeout(t *testing.T) {
145
- t.Skip("ValidTime doesn't appear to work at this time resolution")
146
-
145
ctx, cancel := context.WithCancel(context.Background())
146
defer cancel()
147
apis, err := tp.MakeAPISwarm(t, ctx, 5)
@@ -155,14 +153,25 @@ func (tp *TestSuite) TestBasicPublishResolveTimeout(t *testing.T) {
153
self, err := api.Key().Self(ctx)
154
require.NoError(t, err)
155
158
- name, err := api.Name().Publish(ctx, p, opt.Name.ValidTime(time.Millisecond*100))
156
+ name, err := api.Name().Publish(ctx, p, opt.Name.ValidTime(time.Second*1))
157
require.NoError(t, err)
158
require.Equal(t, name.String(), ipns.NameFromPeer(self.ID()).String())
159
162
- time.Sleep(time.Second)
160
+ // First resolve should succeed (before expiration)
161
+ resPath, err := api.Name().Resolve(ctx, name.String())
162
+ require.NoError(t, err)
163
+ require.Equal(t, p.String(), resPath.String())
164
165
+ // Wait for record to expire (1 second ValidTime + buffer)
166
+ time.Sleep(time.Second * 2)
167
+
168
+ // Second resolve should now fail after ValidTime expiration (cached)
169
_, err = api.Name().Resolve(ctx, name.String())
165
- require.NoError(t, err)
170
+ require.Error(t, err, "IPNS resolution should fail after ValidTime expires (cached)")
171
+
172
+ // Third resolve should also fail after ValidTime expiration (non-cached)
173
+ _, err = api.Name().Resolve(ctx, name.String(), opt.Name.Cache(false))
174
+ require.Error(t, err, "IPNS resolution should fail after ValidTime expires (non-cached)")
175
}
176
177
// TODO: When swarm api is created, add multinode tests
core/node/builder.go
+2
-1
@@ -7,6 +7,7 @@ import (
7
8
"go.uber.org/fx"
9
10
+ "github.com/ipfs/boxo/autoconf"
11
"github.com/ipfs/kubo/core/node/helpers"
12
"github.com/ipfs/kubo/core/node/libp2p"
13
"github.com/ipfs/kubo/repo"
@@ -125,7 +126,7 @@ func defaultRepo(dstore repo.Datastore) (repo.Repo, error) {
126
return nil, err
127
}
128
128
- c.Bootstrap = cfg.DefaultBootstrapAddresses
129
+ c.Bootstrap = autoconf.FallbackBootstrapPeers
130
c.Addresses.Swarm = []string{"/ip4/0.0.0.0/tcp/4001", "/ip4/0.0.0.0/udp/4001/quic-v1"}
131
c.Identity.PeerID = pid.String()
132
c.Identity.PrivKey = base64.StdEncoding.EncodeToString(privkeyb)
core/node/dns.go
+4
-1
@@ -16,5 +16,8 @@ func DNSResolver(cfg *config.Config) (*madns.Resolver, error) {
16
dohOpts = append(dohOpts, doh.WithMaxCacheTTL(cfg.DNS.MaxCacheTTL.WithDefault(time.Duration(math.MaxUint32)*time.Second)))
17
}
18
19
- return gateway.NewDNSResolver(cfg.DNS.Resolvers, dohOpts...)
19
+ // Replace "auto" DNS resolver placeholders with autoconf values
20
+ resolvers := cfg.DNSResolversWithAutoConf()
21
+
22
+ return gateway.NewDNSResolver(resolvers, dohOpts...)
23
}
core/node/libp2p/host.go
+2
-1
@@ -49,7 +49,8 @@ func Host(mctx helpers.MetricsCtx, lc fx.Lifecycle, params P2PHostIn) (out P2PHo
49
if err != nil {
50
return out, err
51
}
52
- bootstrappers, err := cfg.BootstrapPeers()
52
+ // Use auto-config resolution for actual connectivity
53
+ bootstrappers, err := cfg.BootstrapPeersWithAutoConf()
54
if err != nil {
55
return out, err
56
}
core/node/libp2p/routing.go
+2
-1
@@ -95,7 +95,8 @@ func BaseRouting(cfg *config.Config) interface{} {
95
if err != nil {
96
return out, err
97
}
98
- bspeers, err := cfg.BootstrapPeers()
98
+ // Use auto-config resolution for actual connectivity
99
+ bspeers, err := cfg.BootstrapPeersWithAutoConf()
100
if err != nil {
101
return out, err
102
}
core/node/libp2p/routingopt.go
+150
-24
@@ -2,9 +2,12 @@ package libp2p
2
3
import (
4
"context"
5
+ "fmt"
6
"os"
7
+ "strings"
8
"time"
9
10
+ "github.com/ipfs/boxo/autoconf"
11
"github.com/ipfs/go-datastore"
12
"github.com/ipfs/kubo/config"
13
irouting "github.com/ipfs/kubo/routing"
@@ -32,46 +35,144 @@ type RoutingOption func(args RoutingOptionArgs) (routing.Routing, error)
35
36
var noopRouter = routinghelpers.Null{}
37
38
+// EndpointSource tracks where a URL came from to determine appropriate capabilities
39
+type EndpointSource struct {
40
+ URL string
41
+ SupportsRead bool // came from DelegatedRoutersWithAutoConf (Read operations)
42
+ SupportsWrite bool // came from DelegatedPublishersWithAutoConf (Write operations)
43
+}
44
+
45
+// determineCapabilities determines endpoint capabilities based on URL path and source
46
+func determineCapabilities(endpoint EndpointSource) (string, autoconf.EndpointCapabilities, error) {
47
+ parsed, err := autoconf.DetermineKnownCapabilities(endpoint.URL, endpoint.SupportsRead, endpoint.SupportsWrite)
48
+ if err != nil {
49
+ log.Debugf("Skipping endpoint %q: %v", endpoint.URL, err)
50
+ return "", autoconf.EndpointCapabilities{}, nil // Return empty caps, not error
51
+ }
52
+
53
+ return parsed.BaseURL, parsed.Capabilities, nil
54
+}
55
+
56
+// collectAllEndpoints gathers URLs from both router and publisher sources
57
+func collectAllEndpoints(cfg *config.Config) []EndpointSource {
58
+ var endpoints []EndpointSource
59
+
60
+ // Get router URLs (Read operations)
61
+ var routerURLs []string
62
+ if envRouters := os.Getenv(config.EnvHTTPRouters); envRouters != "" {
63
+ // Use environment variable override if set (space or comma separated)
64
+ splitFunc := func(r rune) bool { return r == ',' || r == ' ' }
65
+ routerURLs = strings.FieldsFunc(envRouters, splitFunc)
66
+ log.Warnf("Using HTTP routers from %s environment variable instead of config/autoconf: %v", config.EnvHTTPRouters, routerURLs)
67
+ } else {
68
+ // Use delegated routers from autoconf
69
+ routerURLs = cfg.DelegatedRoutersWithAutoConf()
70
+ // No fallback - if autoconf doesn't provide endpoints, use empty list
71
+ // This exposes any autoconf issues rather than masking them with hardcoded defaults
72
+ }
73
+
74
+ // Add router URLs to collection
75
+ for _, url := range routerURLs {
76
+ endpoints = append(endpoints, EndpointSource{
77
+ URL: url,
78
+ SupportsRead: true,
79
+ SupportsWrite: false,
80
+ })
81
+ }
82
+
83
+ // Get publisher URLs (Write operations)
84
+ publisherURLs := cfg.DelegatedPublishersWithAutoConf()
85
+
86
+ // Add publisher URLs, merging with existing router URLs if they match
87
+ for _, url := range publisherURLs {
88
+ found := false
89
+ for i, existing := range endpoints {
90
+ if existing.URL == url {
91
+ endpoints[i].SupportsWrite = true
92
+ found = true
93
+ break
94
+ }
95
+ }
96
+ if !found {
97
+ endpoints = append(endpoints, EndpointSource{
98
+ URL: url,
99
+ SupportsRead: false,
100
+ SupportsWrite: true,
101
+ })
102
+ }
103
+ }
104
+
105
+ return endpoints
106
+}
107
+
108
func constructDefaultHTTPRouters(cfg *config.Config) ([]*routinghelpers.ParallelRouter, error) {
109
var routers []*routinghelpers.ParallelRouter
110
httpRetrievalEnabled := cfg.HTTPRetrieval.Enabled.WithDefault(config.DefaultHTTPRetrievalEnabled)
111
39
- // Use config.DefaultHTTPRouters if custom override was sent via config.EnvHTTPRouters
40
- // or if user did not set any preference in cfg.Routing.DelegatedRouters
41
- var httpRouterEndpoints []string
42
- if os.Getenv(config.EnvHTTPRouters) != "" || len(cfg.Routing.DelegatedRouters) == 0 {
43
- httpRouterEndpoints = config.DefaultHTTPRouters
44
- } else {
45
- httpRouterEndpoints = cfg.Routing.DelegatedRouters
112
+ // Collect URLs from both router and publisher sources
113
+ endpoints := collectAllEndpoints(cfg)
114
+
115
+ // Group endpoints by origin (base URL) and aggregate capabilities
116
+ originCapabilities := make(map[string]autoconf.EndpointCapabilities)
117
+ for _, endpoint := range endpoints {
118
+ // Parse endpoint and determine capabilities based on source
119
+ baseURL, capabilities, err := determineCapabilities(endpoint)
120
+ if err != nil {
121
+ return nil, fmt.Errorf("failed to parse endpoint %q: %w", endpoint.URL, err)
122
+ }
123
+
124
+ // Aggregate capabilities for this origin
125
+ existing := originCapabilities[baseURL]
126
+ existing.Merge(capabilities)
127
+ originCapabilities[baseURL] = existing
128
}
129
48
- // Append HTTP routers for additional speed
49
- for _, endpoint := range httpRouterEndpoints {
50
- httpRouter, err := irouting.ConstructHTTPRouter(endpoint, cfg.Identity.PeerID, httpAddrsFromConfig(cfg.Addresses), cfg.Identity.PrivKey, httpRetrievalEnabled)
130
+ // Create single HTTP router and composer per origin
131
+ for baseURL, capabilities := range originCapabilities {
132
+ // Construct HTTP router using base URL (without path)
133
+ httpRouter, err := irouting.ConstructHTTPRouter(baseURL, cfg.Identity.PeerID, httpAddrsFromConfig(cfg.Addresses), cfg.Identity.PrivKey, httpRetrievalEnabled)
134
if err != nil {
135
return nil, err
136
}
54
- // Mapping router to /routing/v1/* endpoints
137
+
138
+ // Configure router operations based on aggregated capabilities
139
// https://specs.ipfs.tech/routing/http-routing-v1/
56
- r := &irouting.Composer{
57
- GetValueRouter: httpRouter, // GET /routing/v1/ipns
58
- PutValueRouter: httpRouter, // PUT /routing/v1/ipns
140
+ composer := &irouting.Composer{
141
+ GetValueRouter: noopRouter, // Default disabled, enabled below based on capabilities
142
+ PutValueRouter: noopRouter, // Default disabled, enabled below based on capabilities
143
ProvideRouter: noopRouter, // we don't have spec for sending provides to /routing/v1 (revisit once https://github.com/ipfs/specs/pull/378 or similar is ratified)
60
- FindPeersRouter: httpRouter, // /routing/v1/peers
61
- FindProvidersRouter: httpRouter, // /routing/v1/providers
144
+ FindPeersRouter: noopRouter, // Default disabled, enabled below based on capabilities
145
+ FindProvidersRouter: noopRouter, // Default disabled, enabled below based on capabilities
146
+ }
147
+
148
+ // Enable specific capabilities
149
+ if capabilities.IPNSGet {
150
+ composer.GetValueRouter = httpRouter // GET /routing/v1/ipns for IPNS resolution
151
+ }
152
+ if capabilities.IPNSPut {
153
+ composer.PutValueRouter = httpRouter // PUT /routing/v1/ipns for IPNS publishing
154
+ }
155
+ if capabilities.Peers {
156
+ composer.FindPeersRouter = httpRouter // GET /routing/v1/peers
157
+ }
158
+ if capabilities.Providers {
159
+ composer.FindProvidersRouter = httpRouter // GET /routing/v1/providers
160
}
161
64
- if endpoint == config.CidContactRoutingURL {
65
- // Special-case: cid.contact only supports /routing/v1/providers/cid
66
- // we disable other endpoints to avoid sending requests that always fail
67
- r.GetValueRouter = noopRouter
68
- r.PutValueRouter = noopRouter
69
- r.ProvideRouter = noopRouter
70
- r.FindPeersRouter = noopRouter
162
+ // Handle special cases and backward compatibility
163
+ if baseURL == config.CidContactRoutingURL {
164
+ // Special-case: cid.contact only supports /routing/v1/providers/cid endpoint
165
+ // Override any capabilities detected from URL path to ensure only providers is enabled
166
+ // TODO: Consider moving this to configuration or removing once cid.contact adds more capabilities
167
+ composer.GetValueRouter = noopRouter
168
+ composer.PutValueRouter = noopRouter
169
+ composer.ProvideRouter = noopRouter
170
+ composer.FindPeersRouter = noopRouter
171
+ composer.FindProvidersRouter = httpRouter // Only providers supported
172
}
173
174
routers = append(routers, &routinghelpers.ParallelRouter{
74
- Router: r,
175
+ Router: composer,
176
IgnoreError: true, // https://github.com/ipfs/kubo/pull/9475#discussion_r1042507387
177
Timeout: 15 * time.Second, // 5x server value from https://github.com/ipfs/kubo/pull/9475#discussion_r1042428529
178
DoNotWaitForSearchValue: true,
@@ -81,6 +182,31 @@ func constructDefaultHTTPRouters(cfg *config.Config) ([]*routinghelpers.Parallel
182
return routers, nil
183
}
184
185
+// ConstructDelegatedOnlyRouting returns routers used when Routing.Type is set to "delegated"
186
+// This provides HTTP-only routing without DHT, using only delegated routers and IPNS publishers.
187
+// Useful for environments where DHT connectivity is not available or desired
188
+func ConstructDelegatedOnlyRouting(cfg *config.Config) RoutingOption {
189
+ return func(args RoutingOptionArgs) (routing.Routing, error) {
190
+ // Use only HTTP routers (includes both read and write capabilities) - no DHT
191
+ var routers []*routinghelpers.ParallelRouter
192
+
193
+ // Add HTTP delegated routers (includes both router and publisher capabilities)
194
+ httpRouters, err := constructDefaultHTTPRouters(cfg)
195
+ if err != nil {
196
+ return nil, err
197
+ }
198
+ routers = append(routers, httpRouters...)
199
+
200
+ // Validate that we have at least one router configured
201
+ if len(routers) == 0 {
202
+ return nil, fmt.Errorf("no delegated routers or publishers configured for 'delegated' routing mode")
203
+ }
204
+
205
+ routing := routinghelpers.NewComposableParallel(routers)
206
+ return routing, nil
207
+ }
208
+}
209
+
210
// ConstructDefaultRouting returns routers used when Routing.Type is unset or set to "auto"
211
func ConstructDefaultRouting(cfg *config.Config, routingOpt RoutingOption) RoutingOption {
212
return func(args RoutingOptionArgs) (routing.Routing, error) {
core/node/libp2p/routingopt_test.go
+190
@@ -3,7 +3,9 @@ package libp2p
3
import (
4
"testing"
5
6
+ "github.com/ipfs/boxo/autoconf"
7
config "github.com/ipfs/kubo/config"
8
+ "github.com/stretchr/testify/assert"
9
"github.com/stretchr/testify/require"
10
)
11
@@ -32,3 +34,191 @@ func TestHttpAddrsFromConfig(t *testing.T) {
34
AppendAnnounce: []string{"/ip4/192.168.0.2/tcp/4001"},
35
}), "AppendAnnounce addrs should be included if specified")
36
}
37
+
38
+func TestDetermineCapabilities(t *testing.T) {
39
+ tests := []struct {
40
+ name string
41
+ endpoint EndpointSource
42
+ expectedBaseURL string
43
+ expectedCapabilities autoconf.EndpointCapabilities
44
+ expectError bool
45
+ }{
46
+ {
47
+ name: "URL with no path should have all Read capabilities",
48
+ endpoint: EndpointSource{
49
+ URL: "https://example.com",
50
+ SupportsRead: true,
51
+ SupportsWrite: false,
52
+ },
53
+ expectedBaseURL: "https://example.com",
54
+ expectedCapabilities: autoconf.EndpointCapabilities{
55
+ Providers: true,
56
+ Peers: true,
57
+ IPNSGet: true,
58
+ IPNSPut: false,
59
+ },
60
+ expectError: false,
61
+ },
62
+ {
63
+ name: "URL with trailing slash should have all Read capabilities",
64
+ endpoint: EndpointSource{
65
+ URL: "https://example.com/",
66
+ SupportsRead: true,
67
+ SupportsWrite: false,
68
+ },
69
+ expectedBaseURL: "https://example.com",
70
+ expectedCapabilities: autoconf.EndpointCapabilities{
71
+ Providers: true,
72
+ Peers: true,
73
+ IPNSGet: true,
74
+ IPNSPut: false,
75
+ },
76
+ expectError: false,
77
+ },
78
+ {
79
+ name: "URL with IPNS path should have only IPNS capabilities",
80
+ endpoint: EndpointSource{
81
+ URL: "https://example.com/routing/v1/ipns",
82
+ SupportsRead: true,
83
+ SupportsWrite: true,
84
+ },
85
+ expectedBaseURL: "https://example.com",
86
+ expectedCapabilities: autoconf.EndpointCapabilities{
87
+ Providers: false,
88
+ Peers: false,
89
+ IPNSGet: true,
90
+ IPNSPut: true,
91
+ },
92
+ expectError: false,
93
+ },
94
+ {
95
+ name: "URL with providers path should have only Providers capability",
96
+ endpoint: EndpointSource{
97
+ URL: "https://example.com/routing/v1/providers",
98
+ SupportsRead: true,
99
+ SupportsWrite: false,
100
+ },
101
+ expectedBaseURL: "https://example.com",
102
+ expectedCapabilities: autoconf.EndpointCapabilities{
103
+ Providers: true,
104
+ Peers: false,
105
+ IPNSGet: false,
106
+ IPNSPut: false,
107
+ },
108
+ expectError: false,
109
+ },
110
+ {
111
+ name: "URL with peers path should have only Peers capability",
112
+ endpoint: EndpointSource{
113
+ URL: "https://example.com/routing/v1/peers",
114
+ SupportsRead: true,
115
+ SupportsWrite: false,
116
+ },
117
+ expectedBaseURL: "https://example.com",
118
+ expectedCapabilities: autoconf.EndpointCapabilities{
119
+ Providers: false,
120
+ Peers: true,
121
+ IPNSGet: false,
122
+ IPNSPut: false,
123
+ },
124
+ expectError: false,
125
+ },
126
+ {
127
+ name: "URL with Write support only should enable IPNSPut for no-path endpoint",
128
+ endpoint: EndpointSource{
129
+ URL: "https://example.com",
130
+ SupportsRead: false,
131
+ SupportsWrite: true,
132
+ },
133
+ expectedBaseURL: "https://example.com",
134
+ expectedCapabilities: autoconf.EndpointCapabilities{
135
+ Providers: false,
136
+ Peers: false,
137
+ IPNSGet: false,
138
+ IPNSPut: true,
139
+ },
140
+ expectError: false,
141
+ },
142
+ }
143
+
144
+ for _, tt := range tests {
145
+ t.Run(tt.name, func(t *testing.T) {
146
+ baseURL, capabilities, err := determineCapabilities(tt.endpoint)
147
+
148
+ if tt.expectError {
149
+ assert.Error(t, err)
150
+ return
151
+ }
152
+
153
+ require.NoError(t, err)
154
+ assert.Equal(t, tt.expectedBaseURL, baseURL)
155
+ assert.Equal(t, tt.expectedCapabilities, capabilities)
156
+ })
157
+ }
158
+}
159
+
160
+func TestEndpointCapabilitiesReadWriteLogic(t *testing.T) {
161
+ t.Run("Read endpoint with no path should enable read capabilities", func(t *testing.T) {
162
+ endpoint := EndpointSource{
163
+ URL: "https://example.com",
164
+ SupportsRead: true,
165
+ SupportsWrite: false,
166
+ }
167
+ _, capabilities, err := determineCapabilities(endpoint)
168
+ require.NoError(t, err)
169
+
170
+ // Read endpoint with no path should enable all read capabilities
171
+ assert.True(t, capabilities.Providers)
172
+ assert.True(t, capabilities.Peers)
173
+ assert.True(t, capabilities.IPNSGet)
174
+ assert.False(t, capabilities.IPNSPut) // Write capability should be false
175
+ })
176
+
177
+ t.Run("Write endpoint with no path should enable write capabilities", func(t *testing.T) {
178
+ endpoint := EndpointSource{
179
+ URL: "https://example.com",
180
+ SupportsRead: false,
181
+ SupportsWrite: true,
182
+ }
183
+ _, capabilities, err := determineCapabilities(endpoint)
184
+ require.NoError(t, err)
185
+
186
+ // Write endpoint with no path should only enable IPNS write capability
187
+ assert.False(t, capabilities.Providers)
188
+ assert.False(t, capabilities.Peers)
189
+ assert.False(t, capabilities.IPNSGet)
190
+ assert.True(t, capabilities.IPNSPut) // Only write capability should be true
191
+ })
192
+
193
+ t.Run("Specific path should only enable matching capabilities", func(t *testing.T) {
194
+ endpoint := EndpointSource{
195
+ URL: "https://example.com/routing/v1/ipns",
196
+ SupportsRead: true,
197
+ SupportsWrite: true,
198
+ }
199
+ _, capabilities, err := determineCapabilities(endpoint)
200
+ require.NoError(t, err)
201
+
202
+ // Specific IPNS path should only enable IPNS capabilities based on source
203
+ assert.False(t, capabilities.Providers)
204
+ assert.False(t, capabilities.Peers)
205
+ assert.True(t, capabilities.IPNSGet) // Read capability enabled
206
+ assert.True(t, capabilities.IPNSPut) // Write capability enabled
207
+ })
208
+
209
+ t.Run("Unsupported paths should result in empty capabilities", func(t *testing.T) {
210
+ endpoint := EndpointSource{
211
+ URL: "https://example.com/routing/v1/unsupported",
212
+ SupportsRead: true,
213
+ SupportsWrite: false,
214
+ }
215
+ _, capabilities, err := determineCapabilities(endpoint)
216
+ require.NoError(t, err)
217
+
218
+ // Unsupported paths should result in no capabilities
219
+ assert.False(t, capabilities.Providers)
220
+ assert.False(t, capabilities.Peers)
221
+ assert.False(t, capabilities.IPNSGet)
222
+ assert.False(t, capabilities.IPNSPut)
223
+ })
224
+}
docs/changelogs/v0.37.md
+65
-3
@@ -10,7 +10,10 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
10
11
- [Overview](#overview)
12
- [🔦 Highlights](#-highlights)
13
+ - [🚀 Repository migration from v16 to v17 with embedded tooling](#-repository-migration-from-v16-to-v17-with-embedded-tooling)
14
- [🚦 Gateway concurrent request limits and retrieval timeouts](#-gateway-concurrent-request-limits-and-retrieval-timeouts)
15
+ - [🔧 AutoConf: Complete control over network defaults](#-autoconf-complete-control-over-network-defaults)
16
+ - [New IPNS publishing options](#new-ipns-publishing-options)
17
- [Clear provide queue when reprovide strategy changes](#clear-provide-queue-when-reprovide-strategy-changes)
18
- [🪵 Revamped `ipfs log level` command](#-revamped-ipfs-log-level-command)
19
- [📌 Named pins in `ipfs add` command](#-named-pins-in-ipfs-add-command)
@@ -29,6 +32,14 @@ This release was brought to you by the [Shipyard](https://ipshipyard.com/) team.
32
33
### 🔦 Highlights
34
35
+#### 🚀 Repository migration from v16 to v17 with embedded tooling
36
+
37
+This release migrates the Kubo repository from version 16 to version 17. Migrations are now built directly into the binary - completing in milliseconds without internet access or external downloads.
38
+
39
+`ipfs daemon --migrate` performs migrations automatically. Manual migration: `ipfs repo migrate --to=17` (or `--to=16 --allow-downgrade` for compatibility). Embedded migrations apply to v17+; older versions still require external tools.
40
+
41
+**Legacy migration deprecation**: Support for legacy migrations that download binaries from the internet will be removed in a future version. Only embedded migrations for the last 3 releases will be supported. Users with very old repositories should update in stages rather than skipping multiple versions.
42
+
43
#### 🚦 Gateway concurrent request limits and retrieval timeouts
44
45
New configurable limits protect gateway resources during high load:
@@ -48,13 +59,62 @@ Tuning tips:
59
- Watch `ipfs_http_gw_concurrent_requests` for saturation
60
- Track `ipfs_http_gw_retrieval_timeouts_total` vs success rates to identify timeout patterns indicating routing or storage provider issues
61
62
+#### 🔧 AutoConf: Complete control over network defaults
63
+
64
+Configuration fields now support `["auto"]` placeholders that resolve to network defaults from [`AutoConf.URL`](https://github.com/ipfs/kubo/blob/master/docs/config.md#autoconfurl). These defaults can be inspected, replaced with custom values, or disabled entirely. Previously, empty configuration fields like `Routing.DelegatedRouters: []` would use hardcoded defaults - this system makes those defaults explicit through `"auto"` values. When upgrading to Kubo 0.37, custom configurations remain unchanged.
65
+
66
+New `--expand-auto` flag shows resolved values for any config field:
67
+
68
+```bash
69
+ipfs config show --expand-auto # View all resolved endpoints
70
+ipfs config Bootstrap --expand-auto # Check specific values
71
+ipfs config Routing.DelegatedRouters --expand-auto
72
+ipfs config DNS.Resolvers --expand-auto
73
+```
74
+
75
+Configuration can be managed via:
76
+- Replace `"auto"` with custom endpoints or set `[]` to disable features
77
+- Switch modes with `--profile=autoconf-on|autoconf-off`
78
+- Configure via `AutoConf.Enabled` and custom manifests via `AutoConf.URL`
79
+
80
+```bash
81
+# Enable automatic configuration
82
+ipfs config profiles apply autoconf-on
83
+
84
+# Or manually set specific fields
85
+ipfs config Bootstrap '["auto"]'
86
+ipfs config --json DNS.Resolvers '{".": ["https://dns.example.com/dns-query"], "eth.": ["auto"]}'
87
+```
88
+
89
+Organizations can host custom AutoConf manifests for private networks. See [AutoConf documentation](https://github.com/ipfs/kubo/blob/master/docs/config.md#autoconf) and format spec at https://conf.ipfs-mainnet.org/
90
+
91
+#### New IPNS publishing options
92
+
93
+Added support for controlling IPNS record publishing strategies.
94
+
95
+**Delegated publishers configuration:**
96
+
97
+[`Ipns.DelegatedPublishers`](https://github.com/ipfs/kubo/blob/master/docs/config.md#ipnsdelegatedpublishers) configures HTTP endpoints for IPNS publishing. Supports `"auto"` for network defaults or custom HTTP endpoints.
98
+
99
+**New command flags:**
100
+```bash
101
+# Publish only to HTTP services defined in Ipns.DelegatedPublishers (skip DHT entirely)
102
+ipfs name publish --delegated-only /ipfs/QmHash
103
+
104
+# Publish only locally (no network requests)
105
+ipfs name publish --allow-offline /ipfs/QmHash
106
+```
107
+
108
+These flags enable HTTP-only publishing or offline-only operations for testing.
109
+
110
+
111
#### Clear provide queue when reprovide strategy changes
112
53
-Your content sharing strategy changes now take effect cleanly, without interference from previously queued items.
113
+Changing [`Reprovider.Strategy`](https://github.com/ipfs/kubo/blob/master/docs/config.md#reproviderstrategy) and restarting Kubo now automatically clears the provide queue. Only content matching the new strategy will be announced.
114
55
-When you change [`Reprovider.Strategy`](https://github.com/ipfs/kubo/blob/master/docs/config.md#reproviderstrategy) and restart Kubo, the provide queue is automatically cleared. This ensures only content matching your new strategy will be announced to the network.
115
+Manual queue clearing is also available:
116
57
-A new `ipfs provide clear` command also allows manual queue clearing for debugging purposes.
117
+- `ipfs provide clear` - clear all queued content announcements
118
119
> [!NOTE]
120
> Upgrading to Kubo 0.37 will automatically clear any preexisting provide queue. The next time `Reprovider.Interval` hits, `Reprovider.Strategy` will be executed on a clean slate, ensuring consistent behavior with your current configuration.
@@ -160,6 +220,8 @@ Per a suggestion from the IPFS Foundation, Kubo now sends optional anonymized te
220
"routing_delegated_count": 0,
221
"autonat_service_mode": "enabled",
222
"autonat_reachability": "",
223
+ "autoconf": true,
224
+ "autoconf_custom": false,
225
"swarm_enable_hole_punching": true,
226
"swarm_circuit_addresses": false,
227
"swarm_ipv4_public_addresses": true,
docs/config.md
+226
-20
@@ -36,6 +36,11 @@ config file at runtime.
36
- [`AutoTLS.RegistrationToken`](#autotlsregistrationtoken)
37
- [`AutoTLS.RegistrationDelay`](#autotlsregistrationdelay)
38
- [`AutoTLS.CAEndpoint`](#autotlscaendpoint)
39
+ - [`AutoConf`](#autoconf)
40
+ - [`AutoConf.URL`](#autoconfurl)
41
+ - [`AutoConf.Enabled`](#autoconfenabled)
42
+ - [`AutoConf.RefreshInterval`](#autoconfrefreshinterval)
43
+ - [`AutoConf.TLSInsecureSkipVerify`](#autoconftlsinsecureskipverify)
44
- [`Bitswap`](#bitswap)
45
- [`Bitswap.Libp2pEnabled`](#bitswaplibp2penabled)
46
- [`Bitswap.ServerEnabled`](#bitswapserverenabled)
@@ -100,6 +105,7 @@ config file at runtime.
105
- [`Ipns.ResolveCacheSize`](#ipnsresolvecachesize)
106
- [`Ipns.MaxCacheTTL`](#ipnsmaxcachettl)
107
- [`Ipns.UsePubsub`](#ipnsusepubsub)
108
+ - [`Ipns.DelegatedPublishers`](#ipnsdelegatedpublishers)
109
- [`Migration`](#migration)
110
- [`Migration.DownloadSources`](#migrationdownloadsources)
111
- [`Migration.Keep`](#migrationkeep)
@@ -225,6 +231,8 @@ config file at runtime.
231
- [`default-datastore` profile](#default-datastore-profile)
232
- [`local-discovery` profile](#local-discovery-profile)
233
- [`default-networking` profile](#default-networking-profile)
234
+ - [`autoconf-on` profile](#autoconf-on-profile)
235
+ - [`autoconf-off` profile](#autoconf-off-profile)
236
- [`flatfs` profile](#flatfs-profile)
237
- [`flatfs-measure` profile](#flatfs-measure-profile)
238
- [`pebbleds` profile](#pebbleds-profile)
@@ -538,6 +546,150 @@ Default: 1 Minute
546
547
Type: `duration` (when `0`/unset, the default value is used)
548
549
+## `AutoConf`
550
+
551
+The AutoConf feature enables Kubo nodes to automatically fetch and apply network configuration from a remote JSON endpoint. This system allows dynamic configuration updates for bootstrap peers, DNS resolvers, delegated routing, and IPNS publishing endpoints without requiring manual updates to each node's local config.
552
+
553
+AutoConf works by using special `"auto"` placeholder values in configuration fields. When Kubo encounters these placeholders, it fetches the latest configuration from the specified URL and resolves the placeholders with the appropriate values at runtime. The original configuration file remains unchanged - `"auto"` values are preserved in the JSON and only resolved in memory during node operation.
554
+
555
+### Key Features
556
+
557
+- **Remote Configuration**: Fetch network defaults from a trusted URL
558
+- **Automatic Updates**: Periodic background checks for configuration updates
559
+- **Graceful Fallback**: Uses hardcoded IPFS Mainnet bootstrappers when remote config is unavailable
560
+- **Validation**: Ensures all fetched configuration values are valid multiaddrs and URLs
561
+- **Caching**: Stores multiple versions locally with ETags for efficient updates
562
+- **User Notification**: Logs ERROR when new configuration is available requiring node restart
563
+- **Debug Logging**: AutoConf operations can be inspected by setting `GOLOG_LOG_LEVEL="error,autoconf=debug"`
564
+
565
+### Supported Fields
566
+
567
+AutoConf can resolve `"auto"` placeholders in the following configuration fields:
568
+
569
+- `Bootstrap` - Bootstrap peer addresses
570
+- `DNS.Resolvers` - DNS-over-HTTPS resolver endpoints
571
+- `Routing.DelegatedRouters` - Delegated routing HTTP API endpoints
572
+- `Ipns.DelegatedPublishers` - IPNS delegated publishing HTTP API endpoints
573
+
574
+### Usage Example
575
+
576
+```json
577
+{
578
+ "AutoConf": {
579
+ "URL": "https://example.com/autoconf.json",
580
+ "Enabled": true,
581
+ "RefreshInterval": "24h"
582
+ },
583
+ "Bootstrap": ["auto"],
584
+ "DNS": {
585
+ "Resolvers": {
586
+ ".": ["auto"],
587
+ "eth.": ["auto"],
588
+ "custom.": ["https://dns.example.com/dns-query"]
589
+ }
590
+ },
591
+ "Routing": {
592
+ "DelegatedRouters": ["auto", "https://router.example.org/routing/v1"]
593
+ }
594
+}
595
+```
596
+
597
+**Notes:**
598
+
599
+- Configuration fetching happens at daemon startup and periodically in the background
600
+- When new configuration is detected, users must restart their node to apply changes
601
+- Mixed configurations are supported: you can use both `"auto"` and static values
602
+- If AutoConf is disabled but `"auto"` values exist, daemon startup will fail with validation errors
603
+- Cache is stored in `$IPFS_PATH/autoconf/` with up to 3 versions retained
604
+
605
+### Path-Based Routing Configuration
606
+
607
+AutoConf supports path-based routing URLs that automatically enable specific routing operations based on the URL path. This allows precise control over which HTTP Routing V1 endpoints are used for different operations:
608
+
609
+**Supported paths:**
610
+- `/routing/v1/providers` - Enables provider record lookups only
611
+- `/routing/v1/peers` - Enables peer routing lookups only
612
+- `/routing/v1/ipns` - Enables IPNS record operations only
613
+- No path - Enables all routing operations (backward compatibility)
614
+
615
+**AutoConf JSON structure with path-based routing:**
616
+
617
+```json
618
+{
619
+ "DelegatedRouters": {
620
+ "mainnet-for-nodes-with-dht": [
621
+ "https://cid.contact/routing/v1/providers"
622
+ ],
623
+ "mainnet-for-nodes-without-dht": [
624
+ "https://delegated-ipfs.dev/routing/v1/providers",
625
+ "https://delegated-ipfs.dev/routing/v1/peers",
626
+ "https://delegated-ipfs.dev/routing/v1/ipns"
627
+ ]
628
+ },
629
+ "DelegatedPublishers": {
630
+ "mainnet-for-ipns-publishers-with-http": [
631
+ "https://delegated-ipfs.dev/routing/v1/ipns"
632
+ ]
633
+ }
634
+}
635
+```
636
+
637
+**Node type categories:**
638
+- `mainnet-for-nodes-with-dht`: Mainnet nodes with DHT enabled (typically only need additional provider lookups)
639
+- `mainnet-for-nodes-without-dht`: Mainnet nodes without DHT (need comprehensive routing services)
640
+- `mainnet-for-ipns-publishers-with-http`: Mainnet nodes that publish IPNS records via HTTP
641
+
642
+This design enables efficient, selective routing where each endpoint URL automatically determines its capabilities based on the path, while maintaining semantic grouping by node configuration type.
643
+
644
+Default: `{}`
645
+
646
+Type: `object`
647
+
648
+### `AutoConf.Enabled`
649
+
650
+Controls whether the AutoConf system is active. When enabled, Kubo will fetch configuration from the specified URL and resolve `"auto"` placeholders at runtime. When disabled, any `"auto"` values in the configuration will cause daemon startup to fail with validation errors.
651
+
652
+This provides a safety mechanism to ensure nodes don't start with unresolved placeholders when AutoConf is intentionally disabled.
653
+
654
+Default: `true`
655
+
656
+Type: `flag`
657
+
658
+### `AutoConf.URL`
659
+
660
+Specifies the HTTP(S) URL from which to fetch the autoconf JSON. The endpoint should return a JSON document containing Bootstrap peers, DNS resolvers, delegated routing endpoints, and IPNS publishing endpoints that will replace `"auto"` placeholders in the local configuration.
661
+
662
+The URL must serve a JSON document matching the AutoConf schema. Kubo validates all multiaddr and URL values before caching to ensure they are properly formatted.
663
+
664
+When not specified in the configuration, the default mainnet URL is used automatically.
665
+
666
+<a href="https://ipshipyard.com/"><img align="right" src="https://github.com/user-attachments/assets/39ed3504-bb71-47f6-9bf8-cb9a1698f272" /></a>
667
+
668
+> [!NOTE]
669
+> Public good autoconf manifest at `conf.ipfs-mainnet.org` is provided by the team at [Shipyard](https://ipshipyard.com).
670
+
671
+Default: `"https://conf.ipfs-mainnet.org/autoconf.json"` (when not specified)
672
+
673
+Type: `optionalString`
674
+
675
+### `AutoConf.RefreshInterval`
676
+
677
+Specifies how frequently Kubo should refresh autoconf data. This controls both how often cached autoconf data is considered fresh and how frequently the background service checks for new configuration updates.
678
+
679
+When a new configuration version is detected during background updates, Kubo logs an ERROR message informing the user that a node restart is required to apply the changes to any `"auto"` entries in their configuration.
680
+
681
+Default: `24h`
682
+
683
+Type: `optionalDuration`
684
+
685
+### `AutoConf.TLSInsecureSkipVerify`
686
+
687
+**FOR TESTING ONLY** - Allows skipping TLS certificate verification when fetching autoconf from HTTPS URLs. This should never be enabled in production as it makes the configuration fetching vulnerable to man-in-the-middle attacks.
688
+
689
+Default: `false`
690
+
691
+Type: `flag`
692
+
693
## `AutoTLS`
694
695
The [AutoTLS](https://blog.libp2p.io/autotls/) feature enables publicly reachable Kubo nodes (those dialable from the public
@@ -657,6 +809,7 @@ Default: [certmagic.LetsEncryptProductionCA](https://pkg.go.dev/github.com/caddy
809
810
Type: `optionalString`
811
812
+
813
## `Bitswap`
814
815
High level client and server configuration of the [Bitswap Protocol](https://specs.ipfs.tech/bitswap-protocol/) over libp2p.
@@ -690,11 +843,18 @@ Type: `flag`
843
844
## `Bootstrap`
845
693
-Bootstrap is an array of [multiaddrs][multiaddr] of trusted nodes that your node connects to, to fetch other nodes of the network on startup.
846
+Bootstrap peers help your node discover and connect to the IPFS network when starting up. This array contains [multiaddrs][multiaddr] of trusted nodes that your node contacts first to find other peers and content.
847
695
-Default: [`config.DefaultBootstrapAddresses`](https://github.com/ipfs/kubo/blob/master/config/bootstrap_peers.go)
848
+The special value `"auto"` automatically uses curated, up-to-date bootstrap peers from [AutoConf](#autoconf), ensuring your node can always connect to the healthy network without manual maintenance.
849
697
-Type: `array[string]` ([multiaddrs][multiaddr])
850
+**What this gives you:**
851
+- **Reliable startup**: Your node can always find the network, even if some bootstrap peers go offline
852
+- **Automatic updates**: New bootstrap peers are added as the network evolves
853
+- **Custom control**: Add your own trusted peers alongside or instead of the defaults
854
+
855
+Default: `["auto"]`
856
+
857
+Type: `array[string]` ([multiaddrs][multiaddr] or `"auto"`)
858
859
## `Datastore`
860
@@ -1484,21 +1644,52 @@ Default: `disabled`
1644
1645
Type: `flag`
1646
1647
+### `Ipns.DelegatedPublishers`
1648
+
1649
+A list of IPNS publishers to delegate publishing operations to. When configured, IPNS publish operations are sent to these remote HTTP services in addition to or instead of local DHT publishing, depending on [`Routing.Type`](#routingtype) configuration.
1650
+
1651
+These endpoints must support the [IPNS API](https://specs.ipfs.tech/routing/http-routing-v1/#ipns-api) from the Delegated Routing V1 HTTP specification.
1652
+
1653
+The special value `"auto"` uses delegated publishers from [AutoConf](#autoconf) when enabled.
1654
+
1655
+**Publishing behavior depends on routing configuration:**
1656
+
1657
+- `Routing.Type=auto` (default): Uses both DHT and HTTP delegated publishers
1658
+- `Routing.Type=delegated`: Uses only HTTP delegated publishers (DHT disabled)
1659
+
1660
+**Command flags control publishing method:**
1661
+
1662
+- `ipfs name publish /ipfs/QmHash` - Uses configured routing (default behavior)
1663
+- `ipfs name publish --allow-offline /ipfs/QmHash` - Local datastore only, no network requests
1664
+- `ipfs name publish --delegated-only /ipfs/QmHash` - HTTP delegated publishers only, requires configuration
1665
+
1666
+For self-hosting, you can run your own `/routing/v1/ipns` endpoint using [someguy](https://github.com/ipfs/someguy/).
1667
+
1668
+Default: `["auto"]`
1669
+
1670
+Type: `array[string]` (URLs or `"auto"`)
1671
+
1672
## `Migration`
1673
1489
-Migration configures how migrations are downloaded and if the downloads are added to IPFS locally.
1674
+> [!WARNING]
1675
+> **DEPRECATED:** Only applies to legacy migrations (repo versions <16). Modern repos (v16+) use embedded migrations.
1676
+> This section is optional and will not appear in new configurations.
1677
1678
### `Migration.DownloadSources`
1679
1493
-Sources in order of preference, where "IPFS" means use IPFS and "HTTPS" means use default gateways. Any other values are interpreted as hostnames for custom gateways. An empty list means "use default sources".
1680
+**DEPRECATED:** Download sources for legacy migrations. Only `"HTTPS"` is supported.
1681
+
1682
+Type: `array[string]` (optional)
1683
1495
-Default: `["HTTPS", "IPFS"]`
1684
+Default: `["HTTPS"]`
1685
1686
### `Migration.Keep`
1687
1499
-Specifies whether or not to keep the migration after downloading it. Options are "discard", "cache", "pin". Empty string for default.
1688
+**DEPRECATED:** Controls retention of legacy migration binaries. Options: `"cache"` (default), `"discard"`, `"keep"`.
1689
+
1690
+Type: `string` (optional)
1691
1501
-Default: `cache`
1692
+Default: `"cache"`
1693
1694
## `Mounts`
1695
@@ -1908,7 +2099,7 @@ Contains options for content, peer, and IPNS routing mechanisms.
2099
2100
### `Routing.Type`
2101
1911
-There are multiple routing options: "auto", "autoclient", "none", "dht", "dhtclient", and "custom".
2102
+There are multiple routing options: "auto", "autoclient", "none", "dht", "dhtclient", "delegated", and "custom".
2103
2104
* **DEFAULT:** If unset, or set to "auto", your node will use the public IPFS DHT (aka "Amino")
2105
and parallel [`Routing.DelegatedRouters`](#routingdelegatedrouters) for additional speed.
@@ -1945,6 +2136,15 @@ by leveraging [`Routing.DelegatedRouters`](#routingdelegatedrouters) HTTP endpoi
2136
introduced in [IPIP-337](https://github.com/ipfs/specs/pull/337)
2137
in addition to the Amino DHT.
2138
2139
+When `Routing.Type` is set to `delegated`, your node will use **only** HTTP delegated routers and IPNS publishers,
2140
+without initializing the Amino DHT at all. This mode is useful for environments where peer-to-peer DHT connectivity
2141
+is not available or desired, while still enabling content routing and IPNS publishing via HTTP APIs.
2142
+This mode requires configuring [`Routing.DelegatedRouters`](#routingdelegatedrouters) for content routing and
2143
+[`Ipns.DelegatedPublishers`](#ipnsdelegatedpublishers) for IPNS publishing.
2144
+
2145
+**Note:** `delegated` mode operates as read-only for content providing - your node cannot announce content to the network
2146
+since there is no DHT connectivity. Content providing is automatically disabled when using this routing type.
2147
+
2148
[Advanced routing rules](https://github.com/ipfs/kubo/blob/master/docs/delegated-routing.md) can be configured in `Routing.Routers` after setting `Routing.Type` to `custom`.
2149
2150
Default: `auto` (DHT + [`Routing.DelegatedRouters`](#routingdelegatedrouters))
@@ -2031,14 +2231,16 @@ Type: `array[string]`
2231
An array of URL hostnames for delegated routers to be queried in addition to the Amino DHT when `Routing.Type` is set to `auto` (default) or `autoclient`.
2232
These endpoints must support the [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/).
2233
2234
+The special value `"auto"` uses delegated routers from [AutoConf](#autoconf) when enabled.
2235
+
2236
> [!TIP]
2237
> Delegated routing allows IPFS implementations to offload tasks like content routing, peer routing, and naming to a separate process or server while also benefiting from HTTP caching.
2238
>
2239
> One can run their own delegated router either by implementing the [Delegated Routing V1 HTTP API](https://specs.ipfs.tech/routing/http-routing-v1/) themselves, or by using [Someguy](https://github.com/ipfs/someguy), a turn-key implementation that proxies requests to other routing systems. A public utility instance of Someguy is hosted at [`https://delegated-ipfs.dev`](https://docs.ipfs.tech/concepts/public-utilities/#delegated-routing).
2240
2039
-Default: `["https://cid.contact"]` (empty or `nil` will also use this default; to disable delegated routing, set `Routing.Type` to `dht` or `dhtclient`)
2241
+Default: `["auto"]`
2242
2041
-Type: `array[string]`
2243
+Type: `array[string]` (URLs or `"auto"`)
2244
2245
### `Routing.Routers`
2246
@@ -2795,16 +2997,10 @@ Example:
2997
Be mindful that:
2998
- Currently only `https://` URLs for [DNS over HTTPS (DoH)](https://en.wikipedia.org/wiki/DNS_over_HTTPS) endpoints are supported as values.
2999
- The default catch-all resolver is the cleartext one provided by your operating system. It can be overridden by adding a DoH entry for the DNS root indicated by `.` as illustrated above.
2798
-- Out-of-the-box support for selected non-ICANN TLDs relies on third-party centralized services provided by respective communities on best-effort basis. The implicit DoH resolvers are:
2799
- ```json
2800
- {
2801
- "eth.": "https://dns.eth.limo/dns-query",
2802
- "crypto.": "https://resolver.unstoppable.io/dns-query"
2803
- }
2804
- ```
2805
- To get all the benefits of a decentralized naming system we strongly suggest setting DoH endpoint to an empty string and running own decentralized resolver as catch-all one on localhost.
3000
+- Out-of-the-box support for selected non-ICANN TLDs relies on third-party centralized services provided by respective communities on best-effort basis.
3001
+- The special value `"auto"` uses DNS resolvers from [AutoConf](#autoconf) when enabled. For example: `{".": "auto"}` uses any custom DoH resolver (global or per TLD) provided by AutoConf system.
3002
2807
-Default: `{}`
3003
+Default: `{".": "auto"}`
3004
3005
Type: `object[string -> string]`
3006
@@ -3137,6 +3333,16 @@ is useful when using the daemon in test environments.
3333
Restores default network settings.
3334
Inverse profile of the test profile.
3335
3336
+### `autoconf-on` profile
3337
+
3338
+Safe default for joining the public IPFS Mainnet swarm with automatic configuration.
3339
+Can also be used with custom AutoConf.URL for other networks.
3340
+
3341
+### `autoconf-off` profile
3342
+
3343
+Disables AutoConf and clears all networking fields for manual configuration.
3344
+Use this for private networks or when you want explicit control over all endpoints.
3345
+
3346
### `flatfs` profile
3347
3348
Configures the node to use the flatfs datastore.
docs/environment-variables.md
+11
-5
@@ -153,9 +153,15 @@ $ ipfs resolve -r /ipns/dnslink-test2.example.com
153
154
## `IPFS_HTTP_ROUTERS`
155
156
-Overrides all implicit HTTP routers enabled when `Routing.Type=auto` with
157
-the space-separated list of URLs provided in this variable.
158
-Useful for testing and debugging in offline contexts.
156
+Overrides AutoConf and all other HTTP routers when set.
157
+When `Routing.Type=auto`, this environment variable takes precedence over
158
+both AutoConf-provided endpoints and any manually configured delegated routers.
159
+The value should be a space or comma-separated list of HTTP routing endpoint URLs.
160
+
161
+This is useful for:
162
+- Testing and debugging in offline contexts
163
+- Overriding AutoConf endpoints temporarily
164
+- Using custom or private HTTP routing services
165
166
Example:
167
@@ -164,11 +170,11 @@ $ ipfs config Routing.Type auto
170
$ IPFS_HTTP_ROUTERS="http://127.0.0.1:7423" ipfs daemon
171
```
172
167
-The above will replace implicit HTTP routers with single one, allowing for
173
+The above will replace all AutoConf endpoints with a single local one, allowing for
174
inspection/debug of HTTP requests sent by Kubo via `while true ; do nc -l 7423; done`
175
or more advanced tools like [mitmproxy](https://docs.mitmproxy.org/stable/#mitmproxy).
176
171
-Default: `config.DefaultHTTPRouters`
177
+When not set, Kubo uses endpoints from AutoConf (when enabled) or manually configured `Routing.DelegatedRouters`.
178
179
## `IPFS_HTTP_ROUTERS_FILTER_PROTOCOLS`
180
docs/experimental-features.md
+1
@@ -680,3 +680,4 @@ ipfs config --json Experimental.GatewayOverLibp2p true
680
## Accelerated DHT Client
681
682
This feature now lives at [`Routing.AcceleratedDHTClient`](https://github.com/ipfs/kubo/blob/master/docs/config.md#routingaccelerateddhtclient).
683
+
docs/telemetry.md
+1
@@ -57,6 +57,7 @@ The telemetry plugin collects the following anonymized data:
57
- **Bootstrap peers**: Whether custom bootstrap peers are used.
58
- **Routing type**: Whether the node uses DHT, IPFS, or a custom routing setup.
59
- **AutoNAT settings**: Whether AutoNAT is enabled and its reachability status.
60
+- **AutoConf settings**: Whether AutoConf is enabled and whether a custom URL is used.
61
- **Swarm settings**: Whether hole punching is enabled, and whether public IP addresses are used.
62
63
### TLS and Discovery
go.mod
+1
-1
@@ -61,6 +61,7 @@ require (
61
github.com/libp2p/go-libp2p-routing-helpers v0.7.5
62
github.com/libp2p/go-libp2p-testing v0.12.0
63
github.com/libp2p/go-socket-activation v0.1.1
64
+ github.com/miekg/dns v1.1.68
65
github.com/multiformats/go-multiaddr v0.16.1
66
github.com/multiformats/go-multiaddr-dns v0.4.1
67
github.com/multiformats/go-multibase v0.2.0
@@ -174,7 +175,6 @@ require (
175
github.com/mattn/go-runewidth v0.0.15 // indirect
176
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
177
github.com/mholt/acmez/v3 v3.1.2 // indirect
177
- github.com/miekg/dns v1.1.68 // indirect
178
github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect
179
github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect
180
github.com/minio/sha256-simd v1.0.1 // indirect
plugin/plugins/telemetry/telemetry.go
+11
-14
@@ -90,6 +90,9 @@ type LogEvent struct {
90
AutoNATServiceMode string `json:"autonat_service_mode"`
91
AutoNATReachability string `json:"autonat_reachability"`
92
93
+ AutoConf bool `json:"autoconf"`
94
+ AutoConfCustom bool `json:"autoconf_custom"`
95
+
96
SwarmEnableHolePunching bool `json:"swarm_enable_hole_punching"`
97
SwarmCircuitAddresses bool `json:"swarm_circuit_addresses"`
98
SwarmIPv4PublicAddresses bool `json:"swarm_ipv4_public_addresses"`
@@ -247,21 +250,9 @@ func (p *telemetryPlugin) loadUUID() error {
250
}
251
252
func (p *telemetryPlugin) hasDefaultBootstrapPeers() bool {
250
- defaultPeers := config.DefaultBootstrapAddresses
253
+ // With autoconf, default bootstrap is represented as ["auto"]
254
currentPeers := p.config.Bootstrap
252
- if len(defaultPeers) != len(currentPeers) {
253
- return false
254
- }
255
- peerMap := make(map[string]struct{}, len(defaultPeers))
256
- for _, peer := range defaultPeers {
257
- peerMap[peer] = struct{}{}
258
- }
259
- for _, peer := range currentPeers {
260
- if _, ok := peerMap[peer]; !ok {
261
- return false
262
- }
263
- }
264
- return true
255
+ return len(currentPeers) == 1 && currentPeers[0] == "auto"
256
}
257
258
func (p *telemetryPlugin) showInfo() {
@@ -352,6 +343,7 @@ func (p *telemetryPlugin) prepareEvent() {
343
p.collectBasicInfo()
344
p.collectRoutingInfo()
345
p.collectAutoNATInfo()
346
+ p.collectAutoConfInfo()
347
p.collectSwarmInfo()
348
p.collectAutoTLSInfo()
349
p.collectDiscoveryInfo()
@@ -467,6 +459,11 @@ func (p *telemetryPlugin) collectAutoTLSInfo() {
459
p.event.AutoTLSDomainSuffixCustom = domainSuffix != config.DefaultDomainSuffix
460
}
461
462
+func (p *telemetryPlugin) collectAutoConfInfo() {
463
+ p.event.AutoConf = p.config.AutoConf.Enabled.WithDefault(config.DefaultAutoConfEnabled)
464
+ p.event.AutoConfCustom = p.config.AutoConf.URL.WithDefault(config.DefaultAutoConfURL) != config.DefaultAutoConfURL
465
+}
466
+
467
func (p *telemetryPlugin) collectDiscoveryInfo() {
468
p.event.DiscoveryMDNSEnabled = p.config.Discovery.MDNS.Enabled
469
}
repo/fsrepo/fsrepo.go
+2
-1
@@ -14,6 +14,7 @@ import (
14
15
filestore "github.com/ipfs/boxo/filestore"
16
keystore "github.com/ipfs/boxo/keystore"
17
+ version "github.com/ipfs/kubo"
18
repo "github.com/ipfs/kubo/repo"
19
"github.com/ipfs/kubo/repo/common"
20
rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager"
@@ -36,7 +37,7 @@ const LockFile = "repo.lock"
37
var log = logging.Logger("fsrepo")
38
39
// RepoVersion is the version number that we are currently expecting to see.
39
-var RepoVersion = 16
40
+var RepoVersion = version.RepoVersion
41
42
var migrationInstructions = `See https://github.com/ipfs/fs-repo-migrations/blob/master/run.md
43
Sorry for the inconvenience. In the future, these will run automatically.`
repo/fsrepo/migrations/README.md
new
+134
@@ -0,0 +1,134 @@
1
+# IPFS Repository Migrations
2
+
3
+This directory contains the migration system for IPFS repositories, handling both embedded and external migrations.
4
+
5
+## Migration System Overview
6
+
7
+### Embedded vs External Migrations
8
+
9
+Starting from **repo version 17**, Kubo uses **embedded migrations** that are built into the binary, eliminating the need to download external migration tools.
10
+
11
+- **Repo versions <17**: Use external binary migrations downloaded from fs-repo-migrations
12
+- **Repo version 17+**: Use embedded migrations built into Kubo
13
+
14
+### Migration Functions
15
+
16
+#### `migrations.RunEmbeddedMigrations()`
17
+- **Purpose**: Runs migrations that are embedded directly in the Kubo binary
18
+- **Scope**: Handles repo version 17+ migrations
19
+- **Performance**: Fast execution, no network downloads required
20
+- **Dependencies**: Self-contained, uses only Kubo's internal dependencies
21
+- **Usage**: Primary migration method for modern repo versions
22
+
23
+**Parameters**:
24
+- `ctx`: Context for cancellation and timeouts
25
+- `targetVersion`: Target repository version to migrate to
26
+- `repoPath`: Path to the IPFS repository directory
27
+- `allowDowngrade`: Whether to allow downgrade migrations
28
+
29
+```go
30
+err = migrations.RunEmbeddedMigrations(ctx, targetVersion, repoPath, allowDowngrade)
31
+if err != nil {
32
+ // Handle migration failure, may fall back to external migrations
33
+}
34
+```
35
+
36
+#### `migrations.RunMigration()` with `migrations.ReadMigrationConfig()`
37
+- **Purpose**: Runs external binary migrations downloaded from fs-repo-migrations
38
+- **Scope**: Handles legacy repo versions <17 and serves as fallback
39
+- **Performance**: Slower due to network downloads and external process execution
40
+- **Dependencies**: Requires fs-repo-migrations binaries and network access
41
+- **Usage**: Fallback method for legacy migrations
42
+
43
+```go
44
+// Read migration configuration for external migrations
45
+migrationCfg, err := migrations.ReadMigrationConfig(repoPath, configFile)
46
+fetcher, err := migrations.GetMigrationFetcher(migrationCfg.DownloadSources, ...)
47
+err = migrations.RunMigration(ctx, fetcher, targetVersion, repoPath, allowDowngrade)
48
+```
49
+
50
+## Migration Flow in Daemon Startup
51
+
52
+1. **Primary**: Try embedded migrations first (`RunEmbeddedMigrations`)
53
+2. **Fallback**: If embedded migration fails, fall back to external migrations (`RunMigration`)
54
+3. **Legacy Support**: External migrations ensure compatibility with older repo versions
55
+
56
+## Directory Structure
57
+
58
+```
59
+repo/fsrepo/migrations/
60
+├── README.md # This file
61
+├── embedded.go # Embedded migration system
62
+├── embedded_test.go # Tests for embedded migrations
63
+├── migrations.go # External migration system
64
+├── fs-repo-16-to-17/ # First embedded migration (16→17)
65
+│ ├── migration/
66
+│ │ ├── migration.go # Migration logic
67
+│ │ └── migration_test.go # Migration tests
68
+│ ├── atomicfile/
69
+│ │ └── atomicfile.go # Atomic file operations
70
+│ ├── main.go # Standalone migration binary
71
+│ └── README.md # Migration-specific documentation
72
+└── [other migration utilities]
73
+```
74
+
75
+## Adding New Embedded Migrations
76
+
77
+To add a new embedded migration (e.g., fs-repo-17-to-18):
78
+
79
+1. **Create migration package**: `fs-repo-17-to-18/migration/migration.go`
80
+2. **Implement interface**: Ensure your migration implements the `EmbeddedMigration` interface
81
+3. **Register migration**: Add to `embeddedMigrations` map in `embedded.go`
82
+4. **Add tests**: Create comprehensive tests for your migration logic
83
+5. **Update repo version**: Increment `RepoVersion` in `fsrepo.go`
84
+
85
+```go
86
+// In embedded.go
87
+var embeddedMigrations = map[string]EmbeddedMigration{
88
+ "fs-repo-16-to-17": &mg16.Migration{},
89
+ "fs-repo-17-to-18": &mg17.Migration{}, // Add new migration
90
+}
91
+```
92
+
93
+## Migration Requirements
94
+
95
+Each embedded migration must:
96
+- Implement the `EmbeddedMigration` interface
97
+- Be reversible with proper backup handling
98
+- Use atomic file operations to prevent corruption
99
+- Preserve user customizations
100
+- Include comprehensive tests
101
+- Follow the established naming pattern
102
+
103
+## External Migration Support
104
+
105
+External migrations are maintained for:
106
+- **Backward compatibility** with repo versions <17
107
+- **Fallback mechanism** if embedded migrations fail
108
+- **Legacy installations** that cannot be upgraded directly
109
+
110
+The external migration system will continue to work but is not the preferred method for new migrations.
111
+
112
+## Security and Safety
113
+
114
+All migrations (embedded and external) include:
115
+- **Atomic operations**: Prevent repository corruption
116
+- **Backup creation**: Allow rollback if migration fails
117
+- **Version validation**: Ensure migrations run on correct repo versions
118
+- **Error handling**: Graceful failure with informative messages
119
+- **User preservation**: Maintain custom configurations during migration
120
+
121
+## Testing
122
+
123
+Test both embedded and external migration systems:
124
+
125
+```bash
126
+# Test embedded migrations
127
+go test ./repo/fsrepo/migrations/ -run TestEmbedded
128
+
129
+# Test specific migration
130
+go test ./repo/fsrepo/migrations/fs-repo-16-to-17/migration/
131
+
132
+# Test migration registration
133
+go test ./repo/fsrepo/migrations/ -run TestHasEmbedded
134
+```
\ No newline at end of file
repo/fsrepo/migrations/atomicfile/atomicfile.go
new
+59
@@ -0,0 +1,59 @@
1
+package atomicfile
2
+
3
+import (
4
+ "io"
5
+ "os"
6
+ "path/filepath"
7
+)
8
+
9
+// File represents an atomic file writer
10
+type File struct {
11
+ *os.File
12
+ path string
13
+}
14
+
15
+// New creates a new atomic file writer
16
+func New(path string, mode os.FileMode) (*File, error) {
17
+ dir := filepath.Dir(path)
18
+ tempFile, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path))
19
+ if err != nil {
20
+ return nil, err
21
+ }
22
+
23
+ if err := tempFile.Chmod(mode); err != nil {
24
+ tempFile.Close()
25
+ os.Remove(tempFile.Name())
26
+ return nil, err
27
+ }
28
+
29
+ return &File{
30
+ File: tempFile,
31
+ path: path,
32
+ }, nil
33
+}
34
+
35
+// Close atomically replaces the target file with the temporary file
36
+func (f *File) Close() error {
37
+ if err := f.File.Close(); err != nil {
38
+ os.Remove(f.File.Name())
39
+ return err
40
+ }
41
+
42
+ if err := os.Rename(f.File.Name(), f.path); err != nil {
43
+ os.Remove(f.File.Name())
44
+ return err
45
+ }
46
+
47
+ return nil
48
+}
49
+
50
+// Abort removes the temporary file without replacing the target
51
+func (f *File) Abort() error {
52
+ f.File.Close()
53
+ return os.Remove(f.File.Name())
54
+}
55
+
56
+// ReadFrom reads from the given reader into the atomic file
57
+func (f *File) ReadFrom(r io.Reader) (int64, error) {
58
+ return io.Copy(f.File, r)
59
+}
repo/fsrepo/migrations/embedded.go
new
+146
@@ -0,0 +1,146 @@
1
+package migrations
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "log"
7
+ "os"
8
+
9
+ mg16 "github.com/ipfs/kubo/repo/fsrepo/migrations/fs-repo-16-to-17/migration"
10
+)
11
+
12
+// EmbeddedMigration represents an embedded migration that can be run directly
13
+type EmbeddedMigration interface {
14
+ Versions() string
15
+ Apply(opts mg16.Options) error
16
+ Revert(opts mg16.Options) error
17
+ Reversible() bool
18
+}
19
+
20
+// embeddedMigrations contains all embedded migrations
21
+var embeddedMigrations = map[string]EmbeddedMigration{
22
+ "fs-repo-16-to-17": &mg16.Migration{},
23
+}
24
+
25
+// RunEmbeddedMigration runs an embedded migration if available
26
+func RunEmbeddedMigration(ctx context.Context, migrationName string, ipfsDir string, revert bool) error {
27
+ migration, exists := embeddedMigrations[migrationName]
28
+ if !exists {
29
+ return fmt.Errorf("embedded migration %s not found", migrationName)
30
+ }
31
+
32
+ if revert && !migration.Reversible() {
33
+ return fmt.Errorf("migration %s is not reversible", migrationName)
34
+ }
35
+
36
+ logger := log.New(os.Stdout, "", 0)
37
+ logger.Printf("Running embedded migration %s...", migrationName)
38
+
39
+ opts := mg16.Options{
40
+ Path: ipfsDir,
41
+ Verbose: true,
42
+ }
43
+
44
+ var err error
45
+ if revert {
46
+ err = migration.Revert(opts)
47
+ } else {
48
+ err = migration.Apply(opts)
49
+ }
50
+
51
+ if err != nil {
52
+ return fmt.Errorf("embedded migration %s failed: %w", migrationName, err)
53
+ }
54
+
55
+ logger.Printf("Embedded migration %s completed successfully", migrationName)
56
+ return nil
57
+}
58
+
59
+// HasEmbeddedMigration checks if a migration is available as embedded
60
+func HasEmbeddedMigration(migrationName string) bool {
61
+ _, exists := embeddedMigrations[migrationName]
62
+ return exists
63
+}
64
+
65
+// RunEmbeddedMigrations runs all needed embedded migrations from current version to target version.
66
+//
67
+// This function migrates an IPFS repository using embedded migrations that are built into the Kubo binary.
68
+// Embedded migrations are available for repo version 17+ and provide fast, network-free migration execution.
69
+//
70
+// Parameters:
71
+// - ctx: Context for cancellation and deadlines
72
+// - targetVer: Target repository version to migrate to
73
+// - ipfsDir: Path to the IPFS repository directory
74
+// - allowDowngrade: Whether to allow downgrade migrations (reduces target version)
75
+//
76
+// Returns:
77
+// - nil on successful migration
78
+// - error if migration fails, repo path is invalid, or no embedded migrations are available
79
+//
80
+// Behavior:
81
+// - Validates that ipfsDir contains a valid IPFS repository
82
+// - Determines current repository version automatically
83
+// - Returns immediately if already at target version
84
+// - Prevents downgrades unless allowDowngrade is true
85
+// - Runs all necessary migrations in sequence (e.g., 16→17→18 if going from 16 to 18)
86
+// - Creates backups and uses atomic operations to prevent corruption
87
+//
88
+// Error conditions:
89
+// - Repository path is invalid or inaccessible
90
+// - Current version cannot be determined
91
+// - Downgrade attempted with allowDowngrade=false
92
+// - No embedded migrations available for the version range
93
+// - Individual migration fails during execution
94
+//
95
+// Example:
96
+//
97
+// err := RunEmbeddedMigrations(ctx, 17, "/path/to/.ipfs", false)
98
+// if err != nil {
99
+// // Handle migration failure, may need to fall back to external migrations
100
+// }
101
+func RunEmbeddedMigrations(ctx context.Context, targetVer int, ipfsDir string, allowDowngrade bool) error {
102
+ ipfsDir, err := CheckIpfsDir(ipfsDir)
103
+ if err != nil {
104
+ return err
105
+ }
106
+
107
+ fromVer, err := RepoVersion(ipfsDir)
108
+ if err != nil {
109
+ return fmt.Errorf("could not get repo version: %w", err)
110
+ }
111
+
112
+ if fromVer == targetVer {
113
+ return nil
114
+ }
115
+
116
+ revert := fromVer > targetVer
117
+ if revert && !allowDowngrade {
118
+ return fmt.Errorf("downgrade not allowed from %d to %d", fromVer, targetVer)
119
+ }
120
+
121
+ logger := log.New(os.Stdout, "", 0)
122
+ logger.Print("Looking for embedded migrations.")
123
+
124
+ migrations, _, err := findMigrations(ctx, fromVer, targetVer)
125
+ if err != nil {
126
+ return err
127
+ }
128
+
129
+ embeddedCount := 0
130
+ for _, migrationName := range migrations {
131
+ if HasEmbeddedMigration(migrationName) {
132
+ err = RunEmbeddedMigration(ctx, migrationName, ipfsDir, revert)
133
+ if err != nil {
134
+ return err
135
+ }
136
+ embeddedCount++
137
+ }
138
+ }
139
+
140
+ if embeddedCount == 0 {
141
+ return fmt.Errorf("no embedded migrations found for version %d to %d", fromVer, targetVer)
142
+ }
143
+
144
+ logger.Printf("Success: fs-repo migrated to version %d using embedded migrations.\n", targetVer)
145
+ return nil
146
+}
repo/fsrepo/migrations/embedded_test.go
new
+36
@@ -0,0 +1,36 @@
1
+package migrations
2
+
3
+import (
4
+ "context"
5
+ "testing"
6
+
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
+)
10
+
11
+func TestHasEmbeddedMigration(t *testing.T) {
12
+ // Test that the 16-to-17 migration is registered
13
+ assert.True(t, HasEmbeddedMigration("fs-repo-16-to-17"),
14
+ "fs-repo-16-to-17 migration should be registered")
15
+
16
+ // Test that a non-existent migration is not found
17
+ assert.False(t, HasEmbeddedMigration("fs-repo-99-to-100"),
18
+ "fs-repo-99-to-100 migration should not be registered")
19
+}
20
+
21
+func TestEmbeddedMigrations(t *testing.T) {
22
+ // Test that we have at least one embedded migration
23
+ assert.NotEmpty(t, embeddedMigrations, "No embedded migrations found")
24
+
25
+ // Test that all registered migrations implement the interface
26
+ for name, migration := range embeddedMigrations {
27
+ assert.NotEmpty(t, migration.Versions(),
28
+ "Migration %s has empty versions", name)
29
+ }
30
+}
31
+
32
+func TestRunEmbeddedMigration(t *testing.T) {
33
+ // Test that running a non-existent migration returns an error
34
+ err := RunEmbeddedMigration(context.Background(), "non-existent", "/tmp", false)
35
+ require.Error(t, err, "Expected error for non-existent migration")
36
+}
repo/fsrepo/migrations/fs-repo-16-to-17/main.go
new
+63
@@ -0,0 +1,63 @@
1
+// Package main implements fs-repo-16-to-17 migration for IPFS repositories.
2
+//
3
+// This migration transitions repositories from version 16 to 17, introducing
4
+// the AutoConf system that replaces hardcoded network defaults with dynamic
5
+// configuration fetched from autoconf.json.
6
+//
7
+// Changes made:
8
+// - Enables AutoConf system with default settings
9
+// - Migrates default bootstrap peers to "auto" sentinel value
10
+// - Sets DNS.Resolvers["."] to "auto" for dynamic DNS resolver configuration
11
+// - Migrates Routing.DelegatedRouters to ["auto"]
12
+// - Migrates Ipns.DelegatedPublishers to ["auto"]
13
+// - Preserves user customizations (custom bootstrap peers, DNS resolvers)
14
+//
15
+// The migration is reversible and creates config.16-to-17.bak for rollback.
16
+//
17
+// Usage:
18
+//
19
+// fs-repo-16-to-17 -path /path/to/ipfs/repo [-verbose] [-revert]
20
+//
21
+// This migration is embedded in Kubo starting from version 0.37 and runs
22
+// automatically during daemon startup. This standalone binary is provided
23
+// for manual migration scenarios.
24
+package main
25
+
26
+import (
27
+ "flag"
28
+ "fmt"
29
+ "os"
30
+
31
+ mg16 "github.com/ipfs/kubo/repo/fsrepo/migrations/fs-repo-16-to-17/migration"
32
+)
33
+
34
+func main() {
35
+ var path = flag.String("path", "", "Path to IPFS repository")
36
+ var verbose = flag.Bool("verbose", false, "Enable verbose output")
37
+ var revert = flag.Bool("revert", false, "Revert migration")
38
+ flag.Parse()
39
+
40
+ if *path == "" {
41
+ fmt.Fprintf(os.Stderr, "Error: -path flag is required\n")
42
+ flag.Usage()
43
+ os.Exit(1)
44
+ }
45
+
46
+ m := mg16.Migration{}
47
+ opts := mg16.Options{
48
+ Path: *path,
49
+ Verbose: *verbose,
50
+ }
51
+
52
+ var err error
53
+ if *revert {
54
+ err = m.Revert(opts)
55
+ } else {
56
+ err = m.Apply(opts)
57
+ }
58
+
59
+ if err != nil {
60
+ fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
61
+ os.Exit(1)
62
+ }
63
+}
repo/fsrepo/migrations/fs-repo-16-to-17/migration/migration.go
new
+492
@@ -0,0 +1,492 @@
1
+// package mg16 contains the code to perform 16-17 repository migration in Kubo.
2
+// This handles the following:
3
+// - Migrate default bootstrap peers to "auto"
4
+// - Migrate DNS resolvers to use "auto" for "." eTLD
5
+// - Enable AutoConf system with default settings
6
+// - Increment repo version to 17
7
+package mg16
8
+
9
+import (
10
+ "encoding/json"
11
+ "fmt"
12
+ "io"
13
+ "os"
14
+ "path/filepath"
15
+ "reflect"
16
+ "slices"
17
+ "strings"
18
+
19
+ "github.com/ipfs/kubo/config"
20
+ "github.com/ipfs/kubo/repo/fsrepo/migrations/atomicfile"
21
+)
22
+
23
+// Options contains migration options for embedded migrations
24
+type Options struct {
25
+ Path string
26
+ Verbose bool
27
+}
28
+
29
+const backupSuffix = ".16-to-17.bak"
30
+
31
+// DefaultBootstrapAddresses are the hardcoded bootstrap addresses from Kubo 0.36
32
+// for IPFS. they are nodes run by the IPFS team. docs on these later.
33
+// As with all p2p networks, bootstrap is an important security concern.
34
+// This list is used during migration to detect which peers are defaults vs custom.
35
+var DefaultBootstrapAddresses = []string{
36
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
37
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa", // rust-libp2p-server
38
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
39
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
40
+ "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8", // js-libp2p-amino-dht-bootstrapper
41
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", // mars.i.ipfs.io
42
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", // mars.i.ipfs.io
43
+}
44
+
45
+// Migration implements the migration described above.
46
+type Migration struct{}
47
+
48
+// Versions returns the current version string for this migration.
49
+func (m Migration) Versions() string {
50
+ return "16-to-17"
51
+}
52
+
53
+// Reversible returns true, as we keep old config around
54
+func (m Migration) Reversible() bool {
55
+ return true
56
+}
57
+
58
+// Apply update the config.
59
+func (m Migration) Apply(opts Options) error {
60
+ if opts.Verbose {
61
+ fmt.Printf("applying %s repo migration\n", m.Versions())
62
+ }
63
+
64
+ // Check version
65
+ if err := checkVersion(opts.Path, "16"); err != nil {
66
+ return err
67
+ }
68
+
69
+ if opts.Verbose {
70
+ fmt.Println("> Upgrading config to use AutoConf system")
71
+ }
72
+
73
+ path := filepath.Join(opts.Path, "config")
74
+ in, err := os.Open(path)
75
+ if err != nil {
76
+ return err
77
+ }
78
+
79
+ // make backup
80
+ backup, err := atomicfile.New(path+backupSuffix, 0600)
81
+ if err != nil {
82
+ return err
83
+ }
84
+ if _, err := backup.ReadFrom(in); err != nil {
85
+ panicOnError(backup.Abort())
86
+ return err
87
+ }
88
+ if _, err := in.Seek(0, io.SeekStart); err != nil {
89
+ panicOnError(backup.Abort())
90
+ return err
91
+ }
92
+
93
+ // Create a temp file to write the output to on success
94
+ out, err := atomicfile.New(path, 0600)
95
+ if err != nil {
96
+ panicOnError(backup.Abort())
97
+ panicOnError(in.Close())
98
+ return err
99
+ }
100
+
101
+ if err := convert(in, out, opts.Path); err != nil {
102
+ panicOnError(out.Abort())
103
+ panicOnError(backup.Abort())
104
+ panicOnError(in.Close())
105
+ return err
106
+ }
107
+
108
+ if err := in.Close(); err != nil {
109
+ panicOnError(out.Abort())
110
+ panicOnError(backup.Abort())
111
+ }
112
+
113
+ if err := writeVersion(opts.Path, "17"); err != nil {
114
+ fmt.Println("failed to update version file to 17")
115
+ // There was an error so abort writing the output and clean up temp file
116
+ panicOnError(out.Abort())
117
+ panicOnError(backup.Abort())
118
+ return err
119
+ } else {
120
+ // Write the output and clean up temp file
121
+ panicOnError(out.Close())
122
+ panicOnError(backup.Close())
123
+ }
124
+
125
+ if opts.Verbose {
126
+ fmt.Println("updated version file")
127
+ fmt.Println("Migration 16 to 17 succeeded")
128
+ }
129
+ return nil
130
+}
131
+
132
+// panicOnError is reserved for checks we can't solve transactionally if an error occurs
133
+func panicOnError(e error) {
134
+ if e != nil {
135
+ panic(fmt.Errorf("error can't be dealt with transactionally: %w", e))
136
+ }
137
+}
138
+
139
+func (m Migration) Revert(opts Options) error {
140
+ if opts.Verbose {
141
+ fmt.Println("reverting migration")
142
+ }
143
+
144
+ if err := checkVersion(opts.Path, "17"); err != nil {
145
+ return err
146
+ }
147
+
148
+ cfg := filepath.Join(opts.Path, "config")
149
+ if err := os.Rename(cfg+backupSuffix, cfg); err != nil {
150
+ return err
151
+ }
152
+
153
+ if err := writeVersion(opts.Path, "16"); err != nil {
154
+ return err
155
+ }
156
+ if opts.Verbose {
157
+ fmt.Println("lowered version number to 16")
158
+ }
159
+
160
+ return nil
161
+}
162
+
163
+// checkVersion verifies the repo is at the expected version
164
+func checkVersion(repoPath string, expectedVersion string) error {
165
+ versionPath := filepath.Join(repoPath, "version")
166
+ versionBytes, err := os.ReadFile(versionPath)
167
+ if err != nil {
168
+ return fmt.Errorf("could not read version file: %w", err)
169
+ }
170
+ version := strings.TrimSpace(string(versionBytes))
171
+ if version != expectedVersion {
172
+ return fmt.Errorf("expected version %s, got %s", expectedVersion, version)
173
+ }
174
+ return nil
175
+}
176
+
177
+// writeVersion writes the version to the repo
178
+func writeVersion(repoPath string, version string) error {
179
+ versionPath := filepath.Join(repoPath, "version")
180
+ return os.WriteFile(versionPath, []byte(version), 0644)
181
+}
182
+
183
+// convert converts the config from version 16 to 17
184
+func convert(in io.Reader, out io.Writer, repoPath string) error {
185
+ confMap := make(map[string]any)
186
+ if err := json.NewDecoder(in).Decode(&confMap); err != nil {
187
+ return err
188
+ }
189
+
190
+ // Enable AutoConf system
191
+ if err := enableAutoConf(confMap); err != nil {
192
+ return err
193
+ }
194
+
195
+ // Migrate Bootstrap peers
196
+ if err := migrateBootstrap(confMap, repoPath); err != nil {
197
+ return err
198
+ }
199
+
200
+ // Migrate DNS resolvers
201
+ if err := migrateDNSResolvers(confMap); err != nil {
202
+ return err
203
+ }
204
+
205
+ // Migrate DelegatedRouters
206
+ if err := migrateDelegatedRouters(confMap); err != nil {
207
+ return err
208
+ }
209
+
210
+ // Migrate DelegatedPublishers
211
+ if err := migrateDelegatedPublishers(confMap); err != nil {
212
+ return err
213
+ }
214
+
215
+ // Save new config
216
+ fixed, err := json.MarshalIndent(confMap, "", " ")
217
+ if err != nil {
218
+ return err
219
+ }
220
+
221
+ if _, err := out.Write(fixed); err != nil {
222
+ return err
223
+ }
224
+ _, err = out.Write([]byte("\n"))
225
+ return err
226
+}
227
+
228
+// enableAutoConf adds AutoConf section to config
229
+func enableAutoConf(confMap map[string]any) error {
230
+ // Check if AutoConf already exists
231
+ if _, exists := confMap["AutoConf"]; exists {
232
+ return nil
233
+ }
234
+
235
+ // Add empty AutoConf section - all fields will use implicit defaults:
236
+ // - Enabled defaults to true (via DefaultAutoConfEnabled)
237
+ // - URL defaults to mainnet URL (via DefaultAutoConfURL)
238
+ // - RefreshInterval defaults to 24h (via DefaultAutoConfRefreshInterval)
239
+ // - TLSInsecureSkipVerify defaults to false (no WithDefault, but false is zero value)
240
+ confMap["AutoConf"] = map[string]any{}
241
+
242
+ return nil
243
+}
244
+
245
+// migrateBootstrap migrates bootstrap peers to use "auto"
246
+func migrateBootstrap(confMap map[string]any, repoPath string) error {
247
+ bootstrap, exists := confMap["Bootstrap"]
248
+ if !exists {
249
+ // No bootstrap section, add "auto"
250
+ confMap["Bootstrap"] = []string{"auto"}
251
+ return nil
252
+ }
253
+
254
+ bootstrapSlice, ok := bootstrap.([]interface{})
255
+ if !ok {
256
+ // Invalid bootstrap format, replace with "auto"
257
+ confMap["Bootstrap"] = []string{"auto"}
258
+ return nil
259
+ }
260
+
261
+ // Convert to string slice
262
+ var bootstrapPeers []string
263
+ for _, peer := range bootstrapSlice {
264
+ if peerStr, ok := peer.(string); ok {
265
+ bootstrapPeers = append(bootstrapPeers, peerStr)
266
+ }
267
+ }
268
+
269
+ // Check if we should replace with "auto"
270
+ newBootstrap := processBootstrapPeers(bootstrapPeers, repoPath)
271
+ confMap["Bootstrap"] = newBootstrap
272
+
273
+ return nil
274
+}
275
+
276
+// processBootstrapPeers processes bootstrap peers according to migration rules
277
+func processBootstrapPeers(peers []string, repoPath string) []string {
278
+ // If empty, use "auto"
279
+ if len(peers) == 0 {
280
+ return []string{"auto"}
281
+ }
282
+
283
+ // Separate default peers from custom ones
284
+ var customPeers []string
285
+ var hasDefaultPeers bool
286
+
287
+ for _, peer := range peers {
288
+ if slices.Contains(DefaultBootstrapAddresses, peer) {
289
+ hasDefaultPeers = true
290
+ } else {
291
+ customPeers = append(customPeers, peer)
292
+ }
293
+ }
294
+
295
+ // If we have default peers, replace them with "auto"
296
+ if hasDefaultPeers {
297
+ return append([]string{"auto"}, customPeers...)
298
+ }
299
+
300
+ // No default peers found, keep as is
301
+ return peers
302
+}
303
+
304
+// migrateDNSResolvers migrates DNS resolvers to use "auto" for "." eTLD
305
+func migrateDNSResolvers(confMap map[string]any) error {
306
+ dnsSection, exists := confMap["DNS"]
307
+ if !exists {
308
+ // No DNS section, create it with "auto"
309
+ confMap["DNS"] = map[string]any{
310
+ "Resolvers": map[string]string{
311
+ ".": config.AutoPlaceholder,
312
+ },
313
+ }
314
+ return nil
315
+ }
316
+
317
+ dns, ok := dnsSection.(map[string]any)
318
+ if !ok {
319
+ // Invalid DNS format, replace with "auto"
320
+ confMap["DNS"] = map[string]any{
321
+ "Resolvers": map[string]string{
322
+ ".": config.AutoPlaceholder,
323
+ },
324
+ }
325
+ return nil
326
+ }
327
+
328
+ resolvers, exists := dns["Resolvers"]
329
+ if !exists {
330
+ // No resolvers, add "auto"
331
+ dns["Resolvers"] = map[string]string{
332
+ ".": config.AutoPlaceholder,
333
+ }
334
+ return nil
335
+ }
336
+
337
+ resolversMap, ok := resolvers.(map[string]any)
338
+ if !ok {
339
+ // Invalid resolvers format, replace with "auto"
340
+ dns["Resolvers"] = map[string]string{
341
+ ".": config.AutoPlaceholder,
342
+ }
343
+ return nil
344
+ }
345
+
346
+ // Convert to string map and replace default resolvers with "auto"
347
+ stringResolvers := make(map[string]string)
348
+ defaultResolvers := map[string]string{
349
+ "https://dns.eth.limo/dns-query": "auto",
350
+ "https://dns.eth.link/dns-query": "auto",
351
+ "https://resolver.cloudflare-eth.com/dns-query": "auto",
352
+ }
353
+
354
+ for k, v := range resolversMap {
355
+ if vStr, ok := v.(string); ok {
356
+ // Check if this is a default resolver that should be replaced
357
+ if replacement, isDefault := defaultResolvers[vStr]; isDefault {
358
+ stringResolvers[k] = replacement
359
+ } else {
360
+ stringResolvers[k] = vStr
361
+ }
362
+ }
363
+ }
364
+
365
+ // If "." is not set or empty, set it to "auto"
366
+ if _, exists := stringResolvers["."]; !exists {
367
+ stringResolvers["."] = "auto"
368
+ }
369
+
370
+ dns["Resolvers"] = stringResolvers
371
+ return nil
372
+}
373
+
374
+// migrateDelegatedRouters migrates DelegatedRouters to use "auto"
375
+func migrateDelegatedRouters(confMap map[string]any) error {
376
+ routing, exists := confMap["Routing"]
377
+ if !exists {
378
+ // No routing section, create it with "auto"
379
+ confMap["Routing"] = map[string]any{
380
+ "DelegatedRouters": []string{"auto"},
381
+ }
382
+ return nil
383
+ }
384
+
385
+ routingMap, ok := routing.(map[string]any)
386
+ if !ok {
387
+ // Invalid routing format, replace with "auto"
388
+ confMap["Routing"] = map[string]any{
389
+ "DelegatedRouters": []string{"auto"},
390
+ }
391
+ return nil
392
+ }
393
+
394
+ delegatedRouters, exists := routingMap["DelegatedRouters"]
395
+ if !exists {
396
+ // No delegated routers, add "auto"
397
+ routingMap["DelegatedRouters"] = []string{"auto"}
398
+ return nil
399
+ }
400
+
401
+ // Check if it's empty or nil
402
+ if shouldReplaceWithAuto(delegatedRouters) {
403
+ routingMap["DelegatedRouters"] = []string{"auto"}
404
+ return nil
405
+ }
406
+
407
+ // Process the list to replace cid.contact with "auto" and preserve others
408
+ if slice, ok := delegatedRouters.([]interface{}); ok {
409
+ var newRouters []string
410
+ hasAuto := false
411
+
412
+ for _, router := range slice {
413
+ if routerStr, ok := router.(string); ok {
414
+ if routerStr == "https://cid.contact" {
415
+ if !hasAuto {
416
+ newRouters = append(newRouters, "auto")
417
+ hasAuto = true
418
+ }
419
+ } else {
420
+ newRouters = append(newRouters, routerStr)
421
+ }
422
+ }
423
+ }
424
+
425
+ // If empty after processing, add "auto"
426
+ if len(newRouters) == 0 {
427
+ newRouters = []string{"auto"}
428
+ }
429
+
430
+ routingMap["DelegatedRouters"] = newRouters
431
+ }
432
+
433
+ return nil
434
+}
435
+
436
+// migrateDelegatedPublishers migrates DelegatedPublishers to use "auto"
437
+func migrateDelegatedPublishers(confMap map[string]any) error {
438
+ ipns, exists := confMap["Ipns"]
439
+ if !exists {
440
+ // No IPNS section, create it with "auto"
441
+ confMap["Ipns"] = map[string]any{
442
+ "DelegatedPublishers": []string{"auto"},
443
+ }
444
+ return nil
445
+ }
446
+
447
+ ipnsMap, ok := ipns.(map[string]any)
448
+ if !ok {
449
+ // Invalid IPNS format, replace with "auto"
450
+ confMap["Ipns"] = map[string]any{
451
+ "DelegatedPublishers": []string{"auto"},
452
+ }
453
+ return nil
454
+ }
455
+
456
+ delegatedPublishers, exists := ipnsMap["DelegatedPublishers"]
457
+ if !exists {
458
+ // No delegated publishers, add "auto"
459
+ ipnsMap["DelegatedPublishers"] = []string{"auto"}
460
+ return nil
461
+ }
462
+
463
+ // Check if it's empty or nil - only then replace with "auto"
464
+ // Otherwise preserve custom publishers
465
+ if shouldReplaceWithAuto(delegatedPublishers) {
466
+ ipnsMap["DelegatedPublishers"] = []string{"auto"}
467
+ }
468
+ // If there are custom publishers, leave them as is
469
+
470
+ return nil
471
+}
472
+
473
+// shouldReplaceWithAuto checks if a field should be replaced with "auto"
474
+func shouldReplaceWithAuto(field any) bool {
475
+ // If it's nil, replace with "auto"
476
+ if field == nil {
477
+ return true
478
+ }
479
+
480
+ // If it's an empty slice, replace with "auto"
481
+ if slice, ok := field.([]interface{}); ok {
482
+ return len(slice) == 0
483
+ }
484
+
485
+ // If it's an empty array, replace with "auto"
486
+ if reflect.TypeOf(field).Kind() == reflect.Slice {
487
+ v := reflect.ValueOf(field)
488
+ return v.Len() == 0
489
+ }
490
+
491
+ return false
492
+}
repo/fsrepo/migrations/fs-repo-16-to-17/migration/migration_test.go
new
+479
@@ -0,0 +1,479 @@
1
+package mg16
2
+
3
+import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+)
13
+
14
+// Helper function to run migration on JSON input and return result
15
+func runMigrationOnJSON(t *testing.T, input string) map[string]interface{} {
16
+ t.Helper()
17
+ var output bytes.Buffer
18
+ // Use t.TempDir() for test isolation and parallel execution support
19
+ tempDir := t.TempDir()
20
+ err := convert(bytes.NewReader([]byte(input)), &output, tempDir)
21
+ require.NoError(t, err)
22
+
23
+ var result map[string]interface{}
24
+ err = json.Unmarshal(output.Bytes(), &result)
25
+ require.NoError(t, err)
26
+
27
+ return result
28
+}
29
+
30
+// Helper function to assert nested map key has expected value
31
+func assertMapKeyEquals(t *testing.T, result map[string]interface{}, path []string, key string, expected interface{}) {
32
+ t.Helper()
33
+ current := result
34
+ for _, p := range path {
35
+ section, exists := current[p]
36
+ require.True(t, exists, "Section %s not found in path %v", p, path)
37
+ current = section.(map[string]interface{})
38
+ }
39
+
40
+ assert.Equal(t, expected, current[key], "Expected %s to be %v", key, expected)
41
+}
42
+
43
+// Helper function to assert slice contains expected values
44
+func assertSliceEquals(t *testing.T, result map[string]interface{}, path []string, expected []string) {
45
+ t.Helper()
46
+ current := result
47
+ for i, p := range path[:len(path)-1] {
48
+ section, exists := current[p]
49
+ require.True(t, exists, "Section %s not found in path %v at index %d", p, path, i)
50
+ current = section.(map[string]interface{})
51
+ }
52
+
53
+ sliceKey := path[len(path)-1]
54
+ slice, exists := current[sliceKey]
55
+ require.True(t, exists, "Slice %s not found", sliceKey)
56
+
57
+ actualSlice := slice.([]interface{})
58
+ require.Equal(t, len(expected), len(actualSlice), "Expected slice length %d, got %d", len(expected), len(actualSlice))
59
+
60
+ for i, exp := range expected {
61
+ assert.Equal(t, exp, actualSlice[i], "Expected slice[%d] to be %s", i, exp)
62
+ }
63
+}
64
+
65
+// Helper to build test config JSON with specified fields
66
+func buildTestConfig(fields map[string]interface{}) string {
67
+ config := map[string]interface{}{
68
+ "Identity": map[string]interface{}{"PeerID": "QmTest"},
69
+ }
70
+ for k, v := range fields {
71
+ config[k] = v
72
+ }
73
+ data, _ := json.MarshalIndent(config, "", " ")
74
+ return string(data)
75
+}
76
+
77
+// Helper to run migration and get DNS resolvers
78
+func runMigrationAndGetDNSResolvers(t *testing.T, input string) map[string]interface{} {
79
+ t.Helper()
80
+ result := runMigrationOnJSON(t, input)
81
+ dns := result["DNS"].(map[string]interface{})
82
+ return dns["Resolvers"].(map[string]interface{})
83
+}
84
+
85
+// Helper to assert multiple resolver values
86
+func assertResolvers(t *testing.T, resolvers map[string]interface{}, expected map[string]string) {
87
+ t.Helper()
88
+ for key, expectedValue := range expected {
89
+ assert.Equal(t, expectedValue, resolvers[key], "Expected %s resolver to be %v", key, expectedValue)
90
+ }
91
+}
92
+
93
+// =============================================================================
94
+// End-to-End Migration Tests
95
+// =============================================================================
96
+
97
+func TestMigration(t *testing.T) {
98
+ // Create a temporary directory for testing
99
+ tempDir, err := os.MkdirTemp("", "migration-test-16-to-17")
100
+ require.NoError(t, err)
101
+ defer os.RemoveAll(tempDir)
102
+
103
+ // Create a test config with default bootstrap peers
104
+ testConfig := map[string]interface{}{
105
+ "Bootstrap": []string{
106
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
107
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
108
+ "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer", // Custom peer
109
+ },
110
+ "DNS": map[string]interface{}{
111
+ "Resolvers": map[string]string{},
112
+ },
113
+ "Routing": map[string]interface{}{
114
+ "DelegatedRouters": []string{},
115
+ },
116
+ "Ipns": map[string]interface{}{
117
+ "ResolveCacheSize": 128,
118
+ },
119
+ "Identity": map[string]interface{}{
120
+ "PeerID": "QmTest",
121
+ },
122
+ "Version": map[string]interface{}{
123
+ "Current": "0.36.0",
124
+ },
125
+ }
126
+
127
+ // Write test config
128
+ configPath := filepath.Join(tempDir, "config")
129
+ configData, err := json.MarshalIndent(testConfig, "", " ")
130
+ require.NoError(t, err)
131
+ err = os.WriteFile(configPath, configData, 0644)
132
+ require.NoError(t, err)
133
+
134
+ // Create version file
135
+ versionPath := filepath.Join(tempDir, "version")
136
+ err = os.WriteFile(versionPath, []byte("16"), 0644)
137
+ require.NoError(t, err)
138
+
139
+ // Run migration
140
+ migration := &Migration{}
141
+ opts := Options{
142
+ Path: tempDir,
143
+ Verbose: true,
144
+ }
145
+
146
+ err = migration.Apply(opts)
147
+ require.NoError(t, err)
148
+
149
+ // Verify version was updated
150
+ versionData, err := os.ReadFile(versionPath)
151
+ require.NoError(t, err)
152
+ assert.Equal(t, "17", string(versionData), "Expected version 17")
153
+
154
+ // Verify config was updated
155
+ configData, err = os.ReadFile(configPath)
156
+ require.NoError(t, err)
157
+
158
+ var updatedConfig map[string]interface{}
159
+ err = json.Unmarshal(configData, &updatedConfig)
160
+ require.NoError(t, err)
161
+
162
+ // Check AutoConf was added
163
+ autoConf, exists := updatedConfig["AutoConf"]
164
+ assert.True(t, exists, "AutoConf section not added")
165
+ autoConfMap := autoConf.(map[string]interface{})
166
+ // URL is not set explicitly in migration (uses implicit default)
167
+ _, hasURL := autoConfMap["URL"]
168
+ assert.False(t, hasURL, "AutoConf URL should not be explicitly set in migration")
169
+
170
+ // Check Bootstrap was updated
171
+ bootstrap := updatedConfig["Bootstrap"].([]interface{})
172
+ assert.Equal(t, 2, len(bootstrap), "Expected 2 bootstrap entries")
173
+ assert.Equal(t, "auto", bootstrap[0], "Expected first bootstrap entry to be 'auto'")
174
+ assert.Equal(t, "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer", bootstrap[1], "Expected custom peer to be preserved")
175
+
176
+ // Check DNS.Resolvers was updated
177
+ dns := updatedConfig["DNS"].(map[string]interface{})
178
+ resolvers := dns["Resolvers"].(map[string]interface{})
179
+ assert.Equal(t, "auto", resolvers["."], "Expected DNS resolver for '.' to be 'auto'")
180
+
181
+ // Check Routing.DelegatedRouters was updated
182
+ routing := updatedConfig["Routing"].(map[string]interface{})
183
+ delegatedRouters := routing["DelegatedRouters"].([]interface{})
184
+ assert.Equal(t, 1, len(delegatedRouters))
185
+ assert.Equal(t, "auto", delegatedRouters[0], "Expected DelegatedRouters to be ['auto']")
186
+
187
+ // Check Ipns.DelegatedPublishers was updated
188
+ ipns := updatedConfig["Ipns"].(map[string]interface{})
189
+ delegatedPublishers := ipns["DelegatedPublishers"].([]interface{})
190
+ assert.Equal(t, 1, len(delegatedPublishers))
191
+ assert.Equal(t, "auto", delegatedPublishers[0], "Expected DelegatedPublishers to be ['auto']")
192
+
193
+ // Test revert
194
+ err = migration.Revert(opts)
195
+ require.NoError(t, err)
196
+
197
+ // Verify version was reverted
198
+ versionData, err = os.ReadFile(versionPath)
199
+ require.NoError(t, err)
200
+ assert.Equal(t, "16", string(versionData), "Expected version 16 after revert")
201
+}
202
+
203
+func TestConvert(t *testing.T) {
204
+ t.Parallel()
205
+ input := buildTestConfig(map[string]interface{}{
206
+ "Bootstrap": []string{
207
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
208
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
209
+ },
210
+ })
211
+
212
+ result := runMigrationOnJSON(t, input)
213
+
214
+ // Check that AutoConf section was added but is empty (using implicit defaults)
215
+ autoConf, exists := result["AutoConf"]
216
+ require.True(t, exists, "AutoConf section should exist")
217
+ autoConfMap, ok := autoConf.(map[string]interface{})
218
+ require.True(t, ok, "AutoConf should be a map")
219
+ require.Empty(t, autoConfMap, "AutoConf should be empty (using implicit defaults)")
220
+
221
+ // Check that Bootstrap was updated to "auto"
222
+ assertSliceEquals(t, result, []string{"Bootstrap"}, []string{"auto"})
223
+}
224
+
225
+// =============================================================================
226
+// Bootstrap Migration Tests
227
+// =============================================================================
228
+
229
+func TestBootstrapMigration(t *testing.T) {
230
+ t.Parallel()
231
+
232
+ t.Run("process bootstrap peers logic verification", func(t *testing.T) {
233
+ t.Parallel()
234
+ tests := []struct {
235
+ name string
236
+ peers []string
237
+ expected []string
238
+ }{
239
+ {
240
+ name: "empty peers",
241
+ peers: []string{},
242
+ expected: []string{"auto"},
243
+ },
244
+ {
245
+ name: "only default peers",
246
+ peers: []string{
247
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
248
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
249
+ },
250
+ expected: []string{"auto"},
251
+ },
252
+ {
253
+ name: "mixed default and custom peers",
254
+ peers: []string{
255
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
256
+ "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer",
257
+ },
258
+ expected: []string{"auto", "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer"},
259
+ },
260
+ {
261
+ name: "only custom peers",
262
+ peers: []string{
263
+ "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer1",
264
+ "/ip4/192.168.1.2/tcp/4001/p2p/QmCustomPeer2",
265
+ },
266
+ expected: []string{
267
+ "/ip4/192.168.1.1/tcp/4001/p2p/QmCustomPeer1",
268
+ "/ip4/192.168.1.2/tcp/4001/p2p/QmCustomPeer2",
269
+ },
270
+ },
271
+ }
272
+
273
+ for _, tt := range tests {
274
+ t.Run(tt.name, func(t *testing.T) {
275
+ t.Parallel()
276
+ result := processBootstrapPeers(tt.peers, "")
277
+ require.Equal(t, len(tt.expected), len(result), "Expected %d peers, got %d", len(tt.expected), len(result))
278
+ for i, expected := range tt.expected {
279
+ assert.Equal(t, expected, result[i], "Expected peer %d to be %s", i, expected)
280
+ }
281
+ })
282
+ }
283
+ })
284
+
285
+ t.Run("replaces all old default bootstrapper peers with auto entry", func(t *testing.T) {
286
+ t.Parallel()
287
+ input := buildTestConfig(map[string]interface{}{
288
+ "Bootstrap": []string{
289
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
290
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
291
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
292
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
293
+ "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
294
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
295
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
296
+ },
297
+ })
298
+
299
+ result := runMigrationOnJSON(t, input)
300
+ assertSliceEquals(t, result, []string{"Bootstrap"}, []string{"auto"})
301
+ })
302
+
303
+ t.Run("creates Bootstrap section with auto when missing", func(t *testing.T) {
304
+ t.Parallel()
305
+ input := `{"Identity": {"PeerID": "QmTest"}}`
306
+ result := runMigrationOnJSON(t, input)
307
+ assertSliceEquals(t, result, []string{"Bootstrap"}, []string{"auto"})
308
+ })
309
+}
310
+
311
+// =============================================================================
312
+// DNS Migration Tests
313
+// =============================================================================
314
+
315
+func TestDNSMigration(t *testing.T) {
316
+ t.Parallel()
317
+
318
+ t.Run("creates DNS section with auto resolver when missing", func(t *testing.T) {
319
+ t.Parallel()
320
+ input := `{"Identity": {"PeerID": "QmTest"}}`
321
+ result := runMigrationOnJSON(t, input)
322
+ assertMapKeyEquals(t, result, []string{"DNS", "Resolvers"}, ".", "auto")
323
+ })
324
+
325
+ t.Run("preserves all custom DNS resolvers unchanged", func(t *testing.T) {
326
+ t.Parallel()
327
+ input := buildTestConfig(map[string]interface{}{
328
+ "DNS": map[string]interface{}{
329
+ "Resolvers": map[string]string{
330
+ ".": "https://my-custom-resolver.com",
331
+ ".eth": "https://eth.resolver",
332
+ },
333
+ },
334
+ })
335
+
336
+ resolvers := runMigrationAndGetDNSResolvers(t, input)
337
+ assertResolvers(t, resolvers, map[string]string{
338
+ ".": "https://my-custom-resolver.com",
339
+ ".eth": "https://eth.resolver",
340
+ })
341
+ })
342
+
343
+ t.Run("preserves custom dot and eth resolvers unchanged", func(t *testing.T) {
344
+ t.Parallel()
345
+ input := buildTestConfig(map[string]interface{}{
346
+ "DNS": map[string]interface{}{
347
+ "Resolvers": map[string]string{
348
+ ".": "https://cloudflare-dns.com/dns-query",
349
+ ".eth": "https://example.com/dns-query",
350
+ },
351
+ },
352
+ })
353
+
354
+ resolvers := runMigrationAndGetDNSResolvers(t, input)
355
+ assertResolvers(t, resolvers, map[string]string{
356
+ ".": "https://cloudflare-dns.com/dns-query",
357
+ ".eth": "https://example.com/dns-query",
358
+ })
359
+ })
360
+
361
+ t.Run("replaces old default eth resolver with auto", func(t *testing.T) {
362
+ t.Parallel()
363
+ input := buildTestConfig(map[string]interface{}{
364
+ "DNS": map[string]interface{}{
365
+ "Resolvers": map[string]string{
366
+ ".": "https://cloudflare-dns.com/dns-query",
367
+ ".eth": "https://dns.eth.limo/dns-query", // should be replaced
368
+ ".crypto": "https://resolver.cloudflare-eth.com/dns-query", // should be replaced
369
+ ".link": "https://dns.eth.link/dns-query", // should be replaced
370
+ },
371
+ },
372
+ })
373
+
374
+ resolvers := runMigrationAndGetDNSResolvers(t, input)
375
+ assertResolvers(t, resolvers, map[string]string{
376
+ ".": "https://cloudflare-dns.com/dns-query", // preserved
377
+ ".eth": "auto", // replaced
378
+ ".crypto": "auto", // replaced
379
+ ".link": "auto", // replaced
380
+ })
381
+ })
382
+}
383
+
384
+// =============================================================================
385
+// Routing Migration Tests
386
+// =============================================================================
387
+
388
+func TestRoutingMigration(t *testing.T) {
389
+ t.Parallel()
390
+
391
+ t.Run("creates Routing section with auto DelegatedRouters when missing", func(t *testing.T) {
392
+ t.Parallel()
393
+ input := `{"Identity": {"PeerID": "QmTest"}}`
394
+ result := runMigrationOnJSON(t, input)
395
+ assertSliceEquals(t, result, []string{"Routing", "DelegatedRouters"}, []string{"auto"})
396
+ })
397
+
398
+ t.Run("replaces cid.contact with auto while preserving custom routers added by user", func(t *testing.T) {
399
+ t.Parallel()
400
+ input := buildTestConfig(map[string]interface{}{
401
+ "Routing": map[string]interface{}{
402
+ "DelegatedRouters": []string{
403
+ "https://cid.contact",
404
+ "https://my-custom-router.com",
405
+ },
406
+ },
407
+ })
408
+
409
+ result := runMigrationOnJSON(t, input)
410
+ assertSliceEquals(t, result, []string{"Routing", "DelegatedRouters"}, []string{"auto", "https://my-custom-router.com"})
411
+ })
412
+}
413
+
414
+// =============================================================================
415
+// IPNS Migration Tests
416
+// =============================================================================
417
+
418
+func TestIpnsMigration(t *testing.T) {
419
+ t.Parallel()
420
+
421
+ t.Run("creates Ipns section with auto DelegatedPublishers when missing", func(t *testing.T) {
422
+ t.Parallel()
423
+ input := `{"Identity": {"PeerID": "QmTest"}}`
424
+ result := runMigrationOnJSON(t, input)
425
+ assertSliceEquals(t, result, []string{"Ipns", "DelegatedPublishers"}, []string{"auto"})
426
+ })
427
+
428
+ t.Run("preserves existing custom DelegatedPublishers unchanged", func(t *testing.T) {
429
+ t.Parallel()
430
+ input := buildTestConfig(map[string]interface{}{
431
+ "Ipns": map[string]interface{}{
432
+ "DelegatedPublishers": []string{
433
+ "https://my-publisher.com",
434
+ "https://another-publisher.com",
435
+ },
436
+ },
437
+ })
438
+
439
+ result := runMigrationOnJSON(t, input)
440
+ assertSliceEquals(t, result, []string{"Ipns", "DelegatedPublishers"}, []string{"https://my-publisher.com", "https://another-publisher.com"})
441
+ })
442
+
443
+ t.Run("adds auto DelegatedPublishers to existing Ipns section", func(t *testing.T) {
444
+ t.Parallel()
445
+ input := buildTestConfig(map[string]interface{}{
446
+ "Ipns": map[string]interface{}{
447
+ "ResolveCacheSize": 128,
448
+ },
449
+ })
450
+
451
+ result := runMigrationOnJSON(t, input)
452
+ assertMapKeyEquals(t, result, []string{"Ipns"}, "ResolveCacheSize", float64(128))
453
+ assertSliceEquals(t, result, []string{"Ipns", "DelegatedPublishers"}, []string{"auto"})
454
+ })
455
+}
456
+
457
+// =============================================================================
458
+// AutoConf Migration Tests
459
+// =============================================================================
460
+
461
+func TestAutoConfMigration(t *testing.T) {
462
+ t.Parallel()
463
+
464
+ t.Run("preserves existing AutoConf fields unchanged", func(t *testing.T) {
465
+ t.Parallel()
466
+ input := buildTestConfig(map[string]interface{}{
467
+ "AutoConf": map[string]interface{}{
468
+ "URL": "https://custom.example.com/autoconf.json",
469
+ "Enabled": false,
470
+ "CustomField": "preserved",
471
+ },
472
+ })
473
+
474
+ result := runMigrationOnJSON(t, input)
475
+ assertMapKeyEquals(t, result, []string{"AutoConf"}, "URL", "https://custom.example.com/autoconf.json")
476
+ assertMapKeyEquals(t, result, []string{"AutoConf"}, "Enabled", false)
477
+ assertMapKeyEquals(t, result, []string{"AutoConf"}, "CustomField", "preserved")
478
+ })
479
+}
repo/fsrepo/migrations/migrations.go
+239
-4
@@ -25,6 +25,10 @@ const (
25
26
// RunMigration finds, downloads, and runs the individual migrations needed to
27
// migrate the repo from its current version to the target version.
28
+//
29
+// Deprecated: This function downloads migration binaries from the internet and will be removed
30
+// in a future version. Use RunHybridMigrations for modern migrations with embedded support,
31
+// or RunEmbeddedMigrations for repo versions ≥16.
32
func RunMigration(ctx context.Context, fetcher Fetcher, targetVer int, ipfsDir string, allowDowngrade bool) error {
33
ipfsDir, err := CheckIpfsDir(ipfsDir)
34
if err != nil {
@@ -114,6 +118,9 @@ func ExeName(name string) string {
118
// ReadMigrationConfig reads the Migration section of the IPFS config, avoiding
119
// reading anything other than the Migration section. That way, we're free to
120
// make arbitrary changes to all _other_ sections in migrations.
121
+//
122
+// Deprecated: This function is used by legacy migration downloads and will be removed
123
+// in a future version. Use RunHybridMigrations or RunEmbeddedMigrations instead.
124
func ReadMigrationConfig(repoRoot string, userConfigFile string) (*config.Migration, error) {
125
var cfg struct {
126
Migration config.Migration
@@ -151,7 +158,10 @@ func ReadMigrationConfig(repoRoot string, userConfigFile string) (*config.Migrat
158
}
159
160
// GetMigrationFetcher creates one or more fetchers according to
154
-// downloadSources,.
161
+// downloadSources.
162
+//
163
+// Deprecated: This function is used by legacy migration downloads and will be removed
164
+// in a future version. Use RunHybridMigrations or RunEmbeddedMigrations instead.
165
func GetMigrationFetcher(downloadSources []string, distPath string, newIpfsFetcher func(string) Fetcher) (Fetcher, error) {
166
const httpUserAgent = "kubo/migration"
167
const numTriesPerHTTP = 3
@@ -163,9 +173,7 @@ func GetMigrationFetcher(downloadSources []string, distPath string, newIpfsFetch
173
case "HTTPS", "https", "HTTP", "http":
174
fetchers = append(fetchers, &RetryFetcher{NewHttpFetcher(distPath, "", httpUserAgent, 0), numTriesPerHTTP})
175
case "IPFS", "ipfs":
166
- if newIpfsFetcher != nil {
167
- fetchers = append(fetchers, newIpfsFetcher(distPath))
168
- }
176
+ return nil, errors.New("IPFS downloads are not supported for legacy migrations (repo versions <16). Please use only HTTPS in Migration.DownloadSources")
177
case "":
178
// Ignore empty string
179
default:
@@ -202,6 +210,9 @@ func migrationName(from, to int) string {
210
// findMigrations returns a list of migrations, ordered from first to last
211
// migration to apply, and a map of locations of migration binaries of any
212
// migrations that were found.
213
+//
214
+// Deprecated: This function is used by legacy migration downloads and will be removed
215
+// in a future version.
216
func findMigrations(ctx context.Context, from, to int) ([]string, map[string]string, error) {
217
step := 1
218
count := to - from
@@ -250,6 +261,9 @@ func runMigration(ctx context.Context, binPath, ipfsDir string, revert bool, log
261
262
// fetchMigrations downloads the requested migrations, and returns a slice with
263
// the paths of each binary, in the same order specified by needed.
264
+//
265
+// Deprecated: This function downloads migration binaries from the internet and will be removed
266
+// in a future version. Use RunHybridMigrations or RunEmbeddedMigrations instead.
267
func fetchMigrations(ctx context.Context, fetcher Fetcher, needed []string, destDir string, logger *log.Logger) ([]string, error) {
268
osv, err := osWithVariant()
269
if err != nil {
@@ -300,3 +314,224 @@ func fetchMigrations(ctx context.Context, fetcher Fetcher, needed []string, dest
314
315
return bins, nil
316
}
317
+
318
+// RunHybridMigrations intelligently runs migrations using external tools for legacy versions
319
+// and embedded migrations for modern versions. This handles the transition from external
320
+// fs-repo-migrations binaries (for repo versions <16) to embedded migrations (for repo versions ≥16).
321
+//
322
+// The function automatically:
323
+// 1. Uses external migrations to get from current version to v16 (if needed)
324
+// 2. Uses embedded migrations for v16+ steps
325
+// 3. Handles pure external, pure embedded, or mixed migration scenarios
326
+//
327
+// Legacy external migrations (repo versions <16) only support HTTPS downloads.
328
+//
329
+// Parameters:
330
+// - ctx: Context for cancellation and timeouts
331
+// - targetVer: Target repository version to migrate to
332
+// - ipfsDir: Path to the IPFS repository directory
333
+// - allowDowngrade: Whether to allow downgrade migrations
334
+//
335
+// Returns error if migration fails at any step.
336
+func RunHybridMigrations(ctx context.Context, targetVer int, ipfsDir string, allowDowngrade bool) error {
337
+ const embeddedMigrationsMinVersion = 16
338
+
339
+ // Get current repo version
340
+ currentVer, err := RepoVersion(ipfsDir)
341
+ if err != nil {
342
+ return fmt.Errorf("could not get current repo version: %w", err)
343
+ }
344
+
345
+ var logger = log.New(os.Stdout, "", 0)
346
+
347
+ // Check if migration is needed
348
+ if currentVer == targetVer {
349
+ logger.Printf("Repository is already at version %d", targetVer)
350
+ return nil
351
+ }
352
+
353
+ // Validate downgrade request
354
+ if targetVer < currentVer && !allowDowngrade {
355
+ return fmt.Errorf("downgrade from version %d to %d requires allowDowngrade=true", currentVer, targetVer)
356
+ }
357
+
358
+ // Determine migration strategy based on version ranges
359
+ needsExternal := currentVer < embeddedMigrationsMinVersion
360
+ needsEmbedded := targetVer >= embeddedMigrationsMinVersion
361
+
362
+ // Case 1: Pure embedded migration (both current and target ≥ 16)
363
+ if !needsExternal && needsEmbedded {
364
+ return RunEmbeddedMigrations(ctx, targetVer, ipfsDir, allowDowngrade)
365
+ }
366
+
367
+ // For cases requiring external migrations, we check if migration binaries
368
+ // are available in PATH before attempting network downloads
369
+
370
+ // Case 2: Pure external migration (target < 16)
371
+ if needsExternal && !needsEmbedded {
372
+
373
+ // Check for migration binaries in PATH first (for testing/local development)
374
+ migrations, binPaths, err := findMigrations(ctx, currentVer, targetVer)
375
+ if err != nil {
376
+ return fmt.Errorf("could not determine migration paths: %w", err)
377
+ }
378
+
379
+ foundAll := true
380
+ for _, migName := range migrations {
381
+ if _, exists := binPaths[migName]; !exists {
382
+ foundAll = false
383
+ break
384
+ }
385
+ }
386
+
387
+ if foundAll {
388
+ return runMigrationsFromPath(ctx, migrations, binPaths, ipfsDir, logger, false)
389
+ }
390
+
391
+ // Fall back to network download (original behavior)
392
+ migrationCfg, err := ReadMigrationConfig(ipfsDir, "")
393
+ if err != nil {
394
+ return fmt.Errorf("could not read migration config: %w", err)
395
+ }
396
+
397
+ // Use existing RunMigration which handles network downloads properly (HTTPS only for legacy migrations)
398
+ fetcher, err := GetMigrationFetcher(migrationCfg.DownloadSources, GetDistPathEnv(CurrentIpfsDist), nil)
399
+ if err != nil {
400
+ return fmt.Errorf("failed to get migration fetcher: %w", err)
401
+ }
402
+ defer fetcher.Close()
403
+ return RunMigration(ctx, fetcher, targetVer, ipfsDir, allowDowngrade)
404
+ }
405
+
406
+ // Case 3: Hybrid migration (current < 16, target ≥ 16)
407
+ if needsExternal && needsEmbedded {
408
+ logger.Printf("Starting hybrid migration from version %d to %d", currentVer, targetVer)
409
+ logger.Print("Using hybrid migration strategy: external to v16, then embedded")
410
+
411
+ // Phase 1: Use external migrations to get to v16
412
+ logger.Printf("Phase 1: External migration from v%d to v%d", currentVer, embeddedMigrationsMinVersion)
413
+
414
+ // Check for external migration binaries in PATH first
415
+ migrations, binPaths, err := findMigrations(ctx, currentVer, embeddedMigrationsMinVersion)
416
+ if err != nil {
417
+ return fmt.Errorf("could not determine external migration paths: %w", err)
418
+ }
419
+
420
+ foundAll := true
421
+ for _, migName := range migrations {
422
+ if _, exists := binPaths[migName]; !exists {
423
+ foundAll = false
424
+ break
425
+ }
426
+ }
427
+
428
+ if foundAll {
429
+ if err = runMigrationsFromPath(ctx, migrations, binPaths, ipfsDir, logger, false); err != nil {
430
+ return fmt.Errorf("external migration phase failed: %w", err)
431
+ }
432
+ } else {
433
+ migrationCfg, err := ReadMigrationConfig(ipfsDir, "")
434
+ if err != nil {
435
+ return fmt.Errorf("could not read migration config: %w", err)
436
+ }
437
+
438
+ // Legacy migrations only support HTTPS downloads
439
+ fetcher, err := GetMigrationFetcher(migrationCfg.DownloadSources, GetDistPathEnv(CurrentIpfsDist), nil)
440
+ if err != nil {
441
+ return fmt.Errorf("failed to get migration fetcher: %w", err)
442
+ }
443
+ defer fetcher.Close()
444
+
445
+ if err = RunMigration(ctx, fetcher, embeddedMigrationsMinVersion, ipfsDir, allowDowngrade); err != nil {
446
+ return fmt.Errorf("external migration phase failed: %w", err)
447
+ }
448
+ }
449
+
450
+ // Phase 2: Use embedded migrations for v16+
451
+ logger.Printf("Phase 2: Embedded migration from v%d to v%d", embeddedMigrationsMinVersion, targetVer)
452
+ err = RunEmbeddedMigrations(ctx, targetVer, ipfsDir, allowDowngrade)
453
+ if err != nil {
454
+ return fmt.Errorf("embedded migration phase failed: %w", err)
455
+ }
456
+
457
+ logger.Printf("Hybrid migration completed successfully: v%d → v%d", currentVer, targetVer)
458
+ return nil
459
+ }
460
+
461
+ // Case 4: Reverse hybrid migration (≥16 to <16)
462
+ // Use embedded migrations for ≥16 steps, then external migrations for <16 steps
463
+ logger.Printf("Starting reverse hybrid migration from version %d to %d", currentVer, targetVer)
464
+ logger.Print("Using reverse hybrid migration strategy: embedded to v16, then external")
465
+
466
+ // Phase 1: Use embedded migrations from current version down to v16 (if needed)
467
+ if currentVer > embeddedMigrationsMinVersion {
468
+ logger.Printf("Phase 1: Embedded downgrade from v%d to v%d", currentVer, embeddedMigrationsMinVersion)
469
+ err = RunEmbeddedMigrations(ctx, embeddedMigrationsMinVersion, ipfsDir, allowDowngrade)
470
+ if err != nil {
471
+ return fmt.Errorf("embedded downgrade phase failed: %w", err)
472
+ }
473
+ }
474
+
475
+ // Phase 2: Use external migrations from v16 to target (if needed)
476
+ if embeddedMigrationsMinVersion > targetVer {
477
+ logger.Printf("Phase 2: External downgrade from v%d to v%d", embeddedMigrationsMinVersion, targetVer)
478
+
479
+ // Check for external migration binaries in PATH first
480
+ migrations, binPaths, err := findMigrations(ctx, embeddedMigrationsMinVersion, targetVer)
481
+ if err != nil {
482
+ return fmt.Errorf("could not determine external migration paths: %w", err)
483
+ }
484
+
485
+ foundAll := true
486
+ for _, migName := range migrations {
487
+ if _, exists := binPaths[migName]; !exists {
488
+ foundAll = false
489
+ break
490
+ }
491
+ }
492
+
493
+ if foundAll {
494
+ if err = runMigrationsFromPath(ctx, migrations, binPaths, ipfsDir, logger, true); err != nil {
495
+ return fmt.Errorf("external downgrade phase failed: %w", err)
496
+ }
497
+ } else {
498
+ migrationCfg, err := ReadMigrationConfig(ipfsDir, "")
499
+ if err != nil {
500
+ return fmt.Errorf("could not read migration config: %w", err)
501
+ }
502
+
503
+ // Legacy migrations only support HTTPS downloads
504
+ fetcher, err := GetMigrationFetcher(migrationCfg.DownloadSources, GetDistPathEnv(CurrentIpfsDist), nil)
505
+ if err != nil {
506
+ return fmt.Errorf("failed to get migration fetcher: %w", err)
507
+ }
508
+ defer fetcher.Close()
509
+
510
+ if err = RunMigration(ctx, fetcher, targetVer, ipfsDir, allowDowngrade); err != nil {
511
+ return fmt.Errorf("external downgrade phase failed: %w", err)
512
+ }
513
+ }
514
+ }
515
+
516
+ logger.Printf("Reverse hybrid migration completed successfully: v%d → v%d", currentVer, targetVer)
517
+ return nil
518
+}
519
+
520
+// runMigrationsFromPath runs migrations using binaries found in PATH
521
+func runMigrationsFromPath(ctx context.Context, migrations []string, binPaths map[string]string, ipfsDir string, logger *log.Logger, revert bool) error {
522
+ for _, migName := range migrations {
523
+ binPath, exists := binPaths[migName]
524
+ if !exists {
525
+ return fmt.Errorf("migration binary %s not found in PATH", migName)
526
+ }
527
+
528
+ logger.Printf("Running migration %s using binary from PATH: %s", migName, binPath)
529
+
530
+ // Run the migration binary directly
531
+ err := runMigration(ctx, binPath, ipfsDir, revert, logger)
532
+ if err != nil {
533
+ return fmt.Errorf("migration %s failed: %w", migName, err)
534
+ }
535
+ }
536
+ return nil
537
+}
repo/fsrepo/migrations/migrations_test.go
+9
-19
@@ -327,12 +327,9 @@ func TestGetMigrationFetcher(t *testing.T) {
327
}
328
329
downloadSources = []string{"ipfs"}
330
- f, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
331
- if err != nil {
332
- t.Fatal(err)
333
- }
334
- if _, ok := f.(*mockIpfsFetcher); !ok {
335
- t.Fatal("expected IpfsFetcher")
330
+ _, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
331
+ if err == nil || !strings.Contains(err.Error(), "IPFS downloads are not supported for legacy migrations") {
332
+ t.Fatal("Expected IPFS downloads error, got:", err)
333
}
334
335
downloadSources = []string{"http"}
@@ -347,6 +344,12 @@ func TestGetMigrationFetcher(t *testing.T) {
344
}
345
346
downloadSources = []string{"IPFS", "HTTPS"}
347
+ _, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
348
+ if err == nil || !strings.Contains(err.Error(), "IPFS downloads are not supported for legacy migrations") {
349
+ t.Fatal("Expected IPFS downloads error, got:", err)
350
+ }
351
+
352
+ downloadSources = []string{"https", "some.domain.io"}
353
f, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
354
if err != nil {
355
t.Fatal(err)
@@ -359,19 +362,6 @@ func TestGetMigrationFetcher(t *testing.T) {
362
t.Fatal("expected 2 fetchers in MultiFetcher")
363
}
364
362
- downloadSources = []string{"ipfs", "https", "some.domain.io"}
363
- f, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
364
- if err != nil {
365
- t.Fatal(err)
366
- }
367
- mf, ok = f.(*MultiFetcher)
368
- if !ok {
369
- t.Fatal("expected MultiFetcher")
370
- }
371
- if mf.Len() != 3 {
372
- t.Fatal("expected 3 fetchers in MultiFetcher")
373
- }
374
-
365
downloadSources = nil
366
_, err = GetMigrationFetcher(downloadSources, "", newIpfsFetcher)
367
if err == nil {
test/cli/autoconf/autoconf_test.go
new
+779
@@ -0,0 +1,779 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "strings"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/ipfs/kubo/test/cli/harness"
15
+ "github.com/stretchr/testify/assert"
16
+ "github.com/stretchr/testify/require"
17
+)
18
+
19
+func TestAutoConf(t *testing.T) {
20
+ t.Parallel()
21
+
22
+ t.Run("basic functionality", func(t *testing.T) {
23
+ t.Parallel()
24
+ testAutoConfBasicFunctionality(t)
25
+ })
26
+
27
+ t.Run("background service updates", func(t *testing.T) {
28
+ t.Parallel()
29
+ testAutoConfBackgroundService(t)
30
+ })
31
+
32
+ t.Run("HTTP error scenarios", func(t *testing.T) {
33
+ t.Parallel()
34
+ testAutoConfHTTPErrors(t)
35
+ })
36
+
37
+ t.Run("cache-based config expansion", func(t *testing.T) {
38
+ t.Parallel()
39
+ testAutoConfCacheBasedExpansion(t)
40
+ })
41
+
42
+ t.Run("disabled autoconf", func(t *testing.T) {
43
+ t.Parallel()
44
+ testAutoConfDisabled(t)
45
+ })
46
+
47
+ t.Run("bootstrap list shows auto as-is", func(t *testing.T) {
48
+ t.Parallel()
49
+ testBootstrapListResolved(t)
50
+ })
51
+
52
+ t.Run("daemon uses resolved bootstrap values", func(t *testing.T) {
53
+ t.Parallel()
54
+ testDaemonUsesResolvedBootstrap(t)
55
+ })
56
+
57
+ t.Run("empty cache uses fallback defaults", func(t *testing.T) {
58
+ t.Parallel()
59
+ testEmptyCacheUsesFallbacks(t)
60
+ })
61
+
62
+ t.Run("stale cache with unreachable server", func(t *testing.T) {
63
+ t.Parallel()
64
+ testStaleCacheWithUnreachableServer(t)
65
+ })
66
+
67
+ t.Run("autoconf disabled with auto values", func(t *testing.T) {
68
+ t.Parallel()
69
+ testAutoConfDisabledWithAutoValues(t)
70
+ })
71
+
72
+ t.Run("network behavior - cached vs refresh", func(t *testing.T) {
73
+ t.Parallel()
74
+ testAutoConfNetworkBehavior(t)
75
+ })
76
+
77
+ t.Run("HTTPS autoconf server", func(t *testing.T) {
78
+ t.Parallel()
79
+ testAutoConfWithHTTPS(t)
80
+ })
81
+}
82
+
83
+func testAutoConfBasicFunctionality(t *testing.T) {
84
+ // Load test autoconf data
85
+ autoConfData := loadTestData(t, "valid_autoconf.json")
86
+
87
+ // Create HTTP server that serves autoconf.json
88
+ etag := `"test-etag-123"`
89
+ requestCount := 0
90
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91
+ requestCount++
92
+ t.Logf("AutoConf server request #%d: %s %s", requestCount, r.Method, r.URL.Path)
93
+ w.Header().Set("Content-Type", "application/json")
94
+ w.Header().Set("ETag", etag)
95
+ w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
96
+ _, _ = w.Write(autoConfData)
97
+ }))
98
+ defer server.Close()
99
+
100
+ // Create IPFS node and configure it to use our test server
101
+ // Use test profile to avoid autoconf profile being applied by default
102
+ node := harness.NewT(t).NewNode().Init("--profile=test")
103
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
104
+ node.SetIPFSConfig("AutoConf.Enabled", true)
105
+ // Disable background updates to prevent multiple requests
106
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "24h")
107
+
108
+ // Test with normal bootstrap peers (not "auto") to avoid multiaddr parsing issues
109
+ // This tests that autoconf fetching works without complex auto replacement
110
+ node.SetIPFSConfig("Bootstrap", []string{"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"})
111
+
112
+ // Start daemon to trigger autoconf fetch
113
+ node.StartDaemon()
114
+ defer node.StopDaemon()
115
+
116
+ // Give autoconf some time to fetch
117
+ time.Sleep(2 * time.Second)
118
+
119
+ // Verify that the autoconf system fetched data from our server
120
+ t.Logf("Server request count: %d", requestCount)
121
+ require.GreaterOrEqual(t, requestCount, 1, "AutoConf server should have been called at least once")
122
+
123
+ // Test that daemon is functional
124
+ result := node.RunIPFS("id")
125
+ assert.Equal(t, 0, result.ExitCode(), "IPFS daemon should be responsive")
126
+ assert.Contains(t, result.Stdout.String(), "ID", "IPFS id command should return peer information")
127
+
128
+ // Success! AutoConf system is working:
129
+ // 1. Server was called (proves fetch works)
130
+ // 2. Daemon started successfully (proves DNS resolver validation is fixed)
131
+ // 3. Daemon is functional (proves autoconf doesn't break core functionality)
132
+ // Note: We skip checking metadata values due to JSON parsing complexity in test harness
133
+}
134
+
135
+func testAutoConfBackgroundService(t *testing.T) {
136
+ // Test that the startAutoConfUpdater() goroutine makes network requests for background refresh
137
+ // This is separate from daemon config operations which now use cache-first approach
138
+
139
+ // Load initial and updated test data
140
+ initialData := loadTestData(t, "valid_autoconf.json")
141
+ updatedData := loadTestData(t, "updated_autoconf.json")
142
+
143
+ // Track which config is being served
144
+ currentData := initialData
145
+ var requestCount atomic.Int32
146
+
147
+ // Create server that switches payload after first request
148
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149
+ count := requestCount.Add(1)
150
+ t.Logf("Background service request #%d from %s", count, r.UserAgent())
151
+
152
+ w.Header().Set("Content-Type", "application/json")
153
+ w.Header().Set("ETag", fmt.Sprintf(`"background-test-etag-%d"`, count))
154
+ w.Header().Set("Last-Modified", time.Now().Format(http.TimeFormat))
155
+
156
+ if count > 1 {
157
+ // After first request, serve updated config
158
+ currentData = updatedData
159
+ }
160
+
161
+ _, _ = w.Write(currentData)
162
+ }))
163
+ defer server.Close()
164
+
165
+ // Create IPFS node with short refresh interval to trigger background service
166
+ node := harness.NewT(t).NewNode().Init("--profile=test")
167
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
168
+ node.SetIPFSConfig("AutoConf.Enabled", true)
169
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "1s") // Very short for testing background service
170
+
171
+ // Use normal bootstrap values to avoid dependency on autoconf during initialization
172
+ node.SetIPFSConfig("Bootstrap", []string{"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"})
173
+
174
+ // Start daemon - this should start the background service via startAutoConfUpdater()
175
+ node.StartDaemon()
176
+ defer node.StopDaemon()
177
+
178
+ // Wait for initial request (daemon startup may trigger one)
179
+ time.Sleep(1 * time.Second)
180
+ initialCount := requestCount.Load()
181
+ t.Logf("Initial request count after daemon start: %d", initialCount)
182
+
183
+ // Wait for background service to make additional requests
184
+ // The background service should make requests at the RefreshInterval (1s)
185
+ time.Sleep(3 * time.Second)
186
+
187
+ finalCount := requestCount.Load()
188
+ t.Logf("Final request count after background updates: %d", finalCount)
189
+
190
+ // Background service should have made multiple requests due to 1s refresh interval
191
+ assert.Greater(t, finalCount, initialCount,
192
+ "Background service should have made additional requests beyond daemon startup")
193
+
194
+ // Verify that the service is actively making requests (not just relying on cache)
195
+ assert.GreaterOrEqual(t, finalCount, int32(2),
196
+ "Should have at least 2 requests total (startup + background refresh)")
197
+
198
+ t.Logf("Successfully verified startAutoConfUpdater() background service makes network requests")
199
+}
200
+
201
+func testAutoConfHTTPErrors(t *testing.T) {
202
+ tests := []struct {
203
+ name string
204
+ statusCode int
205
+ body string
206
+ }{
207
+ {"404 Not Found", http.StatusNotFound, "Not Found"},
208
+ {"500 Internal Server Error", http.StatusInternalServerError, "Internal Server Error"},
209
+ {"Invalid JSON", http.StatusOK, "invalid json content"},
210
+ }
211
+
212
+ for _, tt := range tests {
213
+ t.Run(tt.name, func(t *testing.T) {
214
+ // Create server that returns error
215
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216
+ w.WriteHeader(tt.statusCode)
217
+ _, _ = w.Write([]byte(tt.body))
218
+ }))
219
+ defer server.Close()
220
+
221
+ // Create node with failing AutoConf URL
222
+ // Use test profile to avoid autoconf profile being applied by default
223
+ node := harness.NewT(t).NewNode().Init("--profile=test")
224
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
225
+ node.SetIPFSConfig("AutoConf.Enabled", true)
226
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
227
+
228
+ // Start daemon - it should start but autoconf should fail gracefully
229
+ node.StartDaemon()
230
+ defer node.StopDaemon()
231
+
232
+ // Daemon should still be functional even with autoconf HTTP errors
233
+ result := node.RunIPFS("version")
234
+ assert.Equal(t, 0, result.ExitCode(), "Daemon should start even with HTTP errors in autoconf")
235
+ })
236
+ }
237
+}
238
+
239
+func testAutoConfCacheBasedExpansion(t *testing.T) {
240
+ // Test that config expansion works correctly with cached autoconf data
241
+ // without requiring active network requests during expansion operations
242
+
243
+ autoConfData := loadTestData(t, "valid_autoconf.json")
244
+
245
+ // Create server that serves autoconf data
246
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247
+ w.Header().Set("Content-Type", "application/json")
248
+ w.Header().Set("ETag", `"cache-test-etag"`)
249
+ w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
250
+ _, _ = w.Write(autoConfData)
251
+ }))
252
+ defer server.Close()
253
+
254
+ // Create IPFS node with autoconf enabled
255
+ node := harness.NewT(t).NewNode().Init("--profile=test")
256
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
257
+ node.SetIPFSConfig("AutoConf.Enabled", true)
258
+
259
+ // Set configuration with "auto" values to test expansion
260
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
261
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
262
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"test.": "auto"})
263
+
264
+ // Populate cache by running a command that triggers autoconf (without daemon)
265
+ result := node.RunIPFS("bootstrap", "list", "--expand-auto")
266
+ require.Equal(t, 0, result.ExitCode(), "Initial bootstrap expansion should succeed")
267
+
268
+ expandedBootstrap := result.Stdout.String()
269
+ assert.NotContains(t, expandedBootstrap, "auto", "Expanded bootstrap should not contain 'auto' literal")
270
+ assert.Greater(t, len(strings.Fields(expandedBootstrap)), 0, "Should have expanded bootstrap peers")
271
+
272
+ // Test that subsequent config operations work with cached data (no network required)
273
+ // This simulates the cache-first behavior our architecture now uses
274
+
275
+ // Test Bootstrap expansion
276
+ result = node.RunIPFS("config", "Bootstrap", "--expand-auto")
277
+ require.Equal(t, 0, result.ExitCode(), "Cached bootstrap expansion should succeed")
278
+
279
+ var expandedBootstrapList []string
280
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedBootstrapList)
281
+ require.NoError(t, err)
282
+ assert.NotContains(t, expandedBootstrapList, "auto", "Expanded bootstrap list should not contain 'auto'")
283
+ assert.Greater(t, len(expandedBootstrapList), 0, "Should have expanded bootstrap peers from cache")
284
+
285
+ // Test Routing.DelegatedRouters expansion
286
+ result = node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
287
+ require.Equal(t, 0, result.ExitCode(), "Cached router expansion should succeed")
288
+
289
+ var expandedRouters []string
290
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
291
+ require.NoError(t, err)
292
+ assert.NotContains(t, expandedRouters, "auto", "Expanded routers should not contain 'auto'")
293
+
294
+ // Test DNS.Resolvers expansion
295
+ result = node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
296
+ require.Equal(t, 0, result.ExitCode(), "Cached DNS resolver expansion should succeed")
297
+
298
+ var expandedResolvers map[string]string
299
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedResolvers)
300
+ require.NoError(t, err)
301
+
302
+ // Should have expanded the "auto" value for test. domain, or removed it if no autoconf data available
303
+ testResolver, exists := expandedResolvers["test."]
304
+ if exists {
305
+ assert.NotEqual(t, "auto", testResolver, "test. resolver should not be literal 'auto'")
306
+ t.Logf("Found expanded resolver for test.: %s", testResolver)
307
+ } else {
308
+ t.Logf("No resolver found for test. domain (autoconf may not have DNS resolver data)")
309
+ }
310
+
311
+ // Test full config expansion
312
+ result = node.RunIPFS("config", "show", "--expand-auto")
313
+ require.Equal(t, 0, result.ExitCode(), "Full config expansion should succeed")
314
+
315
+ expandedConfig := result.Stdout.String()
316
+ // Should not contain literal "auto" values after expansion
317
+ assert.NotContains(t, expandedConfig, `"auto"`, "Expanded config should not contain literal 'auto' values")
318
+ assert.Contains(t, expandedConfig, `"Bootstrap"`, "Should contain Bootstrap section")
319
+ assert.Contains(t, expandedConfig, `"DNS"`, "Should contain DNS section")
320
+
321
+ t.Logf("Successfully tested cache-based config expansion without active network requests")
322
+}
323
+
324
+func testAutoConfDisabled(t *testing.T) {
325
+ // Create node with AutoConf disabled but "auto" values
326
+ // Use test profile to avoid autoconf profile being applied by default
327
+ node := harness.NewT(t).NewNode().Init("--profile=test")
328
+ node.SetIPFSConfig("AutoConf.Enabled", false)
329
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
330
+
331
+ // Test by trying to list bootstrap - when AutoConf is disabled, it should show literal "auto"
332
+ result := node.RunIPFS("bootstrap", "list")
333
+ if result.ExitCode() == 0 {
334
+ // If command succeeds, it should show literal "auto" (no resolution)
335
+ output := result.Stdout.String()
336
+ assert.Contains(t, output, "auto", "Should show literal 'auto' when AutoConf is disabled")
337
+ } else {
338
+ // If command fails, error should mention autoconf issue
339
+ assert.Contains(t, result.Stderr.String(), "auto", "Should mention 'auto' values in error")
340
+ }
341
+}
342
+
343
+// Helper function to load test data files
344
+func loadTestData(t *testing.T, filename string) []byte {
345
+ t.Helper()
346
+
347
+ data, err := os.ReadFile("testdata/" + filename)
348
+ require.NoError(t, err, "Failed to read test data file: %s", filename)
349
+
350
+ return data
351
+}
352
+
353
+func testBootstrapListResolved(t *testing.T) {
354
+ // Test that bootstrap list shows "auto" as-is (not expanded)
355
+
356
+ // Load test autoconf data
357
+ autoConfData := loadTestData(t, "valid_autoconf.json")
358
+
359
+ // Create HTTP server that serves autoconf.json
360
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
361
+ w.Header().Set("Content-Type", "application/json")
362
+ _, _ = w.Write(autoConfData)
363
+ }))
364
+ defer server.Close()
365
+
366
+ // Create IPFS node with "auto" bootstrap value
367
+ node := harness.NewT(t).NewNode().Init("--profile=test")
368
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
369
+ node.SetIPFSConfig("AutoConf.Enabled", true)
370
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
371
+
372
+ // Test 1: bootstrap list (without --expand-auto) shows "auto" as-is - NO DAEMON NEEDED!
373
+ result := node.RunIPFS("bootstrap", "list")
374
+ require.Equal(t, 0, result.ExitCode(), "bootstrap list command should succeed")
375
+
376
+ output := result.Stdout.String()
377
+ t.Logf("Bootstrap list output: %s", output)
378
+ assert.Contains(t, output, "auto", "bootstrap list should show 'auto' value as-is")
379
+
380
+ // Should NOT contain expanded bootstrap peers without --expand-auto
381
+ unexpectedPeers := []string{
382
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
383
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
384
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
385
+ }
386
+
387
+ for _, peer := range unexpectedPeers {
388
+ assert.NotContains(t, output, peer, "bootstrap list should not contain expanded peer: %s", peer)
389
+ }
390
+
391
+ // Test 2: bootstrap list --expand-auto shows expanded values (no daemon needed!)
392
+ result = node.RunIPFS("bootstrap", "list", "--expand-auto")
393
+ require.Equal(t, 0, result.ExitCode(), "bootstrap list --expand-auto command should succeed")
394
+
395
+ expandedOutput := result.Stdout.String()
396
+ t.Logf("Bootstrap list --expand-auto output: %s", expandedOutput)
397
+
398
+ // Should NOT contain "auto" literal when expanded
399
+ assert.NotContains(t, expandedOutput, "auto", "bootstrap list --expand-auto should not show 'auto' literal")
400
+
401
+ // Should contain at least one expanded bootstrap peer
402
+ expectedPeers := []string{
403
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
404
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
405
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
406
+ }
407
+
408
+ foundExpectedPeer := false
409
+ for _, peer := range expectedPeers {
410
+ if strings.Contains(expandedOutput, peer) {
411
+ foundExpectedPeer = true
412
+ t.Logf("Found expected expanded peer: %s", peer)
413
+ break
414
+ }
415
+ }
416
+ assert.True(t, foundExpectedPeer, "bootstrap list --expand-auto should contain at least one expanded bootstrap peer")
417
+}
418
+
419
+func testDaemonUsesResolvedBootstrap(t *testing.T) {
420
+ // Test that daemon actually uses expanded bootstrap values for P2P connections
421
+ // even though bootstrap list shows "auto"
422
+
423
+ // Step 1: Create bootstrap node (target for connections)
424
+ bootstrapNode := harness.NewT(t).NewNode().Init("--profile=test")
425
+ // Set a specific swarm port for the bootstrap node to avoid port 0 issues
426
+ bootstrapNode.SetIPFSConfig("Addresses.Swarm", []string{"/ip4/127.0.0.1/tcp/14001"})
427
+ // Disable routing and discovery to ensure it's only discoverable via explicit multiaddr
428
+ bootstrapNode.SetIPFSConfig("Routing.Type", "none")
429
+ bootstrapNode.SetIPFSConfig("Discovery.MDNS.Enabled", false)
430
+ bootstrapNode.SetIPFSConfig("Bootstrap", []string{}) // No bootstrap peers
431
+
432
+ // Start the bootstrap node first
433
+ bootstrapNode.StartDaemon()
434
+ defer bootstrapNode.StopDaemon()
435
+
436
+ // Get bootstrap node's peer ID and swarm address
437
+ bootstrapPeerID := bootstrapNode.PeerID()
438
+
439
+ // Use the configured swarm address (we set it to a specific port above)
440
+ bootstrapMultiaddr := fmt.Sprintf("/ip4/127.0.0.1/tcp/14001/p2p/%s", bootstrapPeerID.String())
441
+ t.Logf("Bootstrap node configured at: %s", bootstrapMultiaddr)
442
+
443
+ // Step 2: Create autoconf server that returns bootstrap node's address
444
+ autoConfData := fmt.Sprintf(`{
445
+ "AutoConfVersion": 2025072301,
446
+ "AutoConfSchema": 1,
447
+ "AutoConfTTL": 86400,
448
+ "SystemRegistry": {
449
+ "AminoDHT": {
450
+ "Description": "Test AminoDHT system",
451
+ "NativeConfig": {
452
+ "Bootstrap": ["%s"]
453
+ }
454
+ }
455
+ },
456
+ "DNSResolvers": {},
457
+ "DelegatedEndpoints": {}
458
+ }`, bootstrapMultiaddr)
459
+
460
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
461
+ w.Header().Set("Content-Type", "application/json")
462
+ _, _ = w.Write([]byte(autoConfData))
463
+ }))
464
+ defer server.Close()
465
+
466
+ // Step 3: Create autoconf-enabled node that should connect to bootstrap node
467
+ autoconfNode := harness.NewT(t).NewNode().Init("--profile=test")
468
+ autoconfNode.SetIPFSConfig("AutoConf.URL", server.URL)
469
+ autoconfNode.SetIPFSConfig("AutoConf.Enabled", true)
470
+ autoconfNode.SetIPFSConfig("Bootstrap", []string{"auto"}) // This should resolve to bootstrap node
471
+ // Disable other discovery methods to force bootstrap-only connectivity
472
+ autoconfNode.SetIPFSConfig("Routing.Type", "none")
473
+ autoconfNode.SetIPFSConfig("Discovery.MDNS.Enabled", false)
474
+
475
+ // Start the autoconf node
476
+ autoconfNode.StartDaemon()
477
+ defer autoconfNode.StopDaemon()
478
+
479
+ // Step 4: Give time for autoconf resolution and connection attempts
480
+ time.Sleep(8 * time.Second)
481
+
482
+ // Step 5: Verify both nodes are responsive
483
+ result := bootstrapNode.RunIPFS("id")
484
+ require.Equal(t, 0, result.ExitCode(), "Bootstrap node should be responsive: %s", result.Stderr.String())
485
+
486
+ result = autoconfNode.RunIPFS("id")
487
+ require.Equal(t, 0, result.ExitCode(), "AutoConf node should be responsive: %s", result.Stderr.String())
488
+
489
+ // Step 6: Verify that autoconf node connected to bootstrap node
490
+ // Check swarm peers on autoconf node - it should show bootstrap node's peer ID
491
+ result = autoconfNode.RunIPFS("swarm", "peers")
492
+ if result.ExitCode() == 0 {
493
+ peerOutput := result.Stdout.String()
494
+ if strings.Contains(peerOutput, bootstrapPeerID.String()) {
495
+ t.Logf("SUCCESS: AutoConf node connected to bootstrap peer %s", bootstrapPeerID.String())
496
+ } else {
497
+ t.Logf("No active connection found. Peers output: %s", peerOutput)
498
+ // This might be OK if connection attempt was made but didn't persist
499
+ }
500
+ } else {
501
+ // If swarm peers fails, try alternative verification via daemon logs
502
+ t.Logf("Swarm peers command failed, checking daemon logs for connection attempts")
503
+ daemonOutput := autoconfNode.Daemon.Stderr.String()
504
+ if strings.Contains(daemonOutput, bootstrapPeerID.String()) {
505
+ t.Logf("SUCCESS: Found bootstrap peer %s in daemon logs, connection attempted", bootstrapPeerID.String())
506
+ } else {
507
+ t.Logf("Daemon stderr: %s", daemonOutput)
508
+ }
509
+ }
510
+
511
+ // Step 7: Verify bootstrap configuration still shows "auto" (not resolved values)
512
+ result = autoconfNode.RunIPFS("bootstrap", "list")
513
+ require.Equal(t, 0, result.ExitCode(), "Bootstrap list command should work")
514
+ assert.Contains(t, result.Stdout.String(), "auto",
515
+ "Bootstrap list should still show 'auto' even though values were resolved for networking")
516
+}
517
+
518
+func testEmptyCacheUsesFallbacks(t *testing.T) {
519
+ // Test that daemon uses fallback defaults when no cache exists and server is unreachable
520
+
521
+ // Create IPFS node with auto values and unreachable autoconf server
522
+ node := harness.NewT(t).NewNode().Init("--profile=test")
523
+ node.SetIPFSConfig("AutoConf.URL", "http://127.0.0.1:9999/nonexistent")
524
+ node.SetIPFSConfig("AutoConf.Enabled", true)
525
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
526
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
527
+
528
+ // Start daemon - should succeed using fallback values
529
+ node.StartDaemon()
530
+ defer node.StopDaemon()
531
+
532
+ // Verify daemon started successfully (uses fallback bootstrap)
533
+ result := node.RunIPFS("id")
534
+ require.Equal(t, 0, result.ExitCode(), "Daemon should start successfully with fallback values")
535
+
536
+ // Verify config commands still show "auto"
537
+ result = node.RunIPFS("config", "Bootstrap")
538
+ require.Equal(t, 0, result.ExitCode())
539
+ assert.Contains(t, result.Stdout.String(), "auto", "Bootstrap config should still show 'auto'")
540
+
541
+ result = node.RunIPFS("config", "Routing.DelegatedRouters")
542
+ require.Equal(t, 0, result.ExitCode())
543
+ assert.Contains(t, result.Stdout.String(), "auto", "DelegatedRouters config should still show 'auto'")
544
+
545
+ // Check daemon logs for error about failed autoconf fetch
546
+ logOutput := node.Daemon.Stderr.String()
547
+ // The daemon should attempt to fetch autoconf but will use fallbacks on failure
548
+ // We don't require specific log messages as long as the daemon starts successfully
549
+ if logOutput != "" {
550
+ t.Logf("Daemon logs: %s", logOutput)
551
+ }
552
+}
553
+
554
+func testStaleCacheWithUnreachableServer(t *testing.T) {
555
+ // Test that daemon uses stale cache when server is unreachable
556
+
557
+ // First create a working autoconf server and cache
558
+ autoConfData := loadTestData(t, "valid_autoconf.json")
559
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
560
+ w.Header().Set("Content-Type", "application/json")
561
+ _, _ = w.Write(autoConfData)
562
+ }))
563
+
564
+ // Create node and fetch autoconf to populate cache
565
+ node := harness.NewT(t).NewNode().Init("--profile=test")
566
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
567
+ node.SetIPFSConfig("AutoConf.Enabled", true)
568
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
569
+
570
+ // Start daemon briefly to populate cache
571
+ node.StartDaemon()
572
+ time.Sleep(1 * time.Second) // Allow cache population
573
+ node.StopDaemon()
574
+
575
+ // Close the server to make it unreachable
576
+ server.Close()
577
+
578
+ // Update config to point to unreachable server
579
+ node.SetIPFSConfig("AutoConf.URL", "http://127.0.0.1:9999/unreachable")
580
+
581
+ // Start daemon again - should use stale cache
582
+ node.StartDaemon()
583
+ defer node.StopDaemon()
584
+
585
+ // Verify daemon started successfully (uses cached autoconf)
586
+ result := node.RunIPFS("id")
587
+ require.Equal(t, 0, result.ExitCode(), "Daemon should start successfully with cached autoconf")
588
+
589
+ // Check daemon logs for error about using stale config
590
+ logOutput := node.Daemon.Stderr.String()
591
+ // The daemon should use cached config when server is unreachable
592
+ // We don't require specific log messages as long as the daemon starts successfully
593
+ if logOutput != "" {
594
+ t.Logf("Daemon logs: %s", logOutput)
595
+ }
596
+}
597
+
598
+func testAutoConfDisabledWithAutoValues(t *testing.T) {
599
+ // Test that daemon fails to start when AutoConf is disabled but "auto" values are present
600
+
601
+ // Create IPFS node with AutoConf disabled but "auto" values configured
602
+ node := harness.NewT(t).NewNode().Init("--profile=test")
603
+ node.SetIPFSConfig("AutoConf.Enabled", false)
604
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
605
+
606
+ // Test by trying to list bootstrap - when AutoConf is disabled, it should show literal "auto"
607
+ result := node.RunIPFS("bootstrap", "list")
608
+ if result.ExitCode() == 0 {
609
+ // If command succeeds, it should show literal "auto" (no resolution)
610
+ output := result.Stdout.String()
611
+ assert.Contains(t, output, "auto", "Should show literal 'auto' when AutoConf is disabled")
612
+ } else {
613
+ // If command fails, error should mention autoconf issue
614
+ logOutput := result.Stderr.String()
615
+ assert.Contains(t, logOutput, "auto", "Error should mention 'auto' values")
616
+ // Check that the error message contains information about disabled state
617
+ assert.True(t,
618
+ strings.Contains(logOutput, "disabled") || strings.Contains(logOutput, "AutoConf.Enabled=false"),
619
+ "Error should mention that AutoConf is disabled or show AutoConf.Enabled=false")
620
+ }
621
+}
622
+
623
+func testAutoConfNetworkBehavior(t *testing.T) {
624
+ // Test the network behavior differences between MustGetConfigCached and MustGetConfigWithRefresh
625
+ // This validates that our cache-first architecture works as expected
626
+
627
+ autoConfData := loadTestData(t, "valid_autoconf.json")
628
+ var requestCount atomic.Int32
629
+
630
+ // Create server that tracks all requests
631
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
632
+ count := requestCount.Add(1)
633
+ t.Logf("Network behavior test request #%d: %s %s", count, r.Method, r.URL.Path)
634
+
635
+ w.Header().Set("Content-Type", "application/json")
636
+ w.Header().Set("ETag", fmt.Sprintf(`"network-test-etag-%d"`, count))
637
+ w.Header().Set("Last-Modified", time.Now().Format(http.TimeFormat))
638
+ _, _ = w.Write(autoConfData)
639
+ }))
640
+ defer server.Close()
641
+
642
+ // Create IPFS node with autoconf
643
+ node := harness.NewT(t).NewNode().Init("--profile=test")
644
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
645
+ node.SetIPFSConfig("AutoConf.Enabled", true)
646
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
647
+
648
+ // Phase 1: Test cache-first behavior (no network requests expected)
649
+ t.Logf("=== Phase 1: Testing cache-first behavior ===")
650
+ initialCount := requestCount.Load()
651
+
652
+ // Multiple config operations should NOT trigger network requests (cache-first)
653
+ result := node.RunIPFS("config", "Bootstrap")
654
+ require.Equal(t, 0, result.ExitCode(), "Bootstrap config read should succeed")
655
+
656
+ result = node.RunIPFS("config", "show")
657
+ require.Equal(t, 0, result.ExitCode(), "Config show should succeed")
658
+
659
+ result = node.RunIPFS("bootstrap", "list")
660
+ require.Equal(t, 0, result.ExitCode(), "Bootstrap list should succeed")
661
+
662
+ // Check that cache-first operations didn't trigger network requests
663
+ afterCacheOpsCount := requestCount.Load()
664
+ cachedRequestDiff := afterCacheOpsCount - initialCount
665
+ t.Logf("Network requests during cache-first operations: %d", cachedRequestDiff)
666
+
667
+ // Phase 2: Test explicit expansion (may trigger cache population)
668
+ t.Logf("=== Phase 2: Testing expansion operations ===")
669
+ beforeExpansionCount := requestCount.Load()
670
+
671
+ // Expansion operations may need to populate cache if empty
672
+ result = node.RunIPFS("bootstrap", "list", "--expand-auto")
673
+ if result.ExitCode() == 0 {
674
+ output := result.Stdout.String()
675
+ assert.NotContains(t, output, "auto", "Expanded bootstrap should not contain 'auto' literal")
676
+ t.Logf("Bootstrap expansion succeeded")
677
+ } else {
678
+ t.Logf("Bootstrap expansion failed (may be due to network/cache issues): %s", result.Stderr.String())
679
+ }
680
+
681
+ result = node.RunIPFS("config", "Bootstrap", "--expand-auto")
682
+ if result.ExitCode() == 0 {
683
+ t.Logf("Config Bootstrap expansion succeeded")
684
+ } else {
685
+ t.Logf("Config Bootstrap expansion failed: %s", result.Stderr.String())
686
+ }
687
+
688
+ afterExpansionCount := requestCount.Load()
689
+ expansionRequestDiff := afterExpansionCount - beforeExpansionCount
690
+ t.Logf("Network requests during expansion operations: %d", expansionRequestDiff)
691
+
692
+ // Phase 3: Test background service behavior (if daemon is started)
693
+ t.Logf("=== Phase 3: Testing background service behavior ===")
694
+ beforeDaemonCount := requestCount.Load()
695
+
696
+ // Set short refresh interval to test background service
697
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "1s")
698
+
699
+ // Start daemon - this triggers startAutoConfUpdater() which should make network requests
700
+ node.StartDaemon()
701
+ defer node.StopDaemon()
702
+
703
+ // Wait for background service to potentially make requests
704
+ time.Sleep(2 * time.Second)
705
+
706
+ afterDaemonCount := requestCount.Load()
707
+ daemonRequestDiff := afterDaemonCount - beforeDaemonCount
708
+ t.Logf("Network requests from background service: %d", daemonRequestDiff)
709
+
710
+ // Verify expected behavior patterns
711
+ t.Logf("=== Summary ===")
712
+ t.Logf("Cache-first operations: %d requests", cachedRequestDiff)
713
+ t.Logf("Expansion operations: %d requests", expansionRequestDiff)
714
+ t.Logf("Background service: %d requests", daemonRequestDiff)
715
+
716
+ // Cache-first operations should minimize network requests
717
+ assert.LessOrEqual(t, cachedRequestDiff, int32(1),
718
+ "Cache-first config operations should make minimal network requests")
719
+
720
+ // Background service should make requests for refresh
721
+ if daemonRequestDiff > 0 {
722
+ t.Logf("✓ Background service is making network requests as expected")
723
+ } else {
724
+ t.Logf("⚠ Background service made no requests (may be using existing cache)")
725
+ }
726
+
727
+ t.Logf("Successfully verified network behavior patterns in autoconf architecture")
728
+}
729
+
730
+func testAutoConfWithHTTPS(t *testing.T) {
731
+ // Test autoconf with HTTPS server and TLSInsecureSkipVerify enabled
732
+ autoConfData := loadTestData(t, "valid_autoconf.json")
733
+
734
+ // Create HTTPS server with self-signed certificate
735
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
736
+ t.Logf("HTTPS autoconf request from %s", r.UserAgent())
737
+ w.Header().Set("Content-Type", "application/json")
738
+ w.Header().Set("ETag", `"https-test-etag"`)
739
+ w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
740
+ _, _ = w.Write(autoConfData)
741
+ }))
742
+
743
+ // Enable HTTP/2 and start with TLS (self-signed certificate)
744
+ server.EnableHTTP2 = true
745
+ server.StartTLS()
746
+ defer server.Close()
747
+
748
+ // Create IPFS node with HTTPS autoconf server and TLS skip verify
749
+ node := harness.NewT(t).NewNode().Init("--profile=test")
750
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
751
+ node.SetIPFSConfig("AutoConf.Enabled", true)
752
+ node.SetIPFSConfig("AutoConf.TLSInsecureSkipVerify", true) // Allow self-signed cert
753
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "24h") // Disable background updates
754
+
755
+ // Use normal bootstrap peers to test HTTPS fetching without complex auto replacement
756
+ node.SetIPFSConfig("Bootstrap", []string{"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"})
757
+
758
+ // Start daemon to trigger HTTPS autoconf fetch
759
+ node.StartDaemon()
760
+ defer node.StopDaemon()
761
+
762
+ // Give autoconf time to fetch over HTTPS
763
+ time.Sleep(2 * time.Second)
764
+
765
+ // Verify daemon is functional with HTTPS autoconf
766
+ result := node.RunIPFS("id")
767
+ assert.Equal(t, 0, result.ExitCode(), "IPFS daemon should be responsive with HTTPS autoconf")
768
+ assert.Contains(t, result.Stdout.String(), "ID", "IPFS id command should return peer information")
769
+
770
+ // Test that config operations work with HTTPS-fetched autoconf cache
771
+ result = node.RunIPFS("config", "show")
772
+ assert.Equal(t, 0, result.ExitCode(), "Config show should work with HTTPS autoconf")
773
+
774
+ // Test bootstrap list functionality
775
+ result = node.RunIPFS("bootstrap", "list")
776
+ assert.Equal(t, 0, result.ExitCode(), "Bootstrap list should work with HTTPS autoconf")
777
+
778
+ t.Logf("Successfully tested AutoConf with HTTPS server and TLS skip verify")
779
+}
test/cli/autoconf/dns_test.go
new
+288
@@ -0,0 +1,288 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/base64"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+
13
+ "github.com/ipfs/kubo/test/cli/harness"
14
+ "github.com/miekg/dns"
15
+ "github.com/stretchr/testify/assert"
16
+ "github.com/stretchr/testify/require"
17
+)
18
+
19
+func TestAutoConfDNS(t *testing.T) {
20
+ t.Parallel()
21
+
22
+ t.Run("DNS resolution with auto DoH resolver", func(t *testing.T) {
23
+ t.Parallel()
24
+ testDNSResolutionWithAutoDoH(t)
25
+ })
26
+
27
+ t.Run("DNS errors are handled properly", func(t *testing.T) {
28
+ t.Parallel()
29
+ testDNSErrorHandling(t)
30
+ })
31
+}
32
+
33
+// mockDoHServer implements a simple DNS-over-HTTPS server for testing
34
+type mockDoHServer struct {
35
+ t *testing.T
36
+ server *httptest.Server
37
+ mu sync.Mutex
38
+ requests []string
39
+ responseFunc func(name string) *dns.Msg
40
+}
41
+
42
+func newMockDoHServer(t *testing.T) *mockDoHServer {
43
+ m := &mockDoHServer{
44
+ t: t,
45
+ requests: []string{},
46
+ }
47
+
48
+ // Default response function returns a dnslink TXT record
49
+ m.responseFunc = func(name string) *dns.Msg {
50
+ msg := &dns.Msg{}
51
+ msg.SetReply(&dns.Msg{Question: []dns.Question{{Name: name, Qtype: dns.TypeTXT}}})
52
+
53
+ if strings.HasPrefix(name, "_dnslink.") {
54
+ // Return a valid dnslink record
55
+ rr := &dns.TXT{
56
+ Hdr: dns.RR_Header{
57
+ Name: name,
58
+ Rrtype: dns.TypeTXT,
59
+ Class: dns.ClassINET,
60
+ Ttl: 300,
61
+ },
62
+ Txt: []string{"dnslink=/ipfs/QmYNQJoKGNHTpPxCBPh9KkDpaExgd2duMa3aF6ytMpHdao"},
63
+ }
64
+ msg.Answer = append(msg.Answer, rr)
65
+ }
66
+
67
+ return msg
68
+ }
69
+
70
+ mux := http.NewServeMux()
71
+ mux.HandleFunc("/dns-query", m.handleDNSQuery)
72
+
73
+ m.server = httptest.NewServer(mux)
74
+ return m
75
+}
76
+
77
+func (m *mockDoHServer) handleDNSQuery(w http.ResponseWriter, r *http.Request) {
78
+ m.mu.Lock()
79
+ defer m.mu.Unlock()
80
+
81
+ var dnsMsg *dns.Msg
82
+
83
+ if r.Method == "GET" {
84
+ // Handle GET with ?dns= parameter
85
+ dnsParam := r.URL.Query().Get("dns")
86
+ if dnsParam == "" {
87
+ http.Error(w, "missing dns parameter", http.StatusBadRequest)
88
+ return
89
+ }
90
+
91
+ data, err := base64.RawURLEncoding.DecodeString(dnsParam)
92
+ if err != nil {
93
+ http.Error(w, "invalid base64", http.StatusBadRequest)
94
+ return
95
+ }
96
+
97
+ dnsMsg = &dns.Msg{}
98
+ if err := dnsMsg.Unpack(data); err != nil {
99
+ http.Error(w, "invalid DNS message", http.StatusBadRequest)
100
+ return
101
+ }
102
+ } else if r.Method == "POST" {
103
+ // Handle POST with DNS wire format
104
+ data, err := io.ReadAll(r.Body)
105
+ if err != nil {
106
+ http.Error(w, "failed to read body", http.StatusBadRequest)
107
+ return
108
+ }
109
+
110
+ dnsMsg = &dns.Msg{}
111
+ if err := dnsMsg.Unpack(data); err != nil {
112
+ http.Error(w, "invalid DNS message", http.StatusBadRequest)
113
+ return
114
+ }
115
+ } else {
116
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
117
+ return
118
+ }
119
+
120
+ // Log the DNS query
121
+ if len(dnsMsg.Question) > 0 {
122
+ qname := dnsMsg.Question[0].Name
123
+ m.requests = append(m.requests, qname)
124
+ m.t.Logf("DoH server received query for: %s", qname)
125
+ }
126
+
127
+ // Generate response
128
+ response := m.responseFunc(dnsMsg.Question[0].Name)
129
+ responseData, err := response.Pack()
130
+ if err != nil {
131
+ http.Error(w, "failed to pack response", http.StatusInternalServerError)
132
+ return
133
+ }
134
+
135
+ w.Header().Set("Content-Type", "application/dns-message")
136
+ _, _ = w.Write(responseData)
137
+}
138
+
139
+func (m *mockDoHServer) getRequests() []string {
140
+ m.mu.Lock()
141
+ defer m.mu.Unlock()
142
+ return append([]string{}, m.requests...)
143
+}
144
+
145
+func (m *mockDoHServer) close() {
146
+ m.server.Close()
147
+}
148
+
149
+func testDNSResolutionWithAutoDoH(t *testing.T) {
150
+ // Create mock DoH server
151
+ dohServer := newMockDoHServer(t)
152
+ defer dohServer.close()
153
+
154
+ // Create autoconf data with DoH resolver for "foo." domain
155
+ autoConfData := fmt.Sprintf(`{
156
+ "AutoConfVersion": 2025072302,
157
+ "AutoConfSchema": 1,
158
+ "AutoConfTTL": 86400,
159
+ "SystemRegistry": {
160
+ "AminoDHT": {
161
+ "Description": "Test AminoDHT system",
162
+ "NativeConfig": {
163
+ "Bootstrap": []
164
+ }
165
+ }
166
+ },
167
+ "DNSResolvers": {
168
+ "foo.": ["%s/dns-query"]
169
+ },
170
+ "DelegatedEndpoints": {}
171
+ }`, dohServer.server.URL)
172
+
173
+ // Create autoconf server
174
+ autoConfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175
+ w.Header().Set("Content-Type", "application/json")
176
+ _, _ = w.Write([]byte(autoConfData))
177
+ }))
178
+ defer autoConfServer.Close()
179
+
180
+ // Create IPFS node with auto DNS resolver
181
+ node := harness.NewT(t).NewNode().Init("--profile=test")
182
+ node.SetIPFSConfig("AutoConf.URL", autoConfServer.URL)
183
+ node.SetIPFSConfig("AutoConf.Enabled", true)
184
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
185
+
186
+ // Start daemon
187
+ node.StartDaemon()
188
+ defer node.StopDaemon()
189
+
190
+ // Verify config still shows "auto" for DNS resolvers
191
+ result := node.RunIPFS("config", "DNS.Resolvers")
192
+ require.Equal(t, 0, result.ExitCode())
193
+ dnsResolversOutput := result.Stdout.String()
194
+ assert.Contains(t, dnsResolversOutput, "foo.", "DNS resolvers should contain foo. domain")
195
+ assert.Contains(t, dnsResolversOutput, "auto", "DNS resolver config should show 'auto'")
196
+
197
+ // Try to resolve a .foo domain
198
+ result = node.RunIPFS("resolve", "/ipns/example.foo")
199
+ require.Equal(t, 0, result.ExitCode())
200
+
201
+ // Should resolve to the IPFS path from our mock DoH server
202
+ output := strings.TrimSpace(result.Stdout.String())
203
+ assert.Equal(t, "/ipfs/QmYNQJoKGNHTpPxCBPh9KkDpaExgd2duMa3aF6ytMpHdao", output,
204
+ "Should resolve to the path returned by DoH server")
205
+
206
+ // Verify DoH server received the DNS query
207
+ requests := dohServer.getRequests()
208
+ require.Greater(t, len(requests), 0, "DoH server should have received at least one request")
209
+
210
+ foundDNSLink := false
211
+ for _, req := range requests {
212
+ if strings.Contains(req, "_dnslink.example.foo") {
213
+ foundDNSLink = true
214
+ break
215
+ }
216
+ }
217
+ assert.True(t, foundDNSLink, "DoH server should have received query for _dnslink.example.foo")
218
+}
219
+
220
+func testDNSErrorHandling(t *testing.T) {
221
+ // Create DoH server that returns NXDOMAIN
222
+ dohServer := newMockDoHServer(t)
223
+ defer dohServer.close()
224
+
225
+ // Configure to return NXDOMAIN
226
+ dohServer.responseFunc = func(name string) *dns.Msg {
227
+ msg := &dns.Msg{}
228
+ msg.SetReply(&dns.Msg{Question: []dns.Question{{Name: name, Qtype: dns.TypeTXT}}})
229
+ msg.Rcode = dns.RcodeNameError // NXDOMAIN
230
+ return msg
231
+ }
232
+
233
+ // Create autoconf data with DoH resolver
234
+ autoConfData := fmt.Sprintf(`{
235
+ "AutoConfVersion": 2025072302,
236
+ "AutoConfSchema": 1,
237
+ "AutoConfTTL": 86400,
238
+ "SystemRegistry": {
239
+ "AminoDHT": {
240
+ "Description": "Test AminoDHT system",
241
+ "NativeConfig": {
242
+ "Bootstrap": []
243
+ }
244
+ }
245
+ },
246
+ "DNSResolvers": {
247
+ "bar.": ["%s/dns-query"]
248
+ },
249
+ "DelegatedEndpoints": {}
250
+ }`, dohServer.server.URL)
251
+
252
+ // Create autoconf server
253
+ autoConfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
254
+ w.Header().Set("Content-Type", "application/json")
255
+ _, _ = w.Write([]byte(autoConfData))
256
+ }))
257
+ defer autoConfServer.Close()
258
+
259
+ // Create IPFS node
260
+ node := harness.NewT(t).NewNode().Init("--profile=test")
261
+ node.SetIPFSConfig("AutoConf.URL", autoConfServer.URL)
262
+ node.SetIPFSConfig("AutoConf.Enabled", true)
263
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"bar.": "auto"})
264
+
265
+ // Start daemon
266
+ node.StartDaemon()
267
+ defer node.StopDaemon()
268
+
269
+ // Try to resolve a non-existent domain
270
+ result := node.RunIPFS("resolve", "/ipns/nonexistent.bar")
271
+ require.NotEqual(t, 0, result.ExitCode(), "Resolution should fail for non-existent domain")
272
+
273
+ // Should contain appropriate error message
274
+ stderr := result.Stderr.String()
275
+ assert.Contains(t, stderr, "could not resolve name",
276
+ "Error should indicate DNS resolution failure")
277
+
278
+ // Verify DoH server received the query
279
+ requests := dohServer.getRequests()
280
+ foundQuery := false
281
+ for _, req := range requests {
282
+ if strings.Contains(req, "_dnslink.nonexistent.bar") {
283
+ foundQuery = true
284
+ break
285
+ }
286
+ }
287
+ assert.True(t, foundQuery, "DoH server should have received query even for failed resolution")
288
+}
test/cli/autoconf/expand_comprehensive_test.go
new
+698
@@ -0,0 +1,698 @@
1
+// Package autoconf provides comprehensive tests for --expand-auto functionality.
2
+//
3
+// Test Scenarios:
4
+// 1. Tests WITH daemon: Most tests start a daemon to fetch and cache autoconf data,
5
+// then test CLI commands that read from that cache using MustGetConfigCached.
6
+// 2. Tests WITHOUT daemon: Error condition tests that don't need cached autoconf.
7
+//
8
+// The daemon setup uses startDaemonAndWaitForAutoConf() helper which:
9
+// - Starts the daemon
10
+// - Waits for HTTP request to mock server (not arbitrary timeout)
11
+// - Returns when autoconf is cached and ready for CLI commands
12
+package autoconf
13
+
14
+import (
15
+ "encoding/json"
16
+ "fmt"
17
+ "net/http"
18
+ "net/http/httptest"
19
+ "os"
20
+ "strings"
21
+ "sync/atomic"
22
+ "testing"
23
+ "time"
24
+
25
+ "github.com/ipfs/kubo/test/cli/harness"
26
+ "github.com/stretchr/testify/assert"
27
+ "github.com/stretchr/testify/require"
28
+)
29
+
30
+func TestExpandAutoComprehensive(t *testing.T) {
31
+ t.Parallel()
32
+
33
+ t.Run("all autoconf fields resolve correctly", func(t *testing.T) {
34
+ t.Parallel()
35
+ testAllAutoConfFieldsResolve(t)
36
+ })
37
+
38
+ t.Run("bootstrap list --expand-auto matches config Bootstrap --expand-auto", func(t *testing.T) {
39
+ t.Parallel()
40
+ testBootstrapCommandConsistency(t)
41
+ })
42
+
43
+ t.Run("write operations fail with --expand-auto", func(t *testing.T) {
44
+ t.Parallel()
45
+ testWriteOperationsFailWithExpandAuto(t)
46
+ })
47
+
48
+ t.Run("config show --expand-auto provides complete expanded view", func(t *testing.T) {
49
+ t.Parallel()
50
+ testConfigShowExpandAutoComplete(t)
51
+ })
52
+
53
+ t.Run("multiple expand-auto calls use cache (single HTTP request)", func(t *testing.T) {
54
+ t.Parallel()
55
+ testMultipleExpandAutoUsesCache(t)
56
+ })
57
+
58
+ t.Run("CLI uses cache only while daemon handles background updates", func(t *testing.T) {
59
+ t.Parallel()
60
+ testCLIUsesCacheOnlyDaemonUpdatesBackground(t)
61
+ })
62
+}
63
+
64
+// testAllAutoConfFieldsResolve verifies that all autoconf fields (Bootstrap, DNS.Resolvers,
65
+// Routing.DelegatedRouters, and Ipns.DelegatedPublishers) can be resolved from "auto" values
66
+// to their actual configuration using --expand-auto flag with daemon-cached autoconf data.
67
+//
68
+// This test is critical because:
69
+// 1. It validates the core autoconf resolution functionality across all supported fields
70
+// 2. It ensures that "auto" placeholders are properly replaced with real configuration values
71
+// 3. It verifies that the autoconf JSON structure is correctly parsed and applied
72
+// 4. It tests the end-to-end flow from HTTP fetch to config field expansion
73
+func testAllAutoConfFieldsResolve(t *testing.T) {
74
+ // Test scenario: CLI with daemon started and autoconf cached
75
+ // This validates core autoconf resolution functionality across all supported fields
76
+
77
+ // Track HTTP requests to verify mock server is being used
78
+ var requestCount atomic.Int32
79
+ var autoConfData []byte
80
+
81
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82
+ count := requestCount.Add(1)
83
+ t.Logf("Mock autoconf server request #%d: %s %s", count, r.Method, r.URL.Path)
84
+
85
+ // Create comprehensive autoconf response matching Schema 4 format
86
+ // Use server URLs to ensure they're reachable and valid
87
+ serverURL := fmt.Sprintf("http://%s", r.Host) // Get the server URL from the request
88
+ autoConf := map[string]interface{}{
89
+ "AutoConfVersion": 2025072301,
90
+ "AutoConfSchema": 1,
91
+ "AutoConfTTL": 86400,
92
+ "SystemRegistry": map[string]interface{}{
93
+ "AminoDHT": map[string]interface{}{
94
+ "URL": "https://github.com/ipfs/specs/pull/497",
95
+ "Description": "Test AminoDHT system",
96
+ "NativeConfig": map[string]interface{}{
97
+ "Bootstrap": []string{
98
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
99
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
100
+ },
101
+ },
102
+ "DelegatedConfig": map[string]interface{}{
103
+ "Read": []string{"/routing/v1/providers", "/routing/v1/peers", "/routing/v1/ipns"},
104
+ "Write": []string{"/routing/v1/ipns"},
105
+ },
106
+ },
107
+ "IPNI": map[string]interface{}{
108
+ "URL": serverURL + "/ipni-system",
109
+ "Description": "Test IPNI system",
110
+ "DelegatedConfig": map[string]interface{}{
111
+ "Read": []string{"/routing/v1/providers"},
112
+ "Write": []string{},
113
+ },
114
+ },
115
+ "CustomIPNS": map[string]interface{}{
116
+ "URL": serverURL + "/ipns-system",
117
+ "Description": "Test IPNS system",
118
+ "DelegatedConfig": map[string]interface{}{
119
+ "Read": []string{"/routing/v1/ipns"},
120
+ "Write": []string{"/routing/v1/ipns"},
121
+ },
122
+ },
123
+ },
124
+ "DNSResolvers": map[string][]string{
125
+ ".": {"https://cloudflare-dns.com/dns-query"},
126
+ "eth.": {"https://dns.google/dns-query"},
127
+ },
128
+ "DelegatedEndpoints": map[string]interface{}{
129
+ serverURL: map[string]interface{}{
130
+ "Systems": []string{"IPNI", "CustomIPNS"}, // Use non-AminoDHT systems to avoid filtering
131
+ "Read": []string{"/routing/v1/providers", "/routing/v1/ipns"},
132
+ "Write": []string{"/routing/v1/ipns"},
133
+ },
134
+ },
135
+ }
136
+
137
+ var err error
138
+ autoConfData, err = json.Marshal(autoConf)
139
+ if err != nil {
140
+ t.Fatalf("Failed to marshal autoConf: %v", err)
141
+ }
142
+
143
+ t.Logf("Serving mock autoconf data: %s", string(autoConfData))
144
+
145
+ w.Header().Set("Content-Type", "application/json")
146
+ w.Header().Set("ETag", `"test-mock-config"`)
147
+ w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
148
+ _, _ = w.Write(autoConfData)
149
+ }))
150
+ defer server.Close()
151
+
152
+ // Create IPFS node with all auto values
153
+ node := harness.NewT(t).NewNode().Init("--profile=test")
154
+
155
+ // Clear any existing autoconf cache to prevent interference
156
+ result := node.RunIPFS("config", "show")
157
+ if result.ExitCode() == 0 {
158
+ var cfg map[string]interface{}
159
+ if json.Unmarshal([]byte(result.Stdout.String()), &cfg) == nil {
160
+ if repoPath, exists := cfg["path"]; exists {
161
+ if pathStr, ok := repoPath.(string); ok {
162
+ t.Logf("Clearing autoconf cache from %s/autoconf", pathStr)
163
+ // Note: We can't directly remove files, but clearing cache via config change should help
164
+ }
165
+ }
166
+ }
167
+ }
168
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
169
+ node.SetIPFSConfig("AutoConf.Enabled", true)
170
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "1s") // Force fresh fetches for testing
171
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
172
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{
173
+ ".": "auto",
174
+ "eth.": "auto",
175
+ })
176
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
177
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
178
+
179
+ // Start daemon and wait for autoconf fetch
180
+ daemon := startDaemonAndWaitForAutoConf(t, node, &requestCount)
181
+ defer daemon.StopDaemon()
182
+
183
+ // Test 1: Bootstrap resolution
184
+ result = node.RunIPFS("config", "Bootstrap", "--expand-auto")
185
+ require.Equal(t, 0, result.ExitCode(), "Bootstrap expansion should succeed")
186
+
187
+ var expandedBootstrap []string
188
+ var err error
189
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedBootstrap)
190
+ require.NoError(t, err)
191
+
192
+ assert.NotContains(t, expandedBootstrap, "auto", "Bootstrap should not contain 'auto'")
193
+ assert.Contains(t, expandedBootstrap, "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN")
194
+ assert.Contains(t, expandedBootstrap, "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa")
195
+ t.Logf("Bootstrap expanded to: %v", expandedBootstrap)
196
+
197
+ // Test 2: DNS.Resolvers resolution
198
+ result = node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
199
+ require.Equal(t, 0, result.ExitCode(), "DNS.Resolvers expansion should succeed")
200
+
201
+ var expandedResolvers map[string]string
202
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedResolvers)
203
+ require.NoError(t, err)
204
+
205
+ assert.NotContains(t, expandedResolvers, "auto", "DNS.Resolvers should not contain 'auto'")
206
+ assert.Equal(t, "https://cloudflare-dns.com/dns-query", expandedResolvers["."])
207
+ assert.Equal(t, "https://dns.google/dns-query", expandedResolvers["eth."])
208
+ t.Logf("DNS.Resolvers expanded to: %v", expandedResolvers)
209
+
210
+ // Test 3: Routing.DelegatedRouters resolution
211
+ result = node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
212
+ require.Equal(t, 0, result.ExitCode(), "Routing.DelegatedRouters expansion should succeed")
213
+
214
+ var expandedRouters []string
215
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
216
+ require.NoError(t, err)
217
+
218
+ assert.NotContains(t, expandedRouters, "auto", "DelegatedRouters should not contain 'auto'")
219
+
220
+ // Test should strictly require mock autoconf to work - no fallback acceptance
221
+ // The mock endpoint has Read paths ["/routing/v1/providers", "/routing/v1/ipns"]
222
+ // so we expect 2 URLs with those paths
223
+ expectedMockURLs := []string{
224
+ server.URL + "/routing/v1/providers",
225
+ server.URL + "/routing/v1/ipns",
226
+ }
227
+ require.Equal(t, 2, len(expandedRouters),
228
+ "Should have exactly 2 routers from mock autoconf (one for each Read path). Got %d routers: %v. "+
229
+ "This indicates autoconf is not working properly - check if mock server data is being parsed and filtered correctly.",
230
+ len(expandedRouters), expandedRouters)
231
+
232
+ // Check that both expected URLs are present
233
+ for _, expectedURL := range expectedMockURLs {
234
+ assert.Contains(t, expandedRouters, expectedURL,
235
+ "Should contain mock autoconf endpoint with path %s. Got: %v. "+
236
+ "This indicates autoconf endpoint path generation is not working properly.",
237
+ expectedURL, expandedRouters)
238
+ }
239
+
240
+ // Test 4: Ipns.DelegatedPublishers resolution
241
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
242
+ require.Equal(t, 0, result.ExitCode(), "Ipns.DelegatedPublishers expansion should succeed")
243
+
244
+ var expandedPublishers []string
245
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
246
+ require.NoError(t, err)
247
+
248
+ assert.NotContains(t, expandedPublishers, "auto", "DelegatedPublishers should not contain 'auto'")
249
+
250
+ // Test should require mock autoconf endpoint for IPNS publishing
251
+ // The mock endpoint supports /routing/v1/ipns write operations, so it should be included with path
252
+ expectedMockPublisherURL := server.URL + "/routing/v1/ipns"
253
+ require.Equal(t, 1, len(expandedPublishers),
254
+ "Should have exactly 1 IPNS publisher from mock autoconf. Got %d publishers: %v. "+
255
+ "This indicates autoconf IPNS publisher filtering is not working properly.",
256
+ len(expandedPublishers), expandedPublishers)
257
+ assert.Equal(t, expectedMockPublisherURL, expandedPublishers[0],
258
+ "Should use mock autoconf endpoint %s for IPNS publishing, not fallback. Got: %s. "+
259
+ "This indicates autoconf IPNS publisher resolution is not working properly.",
260
+ expectedMockPublisherURL, expandedPublishers[0])
261
+
262
+ // CRITICAL: Verify that mock server was actually used
263
+ finalRequestCount := requestCount.Load()
264
+ require.Greater(t, finalRequestCount, int32(0),
265
+ "Mock autoconf server should have been called at least once. Got %d requests. "+
266
+ "This indicates the test is using cached or fallback config instead of mock data.", finalRequestCount)
267
+ t.Logf("Mock server was called %d times - test is using mock data", finalRequestCount)
268
+}
269
+
270
+// testBootstrapCommandConsistency verifies that `ipfs bootstrap list --expand-auto` and
271
+// `ipfs config Bootstrap --expand-auto` return identical results when both use autoconf.
272
+//
273
+// This test is important because:
274
+// 1. It ensures consistency between different CLI commands that access the same data
275
+// 2. It validates that both the bootstrap-specific command and generic config command
276
+// use the same underlying autoconf resolution mechanism
277
+// 3. It prevents regression where different commands might resolve "auto" differently
278
+// 4. It ensures users get consistent results regardless of which command they use
279
+func testBootstrapCommandConsistency(t *testing.T) {
280
+ // Test scenario: CLI with daemon started and autoconf cached
281
+ // This ensures both bootstrap commands read from the same cached autoconf data
282
+
283
+ // Load test autoconf data
284
+ autoConfData := loadTestDataComprehensive(t, "valid_autoconf.json")
285
+
286
+ // Track HTTP requests to verify daemon fetches autoconf
287
+ var requestCount atomic.Int32
288
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
289
+ requestCount.Add(1)
290
+ t.Logf("Bootstrap consistency test request: %s %s", r.Method, r.URL.Path)
291
+ w.Header().Set("Content-Type", "application/json")
292
+ _, _ = w.Write(autoConfData)
293
+ }))
294
+ defer server.Close()
295
+
296
+ // Create IPFS node with auto bootstrap
297
+ node := harness.NewT(t).NewNode().Init("--profile=test")
298
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
299
+ node.SetIPFSConfig("AutoConf.Enabled", true)
300
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
301
+
302
+ // Start daemon and wait for autoconf fetch
303
+ daemon := startDaemonAndWaitForAutoConf(t, node, &requestCount)
304
+ defer daemon.StopDaemon()
305
+
306
+ // Get bootstrap via config command
307
+ configResult := node.RunIPFS("config", "Bootstrap", "--expand-auto")
308
+ require.Equal(t, 0, configResult.ExitCode(), "config Bootstrap --expand-auto should succeed")
309
+
310
+ // Get bootstrap via bootstrap command
311
+ bootstrapResult := node.RunIPFS("bootstrap", "list", "--expand-auto")
312
+ require.Equal(t, 0, bootstrapResult.ExitCode(), "bootstrap list --expand-auto should succeed")
313
+
314
+ // Parse both results
315
+ var configBootstrap, bootstrapBootstrap []string
316
+ err := json.Unmarshal([]byte(configResult.Stdout.String()), &configBootstrap)
317
+ require.NoError(t, err)
318
+
319
+ // Bootstrap command output is line-separated, not JSON
320
+ bootstrapOutput := strings.TrimSpace(bootstrapResult.Stdout.String())
321
+ if bootstrapOutput != "" {
322
+ bootstrapBootstrap = strings.Split(bootstrapOutput, "\n")
323
+ }
324
+
325
+ // Results should be equivalent
326
+ assert.Equal(t, len(configBootstrap), len(bootstrapBootstrap), "Both commands should return same number of peers")
327
+
328
+ // Both should contain same peers (order might differ due to different output formats)
329
+ for _, peer := range configBootstrap {
330
+ found := false
331
+ for _, bsPeer := range bootstrapBootstrap {
332
+ if strings.TrimSpace(bsPeer) == peer {
333
+ found = true
334
+ break
335
+ }
336
+ }
337
+ assert.True(t, found, "Peer %s should be in both results", peer)
338
+ }
339
+
340
+ t.Logf("Config command result: %v", configBootstrap)
341
+ t.Logf("Bootstrap command result: %v", bootstrapBootstrap)
342
+}
343
+
344
+// testWriteOperationsFailWithExpandAuto verifies that --expand-auto flag is properly
345
+// restricted to read-only operations and fails when used with config write operations.
346
+//
347
+// This test is essential because:
348
+// 1. It enforces the security principle that --expand-auto should only be used for reading
349
+// 2. It prevents users from accidentally overwriting config with expanded values
350
+// 3. It ensures that "auto" placeholders are preserved in the stored configuration
351
+// 4. It validates proper error handling and user guidance when misused
352
+// 5. It protects against accidental loss of the "auto" semantic meaning
353
+func testWriteOperationsFailWithExpandAuto(t *testing.T) {
354
+ // Test scenario: CLI without daemon (tests error conditions)
355
+ // This test doesn't need daemon setup since it's testing that write operations
356
+ // with --expand-auto should fail with appropriate error messages
357
+
358
+ // Create IPFS node
359
+ node := harness.NewT(t).NewNode().Init("--profile=test")
360
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
361
+
362
+ // Test that setting config with --expand-auto fails
363
+ testCases := []struct {
364
+ name string
365
+ args []string
366
+ }{
367
+ {"config set with expand-auto", []string{"config", "Bootstrap", "[\"test\"]", "--expand-auto"}},
368
+ {"config set JSON with expand-auto", []string{"config", "Bootstrap", "[\"test\"]", "--json", "--expand-auto"}},
369
+ {"config set bool with expand-auto", []string{"config", "SomeField", "true", "--bool", "--expand-auto"}},
370
+ }
371
+
372
+ for _, tc := range testCases {
373
+ t.Run(tc.name, func(t *testing.T) {
374
+ result := node.RunIPFS(tc.args...)
375
+ assert.NotEqual(t, 0, result.ExitCode(), "Write operation with --expand-auto should fail")
376
+
377
+ stderr := result.Stderr.String()
378
+ assert.Contains(t, stderr, "--expand-auto", "Error should mention --expand-auto")
379
+ assert.Contains(t, stderr, "reading", "Error should mention reading limitation")
380
+ t.Logf("Expected error: %s", stderr)
381
+ })
382
+ }
383
+}
384
+
385
+// testConfigShowExpandAutoComplete verifies that `ipfs config show --expand-auto`
386
+// produces a complete configuration with all "auto" values expanded to their resolved forms.
387
+//
388
+// This test is important because:
389
+// 1. It validates the full-config expansion functionality for comprehensive troubleshooting
390
+// 2. It ensures that users can see the complete resolved configuration state
391
+// 3. It verifies that all "auto" placeholders are replaced, not just individual fields
392
+// 4. It tests that the resulting JSON is valid and well-formed
393
+// 5. It provides a way to export/backup the fully expanded configuration
394
+func testConfigShowExpandAutoComplete(t *testing.T) {
395
+ // Test scenario: CLI with daemon started and autoconf cached
396
+
397
+ // Load test autoconf data
398
+ autoConfData := loadTestDataComprehensive(t, "valid_autoconf.json")
399
+
400
+ // Track HTTP requests to verify daemon fetches autoconf
401
+ var requestCount atomic.Int32
402
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
403
+ requestCount.Add(1)
404
+ t.Logf("Config show test request: %s %s", r.Method, r.URL.Path)
405
+ w.Header().Set("Content-Type", "application/json")
406
+ _, _ = w.Write(autoConfData)
407
+ }))
408
+ defer server.Close()
409
+
410
+ // Create IPFS node with multiple auto values
411
+ node := harness.NewT(t).NewNode().Init("--profile=test")
412
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
413
+ node.SetIPFSConfig("AutoConf.Enabled", true)
414
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
415
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{".": "auto"})
416
+
417
+ // Start daemon and wait for autoconf fetch
418
+ daemon := startDaemonAndWaitForAutoConf(t, node, &requestCount)
419
+ defer daemon.StopDaemon()
420
+
421
+ // Test config show --expand-auto
422
+ result := node.RunIPFS("config", "show", "--expand-auto")
423
+ require.Equal(t, 0, result.ExitCode(), "config show --expand-auto should succeed")
424
+
425
+ expandedConfig := result.Stdout.String()
426
+
427
+ // Should not contain any literal "auto" values
428
+ assert.NotContains(t, expandedConfig, `"auto"`, "Expanded config should not contain literal 'auto' values")
429
+
430
+ // Should contain expected expanded sections
431
+ assert.Contains(t, expandedConfig, `"Bootstrap"`, "Should contain Bootstrap section")
432
+ assert.Contains(t, expandedConfig, `"DNS"`, "Should contain DNS section")
433
+ assert.Contains(t, expandedConfig, `"Resolvers"`, "Should contain Resolvers section")
434
+
435
+ // Should contain expanded peer addresses (not "auto")
436
+ assert.Contains(t, expandedConfig, "bootstrap.libp2p.io", "Should contain expanded bootstrap peers")
437
+
438
+ // Should be valid JSON
439
+ var configMap map[string]interface{}
440
+ err := json.Unmarshal([]byte(expandedConfig), &configMap)
441
+ require.NoError(t, err, "Expanded config should be valid JSON")
442
+
443
+ // Verify specific fields were expanded
444
+ if bootstrap, ok := configMap["Bootstrap"].([]interface{}); ok {
445
+ assert.Greater(t, len(bootstrap), 0, "Bootstrap should have expanded entries")
446
+ for _, peer := range bootstrap {
447
+ assert.NotEqual(t, "auto", peer, "Bootstrap entries should not be 'auto'")
448
+ }
449
+ }
450
+
451
+ t.Logf("Config show --expand-auto produced %d characters of expanded config", len(expandedConfig))
452
+}
453
+
454
+// testMultipleExpandAutoUsesCache verifies that multiple consecutive --expand-auto calls
455
+// efficiently use cached autoconf data instead of making repeated HTTP requests.
456
+//
457
+// This test is critical for performance because:
458
+// 1. It validates that the caching mechanism works correctly to reduce network overhead
459
+// 2. It ensures that users can make multiple config queries without causing excessive HTTP traffic
460
+// 3. It verifies that cached data is shared across different config fields and commands
461
+// 4. It tests that HTTP headers (ETag/Last-Modified) are properly used for cache validation
462
+// 5. It prevents regression where each --expand-auto call would trigger a new HTTP request
463
+// 6. It demonstrates the performance benefit: 5 operations with only 1 network request
464
+func testMultipleExpandAutoUsesCache(t *testing.T) {
465
+ // Test scenario: CLI with daemon started and autoconf cached
466
+
467
+ // Create comprehensive autoconf response
468
+ autoConfData := loadTestDataComprehensive(t, "valid_autoconf.json")
469
+
470
+ // Track HTTP requests to verify caching
471
+ var requestCount atomic.Int32
472
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
473
+ count := requestCount.Add(1)
474
+ t.Logf("AutoConf cache test request #%d: %s %s", count, r.Method, r.URL.Path)
475
+
476
+ w.Header().Set("Content-Type", "application/json")
477
+ w.Header().Set("ETag", `"cache-test-123"`)
478
+ w.Header().Set("Last-Modified", "Wed, 21 Oct 2015 07:28:00 GMT")
479
+ _, _ = w.Write(autoConfData)
480
+ }))
481
+ defer server.Close()
482
+
483
+ // Create IPFS node with all auto values
484
+ node := harness.NewT(t).NewNode().Init("--profile=test")
485
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
486
+ node.SetIPFSConfig("AutoConf.Enabled", true)
487
+ // Note: Using default RefreshInterval (24h) to ensure caching - explicit setting would require rebuilt binary
488
+
489
+ // Set up auto values for multiple fields
490
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
491
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
492
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
493
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
494
+
495
+ // Start daemon and wait for autoconf fetch
496
+ daemon := startDaemonAndWaitForAutoConf(t, node, &requestCount)
497
+ defer daemon.StopDaemon()
498
+
499
+ // Reset counter to only track our expand-auto calls
500
+ requestCount.Store(0)
501
+
502
+ // Make multiple --expand-auto calls on different fields
503
+ t.Log("Testing multiple --expand-auto calls should use cache...")
504
+
505
+ // Call 1: Bootstrap --expand-auto (should trigger HTTP request)
506
+ result1 := node.RunIPFS("config", "Bootstrap", "--expand-auto")
507
+ require.Equal(t, 0, result1.ExitCode(), "Bootstrap --expand-auto should succeed")
508
+
509
+ var expandedBootstrap []string
510
+ err := json.Unmarshal([]byte(result1.Stdout.String()), &expandedBootstrap)
511
+ require.NoError(t, err)
512
+ assert.NotContains(t, expandedBootstrap, "auto", "Bootstrap should be expanded")
513
+ assert.Greater(t, len(expandedBootstrap), 0, "Bootstrap should have entries")
514
+
515
+ // Call 2: DNS.Resolvers --expand-auto (should use cache, no HTTP)
516
+ result2 := node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
517
+ require.Equal(t, 0, result2.ExitCode(), "DNS.Resolvers --expand-auto should succeed")
518
+
519
+ var expandedResolvers map[string]string
520
+ err = json.Unmarshal([]byte(result2.Stdout.String()), &expandedResolvers)
521
+ require.NoError(t, err)
522
+
523
+ // Call 3: Routing.DelegatedRouters --expand-auto (should use cache, no HTTP)
524
+ result3 := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
525
+ require.Equal(t, 0, result3.ExitCode(), "Routing.DelegatedRouters --expand-auto should succeed")
526
+
527
+ var expandedRouters []string
528
+ err = json.Unmarshal([]byte(result3.Stdout.String()), &expandedRouters)
529
+ require.NoError(t, err)
530
+ assert.NotContains(t, expandedRouters, "auto", "Routers should be expanded")
531
+
532
+ // Call 4: Ipns.DelegatedPublishers --expand-auto (should use cache, no HTTP)
533
+ result4 := node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
534
+ require.Equal(t, 0, result4.ExitCode(), "Ipns.DelegatedPublishers --expand-auto should succeed")
535
+
536
+ var expandedPublishers []string
537
+ err = json.Unmarshal([]byte(result4.Stdout.String()), &expandedPublishers)
538
+ require.NoError(t, err)
539
+ assert.NotContains(t, expandedPublishers, "auto", "Publishers should be expanded")
540
+
541
+ // Call 5: config show --expand-auto (should use cache, no HTTP)
542
+ result5 := node.RunIPFS("config", "show", "--expand-auto")
543
+ require.Equal(t, 0, result5.ExitCode(), "config show --expand-auto should succeed")
544
+
545
+ expandedConfig := result5.Stdout.String()
546
+ assert.NotContains(t, expandedConfig, `"auto"`, "Full config should not contain 'auto' values")
547
+
548
+ // CRITICAL TEST: Verify NO HTTP requests were made for --expand-auto calls (using cache)
549
+ finalRequestCount := requestCount.Load()
550
+ assert.Equal(t, int32(0), finalRequestCount,
551
+ "Multiple --expand-auto calls should result in 0 HTTP requests (using cache). Got %d requests", finalRequestCount)
552
+
553
+ t.Logf("Made 5 --expand-auto calls, resulted in %d HTTP request(s) - cache is being used!", finalRequestCount)
554
+
555
+ // Now simulate a manual cache refresh (what the background updater would do)
556
+ t.Log("Simulating manual cache refresh...")
557
+
558
+ // Update the mock server to return different data
559
+ autoConfData2 := loadTestDataComprehensive(t, "updated_autoconf.json")
560
+ server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
561
+ count := requestCount.Add(1)
562
+ t.Logf("Manual refresh request #%d: %s %s", count, r.Method, r.URL.Path)
563
+ w.Header().Set("Content-Type", "application/json")
564
+ w.Header().Set("ETag", `"cache-test-456"`)
565
+ w.Header().Set("Last-Modified", "Thu, 22 Oct 2015 08:00:00 GMT")
566
+ _, _ = w.Write(autoConfData2)
567
+ })
568
+
569
+ // Note: In the actual daemon, the background updater would call MustGetConfigWithRefresh
570
+ // For this test, we'll verify that subsequent --expand-auto calls still use cache
571
+ // and don't trigger additional requests
572
+
573
+ // Reset counter before manual refresh simulation
574
+ beforeRefresh := requestCount.Load()
575
+
576
+ // Make another --expand-auto call - should still use cache
577
+ result6 := node.RunIPFS("config", "Bootstrap", "--expand-auto")
578
+ require.Equal(t, 0, result6.ExitCode(), "Bootstrap --expand-auto after refresh should succeed")
579
+
580
+ afterRefresh := requestCount.Load()
581
+ assert.Equal(t, beforeRefresh, afterRefresh,
582
+ "--expand-auto should continue using cache even after server update")
583
+
584
+ t.Logf("Cache continues to be used after server update - background updater pattern confirmed!")
585
+}
586
+
587
+// testCLIUsesCacheOnlyDaemonUpdatesBackground verifies the correct autoconf behavior:
588
+// daemon makes exactly one HTTP request during startup to fetch and cache data, then
589
+// CLI commands always use cached data without making additional HTTP requests.
590
+//
591
+// This test is essential for correctness because:
592
+// 1. It validates that daemon startup makes exactly one HTTP request to fetch autoconf
593
+// 2. It verifies that CLI --expand-auto never makes HTTP requests (uses cache only)
594
+// 3. It ensures CLI commands remain fast by always using cached data
595
+// 4. It prevents regression where CLI commands might start making HTTP requests
596
+// 5. It confirms the correct separation between daemon (network) and CLI (cache-only) behavior
597
+func testCLIUsesCacheOnlyDaemonUpdatesBackground(t *testing.T) {
598
+ // Test scenario: CLI with daemon and long RefreshInterval (no background updates during test)
599
+
600
+ // Create autoconf response
601
+ autoConfData := loadTestDataComprehensive(t, "valid_autoconf.json")
602
+
603
+ // Track HTTP requests with timestamps
604
+ var requestCount atomic.Int32
605
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
606
+ count := requestCount.Add(1)
607
+ t.Logf("Cache expiry test request #%d at %s: %s %s", count, time.Now().Format("15:04:05.000"), r.Method, r.URL.Path)
608
+
609
+ w.Header().Set("Content-Type", "application/json")
610
+ // Use different ETag for each request to ensure we can detect new fetches
611
+ w.Header().Set("ETag", fmt.Sprintf(`"expiry-test-%d"`, count))
612
+ w.Header().Set("Last-Modified", time.Now().Format(http.TimeFormat))
613
+ _, _ = w.Write(autoConfData)
614
+ }))
615
+ defer server.Close()
616
+
617
+ // Create IPFS node with long refresh interval
618
+ node := harness.NewT(t).NewNode().Init("--profile=test")
619
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
620
+ node.SetIPFSConfig("AutoConf.Enabled", true)
621
+ // Set long RefreshInterval to avoid background updates during test
622
+ node.SetIPFSConfig("AutoConf.RefreshInterval", "1h")
623
+
624
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
625
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"test.": "auto"})
626
+
627
+ // Start daemon and wait for autoconf fetch
628
+ daemon := startDaemonAndWaitForAutoConf(t, node, &requestCount)
629
+ defer daemon.StopDaemon()
630
+
631
+ // Confirm only one request was made during daemon startup
632
+ initialRequestCount := requestCount.Load()
633
+ assert.Equal(t, int32(1), initialRequestCount, "Expected exactly 1 HTTP request during daemon startup, got: %d", initialRequestCount)
634
+ t.Logf("Daemon startup made exactly 1 HTTP request")
635
+
636
+ // Test: CLI commands use cache only (no additional HTTP requests)
637
+ t.Log("Testing that CLI --expand-auto commands use cache only...")
638
+
639
+ // Make several CLI calls - none should trigger HTTP requests
640
+ result1 := node.RunIPFS("config", "Bootstrap", "--expand-auto")
641
+ require.Equal(t, 0, result1.ExitCode(), "Bootstrap --expand-auto should succeed")
642
+
643
+ result2 := node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
644
+ require.Equal(t, 0, result2.ExitCode(), "DNS.Resolvers --expand-auto should succeed")
645
+
646
+ result3 := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
647
+ require.Equal(t, 0, result3.ExitCode(), "Routing.DelegatedRouters --expand-auto should succeed")
648
+
649
+ // Verify the request count remains at 1 (no additional requests from CLI)
650
+ finalRequestCount := requestCount.Load()
651
+ assert.Equal(t, int32(1), finalRequestCount, "Request count should remain at 1 after CLI commands, got: %d", finalRequestCount)
652
+ t.Log("CLI commands use cache only - request count remains at 1")
653
+
654
+ t.Log("Test completed: Daemon makes 1 startup request, CLI commands use cache only")
655
+}
656
+
657
+// loadTestDataComprehensive is a helper function that loads test autoconf JSON data files.
658
+// It locates the test data directory relative to the test file and reads the specified file.
659
+// This centralized helper ensures consistent test data loading across all comprehensive tests.
660
+func loadTestDataComprehensive(t *testing.T, filename string) []byte {
661
+ t.Helper()
662
+
663
+ data, err := os.ReadFile("testdata/" + filename)
664
+ require.NoError(t, err, "Failed to read test data file: %s", filename)
665
+
666
+ return data
667
+}
668
+
669
+// startDaemonAndWaitForAutoConf starts a daemon and waits for it to fetch autoconf data.
670
+// It returns the node with daemon running and ensures autoconf has been cached before returning.
671
+// This is a DRY helper to avoid repeating daemon setup and request waiting logic in every test.
672
+func startDaemonAndWaitForAutoConf(t *testing.T, node *harness.Node, requestCount *atomic.Int32) *harness.Node {
673
+ t.Helper()
674
+
675
+ // Start daemon to fetch and cache autoconf data
676
+ t.Log("Starting daemon to fetch and cache autoconf data...")
677
+ daemon := node.StartDaemon()
678
+ // StartDaemon returns *Node, no error to check
679
+
680
+ // Wait for daemon to fetch autoconf (wait for HTTP request to mock server)
681
+ t.Log("Waiting for daemon to fetch autoconf from mock server...")
682
+ timeout := time.After(10 * time.Second) // Safety timeout
683
+ ticker := time.NewTicker(10 * time.Millisecond)
684
+ defer ticker.Stop()
685
+
686
+ for {
687
+ select {
688
+ case <-timeout:
689
+ t.Fatal("Timeout waiting for autoconf fetch")
690
+ case <-ticker.C:
691
+ if requestCount.Load() > 0 {
692
+ t.Logf("Daemon fetched autoconf (%d requests made)", requestCount.Load())
693
+ t.Log("AutoConf should now be cached by daemon")
694
+ return daemon
695
+ }
696
+ }
697
+ }
698
+}
test/cli/autoconf/expand_fallback_test.go
new
+286
@@ -0,0 +1,286 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/ipfs/boxo/autoconf"
12
+ "github.com/ipfs/kubo/test/cli/harness"
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+)
16
+
17
+func TestExpandAutoFallbacks(t *testing.T) {
18
+ t.Parallel()
19
+
20
+ t.Run("expand-auto with unreachable server shows fallbacks", func(t *testing.T) {
21
+ t.Parallel()
22
+ testExpandAutoWithUnreachableServer(t)
23
+ })
24
+
25
+ t.Run("expand-auto with disabled autoconf shows error", func(t *testing.T) {
26
+ t.Parallel()
27
+ testExpandAutoWithDisabledAutoConf(t)
28
+ })
29
+
30
+ t.Run("expand-auto with malformed response shows fallbacks", func(t *testing.T) {
31
+ t.Parallel()
32
+ testExpandAutoWithMalformedResponse(t)
33
+ })
34
+
35
+ t.Run("expand-auto preserves static values in mixed config", func(t *testing.T) {
36
+ t.Parallel()
37
+ testExpandAutoMixedConfigPreservesStatic(t)
38
+ })
39
+
40
+ t.Run("daemon gracefully handles malformed autoconf and uses fallbacks", func(t *testing.T) {
41
+ t.Parallel()
42
+ testDaemonWithMalformedAutoConf(t)
43
+ })
44
+}
45
+
46
+func testExpandAutoWithUnreachableServer(t *testing.T) {
47
+ // Create IPFS node with unreachable AutoConf server
48
+ node := harness.NewT(t).NewNode().Init("--profile=test")
49
+ node.SetIPFSConfig("AutoConf.URL", "http://127.0.0.1:99999/nonexistent") // Unreachable
50
+ node.SetIPFSConfig("AutoConf.Enabled", true)
51
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
52
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
53
+
54
+ // Test that --expand-auto falls back to defaults when server is unreachable
55
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
56
+ require.Equal(t, 0, result.ExitCode(), "config Bootstrap --expand-auto should succeed even with unreachable server")
57
+
58
+ var bootstrap []string
59
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
60
+ require.NoError(t, err)
61
+
62
+ // Should contain fallback bootstrap peers (not "auto" and not empty)
63
+ assert.NotContains(t, bootstrap, "auto", "Fallback bootstrap should not contain 'auto'")
64
+ assert.Greater(t, len(bootstrap), 0, "Fallback bootstrap should not be empty")
65
+
66
+ // Should contain known default bootstrap peers
67
+ foundDefaultPeer := false
68
+ for _, peer := range bootstrap {
69
+ if peer != "" && peer != "auto" {
70
+ foundDefaultPeer = true
71
+ t.Logf("Found fallback bootstrap peer: %s", peer)
72
+ break
73
+ }
74
+ }
75
+ assert.True(t, foundDefaultPeer, "Should contain at least one fallback bootstrap peer")
76
+
77
+ // Test DNS resolvers fallback
78
+ result = node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
79
+ require.Equal(t, 0, result.ExitCode(), "config DNS.Resolvers --expand-auto should succeed with unreachable server")
80
+
81
+ var resolvers map[string]string
82
+ err = json.Unmarshal([]byte(result.Stdout.String()), &resolvers)
83
+ require.NoError(t, err)
84
+
85
+ // When autoconf server is unreachable, DNS resolvers should fall back to defaults
86
+ // The "foo." resolver should not exist in fallbacks (only "eth." has fallback)
87
+ fooResolver, fooExists := resolvers["foo."]
88
+
89
+ if !fooExists {
90
+ t.Log("DNS resolver for 'foo.' has no fallback - correct behavior (only eth. has fallbacks)")
91
+ } else {
92
+ assert.NotEqual(t, "auto", fooResolver, "DNS resolver should not be 'auto' after expansion")
93
+ t.Logf("Unexpected DNS resolver for foo.: %s", fooResolver)
94
+ }
95
+}
96
+
97
+func testExpandAutoWithDisabledAutoConf(t *testing.T) {
98
+ // Create IPFS node with AutoConf disabled
99
+ node := harness.NewT(t).NewNode().Init("--profile=test")
100
+ node.SetIPFSConfig("AutoConf.Enabled", false)
101
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
102
+
103
+ // Test that --expand-auto with disabled AutoConf returns appropriate error or fallback
104
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
105
+
106
+ // When AutoConf is disabled, expand-auto should show empty results
107
+ // since "auto" values are not expanded when AutoConf.Enabled=false
108
+ var bootstrap []string
109
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
110
+ require.NoError(t, err)
111
+
112
+ // With AutoConf disabled, "auto" values are not expanded so we get empty result
113
+ assert.NotContains(t, bootstrap, "auto", "Should not contain 'auto' after expansion")
114
+ assert.Equal(t, 0, len(bootstrap), "Should be empty when AutoConf disabled (auto values not expanded)")
115
+ t.Log("Bootstrap is empty when AutoConf disabled - correct behavior")
116
+}
117
+
118
+func testExpandAutoWithMalformedResponse(t *testing.T) {
119
+ // Create server that returns malformed JSON
120
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121
+ w.Header().Set("Content-Type", "application/json")
122
+ _, _ = w.Write([]byte(`{"invalid": "json", "Bootstrap": [incomplete`)) // Malformed JSON
123
+ }))
124
+ defer server.Close()
125
+
126
+ // Create IPFS node with malformed autoconf server
127
+ node := harness.NewT(t).NewNode().Init("--profile=test")
128
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
129
+ node.SetIPFSConfig("AutoConf.Enabled", true)
130
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
131
+
132
+ // Test that --expand-auto handles malformed response gracefully
133
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
134
+ require.Equal(t, 0, result.ExitCode(), "config Bootstrap --expand-auto should succeed even with malformed response")
135
+
136
+ var bootstrap []string
137
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
138
+ require.NoError(t, err)
139
+
140
+ // Should fall back to defaults, not contain "auto"
141
+ assert.NotContains(t, bootstrap, "auto", "Should not contain 'auto' after fallback")
142
+ assert.Greater(t, len(bootstrap), 0, "Should contain fallback peers after malformed response")
143
+ t.Logf("Bootstrap after malformed response: %v", bootstrap)
144
+}
145
+
146
+func testExpandAutoMixedConfigPreservesStatic(t *testing.T) {
147
+ // Load valid test autoconf data
148
+ autoConfData := loadTestDataForFallback(t, "valid_autoconf.json")
149
+
150
+ // Create HTTP server that serves autoconf.json
151
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152
+ w.Header().Set("Content-Type", "application/json")
153
+ _, _ = w.Write(autoConfData)
154
+ }))
155
+ defer server.Close()
156
+
157
+ // Create IPFS node with mixed auto and static values
158
+ node := harness.NewT(t).NewNode().Init("--profile=test")
159
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
160
+ node.SetIPFSConfig("AutoConf.Enabled", true)
161
+
162
+ // Set mixed configuration: static + auto + static
163
+ node.SetIPFSConfig("Bootstrap", []string{
164
+ "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWTest",
165
+ "auto",
166
+ "/ip4/127.0.0.2/tcp/4001/p2p/12D3KooWTest2",
167
+ })
168
+
169
+ // Test that --expand-auto only expands "auto" values, preserves static ones
170
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
171
+ require.Equal(t, 0, result.ExitCode(), "config Bootstrap --expand-auto should succeed")
172
+
173
+ var bootstrap []string
174
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
175
+ require.NoError(t, err)
176
+
177
+ // Should not contain literal "auto" anymore
178
+ assert.NotContains(t, bootstrap, "auto", "Expanded config should not contain literal 'auto'")
179
+
180
+ // Should preserve static values at original positions
181
+ assert.Contains(t, bootstrap, "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWTest", "Should preserve first static peer")
182
+ assert.Contains(t, bootstrap, "/ip4/127.0.0.2/tcp/4001/p2p/12D3KooWTest2", "Should preserve third static peer")
183
+
184
+ // Should have more entries than just the static ones (auto got expanded)
185
+ assert.Greater(t, len(bootstrap), 2, "Should have more than just the 2 static peers")
186
+
187
+ t.Logf("Mixed config expansion result: %v", bootstrap)
188
+
189
+ // Verify order is preserved: static, expanded auto values, static
190
+ assert.Equal(t, "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWTest", bootstrap[0], "First peer should be preserved")
191
+ lastIndex := len(bootstrap) - 1
192
+ assert.Equal(t, "/ip4/127.0.0.2/tcp/4001/p2p/12D3KooWTest2", bootstrap[lastIndex], "Last peer should be preserved")
193
+}
194
+
195
+func testDaemonWithMalformedAutoConf(t *testing.T) {
196
+ // Test scenario: Daemon starts with AutoConf.URL pointing to server that returns malformed JSON
197
+ // This tests that daemon gracefully handles malformed responses and falls back to hardcoded defaults
198
+
199
+ // Create server that returns malformed JSON to simulate broken autoconf service
200
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
201
+ w.Header().Set("Content-Type", "application/json")
202
+ // Return malformed JSON that cannot be parsed
203
+ _, _ = w.Write([]byte(`{"Bootstrap": ["incomplete array", "missing closing bracket"`))
204
+ }))
205
+ defer server.Close()
206
+
207
+ // Create IPFS node with autoconf pointing to malformed server
208
+ node := harness.NewT(t).NewNode().Init("--profile=test")
209
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
210
+ node.SetIPFSConfig("AutoConf.Enabled", true)
211
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
212
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
213
+
214
+ // Start daemon - this will attempt to fetch autoconf from malformed server
215
+ t.Log("Starting daemon with malformed autoconf server...")
216
+ daemon := node.StartDaemon()
217
+ defer daemon.StopDaemon()
218
+
219
+ // Wait for daemon to attempt autoconf fetch and handle the error gracefully
220
+ time.Sleep(6 * time.Second) // defaultTimeout is 5s, add 1s buffer
221
+ t.Log("Daemon should have attempted autoconf fetch and fallen back to defaults")
222
+
223
+ // Test that daemon is still running and CLI commands work with fallback values
224
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
225
+ require.Equal(t, 0, result.ExitCode(), "config Bootstrap --expand-auto should succeed with daemon running")
226
+
227
+ var bootstrap []string
228
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
229
+ require.NoError(t, err)
230
+
231
+ // Should fall back to hardcoded defaults from GetMainnetFallbackConfig()
232
+ // NOTE: These values may change if autoconf library updates GetMainnetFallbackConfig()
233
+ assert.NotContains(t, bootstrap, "auto", "Should not contain 'auto' after fallback")
234
+ assert.Greater(t, len(bootstrap), 0, "Should contain fallback bootstrap peers")
235
+
236
+ // Verify we got actual fallback bootstrap peers from GetMainnetFallbackConfig() AminoDHT NativeConfig
237
+ fallbackConfig := autoconf.GetMainnetFallbackConfig()
238
+ aminoDHTSystem := fallbackConfig.SystemRegistry["AminoDHT"]
239
+ expectedBootstrapPeers := aminoDHTSystem.NativeConfig.Bootstrap
240
+
241
+ foundFallbackPeers := 0
242
+ for _, expectedPeer := range expectedBootstrapPeers {
243
+ for _, actualPeer := range bootstrap {
244
+ if actualPeer == expectedPeer {
245
+ foundFallbackPeers++
246
+ break
247
+ }
248
+ }
249
+ }
250
+ assert.Greater(t, foundFallbackPeers, 0, "Should contain bootstrap peers from GetMainnetFallbackConfig() AminoDHT NativeConfig")
251
+ assert.Equal(t, len(expectedBootstrapPeers), foundFallbackPeers, "Should contain all bootstrap peers from GetMainnetFallbackConfig() AminoDHT NativeConfig")
252
+
253
+ t.Logf("Daemon fallback bootstrap peers after malformed response: %v", bootstrap)
254
+
255
+ // Test DNS resolvers also fall back correctly
256
+ result = node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
257
+ require.Equal(t, 0, result.ExitCode(), "config DNS.Resolvers --expand-auto should succeed with daemon running")
258
+
259
+ var resolvers map[string]string
260
+ err = json.Unmarshal([]byte(result.Stdout.String()), &resolvers)
261
+ require.NoError(t, err)
262
+
263
+ // Should not contain "auto" and should have fallback DNS resolvers
264
+ assert.NotEqual(t, "auto", resolvers["foo."], "DNS resolver should not be 'auto' after fallback")
265
+ if resolvers["foo."] != "" {
266
+ // If resolver is populated, it should be a valid URL from fallbacks
267
+ assert.Contains(t, resolvers["foo."], "https://", "Fallback DNS resolver should be HTTPS URL")
268
+ }
269
+
270
+ t.Logf("Daemon fallback DNS resolvers after malformed response: %v", resolvers)
271
+
272
+ // Verify daemon is still healthy and responsive
273
+ versionResult := node.RunIPFS("version")
274
+ require.Equal(t, 0, versionResult.ExitCode(), "daemon should remain healthy after handling malformed autoconf")
275
+ t.Log("Daemon remains healthy after gracefully handling malformed autoconf response")
276
+}
277
+
278
+// Helper function to load test data files for fallback tests
279
+func loadTestDataForFallback(t *testing.T, filename string) []byte {
280
+ t.Helper()
281
+
282
+ data, err := os.ReadFile("testdata/" + filename)
283
+ require.NoError(t, err, "Failed to read test data file: %s", filename)
284
+
285
+ return data
286
+}
test/cli/autoconf/expand_test.go
new
+732
@@ -0,0 +1,732 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/ipfs/kubo/test/cli/harness"
12
+ "github.com/stretchr/testify/assert"
13
+ "github.com/stretchr/testify/require"
14
+)
15
+
16
+func TestAutoConfExpand(t *testing.T) {
17
+ t.Parallel()
18
+
19
+ t.Run("config commands show auto values", func(t *testing.T) {
20
+ t.Parallel()
21
+ testConfigCommandsShowAutoValues(t)
22
+ })
23
+
24
+ t.Run("mixed configuration preserves both auto and static", func(t *testing.T) {
25
+ t.Parallel()
26
+ testMixedConfigurationPreserved(t)
27
+ })
28
+
29
+ t.Run("config replace preserves auto values", func(t *testing.T) {
30
+ t.Parallel()
31
+ testConfigReplacePreservesAuto(t)
32
+ })
33
+
34
+ t.Run("expand-auto filters unsupported URL paths with delegated routing", func(t *testing.T) {
35
+ t.Parallel()
36
+ testExpandAutoFiltersUnsupportedPathsDelegated(t)
37
+ })
38
+
39
+ t.Run("expand-auto with auto routing uses NewRoutingSystem", func(t *testing.T) {
40
+ t.Parallel()
41
+ testExpandAutoWithAutoRouting(t)
42
+ })
43
+
44
+ t.Run("expand-auto with auto routing shows AminoDHT native vs IPNI delegated", func(t *testing.T) {
45
+ t.Parallel()
46
+ testExpandAutoWithMixedSystems(t)
47
+ })
48
+
49
+ t.Run("expand-auto filters paths with NewRoutingSystem and auto routing", func(t *testing.T) {
50
+ t.Parallel()
51
+ testExpandAutoWithFiltering(t)
52
+ })
53
+
54
+ t.Run("expand-auto falls back to defaults without cache (delegated)", func(t *testing.T) {
55
+ t.Parallel()
56
+ testExpandAutoWithoutCacheDelegated(t)
57
+ })
58
+
59
+ t.Run("expand-auto with auto routing without cache", func(t *testing.T) {
60
+ t.Parallel()
61
+ testExpandAutoWithoutCacheAuto(t)
62
+ })
63
+}
64
+
65
+func testConfigCommandsShowAutoValues(t *testing.T) {
66
+ // Create IPFS node
67
+ node := harness.NewT(t).NewNode().Init("--profile=test")
68
+
69
+ // Set all fields to "auto"
70
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
71
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
72
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
73
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
74
+
75
+ // Test individual field queries
76
+ t.Run("Bootstrap shows auto", func(t *testing.T) {
77
+ result := node.RunIPFS("config", "Bootstrap")
78
+ require.Equal(t, 0, result.ExitCode())
79
+
80
+ var bootstrap []string
81
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
82
+ require.NoError(t, err)
83
+ assert.Equal(t, []string{"auto"}, bootstrap)
84
+ })
85
+
86
+ t.Run("DNS.Resolvers shows auto", func(t *testing.T) {
87
+ result := node.RunIPFS("config", "DNS.Resolvers")
88
+ require.Equal(t, 0, result.ExitCode())
89
+
90
+ var resolvers map[string]string
91
+ err := json.Unmarshal([]byte(result.Stdout.String()), &resolvers)
92
+ require.NoError(t, err)
93
+ assert.Equal(t, map[string]string{"foo.": "auto"}, resolvers)
94
+ })
95
+
96
+ t.Run("Routing.DelegatedRouters shows auto", func(t *testing.T) {
97
+ result := node.RunIPFS("config", "Routing.DelegatedRouters")
98
+ require.Equal(t, 0, result.ExitCode())
99
+
100
+ var routers []string
101
+ err := json.Unmarshal([]byte(result.Stdout.String()), &routers)
102
+ require.NoError(t, err)
103
+ assert.Equal(t, []string{"auto"}, routers)
104
+ })
105
+
106
+ t.Run("Ipns.DelegatedPublishers shows auto", func(t *testing.T) {
107
+ result := node.RunIPFS("config", "Ipns.DelegatedPublishers")
108
+ require.Equal(t, 0, result.ExitCode())
109
+
110
+ var publishers []string
111
+ err := json.Unmarshal([]byte(result.Stdout.String()), &publishers)
112
+ require.NoError(t, err)
113
+ assert.Equal(t, []string{"auto"}, publishers)
114
+ })
115
+
116
+ t.Run("config show contains all auto values", func(t *testing.T) {
117
+ result := node.RunIPFS("config", "show")
118
+ require.Equal(t, 0, result.ExitCode())
119
+
120
+ output := result.Stdout.String()
121
+
122
+ // Check that auto values are present in the full config
123
+ assert.Contains(t, output, `"Bootstrap": [
124
+ "auto"
125
+ ]`, "Bootstrap should contain auto")
126
+
127
+ assert.Contains(t, output, `"DNS": {
128
+ "Resolvers": {
129
+ "foo.": "auto"
130
+ }
131
+ }`, "DNS.Resolvers should contain auto")
132
+
133
+ assert.Contains(t, output, `"DelegatedRouters": [
134
+ "auto"
135
+ ]`, "Routing.DelegatedRouters should contain auto")
136
+
137
+ assert.Contains(t, output, `"DelegatedPublishers": [
138
+ "auto"
139
+ ]`, "Ipns.DelegatedPublishers should contain auto")
140
+ })
141
+
142
+ // Test with autoconf server for --expand-auto functionality
143
+ t.Run("config with --expand-auto expands auto values", func(t *testing.T) {
144
+ // Load test autoconf data
145
+ autoConfData := loadTestDataExpand(t, "valid_autoconf.json")
146
+
147
+ // Create HTTP server that serves autoconf.json
148
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149
+ w.Header().Set("Content-Type", "application/json")
150
+ _, _ = w.Write(autoConfData)
151
+ }))
152
+ defer server.Close()
153
+
154
+ // Configure autoconf for the node
155
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
156
+ node.SetIPFSConfig("AutoConf.Enabled", true)
157
+
158
+ // Test Bootstrap field expansion
159
+ result := node.RunIPFS("config", "Bootstrap", "--expand-auto")
160
+ require.Equal(t, 0, result.ExitCode(), "config Bootstrap --expand-auto should succeed")
161
+
162
+ var expandedBootstrap []string
163
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedBootstrap)
164
+ require.NoError(t, err)
165
+ assert.NotContains(t, expandedBootstrap, "auto", "Expanded bootstrap should not contain 'auto'")
166
+ assert.Greater(t, len(expandedBootstrap), 0, "Expanded bootstrap should contain expanded peers")
167
+
168
+ // Test DNS.Resolvers field expansion
169
+ result = node.RunIPFS("config", "DNS.Resolvers", "--expand-auto")
170
+ require.Equal(t, 0, result.ExitCode(), "config DNS.Resolvers --expand-auto should succeed")
171
+
172
+ var expandedResolvers map[string]string
173
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedResolvers)
174
+ require.NoError(t, err)
175
+ assert.NotEqual(t, "auto", expandedResolvers["foo."], "Expanded DNS resolver should not be 'auto'")
176
+
177
+ // Test Routing.DelegatedRouters field expansion
178
+ result = node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
179
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
180
+
181
+ var expandedRouters []string
182
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
183
+ require.NoError(t, err)
184
+ assert.NotContains(t, expandedRouters, "auto", "Expanded routers should not contain 'auto'")
185
+
186
+ // Test Ipns.DelegatedPublishers field expansion
187
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
188
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
189
+
190
+ var expandedPublishers []string
191
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
192
+ require.NoError(t, err)
193
+ assert.NotContains(t, expandedPublishers, "auto", "Expanded publishers should not contain 'auto'")
194
+
195
+ // Test config show --expand-auto (full config expansion)
196
+ result = node.RunIPFS("config", "show", "--expand-auto")
197
+ require.Equal(t, 0, result.ExitCode(), "config show --expand-auto should succeed")
198
+
199
+ expandedOutput := result.Stdout.String()
200
+ t.Logf("Expanded config output contains: %d characters", len(expandedOutput))
201
+
202
+ // Verify that auto values are expanded in the full config
203
+ assert.NotContains(t, expandedOutput, `"auto"`, "Expanded config should not contain literal 'auto' values")
204
+ assert.Contains(t, expandedOutput, `"Bootstrap"`, "Expanded config should contain Bootstrap section")
205
+ assert.Contains(t, expandedOutput, `"DNS"`, "Expanded config should contain DNS section")
206
+ })
207
+}
208
+
209
+func testMixedConfigurationPreserved(t *testing.T) {
210
+ // Create IPFS node
211
+ node := harness.NewT(t).NewNode().Init("--profile=test")
212
+
213
+ // Set mixed configuration
214
+ node.SetIPFSConfig("Bootstrap", []string{
215
+ "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWTest",
216
+ "auto",
217
+ "/ip4/127.0.0.2/tcp/4001/p2p/12D3KooWTest2",
218
+ })
219
+
220
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{
221
+ "eth.": "https://eth.resolver",
222
+ "foo.": "auto",
223
+ "bar.": "https://bar.resolver",
224
+ })
225
+
226
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{
227
+ "https://static.router",
228
+ "auto",
229
+ })
230
+
231
+ // Verify Bootstrap preserves order and mixes auto with static
232
+ result := node.RunIPFS("config", "Bootstrap")
233
+ require.Equal(t, 0, result.ExitCode())
234
+
235
+ var bootstrap []string
236
+ err := json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
237
+ require.NoError(t, err)
238
+ assert.Equal(t, []string{
239
+ "/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWTest",
240
+ "auto",
241
+ "/ip4/127.0.0.2/tcp/4001/p2p/12D3KooWTest2",
242
+ }, bootstrap)
243
+
244
+ // Verify DNS.Resolvers preserves both auto and static
245
+ result = node.RunIPFS("config", "DNS.Resolvers")
246
+ require.Equal(t, 0, result.ExitCode())
247
+
248
+ var resolvers map[string]string
249
+ err = json.Unmarshal([]byte(result.Stdout.String()), &resolvers)
250
+ require.NoError(t, err)
251
+ assert.Equal(t, "https://eth.resolver", resolvers["eth."])
252
+ assert.Equal(t, "auto", resolvers["foo."])
253
+ assert.Equal(t, "https://bar.resolver", resolvers["bar."])
254
+
255
+ // Verify Routing.DelegatedRouters preserves order
256
+ result = node.RunIPFS("config", "Routing.DelegatedRouters")
257
+ require.Equal(t, 0, result.ExitCode())
258
+
259
+ var routers []string
260
+ err = json.Unmarshal([]byte(result.Stdout.String()), &routers)
261
+ require.NoError(t, err)
262
+ assert.Equal(t, []string{
263
+ "https://static.router",
264
+ "auto",
265
+ }, routers)
266
+}
267
+
268
+func testConfigReplacePreservesAuto(t *testing.T) {
269
+ // Create IPFS node
270
+ h := harness.NewT(t)
271
+ node := h.NewNode().Init("--profile=test")
272
+
273
+ // Set initial auto values
274
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
275
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
276
+
277
+ // Export current config
278
+ result := node.RunIPFS("config", "show")
279
+ require.Equal(t, 0, result.ExitCode())
280
+ originalConfig := result.Stdout.String()
281
+
282
+ // Verify auto values are in the exported config
283
+ assert.Contains(t, originalConfig, `"Bootstrap": [
284
+ "auto"
285
+ ]`)
286
+ assert.Contains(t, originalConfig, `"foo.": "auto"`)
287
+
288
+ // Modify the config string to add a new field but preserve auto values
289
+ var configMap map[string]interface{}
290
+ err := json.Unmarshal([]byte(originalConfig), &configMap)
291
+ require.NoError(t, err)
292
+
293
+ // Add a new field
294
+ configMap["NewTestField"] = "test-value"
295
+
296
+ // Marshal back to JSON
297
+ modifiedConfig, err := json.MarshalIndent(configMap, "", " ")
298
+ require.NoError(t, err)
299
+
300
+ // Write config to file and replace
301
+ configFile := h.WriteToTemp(string(modifiedConfig))
302
+ replaceResult := node.RunIPFS("config", "replace", configFile)
303
+ if replaceResult.ExitCode() != 0 {
304
+ t.Logf("Config replace failed: stdout=%s, stderr=%s", replaceResult.Stdout.String(), replaceResult.Stderr.String())
305
+ }
306
+ require.Equal(t, 0, replaceResult.ExitCode())
307
+
308
+ // Verify auto values are still present after replace
309
+ result = node.RunIPFS("config", "Bootstrap")
310
+ require.Equal(t, 0, result.ExitCode())
311
+
312
+ var bootstrap []string
313
+ err = json.Unmarshal([]byte(result.Stdout.String()), &bootstrap)
314
+ require.NoError(t, err)
315
+ assert.Equal(t, []string{"auto"}, bootstrap, "Bootstrap should still contain auto after config replace")
316
+
317
+ // Verify DNS resolver config is preserved after replace
318
+ result = node.RunIPFS("config", "DNS.Resolvers")
319
+ require.Equal(t, 0, result.ExitCode())
320
+
321
+ var resolvers map[string]string
322
+ err = json.Unmarshal([]byte(result.Stdout.String()), &resolvers)
323
+ require.NoError(t, err)
324
+ assert.Equal(t, "auto", resolvers["foo."], "DNS resolver for foo. should still be auto after config replace")
325
+}
326
+
327
+func testExpandAutoFiltersUnsupportedPathsDelegated(t *testing.T) {
328
+ // Test scenario: CLI with daemon started and autoconf cached using delegated routing
329
+ // This tests the production scenario where delegated routing is enabled and
330
+ // daemon has fetched and cached autoconf data, and CLI commands read from that cache
331
+
332
+ // Create IPFS node
333
+ node := harness.NewT(t).NewNode().Init("--profile=test")
334
+
335
+ // Configure delegated routing to use autoconf URLs
336
+ node.SetIPFSConfig("Routing.Type", "delegated")
337
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
338
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
339
+ // Disable content providing when using delegated routing
340
+ node.SetIPFSConfig("Provider.Enabled", false)
341
+ node.SetIPFSConfig("Reprovider.Interval", "0")
342
+
343
+ // Load test autoconf data with unsupported paths
344
+ autoConfData := loadTestDataExpand(t, "autoconf_with_unsupported_paths.json")
345
+
346
+ // Create HTTP server that serves autoconf.json with unsupported paths
347
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
348
+ w.Header().Set("Content-Type", "application/json")
349
+ _, _ = w.Write(autoConfData)
350
+ }))
351
+ defer server.Close()
352
+
353
+ // Configure autoconf for the node
354
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
355
+ node.SetIPFSConfig("AutoConf.Enabled", true)
356
+
357
+ // Verify the autoconf URL is set correctly
358
+ result := node.RunIPFS("config", "AutoConf.URL")
359
+ require.Equal(t, 0, result.ExitCode(), "config AutoConf.URL should succeed")
360
+ t.Logf("AutoConf URL is set to: %s", result.Stdout.String())
361
+ assert.Contains(t, result.Stdout.String(), "127.0.0.1", "AutoConf URL should contain the test server address")
362
+
363
+ // Start daemon to fetch and cache autoconf data
364
+ t.Log("Starting daemon to fetch and cache autoconf data...")
365
+ daemon := node.StartDaemon()
366
+ defer daemon.StopDaemon()
367
+
368
+ // Wait for autoconf fetch (use autoconf default timeout + buffer)
369
+ time.Sleep(6 * time.Second) // defaultTimeout is 5s, add 1s buffer
370
+ t.Log("AutoConf should now be cached by daemon")
371
+
372
+ // Test Routing.DelegatedRouters field expansion filters unsupported paths
373
+ result = node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
374
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
375
+
376
+ var expandedRouters []string
377
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
378
+ require.NoError(t, err)
379
+
380
+ // After cache prewarming, should get URLs from autoconf that have supported paths
381
+ assert.Contains(t, expandedRouters, "https://supported.example.com/routing/v1/providers", "Should contain supported provider URL")
382
+ assert.Contains(t, expandedRouters, "https://supported.example.com/routing/v1/peers", "Should contain supported peers URL")
383
+ assert.Contains(t, expandedRouters, "https://mixed.example.com/routing/v1/providers", "Should contain mixed provider URL")
384
+ assert.Contains(t, expandedRouters, "https://mixed.example.com/routing/v1/peers", "Should contain mixed peers URL")
385
+
386
+ // Verify unsupported URLs from autoconf are filtered out (not in result)
387
+ assert.NotContains(t, expandedRouters, "https://unsupported.example.com/example/v0/read", "Should filter out unsupported path /example/v0/read")
388
+ assert.NotContains(t, expandedRouters, "https://unsupported.example.com/api/v1/custom", "Should filter out unsupported path /api/v1/custom")
389
+ assert.NotContains(t, expandedRouters, "https://mixed.example.com/unsupported/path", "Should filter out unsupported path /unsupported/path")
390
+
391
+ t.Logf("Filtered routers: %v", expandedRouters)
392
+
393
+ // Test Ipns.DelegatedPublishers field expansion filters unsupported paths
394
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
395
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
396
+
397
+ var expandedPublishers []string
398
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
399
+ require.NoError(t, err)
400
+
401
+ // After cache prewarming, should get URLs from autoconf that have supported paths
402
+ assert.Contains(t, expandedPublishers, "https://supported.example.com/routing/v1/ipns", "Should contain supported IPNS URL")
403
+ assert.Contains(t, expandedPublishers, "https://mixed.example.com/routing/v1/ipns", "Should contain mixed IPNS URL")
404
+
405
+ // Verify unsupported URLs from autoconf are filtered out (not in result)
406
+ assert.NotContains(t, expandedPublishers, "https://unsupported.example.com/example/v0/write", "Should filter out unsupported write path")
407
+
408
+ t.Logf("Filtered publishers: %v", expandedPublishers)
409
+}
410
+
411
+func testExpandAutoWithoutCacheDelegated(t *testing.T) {
412
+ // Test scenario: CLI without daemon ever starting (no cached autoconf) using delegated routing
413
+ // This tests the fallback scenario where delegated routing is configured but CLI commands
414
+ // cannot read from cache and must fall back to hardcoded defaults
415
+
416
+ // Create IPFS node but DO NOT start daemon
417
+ node := harness.NewT(t).NewNode().Init("--profile=test")
418
+
419
+ // Configure delegated routing to use autoconf URLs (but no daemon to fetch them)
420
+ node.SetIPFSConfig("Routing.Type", "delegated")
421
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
422
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
423
+ // Disable content providing when using delegated routing
424
+ node.SetIPFSConfig("Provider.Enabled", false)
425
+ node.SetIPFSConfig("Reprovider.Interval", "0")
426
+
427
+ // Load test autoconf data with unsupported paths (this won't be used since no daemon)
428
+ autoConfData := loadTestDataExpand(t, "autoconf_with_unsupported_paths.json")
429
+
430
+ // Create HTTP server that serves autoconf.json with unsupported paths
431
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
432
+ w.Header().Set("Content-Type", "application/json")
433
+ _, _ = w.Write(autoConfData)
434
+ }))
435
+ defer server.Close()
436
+
437
+ // Configure autoconf for the node (but daemon never starts to fetch it)
438
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
439
+ node.SetIPFSConfig("AutoConf.Enabled", true)
440
+
441
+ // Test Routing.DelegatedRouters field expansion without cached autoconf
442
+ result := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
443
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
444
+
445
+ var expandedRouters []string
446
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
447
+ require.NoError(t, err)
448
+
449
+ // Without cached autoconf, should get fallback URLs from GetMainnetFallbackConfig()
450
+ // NOTE: These values may change if autoconf library updates GetMainnetFallbackConfig()
451
+ assert.Contains(t, expandedRouters, "https://cid.contact/routing/v1/providers", "Should contain fallback provider URL from GetMainnetFallbackConfig()")
452
+
453
+ t.Logf("Fallback routers (no cache): %v", expandedRouters)
454
+
455
+ // Test Ipns.DelegatedPublishers field expansion without cached autoconf
456
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
457
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
458
+
459
+ var expandedPublishers []string
460
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
461
+ require.NoError(t, err)
462
+
463
+ // Without cached autoconf, should get fallback IPNS publishers from GetMainnetFallbackConfig()
464
+ // NOTE: These values may change if autoconf library updates GetMainnetFallbackConfig()
465
+ assert.Contains(t, expandedPublishers, "https://delegated-ipfs.dev/routing/v1/ipns", "Should contain fallback IPNS URL from GetMainnetFallbackConfig()")
466
+
467
+ t.Logf("Fallback publishers (no cache): %v", expandedPublishers)
468
+}
469
+
470
+func testExpandAutoWithAutoRouting(t *testing.T) {
471
+ // Test scenario: CLI with daemon started using auto routing with NewRoutingSystem
472
+ // This tests that non-native systems (NewRoutingSystem) ARE delegated even with auto routing
473
+ // Only native systems like AminoDHT are handled internally with auto routing
474
+
475
+ // Create IPFS node
476
+ node := harness.NewT(t).NewNode().Init("--profile=test")
477
+
478
+ // Configure auto routing with non-native system
479
+ node.SetIPFSConfig("Routing.Type", "auto")
480
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
481
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
482
+
483
+ // Load test autoconf data with NewRoutingSystem (non-native, will be delegated)
484
+ autoConfData := loadTestDataExpand(t, "autoconf_new_routing_system.json")
485
+
486
+ // Create HTTP server that serves autoconf.json
487
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
488
+ w.Header().Set("Content-Type", "application/json")
489
+ _, _ = w.Write(autoConfData)
490
+ }))
491
+ defer server.Close()
492
+
493
+ // Configure autoconf for the node
494
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
495
+ node.SetIPFSConfig("AutoConf.Enabled", true)
496
+
497
+ // Start daemon to fetch and cache autoconf data
498
+ t.Log("Starting daemon to fetch and cache autoconf data...")
499
+ daemon := node.StartDaemon()
500
+ defer daemon.StopDaemon()
501
+
502
+ // Wait for autoconf fetch (use autoconf default timeout + buffer)
503
+ time.Sleep(6 * time.Second) // defaultTimeout is 5s, add 1s buffer
504
+ t.Log("AutoConf should now be cached by daemon")
505
+
506
+ // Test Routing.DelegatedRouters field expansion with auto routing
507
+ result := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
508
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
509
+
510
+ var expandedRouters []string
511
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
512
+ require.NoError(t, err)
513
+
514
+ // With auto routing and NewRoutingSystem (non-native), delegated endpoints should be populated
515
+ assert.Contains(t, expandedRouters, "https://new-routing.example.com/routing/v1/providers", "Should contain NewRoutingSystem provider URL")
516
+ assert.Contains(t, expandedRouters, "https://new-routing.example.com/routing/v1/peers", "Should contain NewRoutingSystem peers URL")
517
+
518
+ t.Logf("Auto routing routers (NewRoutingSystem delegated): %v", expandedRouters)
519
+
520
+ // Test Ipns.DelegatedPublishers field expansion with auto routing
521
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
522
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
523
+
524
+ var expandedPublishers []string
525
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
526
+ require.NoError(t, err)
527
+
528
+ // With auto routing and NewRoutingSystem (non-native), delegated publishers should be populated
529
+ assert.Contains(t, expandedPublishers, "https://new-routing.example.com/routing/v1/ipns", "Should contain NewRoutingSystem IPNS URL")
530
+
531
+ t.Logf("Auto routing publishers (NewRoutingSystem delegated): %v", expandedPublishers)
532
+}
533
+
534
+func testExpandAutoWithMixedSystems(t *testing.T) {
535
+ // Test scenario: Auto routing with both AminoDHT (native) and IPNI (delegated) systems
536
+ // This explicitly confirms that AminoDHT is NOT delegated but IPNI at cid.contact IS delegated
537
+
538
+ // Create IPFS node
539
+ node := harness.NewT(t).NewNode().Init("--profile=test")
540
+
541
+ // Configure auto routing
542
+ node.SetIPFSConfig("Routing.Type", "auto")
543
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
544
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
545
+
546
+ // Load test autoconf data with both AminoDHT and IPNI systems
547
+ autoConfData := loadTestDataExpand(t, "autoconf_amino_and_ipni.json")
548
+
549
+ // Create HTTP server that serves autoconf.json
550
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
551
+ w.Header().Set("Content-Type", "application/json")
552
+ _, _ = w.Write(autoConfData)
553
+ }))
554
+ defer server.Close()
555
+
556
+ // Configure autoconf for the node
557
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
558
+ node.SetIPFSConfig("AutoConf.Enabled", true)
559
+
560
+ // Start daemon to fetch and cache autoconf data
561
+ t.Log("Starting daemon to fetch and cache autoconf data...")
562
+ daemon := node.StartDaemon()
563
+ defer daemon.StopDaemon()
564
+
565
+ // Wait for autoconf fetch (use autoconf default timeout + buffer)
566
+ time.Sleep(6 * time.Second) // defaultTimeout is 5s, add 1s buffer
567
+ t.Log("AutoConf should now be cached by daemon")
568
+
569
+ // Test Routing.DelegatedRouters field expansion
570
+ result := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
571
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
572
+
573
+ var expandedRouters []string
574
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
575
+ require.NoError(t, err)
576
+
577
+ // With auto routing: AminoDHT (native) should NOT be delegated, IPNI should be delegated
578
+ assert.Contains(t, expandedRouters, "https://cid.contact/routing/v1/providers", "Should contain IPNI provider URL (delegated)")
579
+ assert.NotContains(t, expandedRouters, "https://amino-dht.example.com", "Should NOT contain AminoDHT URLs (native)")
580
+
581
+ t.Logf("Mixed systems routers (IPNI delegated, AminoDHT native): %v", expandedRouters)
582
+
583
+ // Test Ipns.DelegatedPublishers field expansion
584
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
585
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
586
+
587
+ var expandedPublishers []string
588
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
589
+ require.NoError(t, err)
590
+
591
+ // IPNI system doesn't have write endpoints, so publishers should be empty
592
+ // (or contain other systems if they have write endpoints)
593
+ t.Logf("Mixed systems publishers (IPNI has no write endpoints): %v", expandedPublishers)
594
+}
595
+
596
+func testExpandAutoWithFiltering(t *testing.T) {
597
+ // Test scenario: Auto routing with NewRoutingSystem and path filtering
598
+ // This tests that path filtering works for delegated systems even with auto routing
599
+
600
+ // Create IPFS node
601
+ node := harness.NewT(t).NewNode().Init("--profile=test")
602
+
603
+ // Configure auto routing
604
+ node.SetIPFSConfig("Routing.Type", "auto")
605
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
606
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
607
+
608
+ // Load test autoconf data with NewRoutingSystem and mixed valid/invalid paths
609
+ autoConfData := loadTestDataExpand(t, "autoconf_new_routing_with_filtering.json")
610
+
611
+ // Create HTTP server that serves autoconf.json
612
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
613
+ w.Header().Set("Content-Type", "application/json")
614
+ _, _ = w.Write(autoConfData)
615
+ }))
616
+ defer server.Close()
617
+
618
+ // Configure autoconf for the node
619
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
620
+ node.SetIPFSConfig("AutoConf.Enabled", true)
621
+
622
+ // Start daemon to fetch and cache autoconf data
623
+ t.Log("Starting daemon to fetch and cache autoconf data...")
624
+ daemon := node.StartDaemon()
625
+ defer daemon.StopDaemon()
626
+
627
+ // Wait for autoconf fetch (use autoconf default timeout + buffer)
628
+ time.Sleep(6 * time.Second) // defaultTimeout is 5s, add 1s buffer
629
+ t.Log("AutoConf should now be cached by daemon")
630
+
631
+ // Test Routing.DelegatedRouters field expansion with filtering
632
+ result := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
633
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
634
+
635
+ var expandedRouters []string
636
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
637
+ require.NoError(t, err)
638
+
639
+ // Should contain supported paths from NewRoutingSystem
640
+ assert.Contains(t, expandedRouters, "https://supported-new.example.com/routing/v1/providers", "Should contain supported provider URL")
641
+ assert.Contains(t, expandedRouters, "https://supported-new.example.com/routing/v1/peers", "Should contain supported peers URL")
642
+ assert.Contains(t, expandedRouters, "https://mixed-new.example.com/routing/v1/providers", "Should contain mixed provider URL")
643
+ assert.Contains(t, expandedRouters, "https://mixed-new.example.com/routing/v1/peers", "Should contain mixed peers URL")
644
+
645
+ // Should NOT contain unsupported paths
646
+ assert.NotContains(t, expandedRouters, "https://unsupported-new.example.com/custom/v0/read", "Should filter out unsupported path")
647
+ assert.NotContains(t, expandedRouters, "https://unsupported-new.example.com/api/v1/nonstandard", "Should filter out unsupported path")
648
+ assert.NotContains(t, expandedRouters, "https://mixed-new.example.com/invalid/path", "Should filter out invalid path from mixed endpoint")
649
+
650
+ t.Logf("Filtered routers (NewRoutingSystem with auto routing): %v", expandedRouters)
651
+
652
+ // Test Ipns.DelegatedPublishers field expansion with filtering
653
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
654
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
655
+
656
+ var expandedPublishers []string
657
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
658
+ require.NoError(t, err)
659
+
660
+ // Should contain supported IPNS paths
661
+ assert.Contains(t, expandedPublishers, "https://supported-new.example.com/routing/v1/ipns", "Should contain supported IPNS URL")
662
+ assert.Contains(t, expandedPublishers, "https://mixed-new.example.com/routing/v1/ipns", "Should contain mixed IPNS URL")
663
+
664
+ // Should NOT contain unsupported write paths
665
+ assert.NotContains(t, expandedPublishers, "https://unsupported-new.example.com/custom/v0/write", "Should filter out unsupported write path")
666
+
667
+ t.Logf("Filtered publishers (NewRoutingSystem with auto routing): %v", expandedPublishers)
668
+}
669
+
670
+func testExpandAutoWithoutCacheAuto(t *testing.T) {
671
+ // Test scenario: CLI without daemon ever starting using auto routing (default)
672
+ // This tests the fallback scenario where auto routing is used but doesn't populate delegated config fields
673
+
674
+ // Create IPFS node but DO NOT start daemon
675
+ node := harness.NewT(t).NewNode().Init("--profile=test")
676
+
677
+ // Configure auto routing - delegated fields are set to "auto" but won't be populated
678
+ // because auto routing uses different internal mechanisms
679
+ node.SetIPFSConfig("Routing.Type", "auto")
680
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
681
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
682
+
683
+ // Load test autoconf data (this won't be used since no daemon and auto routing doesn't use these fields)
684
+ autoConfData := loadTestDataExpand(t, "autoconf_with_unsupported_paths.json")
685
+
686
+ // Create HTTP server (won't be contacted since no daemon)
687
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
688
+ w.Header().Set("Content-Type", "application/json")
689
+ _, _ = w.Write(autoConfData)
690
+ }))
691
+ defer server.Close()
692
+
693
+ // Configure autoconf for the node (but daemon never starts to fetch it)
694
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
695
+ node.SetIPFSConfig("AutoConf.Enabled", true)
696
+
697
+ // Test Routing.DelegatedRouters field expansion without cached autoconf
698
+ result := node.RunIPFS("config", "Routing.DelegatedRouters", "--expand-auto")
699
+ require.Equal(t, 0, result.ExitCode(), "config Routing.DelegatedRouters --expand-auto should succeed")
700
+
701
+ var expandedRouters []string
702
+ err := json.Unmarshal([]byte(result.Stdout.String()), &expandedRouters)
703
+ require.NoError(t, err)
704
+
705
+ // With auto routing, some fallback URLs are still populated from GetMainnetFallbackConfig()
706
+ // NOTE: These values may change if autoconf library updates GetMainnetFallbackConfig()
707
+ assert.Contains(t, expandedRouters, "https://cid.contact/routing/v1/providers", "Should contain fallback provider URL from GetMainnetFallbackConfig()")
708
+
709
+ t.Logf("Auto routing fallback routers (with fallbacks): %v", expandedRouters)
710
+
711
+ // Test Ipns.DelegatedPublishers field expansion without cached autoconf
712
+ result = node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
713
+ require.Equal(t, 0, result.ExitCode(), "config Ipns.DelegatedPublishers --expand-auto should succeed")
714
+
715
+ var expandedPublishers []string
716
+ err = json.Unmarshal([]byte(result.Stdout.String()), &expandedPublishers)
717
+ require.NoError(t, err)
718
+
719
+ // With auto routing, delegated publishers may be empty for fallback scenario
720
+ // This can vary based on which systems have write endpoints in the fallback config
721
+ t.Logf("Auto routing fallback publishers: %v", expandedPublishers)
722
+}
723
+
724
+// Helper function to load test data files
725
+func loadTestDataExpand(t *testing.T, filename string) []byte {
726
+ t.Helper()
727
+
728
+ data, err := os.ReadFile("testdata/" + filename)
729
+ require.NoError(t, err, "Failed to read test data file: %s", filename)
730
+
731
+ return data
732
+}
test/cli/autoconf/extensibility_test.go
new
+255
@@ -0,0 +1,255 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/ipfs/kubo/config"
12
+ "github.com/ipfs/kubo/test/cli/harness"
13
+ "github.com/stretchr/testify/require"
14
+)
15
+
16
+// TestAutoConfExtensibility_NewSystem verifies that the AutoConf system can be extended
17
+// with new routing systems beyond the default AminoDHT and IPNI.
18
+//
19
+// The test verifies that:
20
+// 1. New systems can be added via AutoConf's SystemRegistry
21
+// 2. Native vs delegated system filtering works correctly:
22
+// - Native systems (AminoDHT) provide bootstrap peers and are used for P2P routing
23
+// - Delegated systems (IPNI, NewSystem) provide HTTP endpoints for delegated routing
24
+//
25
+// 3. The system correctly filters endpoints based on routing type
26
+//
27
+// Note: Only native systems contribute bootstrap peers. Delegated systems like "NewSystem"
28
+// only provide HTTP routing endpoints, not P2P bootstrap peers.
29
+func TestAutoConfExtensibility_NewSystem(t *testing.T) {
30
+ if testing.Short() {
31
+ t.Skip("skipping test in short mode")
32
+ }
33
+
34
+ // Setup mock autoconf server with NewSystem
35
+ var mockServer *httptest.Server
36
+ mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37
+ // Create autoconf.json with NewSystem
38
+ autoconfData := map[string]interface{}{
39
+ "AutoConfVersion": 2025072901,
40
+ "AutoConfSchema": 1,
41
+ "AutoConfTTL": 86400,
42
+ "SystemRegistry": map[string]interface{}{
43
+ "AminoDHT": map[string]interface{}{
44
+ "URL": "https://github.com/ipfs/specs/pull/497",
45
+ "Description": "Public DHT swarm",
46
+ "NativeConfig": map[string]interface{}{
47
+ "Bootstrap": []string{
48
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
49
+ },
50
+ },
51
+ "DelegatedConfig": map[string]interface{}{
52
+ "Read": []string{"/routing/v1/providers", "/routing/v1/peers", "/routing/v1/ipns"},
53
+ "Write": []string{"/routing/v1/ipns"},
54
+ },
55
+ },
56
+ "IPNI": map[string]interface{}{
57
+ "URL": "https://ipni.example.com",
58
+ "Description": "Network Indexer",
59
+ "DelegatedConfig": map[string]interface{}{
60
+ "Read": []string{"/routing/v1/providers"},
61
+ "Write": []string{},
62
+ },
63
+ },
64
+ "NewSystem": map[string]interface{}{
65
+ "URL": "https://example.com/newsystem",
66
+ "Description": "Test system for extensibility verification",
67
+ "NativeConfig": map[string]interface{}{
68
+ "Bootstrap": []string{
69
+ "/ip4/127.0.0.1/tcp/9999/p2p/12D3KooWPeQ4r3v6CmVmKXoFGtqEqcr3L8P6La9yH5oEWKtoLVVa",
70
+ },
71
+ },
72
+ "DelegatedConfig": map[string]interface{}{
73
+ "Read": []string{"/routing/v1/providers"},
74
+ "Write": []string{},
75
+ },
76
+ },
77
+ },
78
+ "DNSResolvers": map[string]interface{}{
79
+ "eth.": []string{"https://dns.eth.limo/dns-query"},
80
+ },
81
+ "DelegatedEndpoints": map[string]interface{}{
82
+ "https://ipni.example.com": map[string]interface{}{
83
+ "Systems": []string{"IPNI"},
84
+ "Read": []string{"/routing/v1/providers"},
85
+ "Write": []string{},
86
+ },
87
+ mockServer.URL + "/newsystem": map[string]interface{}{
88
+ "Systems": []string{"NewSystem"},
89
+ "Read": []string{"/routing/v1/providers"},
90
+ "Write": []string{},
91
+ },
92
+ },
93
+ }
94
+
95
+ w.Header().Set("Content-Type", "application/json")
96
+ w.Header().Set("Cache-Control", "max-age=300")
97
+ _ = json.NewEncoder(w).Encode(autoconfData)
98
+ }))
99
+ defer mockServer.Close()
100
+
101
+ // NewSystem mock server URL will be dynamically assigned
102
+ newSystemServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103
+ // Simple mock server for NewSystem endpoint
104
+ response := map[string]interface{}{"Providers": []interface{}{}}
105
+ w.Header().Set("Content-Type", "application/json")
106
+ _ = json.NewEncoder(w).Encode(response)
107
+ }))
108
+ defer newSystemServer.Close()
109
+
110
+ // Update the autoconf to point to the correct NewSystem endpoint
111
+ mockServer.Close()
112
+ mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113
+ autoconfData := map[string]interface{}{
114
+ "AutoConfVersion": 2025072901,
115
+ "AutoConfSchema": 1,
116
+ "AutoConfTTL": 86400,
117
+ "SystemRegistry": map[string]interface{}{
118
+ "AminoDHT": map[string]interface{}{
119
+ "URL": "https://github.com/ipfs/specs/pull/497",
120
+ "Description": "Public DHT swarm",
121
+ "NativeConfig": map[string]interface{}{
122
+ "Bootstrap": []string{
123
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
124
+ },
125
+ },
126
+ "DelegatedConfig": map[string]interface{}{
127
+ "Read": []string{"/routing/v1/providers", "/routing/v1/peers", "/routing/v1/ipns"},
128
+ "Write": []string{"/routing/v1/ipns"},
129
+ },
130
+ },
131
+ "IPNI": map[string]interface{}{
132
+ "URL": "https://ipni.example.com",
133
+ "Description": "Network Indexer",
134
+ "DelegatedConfig": map[string]interface{}{
135
+ "Read": []string{"/routing/v1/providers"},
136
+ "Write": []string{},
137
+ },
138
+ },
139
+ "NewSystem": map[string]interface{}{
140
+ "URL": "https://example.com/newsystem",
141
+ "Description": "Test system for extensibility verification",
142
+ "NativeConfig": map[string]interface{}{
143
+ "Bootstrap": []string{
144
+ "/ip4/127.0.0.1/tcp/9999/p2p/12D3KooWPeQ4r3v6CmVmKXoFGtqEqcr3L8P6La9yH5oEWKtoLVVa",
145
+ },
146
+ },
147
+ "DelegatedConfig": map[string]interface{}{
148
+ "Read": []string{"/routing/v1/providers"},
149
+ "Write": []string{},
150
+ },
151
+ },
152
+ },
153
+ "DNSResolvers": map[string]interface{}{
154
+ "eth.": []string{"https://dns.eth.limo/dns-query"},
155
+ },
156
+ "DelegatedEndpoints": map[string]interface{}{
157
+ "https://ipni.example.com": map[string]interface{}{
158
+ "Systems": []string{"IPNI"},
159
+ "Read": []string{"/routing/v1/providers"},
160
+ "Write": []string{},
161
+ },
162
+ newSystemServer.URL: map[string]interface{}{
163
+ "Systems": []string{"NewSystem"},
164
+ "Read": []string{"/routing/v1/providers"},
165
+ "Write": []string{},
166
+ },
167
+ },
168
+ }
169
+
170
+ w.Header().Set("Content-Type", "application/json")
171
+ w.Header().Set("Cache-Control", "max-age=300")
172
+ _ = json.NewEncoder(w).Encode(autoconfData)
173
+ }))
174
+ defer mockServer.Close()
175
+
176
+ // Create Kubo node with autoconf pointing to mock server
177
+ h := harness.NewT(t)
178
+ node := h.NewNode().Init()
179
+
180
+ // Update config to use mock autoconf server
181
+ node.UpdateConfig(func(cfg *config.Config) {
182
+ cfg.AutoConf.URL = config.NewOptionalString(mockServer.URL)
183
+ cfg.AutoConf.Enabled = config.True
184
+ cfg.AutoConf.RefreshInterval = config.NewOptionalDuration(1 * time.Second)
185
+ cfg.Routing.Type = config.NewOptionalString("auto") // Should enable native AminoDHT + delegated others
186
+ cfg.Bootstrap = []string{"auto"}
187
+ cfg.Routing.DelegatedRouters = []string{"auto"}
188
+ })
189
+
190
+ // Start the daemon
191
+ daemon := node.StartDaemon()
192
+ defer daemon.StopDaemon()
193
+
194
+ // Give the daemon some time to initialize and make requests
195
+ time.Sleep(3 * time.Second)
196
+
197
+ // Test 1: Verify bootstrap includes both AminoDHT and NewSystem peers (deduplicated)
198
+ bootstrapResult := daemon.IPFS("bootstrap", "list", "--expand-auto")
199
+ bootstrapOutput := bootstrapResult.Stdout.String()
200
+ t.Logf("Bootstrap output: %s", bootstrapOutput)
201
+
202
+ // Should contain original DHT bootstrap peer (AminoDHT is a native system)
203
+ require.Contains(t, bootstrapOutput, "QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", "Should contain AminoDHT bootstrap peer")
204
+
205
+ // Note: NewSystem bootstrap peers are NOT included because only native systems
206
+ // (AminoDHT for Routing.Type="auto") contribute bootstrap peers.
207
+ // Delegated systems like NewSystem only provide HTTP routing endpoints.
208
+
209
+ // Test 2: Verify delegated endpoints are filtered correctly
210
+ // For Routing.Type=auto, native systems=[AminoDHT], so:
211
+ // - AminoDHT endpoints should be filtered out
212
+ // - IPNI and NewSystem endpoints should be included
213
+
214
+ // Get the expanded delegated routers using --expand-auto
215
+ routerResult := daemon.IPFS("config", "Routing.DelegatedRouters", "--expand-auto")
216
+ var expandedRouters []string
217
+ require.NoError(t, json.Unmarshal([]byte(routerResult.Stdout.String()), &expandedRouters))
218
+
219
+ t.Logf("Expanded delegated routers: %v", expandedRouters)
220
+
221
+ // Verify we got exactly 2 delegated routers: IPNI and NewSystem
222
+ require.Equal(t, 2, len(expandedRouters), "Should have exactly 2 delegated routers (IPNI and NewSystem). Got %d: %v", len(expandedRouters), expandedRouters)
223
+
224
+ // Convert to URLs for checking
225
+ routerURLs := expandedRouters
226
+
227
+ // Should contain NewSystem endpoint (not native) - now with routing path
228
+ foundNewSystem := false
229
+ expectedNewSystemURL := newSystemServer.URL + "/routing/v1/providers" // Full URL with path, as returned by DelegatedRoutersWithAutoConf
230
+ for _, url := range routerURLs {
231
+ if url == expectedNewSystemURL {
232
+ foundNewSystem = true
233
+ break
234
+ }
235
+ }
236
+ require.True(t, foundNewSystem, "Should contain NewSystem endpoint (%s) for delegated routing, got: %v", expectedNewSystemURL, routerURLs)
237
+
238
+ // Should contain ipni.example.com (IPNI is not native)
239
+ foundIPNI := false
240
+ for _, url := range routerURLs {
241
+ if strings.Contains(url, "ipni.example.com") {
242
+ foundIPNI = true
243
+ break
244
+ }
245
+ }
246
+ require.True(t, foundIPNI, "Should contain ipni.example.com endpoint for IPNI")
247
+
248
+ // Test passes - we've verified that:
249
+ // 1. Bootstrap peers are correctly resolved from native systems only
250
+ // 2. Delegated routers include both IPNI and NewSystem endpoints
251
+ // 3. URL format is correct (base URLs with paths)
252
+ // 4. AutoConf extensibility works for unknown systems
253
+
254
+ t.Log("NewSystem extensibility test passed - Kubo successfully discovered and used unknown routing system")
255
+}
test/cli/autoconf/fuzz_test.go
new
+654
@@ -0,0 +1,654 @@
1
+package autoconf
2
+
3
+import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/ipfs/boxo/autoconf"
14
+ "github.com/stretchr/testify/assert"
15
+ "github.com/stretchr/testify/require"
16
+)
17
+
18
+// testAutoConfWithFallback is a helper function that tests autoconf parsing with fallback detection
19
+func testAutoConfWithFallback(t *testing.T, serverURL string, expectError bool, expectErrorMsg string) (*autoconf.Config, bool) {
20
+ return testAutoConfWithFallbackAndTimeout(t, serverURL, expectError, expectErrorMsg, 10*time.Second)
21
+}
22
+
23
+// testAutoConfWithFallbackAndTimeout is a helper function that tests autoconf parsing with fallback detection and custom timeout
24
+func testAutoConfWithFallbackAndTimeout(t *testing.T, serverURL string, expectError bool, expectErrorMsg string, timeout time.Duration) (*autoconf.Config, bool) {
25
+ // Use fallback detection to test error conditions with MustGetConfigWithRefresh
26
+ fallbackUsed := false
27
+ fallbackConfig := &autoconf.Config{
28
+ AutoConfVersion: -999, // Special marker to detect fallback usage
29
+ AutoConfSchema: -999,
30
+ }
31
+
32
+ client, err := autoconf.NewClient(
33
+ autoconf.WithUserAgent("test-agent"),
34
+ autoconf.WithURL(serverURL),
35
+ autoconf.WithRefreshInterval(autoconf.DefaultRefreshInterval),
36
+ autoconf.WithFallback(func() *autoconf.Config {
37
+ fallbackUsed = true
38
+ return fallbackConfig
39
+ }),
40
+ )
41
+ require.NoError(t, err)
42
+
43
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
44
+ defer cancel()
45
+ result := client.GetCachedOrRefresh(ctx)
46
+
47
+ if expectError {
48
+ require.True(t, fallbackUsed, expectErrorMsg)
49
+ require.Equal(t, int64(-999), result.AutoConfVersion, "Should return fallback config for error case")
50
+ } else {
51
+ require.False(t, fallbackUsed, "Expected no fallback to be used")
52
+ require.NotEqual(t, int64(-999), result.AutoConfVersion, "Should return fetched config for success case")
53
+ }
54
+
55
+ return result, fallbackUsed
56
+}
57
+
58
+func TestAutoConfFuzz(t *testing.T) {
59
+ t.Parallel()
60
+
61
+ t.Run("fuzz autoconf version", testFuzzAutoConfVersion)
62
+ t.Run("fuzz bootstrap arrays", testFuzzBootstrapArrays)
63
+ t.Run("fuzz dns resolvers", testFuzzDNSResolvers)
64
+ t.Run("fuzz delegated routers", testFuzzDelegatedRouters)
65
+ t.Run("fuzz delegated publishers", testFuzzDelegatedPublishers)
66
+ t.Run("fuzz malformed json", testFuzzMalformedJSON)
67
+ t.Run("fuzz large payloads", testFuzzLargePayloads)
68
+}
69
+
70
+func testFuzzAutoConfVersion(t *testing.T) {
71
+ testCases := []struct {
72
+ name string
73
+ version interface{}
74
+ expectError bool
75
+ }{
76
+ {"valid version", 2025071801, false},
77
+ {"zero version", 0, true}, // Should be invalid
78
+ {"negative version", -1, false}, // Parser accepts negative versions
79
+ {"string version", "2025071801", true}, // Should be number
80
+ {"float version", 2025071801.5, true},
81
+ {"very large version", 9999999999999999, false}, // Large but valid int64
82
+ {"null version", nil, true},
83
+ }
84
+
85
+ for _, tc := range testCases {
86
+ t.Run(tc.name, func(t *testing.T) {
87
+ config := map[string]interface{}{
88
+ "AutoConfVersion": tc.version,
89
+ "AutoConfSchema": 1,
90
+ "AutoConfTTL": 86400,
91
+ "SystemRegistry": map[string]interface{}{
92
+ "AminoDHT": map[string]interface{}{
93
+ "Description": "Test AminoDHT system",
94
+ "NativeConfig": map[string]interface{}{
95
+ "Bootstrap": []string{
96
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
97
+ },
98
+ },
99
+ },
100
+ },
101
+ "DNSResolvers": map[string]interface{}{},
102
+ "DelegatedEndpoints": map[string]interface{}{},
103
+ }
104
+
105
+ jsonData, err := json.Marshal(config)
106
+ require.NoError(t, err)
107
+
108
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
109
+ w.Header().Set("Content-Type", "application/json")
110
+ _, _ = w.Write(jsonData)
111
+ }))
112
+ defer server.Close()
113
+
114
+ // Test that our autoconf parser handles this gracefully
115
+ _, _ = testAutoConfWithFallback(t, server.URL, tc.expectError, fmt.Sprintf("Expected fallback to be used for %s", tc.name))
116
+ })
117
+ }
118
+}
119
+
120
+func testFuzzBootstrapArrays(t *testing.T) {
121
+ type testCase struct {
122
+ name string
123
+ bootstrap interface{}
124
+ expectError bool
125
+ validate func(*testing.T, *autoconf.Response)
126
+ }
127
+
128
+ testCases := []testCase{
129
+ {
130
+ name: "valid bootstrap",
131
+ bootstrap: []string{"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"},
132
+ validate: func(t *testing.T, resp *autoconf.Response) {
133
+ expected := []string{"/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"}
134
+ bootstrapPeers := resp.Config.GetBootstrapPeers("AminoDHT")
135
+ assert.Equal(t, expected, bootstrapPeers, "Bootstrap peers should match configured values")
136
+ },
137
+ },
138
+ {
139
+ name: "empty bootstrap",
140
+ bootstrap: []string{},
141
+ validate: func(t *testing.T, resp *autoconf.Response) {
142
+ bootstrapPeers := resp.Config.GetBootstrapPeers("AminoDHT")
143
+ assert.Empty(t, bootstrapPeers, "Empty bootstrap should result in empty peers")
144
+ },
145
+ },
146
+ {
147
+ name: "null bootstrap",
148
+ bootstrap: nil,
149
+ validate: func(t *testing.T, resp *autoconf.Response) {
150
+ bootstrapPeers := resp.Config.GetBootstrapPeers("AminoDHT")
151
+ assert.Empty(t, bootstrapPeers, "Null bootstrap should result in empty peers")
152
+ },
153
+ },
154
+ {
155
+ name: "invalid multiaddr",
156
+ bootstrap: []string{"invalid-multiaddr"},
157
+ expectError: true,
158
+ },
159
+ {
160
+ name: "very long multiaddr",
161
+ bootstrap: []string{"/dnsaddr/" + strings.Repeat("a", 100) + ".com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"},
162
+ validate: func(t *testing.T, resp *autoconf.Response) {
163
+ expected := []string{"/dnsaddr/" + strings.Repeat("a", 100) + ".com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"}
164
+ bootstrapPeers := resp.Config.GetBootstrapPeers("AminoDHT")
165
+ assert.Equal(t, expected, bootstrapPeers, "Very long multiaddr should be preserved")
166
+ },
167
+ },
168
+ {
169
+ name: "bootstrap as string",
170
+ bootstrap: "/dnsaddr/test",
171
+ expectError: true,
172
+ },
173
+ {
174
+ name: "bootstrap as number",
175
+ bootstrap: 123,
176
+ expectError: true,
177
+ },
178
+ {
179
+ name: "mixed types in array",
180
+ bootstrap: []interface{}{"/dnsaddr/test", 123, nil},
181
+ expectError: true,
182
+ },
183
+ {
184
+ name: "extremely large array",
185
+ bootstrap: make([]string, 1000),
186
+ validate: func(t *testing.T, resp *autoconf.Response) {
187
+ // Array will be filled in the loop below
188
+ bootstrapPeers := resp.Config.GetBootstrapPeers("AminoDHT")
189
+ assert.Len(t, bootstrapPeers, 1000, "Large bootstrap array should be preserved")
190
+ },
191
+ },
192
+ }
193
+
194
+ // Fill the large array with valid multiaddrs
195
+ largeArray := testCases[len(testCases)-1].bootstrap.([]string)
196
+ for i := range largeArray {
197
+ largeArray[i] = fmt.Sprintf("/dnsaddr/bootstrap%d.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", i)
198
+ }
199
+
200
+ for _, tc := range testCases {
201
+ t.Run(tc.name, func(t *testing.T) {
202
+ config := map[string]interface{}{
203
+ "AutoConfVersion": 2025072301,
204
+ "AutoConfSchema": 1,
205
+ "AutoConfTTL": 86400,
206
+ "SystemRegistry": map[string]interface{}{
207
+ "AminoDHT": map[string]interface{}{
208
+ "Description": "Test AminoDHT system",
209
+ "NativeConfig": map[string]interface{}{
210
+ "Bootstrap": tc.bootstrap,
211
+ },
212
+ },
213
+ },
214
+ "DNSResolvers": map[string]interface{}{},
215
+ "DelegatedEndpoints": map[string]interface{}{},
216
+ }
217
+
218
+ jsonData, err := json.Marshal(config)
219
+ require.NoError(t, err)
220
+
221
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222
+ w.Header().Set("Content-Type", "application/json")
223
+ _, _ = w.Write(jsonData)
224
+ }))
225
+ defer server.Close()
226
+
227
+ autoConf, fallbackUsed := testAutoConfWithFallback(t, server.URL, tc.expectError, fmt.Sprintf("Expected fallback to be used for %s", tc.name))
228
+
229
+ if !tc.expectError {
230
+ require.NotNil(t, autoConf, "AutoConf should not be nil for successful parsing")
231
+
232
+ // Verify structure is reasonable
233
+ bootstrapPeers := autoConf.GetBootstrapPeers("AminoDHT")
234
+ require.IsType(t, []string{}, bootstrapPeers, "Bootstrap should be []string")
235
+
236
+ // Run test-specific validation if provided (only for non-fallback cases)
237
+ if tc.validate != nil && !fallbackUsed {
238
+ // Create a mock Response for compatibility with validation functions
239
+ mockResponse := &autoconf.Response{Config: autoConf}
240
+ tc.validate(t, mockResponse)
241
+ }
242
+ }
243
+ })
244
+ }
245
+}
246
+
247
+func testFuzzDNSResolvers(t *testing.T) {
248
+ type testCase struct {
249
+ name string
250
+ resolvers interface{}
251
+ expectError bool
252
+ validate func(*testing.T, *autoconf.Response)
253
+ }
254
+
255
+ testCases := []testCase{
256
+ {
257
+ name: "valid resolvers",
258
+ resolvers: map[string][]string{".": {"https://dns.google/dns-query"}},
259
+ validate: func(t *testing.T, resp *autoconf.Response) {
260
+ expected := map[string][]string{".": {"https://dns.google/dns-query"}}
261
+ assert.Equal(t, expected, resp.Config.DNSResolvers, "DNS resolvers should match configured values")
262
+ },
263
+ },
264
+ {
265
+ name: "empty resolvers",
266
+ resolvers: map[string][]string{},
267
+ validate: func(t *testing.T, resp *autoconf.Response) {
268
+ assert.Empty(t, resp.Config.DNSResolvers, "Empty resolvers should result in empty map")
269
+ },
270
+ },
271
+ {
272
+ name: "null resolvers",
273
+ resolvers: nil,
274
+ validate: func(t *testing.T, resp *autoconf.Response) {
275
+ assert.Empty(t, resp.Config.DNSResolvers, "Null resolvers should result in empty map")
276
+ },
277
+ },
278
+ {
279
+ name: "relative URL (missing scheme)",
280
+ resolvers: map[string][]string{".": {"not-a-url"}},
281
+ expectError: true, // Should error due to strict HTTP/HTTPS validation
282
+ },
283
+ {
284
+ name: "invalid URL format",
285
+ resolvers: map[string][]string{".": {"://invalid-missing-scheme"}},
286
+ expectError: true, // Should error because url.Parse() fails
287
+ },
288
+ {
289
+ name: "non-HTTP scheme",
290
+ resolvers: map[string][]string{".": {"ftp://example.com/dns-query"}},
291
+ expectError: true, // Should error due to non-HTTP/HTTPS scheme
292
+ },
293
+ {
294
+ name: "very long domain",
295
+ resolvers: map[string][]string{strings.Repeat("a", 1000) + ".com": {"https://dns.google/dns-query"}},
296
+ validate: func(t *testing.T, resp *autoconf.Response) {
297
+ expected := map[string][]string{strings.Repeat("a", 1000) + ".com": {"https://dns.google/dns-query"}}
298
+ assert.Equal(t, expected, resp.Config.DNSResolvers, "Very long domain should be preserved")
299
+ },
300
+ },
301
+ {
302
+ name: "many resolvers",
303
+ resolvers: generateManyResolvers(100),
304
+ validate: func(t *testing.T, resp *autoconf.Response) {
305
+ expected := generateManyResolvers(100)
306
+ assert.Equal(t, expected, resp.Config.DNSResolvers, "Many resolvers should be preserved")
307
+ assert.Equal(t, 100, len(resp.Config.DNSResolvers), "Should have 100 resolvers")
308
+ },
309
+ },
310
+ {
311
+ name: "resolvers as array",
312
+ resolvers: []string{"https://dns.google/dns-query"},
313
+ expectError: true,
314
+ },
315
+ {
316
+ name: "nested invalid structure",
317
+ resolvers: map[string]interface{}{".": map[string]string{"invalid": "structure"}},
318
+ expectError: true,
319
+ },
320
+ }
321
+
322
+ for _, tc := range testCases {
323
+ t.Run(tc.name, func(t *testing.T) {
324
+ config := map[string]interface{}{
325
+ "AutoConfVersion": 2025072301,
326
+ "AutoConfSchema": 1,
327
+ "AutoConfTTL": 86400,
328
+ "SystemRegistry": map[string]interface{}{
329
+ "AminoDHT": map[string]interface{}{
330
+ "Description": "Test AminoDHT system",
331
+ "NativeConfig": map[string]interface{}{
332
+ "Bootstrap": []string{"/dnsaddr/test"},
333
+ },
334
+ },
335
+ },
336
+ "DNSResolvers": tc.resolvers,
337
+ "DelegatedEndpoints": map[string]interface{}{},
338
+ }
339
+
340
+ jsonData, err := json.Marshal(config)
341
+ require.NoError(t, err)
342
+
343
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
344
+ w.Header().Set("Content-Type", "application/json")
345
+ _, _ = w.Write(jsonData)
346
+ }))
347
+ defer server.Close()
348
+
349
+ autoConf, fallbackUsed := testAutoConfWithFallback(t, server.URL, tc.expectError, fmt.Sprintf("Expected fallback to be used for %s", tc.name))
350
+
351
+ if !tc.expectError {
352
+ require.NotNil(t, autoConf, "AutoConf should not be nil for successful parsing")
353
+
354
+ // Run test-specific validation if provided (only for non-fallback cases)
355
+ if tc.validate != nil && !fallbackUsed {
356
+ // Create a mock Response for compatibility with validation functions
357
+ mockResponse := &autoconf.Response{Config: autoConf}
358
+ tc.validate(t, mockResponse)
359
+ }
360
+ }
361
+ })
362
+ }
363
+}
364
+
365
+func testFuzzDelegatedRouters(t *testing.T) {
366
+ // Test various malformed delegated router configurations
367
+ type testCase struct {
368
+ name string
369
+ routers interface{}
370
+ expectError bool
371
+ validate func(*testing.T, *autoconf.Response)
372
+ }
373
+
374
+ testCases := []testCase{
375
+ {
376
+ name: "valid endpoints",
377
+ routers: map[string]interface{}{
378
+ "https://ipni.example.com": map[string]interface{}{
379
+ "Systems": []string{"IPNI"},
380
+ "Read": []string{"/routing/v1/providers"},
381
+ "Write": []string{},
382
+ },
383
+ },
384
+ validate: func(t *testing.T, resp *autoconf.Response) {
385
+ assert.Len(t, resp.Config.DelegatedEndpoints, 1, "Should have 1 delegated endpoint")
386
+ for url, config := range resp.Config.DelegatedEndpoints {
387
+ assert.Contains(t, url, "ipni.example.com", "Endpoint URL should contain expected domain")
388
+ assert.Contains(t, config.Systems, "IPNI", "Endpoint should have IPNI system")
389
+ assert.Contains(t, config.Read, "/routing/v1/providers", "Endpoint should have providers read path")
390
+ }
391
+ },
392
+ },
393
+ {
394
+ name: "empty routers",
395
+ routers: map[string]interface{}{},
396
+ validate: func(t *testing.T, resp *autoconf.Response) {
397
+ assert.Empty(t, resp.Config.DelegatedEndpoints, "Empty routers should result in empty endpoints")
398
+ },
399
+ },
400
+ {
401
+ name: "null routers",
402
+ routers: nil,
403
+ validate: func(t *testing.T, resp *autoconf.Response) {
404
+ assert.Empty(t, resp.Config.DelegatedEndpoints, "Null routers should result in empty endpoints")
405
+ },
406
+ },
407
+ {
408
+ name: "invalid nested structure",
409
+ routers: map[string]string{"invalid": "structure"},
410
+ expectError: true,
411
+ },
412
+ {
413
+ name: "invalid endpoint URLs",
414
+ routers: map[string]interface{}{
415
+ "not-a-url": map[string]interface{}{
416
+ "Systems": []string{"IPNI"},
417
+ "Read": []string{"/routing/v1/providers"},
418
+ "Write": []string{},
419
+ },
420
+ },
421
+ expectError: true, // Should error due to URL validation
422
+ },
423
+ }
424
+
425
+ for _, tc := range testCases {
426
+ t.Run(tc.name, func(t *testing.T) {
427
+ config := map[string]interface{}{
428
+ "AutoConfVersion": 2025072301,
429
+ "AutoConfSchema": 1,
430
+ "AutoConfTTL": 86400,
431
+ "SystemRegistry": map[string]interface{}{
432
+ "AminoDHT": map[string]interface{}{
433
+ "Description": "Test AminoDHT system",
434
+ "NativeConfig": map[string]interface{}{
435
+ "Bootstrap": []string{"/dnsaddr/test"},
436
+ },
437
+ },
438
+ },
439
+ "DNSResolvers": map[string]interface{}{},
440
+ "DelegatedEndpoints": tc.routers,
441
+ }
442
+
443
+ jsonData, err := json.Marshal(config)
444
+ require.NoError(t, err)
445
+
446
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447
+ w.Header().Set("Content-Type", "application/json")
448
+ _, _ = w.Write(jsonData)
449
+ }))
450
+ defer server.Close()
451
+
452
+ autoConf, fallbackUsed := testAutoConfWithFallback(t, server.URL, tc.expectError, fmt.Sprintf("Expected fallback to be used for %s", tc.name))
453
+
454
+ if !tc.expectError {
455
+ require.NotNil(t, autoConf, "AutoConf should not be nil for successful parsing")
456
+
457
+ // Run test-specific validation if provided (only for non-fallback cases)
458
+ if tc.validate != nil && !fallbackUsed {
459
+ // Create a mock Response for compatibility with validation functions
460
+ mockResponse := &autoconf.Response{Config: autoConf}
461
+ tc.validate(t, mockResponse)
462
+ }
463
+ }
464
+ })
465
+ }
466
+}
467
+
468
+func testFuzzDelegatedPublishers(t *testing.T) {
469
+ // DelegatedPublishers use the same autoclient library validation as DelegatedRouters
470
+ // Test that URL validation works for delegated publishers
471
+ type testCase struct {
472
+ name string
473
+ urls []string
474
+ expectErr bool
475
+ validate func(*testing.T, *autoconf.Response)
476
+ }
477
+
478
+ testCases := []testCase{
479
+ {
480
+ name: "valid HTTPS URLs",
481
+ urls: []string{"https://delegated-ipfs.dev", "https://another-publisher.com"},
482
+ validate: func(t *testing.T, resp *autoconf.Response) {
483
+ assert.Len(t, resp.Config.DelegatedEndpoints, 2, "Should have 2 delegated endpoints")
484
+ foundURLs := make([]string, 0, len(resp.Config.DelegatedEndpoints))
485
+ for url := range resp.Config.DelegatedEndpoints {
486
+ foundURLs = append(foundURLs, url)
487
+ }
488
+ expectedURLs := []string{"https://delegated-ipfs.dev", "https://another-publisher.com"}
489
+ for _, expectedURL := range expectedURLs {
490
+ assert.Contains(t, foundURLs, expectedURL, "Should contain configured URL: %s", expectedURL)
491
+ }
492
+ },
493
+ },
494
+ {
495
+ name: "invalid URL",
496
+ urls: []string{"not-a-url"},
497
+ expectErr: true,
498
+ },
499
+ {
500
+ name: "HTTP URL (accepted during parsing)",
501
+ urls: []string{"http://insecure-publisher.com"},
502
+ validate: func(t *testing.T, resp *autoconf.Response) {
503
+ assert.Len(t, resp.Config.DelegatedEndpoints, 1, "Should have 1 delegated endpoint")
504
+ for url := range resp.Config.DelegatedEndpoints {
505
+ assert.Equal(t, "http://insecure-publisher.com", url, "HTTP URL should be preserved during parsing")
506
+ }
507
+ },
508
+ },
509
+ }
510
+
511
+ for _, tc := range testCases {
512
+ t.Run(tc.name, func(t *testing.T) {
513
+ autoConfData := map[string]interface{}{
514
+ "AutoConfVersion": 2025072301,
515
+ "AutoConfSchema": 1,
516
+ "AutoConfTTL": 86400,
517
+ "SystemRegistry": map[string]interface{}{
518
+ "TestSystem": map[string]interface{}{
519
+ "Description": "Test system for fuzz testing",
520
+ "DelegatedConfig": map[string]interface{}{
521
+ "Read": []string{"/routing/v1/ipns"},
522
+ "Write": []string{"/routing/v1/ipns"},
523
+ },
524
+ },
525
+ },
526
+ "DNSResolvers": map[string]interface{}{},
527
+ "DelegatedEndpoints": map[string]interface{}{},
528
+ }
529
+
530
+ // Add test URLs as delegated endpoints
531
+ for _, url := range tc.urls {
532
+ autoConfData["DelegatedEndpoints"].(map[string]interface{})[url] = map[string]interface{}{
533
+ "Systems": []string{"TestSystem"},
534
+ "Read": []string{"/routing/v1/ipns"},
535
+ "Write": []string{"/routing/v1/ipns"},
536
+ }
537
+ }
538
+
539
+ jsonData, err := json.Marshal(autoConfData)
540
+ require.NoError(t, err)
541
+
542
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
543
+ w.Header().Set("Content-Type", "application/json")
544
+ _, _ = w.Write(jsonData)
545
+ }))
546
+ defer server.Close()
547
+
548
+ // Test that our autoconf parser handles this gracefully
549
+ autoConf, fallbackUsed := testAutoConfWithFallback(t, server.URL, tc.expectErr, fmt.Sprintf("Expected fallback to be used for %s", tc.name))
550
+
551
+ if !tc.expectErr {
552
+ require.NotNil(t, autoConf, "AutoConf should not be nil for successful parsing")
553
+
554
+ // Run test-specific validation if provided (only for non-fallback cases)
555
+ if tc.validate != nil && !fallbackUsed {
556
+ // Create a mock Response for compatibility with validation functions
557
+ mockResponse := &autoconf.Response{Config: autoConf}
558
+ tc.validate(t, mockResponse)
559
+ }
560
+ }
561
+ })
562
+ }
563
+}
564
+
565
+func testFuzzMalformedJSON(t *testing.T) {
566
+ malformedJSONs := []string{
567
+ `{`, // Incomplete JSON
568
+ `{"AutoConfVersion": }`, // Missing value
569
+ `{"AutoConfVersion": 123,}`, // Trailing comma
570
+ `{AutoConfVersion: 123}`, // Unquoted key
571
+ `{"Bootstrap": [}`, // Incomplete array
572
+ `{"Bootstrap": ["/test",]}`, // Trailing comma in array
573
+ `invalid json`, // Not JSON at all
574
+ `null`, // Just null
575
+ `[]`, // Array instead of object
576
+ `""`, // String instead of object
577
+ }
578
+
579
+ for i, malformedJSON := range malformedJSONs {
580
+ t.Run(fmt.Sprintf("malformed_%d", i), func(t *testing.T) {
581
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
582
+ w.Header().Set("Content-Type", "application/json")
583
+ _, _ = w.Write([]byte(malformedJSON))
584
+ }))
585
+ defer server.Close()
586
+
587
+ // All malformed JSON should result in fallback usage
588
+ _, _ = testAutoConfWithFallback(t, server.URL, true, fmt.Sprintf("Expected fallback to be used for malformed JSON: %s", malformedJSON))
589
+ })
590
+ }
591
+}
592
+
593
+func testFuzzLargePayloads(t *testing.T) {
594
+ // Test with very large but valid JSON payloads
595
+ largeBootstrap := make([]string, 10000)
596
+ for i := range largeBootstrap {
597
+ largeBootstrap[i] = fmt.Sprintf("/dnsaddr/bootstrap%d.example.com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", i)
598
+ }
599
+
600
+ largeDNSResolvers := make(map[string][]string)
601
+ for i := 0; i < 1000; i++ {
602
+ domain := fmt.Sprintf("domain%d.example.com", i)
603
+ largeDNSResolvers[domain] = []string{
604
+ fmt.Sprintf("https://resolver%d.example.com/dns-query", i),
605
+ }
606
+ }
607
+
608
+ config := map[string]interface{}{
609
+ "AutoConfVersion": 2025072301,
610
+ "AutoConfSchema": 1,
611
+ "AutoConfTTL": 86400,
612
+ "SystemRegistry": map[string]interface{}{
613
+ "AminoDHT": map[string]interface{}{
614
+ "Description": "Test AminoDHT system",
615
+ "NativeConfig": map[string]interface{}{
616
+ "Bootstrap": largeBootstrap,
617
+ },
618
+ },
619
+ },
620
+ "DNSResolvers": largeDNSResolvers,
621
+ "DelegatedEndpoints": map[string]interface{}{},
622
+ }
623
+
624
+ jsonData, err := json.Marshal(config)
625
+ require.NoError(t, err)
626
+
627
+ t.Logf("Large payload size: %d bytes", len(jsonData))
628
+
629
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
630
+ w.Header().Set("Content-Type", "application/json")
631
+ _, _ = w.Write(jsonData)
632
+ }))
633
+ defer server.Close()
634
+
635
+ // Should handle large payloads gracefully (up to reasonable limits)
636
+ autoConf, _ := testAutoConfWithFallbackAndTimeout(t, server.URL, false, "Large payload should not trigger fallback", 30*time.Second)
637
+ require.NotNil(t, autoConf, "Should return valid config")
638
+
639
+ // Verify bootstrap entries were preserved
640
+ bootstrapPeers := autoConf.GetBootstrapPeers("AminoDHT")
641
+ require.Len(t, bootstrapPeers, 10000, "Should preserve all bootstrap entries")
642
+}
643
+
644
+// Helper function to generate many DNS resolvers for testing
645
+func generateManyResolvers(count int) map[string][]string {
646
+ resolvers := make(map[string][]string)
647
+ for i := 0; i < count; i++ {
648
+ domain := fmt.Sprintf("domain%d.example.com", i)
649
+ resolvers[domain] = []string{
650
+ fmt.Sprintf("https://resolver%d.example.com/dns-query", i),
651
+ }
652
+ }
653
+ return resolvers
654
+}
test/cli/autoconf/ipns_test.go
new
+352
@@ -0,0 +1,352 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/ipfs/boxo/autoconf"
15
+ "github.com/ipfs/kubo/test/cli/harness"
16
+ "github.com/stretchr/testify/assert"
17
+ "github.com/stretchr/testify/require"
18
+)
19
+
20
+// TestAutoConfIPNS tests IPNS publishing with autoconf-resolved delegated publishers
21
+func TestAutoConfIPNS(t *testing.T) {
22
+ t.Parallel()
23
+
24
+ t.Run("PublishingWithWorkingEndpoint", func(t *testing.T) {
25
+ t.Parallel()
26
+ testIPNSPublishingWithWorkingEndpoint(t)
27
+ })
28
+
29
+ t.Run("PublishingResilience", func(t *testing.T) {
30
+ t.Parallel()
31
+ testIPNSPublishingResilience(t)
32
+ })
33
+}
34
+
35
+// testIPNSPublishingWithWorkingEndpoint verifies that IPNS delegated publishing works
36
+// correctly when the HTTP endpoint is functioning normally and accepts requests.
37
+// It also verifies that the PUT payload matches what can be retrieved via routing get.
38
+func testIPNSPublishingWithWorkingEndpoint(t *testing.T) {
39
+ // Create mock IPNS publisher that accepts requests
40
+ publisher := newMockIPNSPublisher(t)
41
+ defer publisher.close()
42
+
43
+ // Create node with delegated publisher
44
+ node := setupNodeWithAutoconf(t, publisher.server.URL, "auto")
45
+ defer node.StopDaemon()
46
+
47
+ // Wait for daemon to be ready
48
+ time.Sleep(5 * time.Second)
49
+
50
+ // Get node's peer ID
51
+ idResult := node.RunIPFS("id", "-f", "<id>")
52
+ require.Equal(t, 0, idResult.ExitCode())
53
+ peerID := strings.TrimSpace(idResult.Stdout.String())
54
+
55
+ // Get peer ID in base36 format (used for IPNS keys)
56
+ idBase36Result := node.RunIPFS("id", "--peerid-base", "base36", "-f", "<id>")
57
+ require.Equal(t, 0, idBase36Result.ExitCode())
58
+ peerIDBase36 := strings.TrimSpace(idBase36Result.Stdout.String())
59
+
60
+ // Verify autoconf resolved "auto" correctly
61
+ result := node.RunIPFS("config", "Ipns.DelegatedPublishers", "--expand-auto")
62
+ var resolvedPublishers []string
63
+ err := json.Unmarshal([]byte(result.Stdout.String()), &resolvedPublishers)
64
+ require.NoError(t, err)
65
+ expectedURL := publisher.server.URL + "/routing/v1/ipns"
66
+ assert.Contains(t, resolvedPublishers, expectedURL, "AutoConf should resolve 'auto' to mock publisher")
67
+
68
+ // Test publishing with --allow-delegated
69
+ testCID := "bafkqablimvwgy3y"
70
+ result = node.RunIPFS("name", "publish", "--allow-delegated", "/ipfs/"+testCID)
71
+ require.Equal(t, 0, result.ExitCode(), "Publishing should succeed")
72
+ assert.Contains(t, result.Stdout.String(), "Published to")
73
+
74
+ // Wait for async HTTP request to delegated publisher
75
+ time.Sleep(2 * time.Second)
76
+
77
+ // Verify HTTP PUT was made to delegated publisher
78
+ publishedKeys := publisher.getPublishedKeys()
79
+ assert.NotEmpty(t, publishedKeys, "HTTP PUT request should have been made to delegated publisher")
80
+
81
+ // Get the PUT payload that was sent to the delegated publisher
82
+ putPayload := publisher.getRecordPayload(peerIDBase36)
83
+ require.NotNil(t, putPayload, "Should have captured PUT payload")
84
+ require.Greater(t, len(putPayload), 0, "PUT payload should not be empty")
85
+
86
+ // Retrieve the IPNS record using routing get
87
+ getResult := node.RunIPFS("routing", "get", "/ipns/"+peerID)
88
+ require.Equal(t, 0, getResult.ExitCode(), "Should be able to retrieve IPNS record")
89
+ getPayload := getResult.Stdout.Bytes()
90
+
91
+ // Compare the payloads
92
+ assert.Equal(t, putPayload, getPayload,
93
+ "PUT payload sent to delegated publisher should match what routing get returns")
94
+
95
+ // Also verify the record points to the expected content
96
+ assert.Contains(t, getResult.Stdout.String(), testCID,
97
+ "Retrieved IPNS record should reference the published CID")
98
+
99
+ // Use ipfs name inspect to verify the IPNS record's value matches the published CID
100
+ // First write the routing get result to a file for inspection
101
+ node.WriteBytes("ipns-record", getPayload)
102
+ inspectResult := node.RunIPFS("name", "inspect", "ipns-record")
103
+ require.Equal(t, 0, inspectResult.ExitCode(), "Should be able to inspect IPNS record")
104
+
105
+ // The inspect output should show the path we published
106
+ inspectOutput := inspectResult.Stdout.String()
107
+ assert.Contains(t, inspectOutput, "/ipfs/"+testCID,
108
+ "IPNS record value should match the published path")
109
+
110
+ // Also verify it's a valid record with proper fields
111
+ assert.Contains(t, inspectOutput, "Value:", "Should have Value field")
112
+ assert.Contains(t, inspectOutput, "Validity:", "Should have Validity field")
113
+ assert.Contains(t, inspectOutput, "Sequence:", "Should have Sequence field")
114
+
115
+ t.Log("Verified: PUT payload to delegated publisher matches routing get result and name inspect confirms correct path")
116
+}
117
+
118
+// testIPNSPublishingResilience verifies that IPNS publishing is resilient by design.
119
+// Publishing succeeds as long as local storage works, even when all delegated endpoints fail.
120
+// This test documents the intentional resilient behavior, not bugs.
121
+func testIPNSPublishingResilience(t *testing.T) {
122
+ testCases := []struct {
123
+ name string
124
+ routingType string // "auto" or "delegated"
125
+ description string
126
+ }{
127
+ {
128
+ name: "AutoRouting",
129
+ routingType: "auto",
130
+ description: "auto mode uses DHT + HTTP, tolerates HTTP failures",
131
+ },
132
+ {
133
+ name: "DelegatedRouting",
134
+ routingType: "delegated",
135
+ description: "delegated mode uses HTTP only, tolerates HTTP failures",
136
+ },
137
+ }
138
+
139
+ for _, tc := range testCases {
140
+ t.Run(tc.name, func(t *testing.T) {
141
+ // Create publisher that always fails
142
+ publisher := newMockIPNSPublisher(t)
143
+ defer publisher.close()
144
+ publisher.responseFunc = func(peerID string, record []byte) int {
145
+ return http.StatusInternalServerError
146
+ }
147
+
148
+ // Create node with failing endpoint
149
+ node := setupNodeWithAutoconf(t, publisher.server.URL, tc.routingType)
150
+ defer node.StopDaemon()
151
+
152
+ // Test different publishing modes - all should succeed due to resilient design
153
+ testCID := "/ipfs/bafkqablimvwgy3y"
154
+
155
+ // Normal publishing (should succeed despite endpoint failures)
156
+ result := node.RunIPFS("name", "publish", testCID)
157
+ assert.Equal(t, 0, result.ExitCode(),
158
+ "%s: Normal publishing should succeed (local storage works)", tc.description)
159
+
160
+ // Publishing with --allow-offline (local only, no network)
161
+ result = node.RunIPFS("name", "publish", "--allow-offline", testCID)
162
+ assert.Equal(t, 0, result.ExitCode(),
163
+ "--allow-offline should succeed (local only)")
164
+
165
+ // Publishing with --allow-delegated (if using auto routing)
166
+ if tc.routingType == "auto" {
167
+ result = node.RunIPFS("name", "publish", "--allow-delegated", testCID)
168
+ assert.Equal(t, 0, result.ExitCode(),
169
+ "--allow-delegated should succeed (no DHT required)")
170
+ }
171
+
172
+ t.Logf("%s: All publishing modes succeeded despite endpoint failures (resilient design)", tc.name)
173
+ })
174
+ }
175
+}
176
+
177
+// ============================================================================
178
+// Helper Functions
179
+// ============================================================================
180
+
181
+// setupNodeWithAutoconf creates an IPFS node with autoconf-configured delegated publishers
182
+func setupNodeWithAutoconf(t *testing.T, publisherURL string, routingType string) *harness.Node {
183
+ // Create autoconf server with the publisher endpoint
184
+ autoconfData := createAutoconfJSON(publisherURL)
185
+ autoconfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186
+ w.Header().Set("Content-Type", "application/json")
187
+ fmt.Fprint(w, autoconfData)
188
+ }))
189
+ t.Cleanup(func() { autoconfServer.Close() })
190
+
191
+ // Create and configure node
192
+ h := harness.NewT(t)
193
+ node := h.NewNode().Init("--profile=test")
194
+
195
+ // Configure autoconf
196
+ node.SetIPFSConfig("AutoConf.URL", autoconfServer.URL)
197
+ node.SetIPFSConfig("AutoConf.Enabled", true)
198
+ node.SetIPFSConfig("Ipns.DelegatedPublishers", []string{"auto"})
199
+ node.SetIPFSConfig("Routing.Type", routingType)
200
+
201
+ // Additional config for delegated routing mode
202
+ if routingType == "delegated" {
203
+ node.SetIPFSConfig("Provider.Enabled", false)
204
+ node.SetIPFSConfig("Reprovider.Interval", "0s")
205
+ }
206
+
207
+ // Add bootstrap peers for connectivity
208
+ node.SetIPFSConfig("Bootstrap", autoconf.FallbackBootstrapPeers)
209
+
210
+ // Start daemon
211
+ node.StartDaemon()
212
+
213
+ return node
214
+}
215
+
216
+// createAutoconfJSON generates autoconf configuration with a delegated IPNS publisher
217
+func createAutoconfJSON(publisherURL string) string {
218
+ // Use bootstrap peers from autoconf fallbacks for consistency
219
+ bootstrapPeers, _ := json.Marshal(autoconf.FallbackBootstrapPeers)
220
+
221
+ return fmt.Sprintf(`{
222
+ "AutoConfVersion": 2025072302,
223
+ "AutoConfSchema": 1,
224
+ "AutoConfTTL": 86400,
225
+ "SystemRegistry": {
226
+ "TestSystem": {
227
+ "Description": "Test system for IPNS publishing",
228
+ "NativeConfig": {
229
+ "Bootstrap": %s
230
+ }
231
+ }
232
+ },
233
+ "DNSResolvers": {},
234
+ "DelegatedEndpoints": {
235
+ "%s": {
236
+ "Systems": ["TestSystem"],
237
+ "Read": ["/routing/v1/ipns"],
238
+ "Write": ["/routing/v1/ipns"]
239
+ }
240
+ }
241
+ }`, string(bootstrapPeers), publisherURL)
242
+}
243
+
244
+// ============================================================================
245
+// Mock IPNS Publisher
246
+// ============================================================================
247
+
248
+// mockIPNSPublisher implements a simple IPNS publishing HTTP API server
249
+type mockIPNSPublisher struct {
250
+ t *testing.T
251
+ server *httptest.Server
252
+ mu sync.Mutex
253
+ publishedKeys map[string]string // peerID -> published CID
254
+ recordPayloads map[string][]byte // peerID -> actual HTTP PUT record payload
255
+ responseFunc func(peerID string, record []byte) int // returns HTTP status code
256
+}
257
+
258
+func newMockIPNSPublisher(t *testing.T) *mockIPNSPublisher {
259
+ m := &mockIPNSPublisher{
260
+ t: t,
261
+ publishedKeys: make(map[string]string),
262
+ recordPayloads: make(map[string][]byte),
263
+ }
264
+
265
+ // Default response function accepts all publishes
266
+ m.responseFunc = func(peerID string, record []byte) int {
267
+ return http.StatusOK
268
+ }
269
+
270
+ mux := http.NewServeMux()
271
+ mux.HandleFunc("/routing/v1/ipns/", m.handleIPNS)
272
+
273
+ m.server = httptest.NewServer(mux)
274
+ return m
275
+}
276
+
277
+func (m *mockIPNSPublisher) handleIPNS(w http.ResponseWriter, r *http.Request) {
278
+ m.mu.Lock()
279
+ defer m.mu.Unlock()
280
+
281
+ // Extract peer ID from path
282
+ parts := strings.Split(r.URL.Path, "/")
283
+ if len(parts) < 5 {
284
+ http.Error(w, "invalid path", http.StatusBadRequest)
285
+ return
286
+ }
287
+
288
+ peerID := parts[4]
289
+
290
+ if r.Method == "PUT" {
291
+ // Handle IPNS record publication
292
+ body, err := io.ReadAll(r.Body)
293
+ if err != nil {
294
+ http.Error(w, "failed to read body", http.StatusBadRequest)
295
+ return
296
+ }
297
+
298
+ // Get response status from response function
299
+ status := m.responseFunc(peerID, body)
300
+
301
+ if status == http.StatusOK {
302
+ if len(body) > 0 {
303
+ // Store the actual record payload
304
+ m.recordPayloads[peerID] = make([]byte, len(body))
305
+ copy(m.recordPayloads[peerID], body)
306
+ }
307
+
308
+ // Mark as published
309
+ m.publishedKeys[peerID] = fmt.Sprintf("published-%d", time.Now().Unix())
310
+ }
311
+
312
+ w.WriteHeader(status)
313
+ if status != http.StatusOK {
314
+ fmt.Fprint(w, `{"error": "publish failed"}`)
315
+ }
316
+ } else if r.Method == "GET" {
317
+ // Handle IPNS record retrieval
318
+ if record, exists := m.publishedKeys[peerID]; exists {
319
+ w.Header().Set("Content-Type", "application/vnd.ipfs.ipns-record")
320
+ fmt.Fprint(w, record)
321
+ } else {
322
+ http.Error(w, "record not found", http.StatusNotFound)
323
+ }
324
+ } else {
325
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
326
+ }
327
+}
328
+
329
+func (m *mockIPNSPublisher) getPublishedKeys() map[string]string {
330
+ m.mu.Lock()
331
+ defer m.mu.Unlock()
332
+ result := make(map[string]string)
333
+ for k, v := range m.publishedKeys {
334
+ result[k] = v
335
+ }
336
+ return result
337
+}
338
+
339
+func (m *mockIPNSPublisher) getRecordPayload(peerID string) []byte {
340
+ m.mu.Lock()
341
+ defer m.mu.Unlock()
342
+ if payload, exists := m.recordPayloads[peerID]; exists {
343
+ result := make([]byte, len(payload))
344
+ copy(result, payload)
345
+ return result
346
+ }
347
+ return nil
348
+}
349
+
350
+func (m *mockIPNSPublisher) close() {
351
+ m.server.Close()
352
+}
test/cli/autoconf/routing_test.go
new
+236
@@ -0,0 +1,236 @@
1
+package autoconf
2
+
3
+import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+
12
+ "github.com/ipfs/kubo/test/cli/harness"
13
+ "github.com/stretchr/testify/assert"
14
+ "github.com/stretchr/testify/require"
15
+)
16
+
17
+func TestAutoConfDelegatedRouting(t *testing.T) {
18
+ t.Parallel()
19
+
20
+ t.Run("delegated routing with auto router", func(t *testing.T) {
21
+ t.Parallel()
22
+ testDelegatedRoutingWithAuto(t)
23
+ })
24
+
25
+ t.Run("routing errors are handled properly", func(t *testing.T) {
26
+ t.Parallel()
27
+ testRoutingErrorHandling(t)
28
+ })
29
+}
30
+
31
+// mockRoutingServer implements a simple Delegated Routing HTTP API server
32
+type mockRoutingServer struct {
33
+ t *testing.T
34
+ server *httptest.Server
35
+ mu sync.Mutex
36
+ requests []string
37
+ providerFunc func(cid string) []map[string]interface{}
38
+}
39
+
40
+func newMockRoutingServer(t *testing.T) *mockRoutingServer {
41
+ m := &mockRoutingServer{
42
+ t: t,
43
+ requests: []string{},
44
+ }
45
+
46
+ // Default provider function returns mock provider records
47
+ m.providerFunc = func(cid string) []map[string]interface{} {
48
+ return []map[string]interface{}{
49
+ {
50
+ "Protocol": "transport-bitswap",
51
+ "Schema": "bitswap",
52
+ "ID": "12D3KooWMockProvider1",
53
+ "Addrs": []string{"/ip4/192.168.1.100/tcp/4001"},
54
+ },
55
+ {
56
+ "Protocol": "transport-bitswap",
57
+ "Schema": "bitswap",
58
+ "ID": "12D3KooWMockProvider2",
59
+ "Addrs": []string{"/ip4/192.168.1.101/tcp/4001"},
60
+ },
61
+ }
62
+ }
63
+
64
+ mux := http.NewServeMux()
65
+ mux.HandleFunc("/routing/v1/providers/", m.handleProviders)
66
+
67
+ m.server = httptest.NewServer(mux)
68
+ return m
69
+}
70
+
71
+func (m *mockRoutingServer) handleProviders(w http.ResponseWriter, r *http.Request) {
72
+ m.mu.Lock()
73
+ defer m.mu.Unlock()
74
+
75
+ // Extract CID from path
76
+ parts := strings.Split(r.URL.Path, "/")
77
+ if len(parts) < 5 {
78
+ http.Error(w, "invalid path", http.StatusBadRequest)
79
+ return
80
+ }
81
+
82
+ cid := parts[4]
83
+ m.requests = append(m.requests, cid)
84
+ m.t.Logf("Routing server received providers request for CID: %s", cid)
85
+
86
+ // Get provider records
87
+ providers := m.providerFunc(cid)
88
+
89
+ // Return NDJSON response as per IPIP-378
90
+ w.Header().Set("Content-Type", "application/x-ndjson")
91
+ encoder := json.NewEncoder(w)
92
+
93
+ for _, provider := range providers {
94
+ if err := encoder.Encode(provider); err != nil {
95
+ m.t.Logf("Failed to encode provider: %v", err)
96
+ return
97
+ }
98
+ }
99
+}
100
+
101
+func (m *mockRoutingServer) close() {
102
+ m.server.Close()
103
+}
104
+
105
+func testDelegatedRoutingWithAuto(t *testing.T) {
106
+ // Create mock routing server
107
+ routingServer := newMockRoutingServer(t)
108
+ defer routingServer.close()
109
+
110
+ // Create autoconf data with delegated router
111
+ autoConfData := fmt.Sprintf(`{
112
+ "AutoConfVersion": 2025072302,
113
+ "AutoConfSchema": 1,
114
+ "AutoConfTTL": 86400,
115
+ "SystemRegistry": {
116
+ "AminoDHT": {
117
+ "Description": "Test AminoDHT system",
118
+ "NativeConfig": {
119
+ "Bootstrap": []
120
+ }
121
+ }
122
+ },
123
+ "DNSResolvers": {},
124
+ "DelegatedEndpoints": {
125
+ "%s": {
126
+ "Systems": ["AminoDHT", "IPNI"],
127
+ "Read": ["/routing/v1/providers", "/routing/v1/peers", "/routing/v1/ipns"],
128
+ "Write": []
129
+ }
130
+ }
131
+ }`, routingServer.server.URL)
132
+
133
+ // Create autoconf server
134
+ autoConfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135
+ w.Header().Set("Content-Type", "application/json")
136
+ _, _ = w.Write([]byte(autoConfData))
137
+ }))
138
+ defer autoConfServer.Close()
139
+
140
+ // Create IPFS node with auto delegated router
141
+ node := harness.NewT(t).NewNode().Init("--profile=test")
142
+ node.SetIPFSConfig("AutoConf.URL", autoConfServer.URL)
143
+ node.SetIPFSConfig("AutoConf.Enabled", true)
144
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
145
+
146
+ // Test that daemon starts successfully with auto routing configuration
147
+ // The actual routing functionality requires online mode, but we can test
148
+ // that the configuration is expanded and daemon starts properly
149
+ node.StartDaemon("--offline")
150
+ defer node.StopDaemon()
151
+
152
+ // Verify config still shows "auto" (this tests that auto values are preserved in user-facing config)
153
+ result := node.RunIPFS("config", "Routing.DelegatedRouters")
154
+ require.Equal(t, 0, result.ExitCode())
155
+
156
+ var routers []string
157
+ err := json.Unmarshal([]byte(result.Stdout.String()), &routers)
158
+ require.NoError(t, err)
159
+ assert.Equal(t, []string{"auto"}, routers, "Delegated routers config should show 'auto'")
160
+
161
+ // Test that daemon is running and accepting commands
162
+ result = node.RunIPFS("version")
163
+ require.Equal(t, 0, result.ExitCode(), "Daemon should be running and accepting commands")
164
+
165
+ // Test that autoconf server was contacted (indicating successful resolution)
166
+ // We can't test actual routing in offline mode, but we can verify that
167
+ // the AutoConf system expanded the "auto" placeholder successfully
168
+ // by checking that the daemon started without errors
169
+ t.Log("AutoConf successfully expanded delegated router configuration and daemon started")
170
+}
171
+
172
+func testRoutingErrorHandling(t *testing.T) {
173
+ // Create routing server that returns no providers
174
+ routingServer := newMockRoutingServer(t)
175
+ defer routingServer.close()
176
+
177
+ // Configure to return no providers (empty response)
178
+ routingServer.providerFunc = func(cid string) []map[string]interface{} {
179
+ return []map[string]interface{}{}
180
+ }
181
+
182
+ // Create autoconf data
183
+ autoConfData := fmt.Sprintf(`{
184
+ "AutoConfVersion": 2025072302,
185
+ "AutoConfSchema": 1,
186
+ "AutoConfTTL": 86400,
187
+ "SystemRegistry": {
188
+ "AminoDHT": {
189
+ "Description": "Test AminoDHT system",
190
+ "NativeConfig": {
191
+ "Bootstrap": []
192
+ }
193
+ }
194
+ },
195
+ "DNSResolvers": {},
196
+ "DelegatedEndpoints": {
197
+ "%s": {
198
+ "Systems": ["AminoDHT", "IPNI"],
199
+ "Read": ["/routing/v1/providers", "/routing/v1/peers", "/routing/v1/ipns"],
200
+ "Write": []
201
+ }
202
+ }
203
+ }`, routingServer.server.URL)
204
+
205
+ // Create autoconf server
206
+ autoConfServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207
+ w.Header().Set("Content-Type", "application/json")
208
+ _, _ = w.Write([]byte(autoConfData))
209
+ }))
210
+ defer autoConfServer.Close()
211
+
212
+ // Create IPFS node
213
+ node := harness.NewT(t).NewNode().Init("--profile=test")
214
+ node.SetIPFSConfig("AutoConf.URL", autoConfServer.URL)
215
+ node.SetIPFSConfig("AutoConf.Enabled", true)
216
+ node.SetIPFSConfig("Routing.DelegatedRouters", []string{"auto"})
217
+
218
+ // Test that daemon starts successfully even when no providers are available
219
+ node.StartDaemon("--offline")
220
+ defer node.StopDaemon()
221
+
222
+ // Verify config shows "auto"
223
+ result := node.RunIPFS("config", "Routing.DelegatedRouters")
224
+ require.Equal(t, 0, result.ExitCode())
225
+
226
+ var routers []string
227
+ err := json.Unmarshal([]byte(result.Stdout.String()), &routers)
228
+ require.NoError(t, err)
229
+ assert.Equal(t, []string{"auto"}, routers, "Delegated routers config should show 'auto'")
230
+
231
+ // Test that daemon is running and accepting commands
232
+ result = node.RunIPFS("version")
233
+ require.Equal(t, 0, result.ExitCode(), "Daemon should be running even with empty routing config")
234
+
235
+ t.Log("AutoConf successfully handled routing configuration with empty providers")
236
+}
test/cli/autoconf/swarm_connect_test.go
new
+90
@@ -0,0 +1,90 @@
1
+package autoconf
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/ipfs/kubo/test/cli/harness"
8
+ "github.com/stretchr/testify/assert"
9
+ "github.com/stretchr/testify/require"
10
+)
11
+
12
+// TestSwarmConnectWithAutoConf tests that ipfs swarm connect works properly
13
+// when AutoConf is enabled and a daemon is running.
14
+//
15
+// This is a regression test for the issue where:
16
+// - AutoConf disabled: ipfs swarm connect works
17
+// - AutoConf enabled: ipfs swarm connect fails with "Error: connect"
18
+//
19
+// The issue affects CLI command fallback behavior when the HTTP API connection fails.
20
+func TestSwarmConnectWithAutoConf(t *testing.T) {
21
+ t.Parallel()
22
+
23
+ t.Run("AutoConf disabled - should work", func(t *testing.T) {
24
+ testSwarmConnectWithAutoConfSetting(t, false, true) // expect success
25
+ })
26
+
27
+ t.Run("AutoConf enabled - should work", func(t *testing.T) {
28
+ testSwarmConnectWithAutoConfSetting(t, true, true) // expect success (fix the bug!)
29
+ })
30
+}
31
+
32
+func testSwarmConnectWithAutoConfSetting(t *testing.T, autoConfEnabled bool, expectSuccess bool) {
33
+ // Create IPFS node with test profile
34
+ node := harness.NewT(t).NewNode().Init("--profile=test")
35
+
36
+ // Configure AutoConf
37
+ node.SetIPFSConfig("AutoConf.Enabled", autoConfEnabled)
38
+
39
+ // Set up bootstrap peers so the node has something to connect to
40
+ // Use the same bootstrap peers from boxo/autoconf fallbacks
41
+ node.SetIPFSConfig("Bootstrap", []string{
42
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
43
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
44
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
45
+ })
46
+
47
+ // CRITICAL: Start the daemon first - this is the key requirement
48
+ // The daemon must be running and working properly
49
+ node.StartDaemon()
50
+ defer node.StopDaemon()
51
+
52
+ // Give daemon time to start up completely
53
+ time.Sleep(3 * time.Second)
54
+
55
+ // Verify daemon is responsive
56
+ result := node.RunIPFS("id")
57
+ require.Equal(t, 0, result.ExitCode(), "Daemon should be responsive before testing swarm connect")
58
+ t.Logf("Daemon is running and responsive. AutoConf enabled: %v", autoConfEnabled)
59
+
60
+ // Now test swarm connect to a bootstrap peer
61
+ // This should work because:
62
+ // 1. The daemon is running
63
+ // 2. The CLI should connect to the daemon via API
64
+ // 3. The daemon should handle the swarm connect request
65
+ result = node.RunIPFS("swarm", "connect", "/dnsaddr/bootstrap.libp2p.io")
66
+
67
+ // swarm connect should work regardless of AutoConf setting
68
+ assert.Equal(t, 0, result.ExitCode(),
69
+ "swarm connect should succeed with AutoConf=%v. stderr: %s",
70
+ autoConfEnabled, result.Stderr.String())
71
+
72
+ // Should contain success message
73
+ output := result.Stdout.String()
74
+ assert.Contains(t, output, "success",
75
+ "swarm connect output should contain 'success' with AutoConf=%v. output: %s",
76
+ autoConfEnabled, output)
77
+
78
+ // Additional diagnostic: Check if ipfs id shows addresses
79
+ // Both AutoConf enabled and disabled should show proper addresses
80
+ result = node.RunIPFS("id")
81
+ require.Equal(t, 0, result.ExitCode(), "ipfs id should work with AutoConf=%v", autoConfEnabled)
82
+
83
+ idOutput := result.Stdout.String()
84
+ t.Logf("ipfs id output with AutoConf=%v: %s", autoConfEnabled, idOutput)
85
+
86
+ // Addresses should not be null regardless of AutoConf setting
87
+ assert.Contains(t, idOutput, `"Addresses"`, "ipfs id should show Addresses field")
88
+ assert.NotContains(t, idOutput, `"Addresses": null`,
89
+ "ipfs id should not show null addresses with AutoConf=%v", autoConfEnabled)
90
+}
test/cli/autoconf/testdata/autoconf_amino_and_ipni.json
new
+60
@@ -0,0 +1,60 @@
1
+{
2
+ "AutoConfVersion": 2025072901,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "AminoDHT": {
7
+ "URL": "https://github.com/ipfs/specs/pull/497",
8
+ "Description": "Public DHT swarm that implements the IPFS Kademlia DHT specification under protocol identifier /ipfs/kad/1.0.0",
9
+ "NativeConfig": {
10
+ "Bootstrap": [
11
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"
12
+ ]
13
+ },
14
+ "DelegatedConfig": {
15
+ "Read": [
16
+ "/routing/v1/providers",
17
+ "/routing/v1/peers",
18
+ "/routing/v1/ipns"
19
+ ],
20
+ "Write": [
21
+ "/routing/v1/ipns"
22
+ ]
23
+ }
24
+ },
25
+ "IPNI": {
26
+ "URL": "https://cid.contact",
27
+ "Description": "Network Indexer - content routing database for large storage providers",
28
+ "DelegatedConfig": {
29
+ "Read": [
30
+ "/routing/v1/providers"
31
+ ],
32
+ "Write": []
33
+ }
34
+ }
35
+ },
36
+ "DNSResolvers": {
37
+ "eth.": [
38
+ "https://dns.eth.limo/dns-query"
39
+ ]
40
+ },
41
+ "DelegatedEndpoints": {
42
+ "https://amino-dht.example.com": {
43
+ "Systems": ["AminoDHT"],
44
+ "Read": [
45
+ "/routing/v1/providers",
46
+ "/routing/v1/peers"
47
+ ],
48
+ "Write": [
49
+ "/routing/v1/ipns"
50
+ ]
51
+ },
52
+ "https://cid.contact": {
53
+ "Systems": ["IPNI"],
54
+ "Read": [
55
+ "/routing/v1/providers"
56
+ ],
57
+ "Write": []
58
+ }
59
+ }
60
+}
\ No newline at end of file
test/cli/autoconf/testdata/autoconf_new_routing_system.json
new
+38
@@ -0,0 +1,38 @@
1
+{
2
+ "AutoConfVersion": 2025072901,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "NewRoutingSystem": {
7
+ "URL": "https://new-routing.example.com",
8
+ "Description": "New routing system for testing delegation with auto routing",
9
+ "DelegatedConfig": {
10
+ "Read": [
11
+ "/routing/v1/providers",
12
+ "/routing/v1/peers",
13
+ "/routing/v1/ipns"
14
+ ],
15
+ "Write": [
16
+ "/routing/v1/ipns"
17
+ ]
18
+ }
19
+ }
20
+ },
21
+ "DNSResolvers": {
22
+ "eth.": [
23
+ "https://dns.eth.limo/dns-query"
24
+ ]
25
+ },
26
+ "DelegatedEndpoints": {
27
+ "https://new-routing.example.com": {
28
+ "Systems": ["NewRoutingSystem"],
29
+ "Read": [
30
+ "/routing/v1/providers",
31
+ "/routing/v1/peers"
32
+ ],
33
+ "Write": [
34
+ "/routing/v1/ipns"
35
+ ]
36
+ }
37
+ }
38
+}
\ No newline at end of file
test/cli/autoconf/testdata/autoconf_new_routing_with_filtering.json
new
+59
@@ -0,0 +1,59 @@
1
+{
2
+ "AutoConfVersion": 2025072901,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "NewRoutingSystem": {
7
+ "URL": "https://new-routing.example.com",
8
+ "Description": "New routing system for testing path filtering with auto routing",
9
+ "DelegatedConfig": {
10
+ "Read": [
11
+ "/routing/v1/providers",
12
+ "/routing/v1/peers",
13
+ "/routing/v1/ipns"
14
+ ],
15
+ "Write": [
16
+ "/routing/v1/ipns"
17
+ ]
18
+ }
19
+ }
20
+ },
21
+ "DNSResolvers": {
22
+ "eth.": [
23
+ "https://dns.eth.limo/dns-query"
24
+ ]
25
+ },
26
+ "DelegatedEndpoints": {
27
+ "https://supported-new.example.com": {
28
+ "Systems": ["NewRoutingSystem"],
29
+ "Read": [
30
+ "/routing/v1/providers",
31
+ "/routing/v1/peers"
32
+ ],
33
+ "Write": [
34
+ "/routing/v1/ipns"
35
+ ]
36
+ },
37
+ "https://unsupported-new.example.com": {
38
+ "Systems": ["NewRoutingSystem"],
39
+ "Read": [
40
+ "/custom/v0/read",
41
+ "/api/v1/nonstandard"
42
+ ],
43
+ "Write": [
44
+ "/custom/v0/write"
45
+ ]
46
+ },
47
+ "https://mixed-new.example.com": {
48
+ "Systems": ["NewRoutingSystem"],
49
+ "Read": [
50
+ "/routing/v1/providers",
51
+ "/invalid/path",
52
+ "/routing/v1/peers"
53
+ ],
54
+ "Write": [
55
+ "/routing/v1/ipns"
56
+ ]
57
+ }
58
+ }
59
+}
\ No newline at end of file
test/cli/autoconf/testdata/autoconf_with_unsupported_paths.json
new
+64
@@ -0,0 +1,64 @@
1
+{
2
+ "AutoConfVersion": 2025072901,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "AminoDHT": {
7
+ "URL": "https://github.com/ipfs/specs/pull/497",
8
+ "Description": "Public DHT swarm that implements the IPFS Kademlia DHT specification under protocol identifier /ipfs/kad/1.0.0",
9
+ "NativeConfig": {
10
+ "Bootstrap": [
11
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"
12
+ ]
13
+ },
14
+ "DelegatedConfig": {
15
+ "Read": [
16
+ "/routing/v1/providers",
17
+ "/routing/v1/peers",
18
+ "/routing/v1/ipns"
19
+ ],
20
+ "Write": [
21
+ "/routing/v1/ipns"
22
+ ]
23
+ }
24
+ }
25
+ },
26
+ "DNSResolvers": {
27
+ "eth.": [
28
+ "https://dns.eth.limo/dns-query"
29
+ ]
30
+ },
31
+ "DelegatedEndpoints": {
32
+ "https://supported.example.com": {
33
+ "Systems": ["AminoDHT"],
34
+ "Read": [
35
+ "/routing/v1/providers",
36
+ "/routing/v1/peers"
37
+ ],
38
+ "Write": [
39
+ "/routing/v1/ipns"
40
+ ]
41
+ },
42
+ "https://unsupported.example.com": {
43
+ "Systems": ["AminoDHT"],
44
+ "Read": [
45
+ "/example/v0/read",
46
+ "/api/v1/custom"
47
+ ],
48
+ "Write": [
49
+ "/example/v0/write"
50
+ ]
51
+ },
52
+ "https://mixed.example.com": {
53
+ "Systems": ["AminoDHT"],
54
+ "Read": [
55
+ "/routing/v1/providers",
56
+ "/unsupported/path",
57
+ "/routing/v1/peers"
58
+ ],
59
+ "Write": [
60
+ "/routing/v1/ipns"
61
+ ]
62
+ }
63
+ }
64
+}
test/cli/autoconf/testdata/updated_autoconf.json
new
+87
@@ -0,0 +1,87 @@
1
+{
2
+ "AutoConfVersion": 2025072902,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "AminoDHT": {
7
+ "URL": "https://github.com/ipfs/specs/pull/497",
8
+ "Description": "Public DHT swarm that implements the IPFS Kademlia DHT specification under protocol identifier /ipfs/kad/1.0.0",
9
+ "NativeConfig": {
10
+ "Bootstrap": [
11
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
12
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
13
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
14
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
15
+ "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
16
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
17
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
18
+ ]
19
+ },
20
+ "DelegatedConfig": {
21
+ "Read": [
22
+ "/routing/v1/providers",
23
+ "/routing/v1/peers",
24
+ "/routing/v1/ipns"
25
+ ],
26
+ "Write": [
27
+ "/routing/v1/ipns"
28
+ ]
29
+ }
30
+ },
31
+ "IPNI": {
32
+ "URL": "https://ipni.example.com",
33
+ "Description": "Network Indexer - content routing database for large storage providers",
34
+ "DelegatedConfig": {
35
+ "Read": [
36
+ "/routing/v1/providers"
37
+ ],
38
+ "Write": []
39
+ }
40
+ }
41
+ },
42
+ "DNSResolvers": {
43
+ "eth.": [
44
+ "https://dns.eth.limo/dns-query",
45
+ "https://dns.eth.link/dns-query"
46
+ ],
47
+ "test.": [
48
+ "https://test.resolver/dns-query"
49
+ ]
50
+ },
51
+ "DelegatedEndpoints": {
52
+ "https://ipni.example.com": {
53
+ "Systems": ["IPNI"],
54
+ "Read": [
55
+ "/routing/v1/providers"
56
+ ],
57
+ "Write": []
58
+ },
59
+ "https://routing.example.com": {
60
+ "Systems": ["IPNI"],
61
+ "Read": [
62
+ "/routing/v1/providers"
63
+ ],
64
+ "Write": []
65
+ },
66
+ "https://delegated-ipfs.dev": {
67
+ "Systems": ["AminoDHT", "IPNI"],
68
+ "Read": [
69
+ "/routing/v1/providers",
70
+ "/routing/v1/peers",
71
+ "/routing/v1/ipns"
72
+ ],
73
+ "Write": [
74
+ "/routing/v1/ipns"
75
+ ]
76
+ },
77
+ "https://ipns.example.com": {
78
+ "Systems": ["AminoDHT"],
79
+ "Read": [
80
+ "/routing/v1/ipns"
81
+ ],
82
+ "Write": [
83
+ "/routing/v1/ipns"
84
+ ]
85
+ }
86
+ }
87
+}
\ No newline at end of file
test/cli/autoconf/testdata/valid_autoconf.json
new
+68
@@ -0,0 +1,68 @@
1
+{
2
+ "AutoConfVersion": 2025072901,
3
+ "AutoConfSchema": 1,
4
+ "AutoConfTTL": 86400,
5
+ "SystemRegistry": {
6
+ "AminoDHT": {
7
+ "URL": "https://github.com/ipfs/specs/pull/497",
8
+ "Description": "Public DHT swarm that implements the IPFS Kademlia DHT specification under protocol identifier /ipfs/kad/1.0.0",
9
+ "NativeConfig": {
10
+ "Bootstrap": [
11
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
12
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
13
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
14
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
15
+ "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
16
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
17
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
18
+ ]
19
+ },
20
+ "DelegatedConfig": {
21
+ "Read": [
22
+ "/routing/v1/providers",
23
+ "/routing/v1/peers",
24
+ "/routing/v1/ipns"
25
+ ],
26
+ "Write": [
27
+ "/routing/v1/ipns"
28
+ ]
29
+ }
30
+ },
31
+ "IPNI": {
32
+ "URL": "https://ipni.example.com",
33
+ "Description": "Network Indexer - content routing database for large storage providers",
34
+ "DelegatedConfig": {
35
+ "Read": [
36
+ "/routing/v1/providers"
37
+ ],
38
+ "Write": []
39
+ }
40
+ }
41
+ },
42
+ "DNSResolvers": {
43
+ "eth.": [
44
+ "https://dns.eth.limo/dns-query",
45
+ "https://dns.eth.link/dns-query"
46
+ ]
47
+ },
48
+ "DelegatedEndpoints": {
49
+ "https://ipni.example.com": {
50
+ "Systems": ["IPNI"],
51
+ "Read": [
52
+ "/routing/v1/providers"
53
+ ],
54
+ "Write": []
55
+ },
56
+ "https://delegated-ipfs.dev": {
57
+ "Systems": ["AminoDHT", "IPNI"],
58
+ "Read": [
59
+ "/routing/v1/providers",
60
+ "/routing/v1/peers",
61
+ "/routing/v1/ipns"
62
+ ],
63
+ "Write": [
64
+ "/routing/v1/ipns"
65
+ ]
66
+ }
67
+ }
68
+}
\ No newline at end of file
test/cli/autoconf/validation_test.go
new
+144
@@ -0,0 +1,144 @@
1
+package autoconf
2
+
3
+import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+
8
+ "github.com/ipfs/kubo/test/cli/harness"
9
+ "github.com/stretchr/testify/assert"
10
+)
11
+
12
+func TestAutoConfValidation(t *testing.T) {
13
+ t.Parallel()
14
+
15
+ t.Run("invalid autoconf JSON prevents caching", func(t *testing.T) {
16
+ t.Parallel()
17
+ testInvalidAutoConfJSONPreventsCaching(t)
18
+ })
19
+
20
+ t.Run("malformed multiaddr in autoconf", func(t *testing.T) {
21
+ t.Parallel()
22
+ testMalformedMultiaddrInAutoConf(t)
23
+ })
24
+
25
+ t.Run("malformed URL in autoconf", func(t *testing.T) {
26
+ t.Parallel()
27
+ testMalformedURLInAutoConf(t)
28
+ })
29
+}
30
+
31
+func testInvalidAutoConfJSONPreventsCaching(t *testing.T) {
32
+ // Create server that serves invalid autoconf JSON
33
+ invalidAutoConfData := `{
34
+ "AutoConfVersion": 123,
35
+ "AutoConfSchema": 1,
36
+ "SystemRegistry": {
37
+ "AminoDHT": {
38
+ "NativeConfig": {
39
+ "Bootstrap": [
40
+ "invalid-multiaddr-that-should-fail"
41
+ ]
42
+ }
43
+ }
44
+ }
45
+ }`
46
+
47
+ requestCount := 0
48
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
49
+ requestCount++
50
+ t.Logf("Invalid autoconf server request #%d: %s %s", requestCount, r.Method, r.URL.Path)
51
+ w.Header().Set("Content-Type", "application/json")
52
+ w.Header().Set("ETag", `"invalid-config-123"`)
53
+ _, _ = w.Write([]byte(invalidAutoConfData))
54
+ }))
55
+ defer server.Close()
56
+
57
+ // Create IPFS node and try to start daemon with invalid autoconf
58
+ node := harness.NewT(t).NewNode().Init("--profile=test")
59
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
60
+ node.SetIPFSConfig("AutoConf.Enabled", true)
61
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
62
+
63
+ // Start daemon to trigger autoconf fetch - this should start but log validation errors
64
+ node.StartDaemon()
65
+ defer node.StopDaemon()
66
+
67
+ // Give autoconf some time to attempt fetch and fail validation
68
+ // The daemon should still start but autoconf should fail
69
+ result := node.RunIPFS("version")
70
+ assert.Equal(t, 0, result.ExitCode(), "Daemon should start even with invalid autoconf")
71
+
72
+ // Verify server was called (autoconf was attempted even though validation failed)
73
+ assert.Greater(t, requestCount, 0, "Invalid autoconf server should have been called")
74
+}
75
+
76
+func testMalformedMultiaddrInAutoConf(t *testing.T) {
77
+ // Create server that serves autoconf with malformed multiaddr
78
+ invalidAutoConfData := `{
79
+ "AutoConfVersion": 456,
80
+ "AutoConfSchema": 1,
81
+ "SystemRegistry": {
82
+ "AminoDHT": {
83
+ "NativeConfig": {
84
+ "Bootstrap": [
85
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
86
+ "not-a-valid-multiaddr"
87
+ ]
88
+ }
89
+ }
90
+ }
91
+ }`
92
+
93
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94
+ w.Header().Set("Content-Type", "application/json")
95
+ _, _ = w.Write([]byte(invalidAutoConfData))
96
+ }))
97
+ defer server.Close()
98
+
99
+ // Create IPFS node
100
+ node := harness.NewT(t).NewNode().Init("--profile=test")
101
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
102
+ node.SetIPFSConfig("AutoConf.Enabled", true)
103
+ node.SetIPFSConfig("Bootstrap", []string{"auto"})
104
+
105
+ // Start daemon to trigger autoconf fetch - daemon should start but autoconf validation should fail
106
+ node.StartDaemon()
107
+ defer node.StopDaemon()
108
+
109
+ // Daemon should still be functional even with invalid autoconf
110
+ result := node.RunIPFS("version")
111
+ assert.Equal(t, 0, result.ExitCode(), "Daemon should start even with invalid autoconf")
112
+}
113
+
114
+func testMalformedURLInAutoConf(t *testing.T) {
115
+ // Create server that serves autoconf with malformed URL
116
+ invalidAutoConfData := `{
117
+ "AutoConfVersion": 789,
118
+ "AutoConfSchema": 1,
119
+ "DNSResolvers": {
120
+ "eth.": ["https://valid.example.com"],
121
+ "bad.": ["://malformed-url-missing-scheme"]
122
+ }
123
+ }`
124
+
125
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126
+ w.Header().Set("Content-Type", "application/json")
127
+ _, _ = w.Write([]byte(invalidAutoConfData))
128
+ }))
129
+ defer server.Close()
130
+
131
+ // Create IPFS node
132
+ node := harness.NewT(t).NewNode().Init("--profile=test")
133
+ node.SetIPFSConfig("AutoConf.URL", server.URL)
134
+ node.SetIPFSConfig("AutoConf.Enabled", true)
135
+ node.SetIPFSConfig("DNS.Resolvers", map[string]string{"foo.": "auto"})
136
+
137
+ // Start daemon to trigger autoconf fetch - daemon should start but autoconf validation should fail
138
+ node.StartDaemon()
139
+ defer node.StopDaemon()
140
+
141
+ // Daemon should still be functional even with invalid autoconf
142
+ result := node.RunIPFS("version")
143
+ assert.Equal(t, 0, result.ExitCode(), "Daemon should start even with invalid autoconf")
144
+}
test/cli/basic_commands_test.go
+4
@@ -70,6 +70,10 @@ func TestIPFSVersionDeps(t *testing.T) {
70
splitModVers := strings.Split(moduleVersion, "@")
71
modPath := splitModVers[0]
72
modVers := splitModVers[1]
73
+ // Skip local replace paths (starting with "./")
74
+ if strings.HasPrefix(modPath, "./") {
75
+ continue
76
+ }
77
assert.NoError(t, gomod.Check(modPath, modVers), "path: %s, version: %s", modPath, modVers)
78
}
79
}
test/cli/bootstrap_auto_test.go
new
+202
@@ -0,0 +1,202 @@
1
+package cli
2
+
3
+import (
4
+ "testing"
5
+
6
+ "github.com/ipfs/kubo/test/cli/harness"
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
+)
10
+
11
+func TestBootstrapCommandsWithAutoPlaceholder(t *testing.T) {
12
+ t.Parallel()
13
+
14
+ t.Run("bootstrap add default", func(t *testing.T) {
15
+ t.Parallel()
16
+ // Test that 'ipfs bootstrap add default' works correctly
17
+ node := harness.NewT(t).NewNode().Init("--profile=test")
18
+ node.SetIPFSConfig("AutoConf.Enabled", true)
19
+ node.SetIPFSConfig("Bootstrap", []string{}) // Start with empty bootstrap
20
+
21
+ // Add default bootstrap peers via "auto" placeholder
22
+ result := node.RunIPFS("bootstrap", "add", "default")
23
+ require.Equal(t, 0, result.ExitCode(), "bootstrap add default should succeed")
24
+
25
+ output := result.Stdout.String()
26
+ t.Logf("Bootstrap add default output: %s", output)
27
+ assert.Contains(t, output, "added auto", "bootstrap add default should report adding 'auto'")
28
+
29
+ // Verify bootstrap list shows "auto"
30
+ listResult := node.RunIPFS("bootstrap", "list")
31
+ require.Equal(t, 0, listResult.ExitCode(), "bootstrap list should succeed")
32
+
33
+ listOutput := listResult.Stdout.String()
34
+ t.Logf("Bootstrap list after add default: %s", listOutput)
35
+ assert.Contains(t, listOutput, "auto", "bootstrap list should show 'auto' placeholder")
36
+ })
37
+
38
+ t.Run("bootstrap add auto explicitly", func(t *testing.T) {
39
+ t.Parallel()
40
+ // Test that 'ipfs bootstrap add auto' works correctly
41
+ node := harness.NewT(t).NewNode().Init("--profile=test")
42
+ node.SetIPFSConfig("AutoConf.Enabled", true)
43
+ node.SetIPFSConfig("Bootstrap", []string{}) // Start with empty bootstrap
44
+
45
+ // Add "auto" placeholder explicitly
46
+ result := node.RunIPFS("bootstrap", "add", "auto")
47
+ require.Equal(t, 0, result.ExitCode(), "bootstrap add auto should succeed")
48
+
49
+ output := result.Stdout.String()
50
+ t.Logf("Bootstrap add auto output: %s", output)
51
+ assert.Contains(t, output, "added auto", "bootstrap add auto should report adding 'auto'")
52
+
53
+ // Verify bootstrap list shows "auto"
54
+ listResult := node.RunIPFS("bootstrap", "list")
55
+ require.Equal(t, 0, listResult.ExitCode(), "bootstrap list should succeed")
56
+
57
+ listOutput := listResult.Stdout.String()
58
+ t.Logf("Bootstrap list after add auto: %s", listOutput)
59
+ assert.Contains(t, listOutput, "auto", "bootstrap list should show 'auto' placeholder")
60
+ })
61
+
62
+ t.Run("bootstrap add default converts to auto", func(t *testing.T) {
63
+ t.Parallel()
64
+ // Test that 'ipfs bootstrap add default' adds "auto" to the bootstrap list
65
+ node := harness.NewT(t).NewNode().Init("--profile=test")
66
+ node.SetIPFSConfig("Bootstrap", []string{}) // Start with empty bootstrap
67
+ node.SetIPFSConfig("AutoConf.Enabled", true) // Enable AutoConf to allow adding "auto"
68
+
69
+ // Add default bootstrap peers
70
+ result := node.RunIPFS("bootstrap", "add", "default")
71
+ require.Equal(t, 0, result.ExitCode(), "bootstrap add default should succeed")
72
+ assert.Contains(t, result.Stdout.String(), "added auto", "should report adding 'auto'")
73
+
74
+ // Verify bootstrap list shows "auto"
75
+ var bootstrap []string
76
+ node.GetIPFSConfig("Bootstrap", &bootstrap)
77
+ require.Equal(t, []string{"auto"}, bootstrap, "Bootstrap should contain ['auto']")
78
+ })
79
+
80
+ t.Run("bootstrap add default fails when AutoConf disabled", func(t *testing.T) {
81
+ t.Parallel()
82
+ // Test that adding default/auto fails when AutoConf is disabled
83
+ node := harness.NewT(t).NewNode().Init("--profile=test")
84
+ node.SetIPFSConfig("Bootstrap", []string{}) // Start with empty bootstrap
85
+ node.SetIPFSConfig("AutoConf.Enabled", false) // Disable AutoConf
86
+
87
+ // Try to add default - should fail
88
+ result := node.RunIPFS("bootstrap", "add", "default")
89
+ require.NotEqual(t, 0, result.ExitCode(), "bootstrap add default should fail when AutoConf disabled")
90
+ assert.Contains(t, result.Stderr.String(), "AutoConf is disabled", "should mention AutoConf is disabled")
91
+
92
+ // Try to add auto - should also fail
93
+ result = node.RunIPFS("bootstrap", "add", "auto")
94
+ require.NotEqual(t, 0, result.ExitCode(), "bootstrap add auto should fail when AutoConf disabled")
95
+ assert.Contains(t, result.Stderr.String(), "AutoConf is disabled", "should mention AutoConf is disabled")
96
+ })
97
+
98
+ t.Run("bootstrap rm with auto placeholder", func(t *testing.T) {
99
+ t.Parallel()
100
+ // Test that selective removal fails properly when "auto" is present
101
+ node := harness.NewT(t).NewNode().Init("--profile=test")
102
+ node.SetIPFSConfig("AutoConf.Enabled", true)
103
+ node.SetIPFSConfig("Bootstrap", []string{"auto"}) // Start with auto
104
+
105
+ // Try to remove a specific peer - should fail with helpful error
106
+ result := node.RunIPFS("bootstrap", "rm", "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN")
107
+ require.NotEqual(t, 0, result.ExitCode(), "bootstrap rm of specific peer should fail when 'auto' is present")
108
+
109
+ output := result.Stderr.String()
110
+ t.Logf("Bootstrap rm error output: %s", output)
111
+ assert.Contains(t, output, "cannot remove individual bootstrap peers when using 'auto' placeholder",
112
+ "should provide helpful error message about auto placeholder")
113
+ assert.Contains(t, output, "disable AutoConf",
114
+ "should suggest disabling AutoConf as solution")
115
+ assert.Contains(t, output, "ipfs bootstrap rm --all",
116
+ "should suggest using rm --all as alternative")
117
+ })
118
+
119
+ t.Run("bootstrap rm --all with auto placeholder", func(t *testing.T) {
120
+ t.Parallel()
121
+ // Test that 'ipfs bootstrap rm --all' works with "auto" placeholder
122
+ node := harness.NewT(t).NewNode().Init("--profile=test")
123
+ node.SetIPFSConfig("AutoConf.Enabled", true)
124
+ node.SetIPFSConfig("Bootstrap", []string{"auto"}) // Start with auto
125
+
126
+ // Remove all bootstrap peers
127
+ result := node.RunIPFS("bootstrap", "rm", "--all")
128
+ require.Equal(t, 0, result.ExitCode(), "bootstrap rm --all should succeed with auto placeholder")
129
+
130
+ output := result.Stdout.String()
131
+ t.Logf("Bootstrap rm --all output: %s", output)
132
+ assert.Contains(t, output, "removed auto", "bootstrap rm --all should report removing 'auto'")
133
+
134
+ // Verify bootstrap list is now empty
135
+ listResult := node.RunIPFS("bootstrap", "list")
136
+ require.Equal(t, 0, listResult.ExitCode(), "bootstrap list should succeed")
137
+
138
+ listOutput := listResult.Stdout.String()
139
+ t.Logf("Bootstrap list after rm --all: %s", listOutput)
140
+ assert.Empty(t, listOutput, "bootstrap list should be empty after rm --all")
141
+
142
+ // Test the rm all subcommand too
143
+ node.SetIPFSConfig("Bootstrap", []string{"auto"}) // Reset to auto
144
+
145
+ result = node.RunIPFS("bootstrap", "rm", "all")
146
+ require.Equal(t, 0, result.ExitCode(), "bootstrap rm all should succeed with auto placeholder")
147
+
148
+ output = result.Stdout.String()
149
+ t.Logf("Bootstrap rm all output: %s", output)
150
+ assert.Contains(t, output, "removed auto", "bootstrap rm all should report removing 'auto'")
151
+ })
152
+
153
+ t.Run("bootstrap mixed auto and specific peers", func(t *testing.T) {
154
+ t.Parallel()
155
+ // Test that bootstrap commands work when mixing "auto" with specific peers
156
+ node := harness.NewT(t).NewNode().Init("--profile=test")
157
+ node.SetIPFSConfig("AutoConf.Enabled", true)
158
+ node.SetIPFSConfig("Bootstrap", []string{}) // Start with empty bootstrap
159
+
160
+ // Add a specific peer first
161
+ specificPeer := "/ip4/127.0.0.1/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
162
+ result := node.RunIPFS("bootstrap", "add", specificPeer)
163
+ require.Equal(t, 0, result.ExitCode(), "bootstrap add specific peer should succeed")
164
+
165
+ // Add auto placeholder
166
+ result = node.RunIPFS("bootstrap", "add", "auto")
167
+ require.Equal(t, 0, result.ExitCode(), "bootstrap add auto should succeed")
168
+
169
+ // Verify bootstrap list shows both
170
+ listResult := node.RunIPFS("bootstrap", "list")
171
+ require.Equal(t, 0, listResult.ExitCode(), "bootstrap list should succeed")
172
+
173
+ listOutput := listResult.Stdout.String()
174
+ t.Logf("Bootstrap list with mixed peers: %s", listOutput)
175
+ assert.Contains(t, listOutput, "auto", "bootstrap list should contain 'auto' placeholder")
176
+ assert.Contains(t, listOutput, specificPeer, "bootstrap list should contain specific peer")
177
+
178
+ // Try to remove the specific peer - should fail because auto is present
179
+ result = node.RunIPFS("bootstrap", "rm", specificPeer)
180
+ require.NotEqual(t, 0, result.ExitCode(), "bootstrap rm of specific peer should fail when 'auto' is present")
181
+
182
+ output := result.Stderr.String()
183
+ assert.Contains(t, output, "cannot remove individual bootstrap peers when using 'auto' placeholder",
184
+ "should provide helpful error message about auto placeholder")
185
+
186
+ // Remove all should work and remove both auto and specific peer
187
+ result = node.RunIPFS("bootstrap", "rm", "--all")
188
+ require.Equal(t, 0, result.ExitCode(), "bootstrap rm --all should succeed")
189
+
190
+ output = result.Stdout.String()
191
+ t.Logf("Bootstrap rm --all output with mixed peers: %s", output)
192
+ // Should report removing both the specific peer and auto
193
+ assert.Contains(t, output, "removed", "should report removing peers")
194
+
195
+ // Verify bootstrap list is now empty
196
+ listResult = node.RunIPFS("bootstrap", "list")
197
+ require.Equal(t, 0, listResult.ExitCode(), "bootstrap list should succeed")
198
+
199
+ listOutput = listResult.Stdout.String()
200
+ assert.Empty(t, listOutput, "bootstrap list should be empty after rm --all")
201
+ })
202
+}
test/cli/harness/node.go
+135
-5
@@ -54,6 +54,42 @@ func BuildNode(ipfsBin, baseDir string, id int) *Node {
54
env := environToMap(os.Environ())
55
env["IPFS_PATH"] = dir
56
57
+ // If using "ipfs" binary name, provide helpful binary information
58
+ if ipfsBin == "ipfs" {
59
+ // Check if cmd/ipfs/ipfs exists (simple relative path check)
60
+ localBinary := "cmd/ipfs/ipfs"
61
+ localExists := false
62
+ if _, err := os.Stat(localBinary); err == nil {
63
+ localExists = true
64
+ if abs, err := filepath.Abs(localBinary); err == nil {
65
+ localBinary = abs
66
+ }
67
+ }
68
+
69
+ // Check if ipfs is available in PATH
70
+ pathBinary, pathErr := exec.LookPath("ipfs")
71
+
72
+ // Handle different scenarios
73
+ if pathErr != nil {
74
+ // No ipfs in PATH
75
+ if localExists {
76
+ fmt.Printf("WARNING: No 'ipfs' found in PATH, but local binary exists at %s\n", localBinary)
77
+ fmt.Printf("Consider adding it to PATH or run: export PATH=\"$(pwd)/cmd/ipfs:$PATH\"\n")
78
+ } else {
79
+ fmt.Printf("ERROR: No 'ipfs' binary found in PATH and no local build at cmd/ipfs/ipfs\n")
80
+ fmt.Printf("Run 'make build' first or install ipfs and add it to PATH\n")
81
+ panic("ipfs binary not available")
82
+ }
83
+ } else {
84
+ // ipfs found in PATH
85
+ if localExists && localBinary != pathBinary {
86
+ fmt.Printf("NOTE: Local binary at %s differs from PATH binary at %s\n", localBinary, pathBinary)
87
+ fmt.Printf("Consider adding the local binary to PATH if you want to use the version built by 'make build'\n")
88
+ }
89
+ // If they match or no local binary, no message needed
90
+ }
91
+ }
92
+
93
return &Node{
94
ID: id,
95
Dir: dir,
@@ -457,28 +493,60 @@ func (n *Node) IsAlive() bool {
493
}
494
495
func (n *Node) SwarmAddrs() []multiaddr.Multiaddr {
460
- res := n.Runner.MustRun(RunRequest{
496
+ res := n.Runner.Run(RunRequest{
497
Path: n.IPFSBin,
498
Args: []string{"swarm", "addrs", "local"},
499
})
500
+ if res.ExitCode() != 0 {
501
+ // If swarm command fails (e.g., daemon not online), return empty slice
502
+ log.Debugf("Node %d: swarm addrs local failed (exit %d): %s", n.ID, res.ExitCode(), res.Stderr.String())
503
+ return []multiaddr.Multiaddr{}
504
+ }
505
out := strings.TrimSpace(res.Stdout.String())
506
+ if out == "" {
507
+ log.Debugf("Node %d: swarm addrs local returned empty output", n.ID)
508
+ return []multiaddr.Multiaddr{}
509
+ }
510
+ log.Debugf("Node %d: swarm addrs local output: %s", n.ID, out)
511
outLines := strings.Split(out, "\n")
512
var addrs []multiaddr.Multiaddr
513
for _, addrStr := range outLines {
514
+ addrStr = strings.TrimSpace(addrStr)
515
+ if addrStr == "" {
516
+ continue
517
+ }
518
ma, err := multiaddr.NewMultiaddr(addrStr)
519
if err != nil {
520
panic(err)
521
}
522
addrs = append(addrs, ma)
523
}
524
+ log.Debugf("Node %d: parsed %d swarm addresses", n.ID, len(addrs))
525
return addrs
526
}
527
528
+// SwarmAddrsWithTimeout waits for swarm addresses to be available
529
+func (n *Node) SwarmAddrsWithTimeout(timeout time.Duration) []multiaddr.Multiaddr {
530
+ start := time.Now()
531
+ for time.Since(start) < timeout {
532
+ addrs := n.SwarmAddrs()
533
+ if len(addrs) > 0 {
534
+ return addrs
535
+ }
536
+ time.Sleep(100 * time.Millisecond)
537
+ }
538
+ return []multiaddr.Multiaddr{}
539
+}
540
+
541
func (n *Node) SwarmAddrsWithPeerIDs() []multiaddr.Multiaddr {
542
+ return n.SwarmAddrsWithPeerIDsTimeout(5 * time.Second)
543
+}
544
+
545
+func (n *Node) SwarmAddrsWithPeerIDsTimeout(timeout time.Duration) []multiaddr.Multiaddr {
546
ipfsProtocol := multiaddr.ProtocolWithCode(multiaddr.P_IPFS).Name
547
peerID := n.PeerID()
548
var addrs []multiaddr.Multiaddr
481
- for _, ma := range n.SwarmAddrs() {
549
+ for _, ma := range n.SwarmAddrsWithTimeout(timeout) {
550
// add the peer ID to the multiaddr if it doesn't have it
551
_, err := ma.ValueForProtocol(multiaddr.P_IPFS)
552
if errors.Is(err, multiaddr.ErrProtocolNotFound) {
@@ -513,18 +581,80 @@ func (n *Node) SwarmAddrsWithoutPeerIDs() []multiaddr.Multiaddr {
581
}
582
583
func (n *Node) Connect(other *Node) *Node {
516
- n.Runner.MustRun(RunRequest{
584
+ // Get the peer addresses to connect to
585
+ addrs := other.SwarmAddrsWithPeerIDs()
586
+ if len(addrs) == 0 {
587
+ // If no addresses available, skip connection
588
+ log.Debugf("No swarm addresses available for connection")
589
+ return n
590
+ }
591
+ // Use Run instead of MustRun to avoid panics on connection failures
592
+ res := n.Runner.Run(RunRequest{
593
Path: n.IPFSBin,
518
- Args: []string{"swarm", "connect", other.SwarmAddrsWithPeerIDs()[0].String()},
594
+ Args: []string{"swarm", "connect", addrs[0].String()},
595
})
596
+ if res.ExitCode() != 0 {
597
+ log.Debugf("swarm connect failed: %s", res.Stderr.String())
598
+ }
599
return n
600
}
601
602
+// ConnectAndWait connects to another node and waits for the connection to be established
603
+func (n *Node) ConnectAndWait(other *Node, timeout time.Duration) error {
604
+ // Get the peer addresses to connect to - wait up to half the timeout for addresses
605
+ addrs := other.SwarmAddrsWithPeerIDsTimeout(timeout / 2)
606
+ if len(addrs) == 0 {
607
+ return fmt.Errorf("no swarm addresses available for node %d after waiting %v", other.ID, timeout/2)
608
+ }
609
+
610
+ otherPeerID := other.PeerID()
611
+
612
+ // Try to connect
613
+ res := n.Runner.Run(RunRequest{
614
+ Path: n.IPFSBin,
615
+ Args: []string{"swarm", "connect", addrs[0].String()},
616
+ })
617
+ if res.ExitCode() != 0 {
618
+ return fmt.Errorf("swarm connect failed: %s", res.Stderr.String())
619
+ }
620
+
621
+ // Wait for connection to be established
622
+ start := time.Now()
623
+ for time.Since(start) < timeout {
624
+ peers := n.Peers()
625
+ for _, peerAddr := range peers {
626
+ if peerID, err := peerAddr.ValueForProtocol(multiaddr.P_P2P); err == nil {
627
+ if peerID == otherPeerID.String() {
628
+ return nil // Connection established
629
+ }
630
+ }
631
+ }
632
+ time.Sleep(100 * time.Millisecond)
633
+ }
634
+
635
+ return fmt.Errorf("timeout waiting for connection to node %d (peer %s)", other.ID, otherPeerID)
636
+}
637
+
638
func (n *Node) Peers() []multiaddr.Multiaddr {
524
- res := n.Runner.MustRun(RunRequest{
639
+ // Wait for daemon to be ready if it's supposed to be running
640
+ if n.Daemon != nil && n.Daemon.Cmd != nil && n.Daemon.Cmd.Process != nil {
641
+ // Give daemon a short time to become ready
642
+ for i := 0; i < 10; i++ {
643
+ if n.IsAlive() {
644
+ break
645
+ }
646
+ time.Sleep(100 * time.Millisecond)
647
+ }
648
+ }
649
+ res := n.Runner.Run(RunRequest{
650
Path: n.IPFSBin,
651
Args: []string{"swarm", "peers"},
652
})
653
+ if res.ExitCode() != 0 {
654
+ // If swarm peers fails (e.g., daemon not online), return empty slice
655
+ log.Debugf("swarm peers failed: %s", res.Stderr.String())
656
+ return []multiaddr.Multiaddr{}
657
+ }
658
var addrs []multiaddr.Multiaddr
659
for _, line := range res.Stdout.Lines() {
660
ma, err := multiaddr.NewMultiaddr(line)
test/cli/migrations/migration_16_to_17_test.go
new
+684
@@ -0,0 +1,684 @@
1
+package migrations
2
+
3
+// NOTE: These migration tests require the local Kubo binary (built with 'make build') to be in PATH.
4
+// The tests migrate from repo version 16 to 17, which requires Kubo version 0.37.0+ (expects repo v17).
5
+// If using system ipfs binary v0.36.0 or older (expects repo v16), no migration will be triggered.
6
+//
7
+// To run these tests successfully:
8
+// export PATH="$(pwd)/cmd/ipfs:$PATH"
9
+// go test ./test/cli/migrations/
10
+
11
+import (
12
+ "bufio"
13
+ "context"
14
+ "encoding/json"
15
+ "io"
16
+ "os"
17
+ "os/exec"
18
+ "path/filepath"
19
+ "strings"
20
+ "testing"
21
+ "time"
22
+
23
+ "github.com/ipfs/kubo/test/cli/harness"
24
+ "github.com/stretchr/testify/require"
25
+)
26
+
27
+func TestMigration16To17(t *testing.T) {
28
+ t.Parallel()
29
+
30
+ // Primary tests using 'ipfs daemon --migrate' command (default in Docker)
31
+ t.Run("daemon migrate: forward migration with auto values", testDaemonMigrationWithAuto)
32
+ t.Run("daemon migrate: forward migration without auto values", testDaemonMigrationWithoutAuto)
33
+ t.Run("daemon migrate: corrupted config handling", testDaemonCorruptedConfigHandling)
34
+ t.Run("daemon migrate: missing fields handling", testDaemonMissingFieldsHandling)
35
+
36
+ // Comparison tests using 'ipfs repo migrate' command
37
+ t.Run("repo migrate: forward migration with auto values", testRepoMigrationWithAuto)
38
+ t.Run("repo migrate: backward migration", testRepoBackwardMigration)
39
+}
40
+
41
+// =============================================================================
42
+// PRIMARY TESTS: 'ipfs daemon --migrate' command (default in Docker)
43
+//
44
+// These tests exercise the primary migration path used in production Docker
45
+// containers where --migrate is enabled by default. This covers:
46
+// - Normal forward migration scenarios
47
+// - Error handling with corrupted configs
48
+// - Migration with minimal/missing config fields
49
+// =============================================================================
50
+
51
+func testDaemonMigrationWithAuto(t *testing.T) {
52
+ // TEST: Forward migration using 'ipfs daemon --migrate' command (PRIMARY)
53
+ // Use static v16 repo fixture from real Kubo 0.36 `ipfs init`
54
+ // NOTE: This test may need to be revised/updated once repo version 18 is released,
55
+ // at that point only keep tests that use 'ipfs repo migrate'
56
+ node := setupStaticV16Repo(t)
57
+
58
+ configPath := filepath.Join(node.Dir, "config")
59
+ versionPath := filepath.Join(node.Dir, "version")
60
+
61
+ // Static fixture already uses port 0 for random port assignment - no config update needed
62
+
63
+ // Run migration using daemon --migrate (automatic during daemon startup)
64
+ // This is the primary method used in Docker containers
65
+ // Monitor output until daemon is ready, then shut it down gracefully
66
+ stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
67
+
68
+ // Debug: Print the actual output
69
+ t.Logf("Daemon output:\n%s", stdoutOutput)
70
+
71
+ // Verify migration was successful based on monitoring
72
+ require.True(t, migrationSuccess, "Migration should have been successful")
73
+ require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
74
+ require.Contains(t, stdoutOutput, "Migration 16 to 17 succeeded", "Migration should have completed successfully")
75
+
76
+ // Verify version was updated to 17
77
+ versionData, err := os.ReadFile(versionPath)
78
+ require.NoError(t, err)
79
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Version should be updated to 17")
80
+
81
+ // Verify migration results using DRY helper
82
+ helper := NewMigrationTestHelper(t, configPath)
83
+ helper.RequireAutoConfDefaults().
84
+ RequireArrayContains("Bootstrap", "auto").
85
+ RequireArrayLength("Bootstrap", 1). // Should only contain "auto" when all peers were defaults
86
+ RequireArrayContains("Routing.DelegatedRouters", "auto").
87
+ RequireArrayContains("Ipns.DelegatedPublishers", "auto")
88
+
89
+ // DNS resolver in static fixture should be empty, so "." should be set to "auto"
90
+ helper.RequireFieldEquals("DNS.Resolvers[.]", "auto")
91
+}
92
+
93
+func testDaemonMigrationWithoutAuto(t *testing.T) {
94
+ // TEST: Forward migration using 'ipfs daemon --migrate' command (PRIMARY)
95
+ // Test migration of a config that already has some custom values
96
+ // NOTE: This test may need to be revised/updated once repo version 18 is released,
97
+ // at that point only keep tests that use 'ipfs repo migrate'
98
+ // Should preserve existing settings and only add missing ones
99
+ node := setupStaticV16Repo(t)
100
+
101
+ // Modify the static fixture to add some custom values for testing mixed scenarios
102
+ configPath := filepath.Join(node.Dir, "config")
103
+
104
+ // Read existing config from static fixture
105
+ var v16Config map[string]interface{}
106
+ configData, err := os.ReadFile(configPath)
107
+ require.NoError(t, err)
108
+ require.NoError(t, json.Unmarshal(configData, &v16Config))
109
+
110
+ // Add custom DNS resolver that should be preserved
111
+ if v16Config["DNS"] == nil {
112
+ v16Config["DNS"] = map[string]interface{}{}
113
+ }
114
+ dnsSection := v16Config["DNS"].(map[string]interface{})
115
+ dnsSection["Resolvers"] = map[string]string{
116
+ ".": "https://custom-dns.example.com/dns-query",
117
+ "eth.": "https://dns.eth.limo/dns-query", // This is a default that will be replaced with "auto"
118
+ }
119
+
120
+ // Write modified config back
121
+ modifiedConfigData, err := json.MarshalIndent(v16Config, "", " ")
122
+ require.NoError(t, err)
123
+ require.NoError(t, os.WriteFile(configPath, modifiedConfigData, 0644))
124
+
125
+ // Static fixture already uses port 0 for random port assignment - no config update needed
126
+
127
+ // Run migration using daemon --migrate command (this is a daemon test)
128
+ // Monitor output until daemon is ready, then shut it down gracefully
129
+ stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
130
+
131
+ // Verify migration was successful based on monitoring
132
+ require.True(t, migrationSuccess, "Migration should have been successful")
133
+ require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
134
+ require.Contains(t, stdoutOutput, "Migration 16 to 17 succeeded", "Migration should have completed successfully")
135
+
136
+ // Verify migration results: custom values preserved alongside "auto"
137
+ helper := NewMigrationTestHelper(t, configPath)
138
+ helper.RequireAutoConfDefaults().
139
+ RequireArrayContains("Bootstrap", "auto").
140
+ RequireFieldEquals("DNS.Resolvers[.]", "https://custom-dns.example.com/dns-query")
141
+
142
+ // Check that eth. resolver was replaced with "auto" since it uses a default URL
143
+ helper.RequireFieldEquals("DNS.Resolvers[eth.]", "auto").
144
+ RequireFieldEquals("DNS.Resolvers[.]", "https://custom-dns.example.com/dns-query")
145
+}
146
+
147
+// =============================================================================
148
+// Tests using 'ipfs daemon --migrate' command
149
+// =============================================================================
150
+
151
+// Test helper structs and functions for cleaner, more DRY tests
152
+
153
+type ConfigField struct {
154
+ Path string
155
+ Expected interface{}
156
+ Message string
157
+}
158
+
159
+type MigrationTestHelper struct {
160
+ t *testing.T
161
+ config map[string]interface{}
162
+}
163
+
164
+func NewMigrationTestHelper(t *testing.T, configPath string) *MigrationTestHelper {
165
+ var config map[string]interface{}
166
+ configData, err := os.ReadFile(configPath)
167
+ require.NoError(t, err)
168
+ require.NoError(t, json.Unmarshal(configData, &config))
169
+
170
+ return &MigrationTestHelper{t: t, config: config}
171
+}
172
+
173
+func (h *MigrationTestHelper) RequireFieldExists(path string) *MigrationTestHelper {
174
+ value := h.getNestedValue(path)
175
+ require.NotNil(h.t, value, "Field %s should exist", path)
176
+ return h
177
+}
178
+
179
+func (h *MigrationTestHelper) RequireFieldEquals(path string, expected interface{}) *MigrationTestHelper {
180
+ value := h.getNestedValue(path)
181
+ require.Equal(h.t, expected, value, "Field %s should equal %v", path, expected)
182
+ return h
183
+}
184
+
185
+func (h *MigrationTestHelper) RequireArrayContains(path string, expected interface{}) *MigrationTestHelper {
186
+ value := h.getNestedValue(path)
187
+ require.IsType(h.t, []interface{}{}, value, "Field %s should be an array", path)
188
+ array := value.([]interface{})
189
+ require.Contains(h.t, array, expected, "Array %s should contain %v", path, expected)
190
+ return h
191
+}
192
+
193
+func (h *MigrationTestHelper) RequireArrayLength(path string, expectedLen int) *MigrationTestHelper {
194
+ value := h.getNestedValue(path)
195
+ require.IsType(h.t, []interface{}{}, value, "Field %s should be an array", path)
196
+ array := value.([]interface{})
197
+ require.Len(h.t, array, expectedLen, "Array %s should have length %d", path, expectedLen)
198
+ return h
199
+}
200
+
201
+func (h *MigrationTestHelper) RequireArrayDoesNotContain(path string, notExpected interface{}) *MigrationTestHelper {
202
+ value := h.getNestedValue(path)
203
+ require.IsType(h.t, []interface{}{}, value, "Field %s should be an array", path)
204
+ array := value.([]interface{})
205
+ require.NotContains(h.t, array, notExpected, "Array %s should not contain %v", path, notExpected)
206
+ return h
207
+}
208
+
209
+func (h *MigrationTestHelper) RequireFieldAbsent(path string) *MigrationTestHelper {
210
+ value := h.getNestedValue(path)
211
+ require.Nil(h.t, value, "Field %s should not exist", path)
212
+ return h
213
+}
214
+
215
+func (h *MigrationTestHelper) RequireAutoConfDefaults() *MigrationTestHelper {
216
+ // AutoConf section should exist but be empty (using implicit defaults)
217
+ return h.RequireFieldExists("AutoConf").
218
+ RequireFieldAbsent("AutoConf.Enabled"). // Should use implicit default (true)
219
+ RequireFieldAbsent("AutoConf.URL"). // Should use implicit default (mainnet URL)
220
+ RequireFieldAbsent("AutoConf.RefreshInterval"). // Should use implicit default (24h)
221
+ RequireFieldAbsent("AutoConf.TLSInsecureSkipVerify") // Should use implicit default (false)
222
+}
223
+
224
+func (h *MigrationTestHelper) RequireAutoFieldsSetToAuto() *MigrationTestHelper {
225
+ return h.RequireArrayContains("Bootstrap", "auto").
226
+ RequireFieldEquals("DNS.Resolvers[.]", "auto").
227
+ RequireArrayContains("Routing.DelegatedRouters", "auto").
228
+ RequireArrayContains("Ipns.DelegatedPublishers", "auto")
229
+}
230
+
231
+func (h *MigrationTestHelper) RequireNoAutoValues() *MigrationTestHelper {
232
+ // Check Bootstrap if it exists
233
+ if h.getNestedValue("Bootstrap") != nil {
234
+ h.RequireArrayDoesNotContain("Bootstrap", "auto")
235
+ }
236
+
237
+ // Check DNS.Resolvers if it exists
238
+ if h.getNestedValue("DNS.Resolvers") != nil {
239
+ h.RequireMapDoesNotContainValue("DNS.Resolvers", "auto")
240
+ }
241
+
242
+ // Check Routing.DelegatedRouters if it exists
243
+ if h.getNestedValue("Routing.DelegatedRouters") != nil {
244
+ h.RequireArrayDoesNotContain("Routing.DelegatedRouters", "auto")
245
+ }
246
+
247
+ // Check Ipns.DelegatedPublishers if it exists
248
+ if h.getNestedValue("Ipns.DelegatedPublishers") != nil {
249
+ h.RequireArrayDoesNotContain("Ipns.DelegatedPublishers", "auto")
250
+ }
251
+
252
+ return h
253
+}
254
+
255
+func (h *MigrationTestHelper) RequireMapDoesNotContainValue(path string, notExpected interface{}) *MigrationTestHelper {
256
+ value := h.getNestedValue(path)
257
+ require.IsType(h.t, map[string]interface{}{}, value, "Field %s should be a map", path)
258
+ mapValue := value.(map[string]interface{})
259
+ for k, v := range mapValue {
260
+ require.NotEqual(h.t, notExpected, v, "Map %s[%s] should not equal %v", path, k, notExpected)
261
+ }
262
+ return h
263
+}
264
+
265
+func (h *MigrationTestHelper) getNestedValue(path string) interface{} {
266
+ segments := h.parseKuboConfigPath(path)
267
+ current := interface{}(h.config)
268
+
269
+ for _, segment := range segments {
270
+ switch segment.Type {
271
+ case "field":
272
+ switch v := current.(type) {
273
+ case map[string]interface{}:
274
+ current = v[segment.Key]
275
+ default:
276
+ return nil
277
+ }
278
+ case "mapKey":
279
+ switch v := current.(type) {
280
+ case map[string]interface{}:
281
+ current = v[segment.Key]
282
+ default:
283
+ return nil
284
+ }
285
+ default:
286
+ return nil
287
+ }
288
+
289
+ if current == nil {
290
+ return nil
291
+ }
292
+ }
293
+
294
+ return current
295
+}
296
+
297
+type PathSegment struct {
298
+ Type string // "field" or "mapKey"
299
+ Key string
300
+}
301
+
302
+func (h *MigrationTestHelper) parseKuboConfigPath(path string) []PathSegment {
303
+ var segments []PathSegment
304
+
305
+ // Split path into parts, respecting bracket boundaries
306
+ parts := h.splitKuboConfigPath(path)
307
+
308
+ for _, part := range parts {
309
+ if strings.Contains(part, "[") && strings.HasSuffix(part, "]") {
310
+ // Handle field[key] notation
311
+ bracketStart := strings.Index(part, "[")
312
+ fieldName := part[:bracketStart]
313
+ mapKey := part[bracketStart+1 : len(part)-1] // Remove [ and ]
314
+
315
+ // Add field segment if present
316
+ if fieldName != "" {
317
+ segments = append(segments, PathSegment{Type: "field", Key: fieldName})
318
+ }
319
+ // Add map key segment
320
+ segments = append(segments, PathSegment{Type: "mapKey", Key: mapKey})
321
+ } else {
322
+ // Regular field access
323
+ if part != "" {
324
+ segments = append(segments, PathSegment{Type: "field", Key: part})
325
+ }
326
+ }
327
+ }
328
+
329
+ return segments
330
+}
331
+
332
+// splitKuboConfigPath splits a path on dots, but preserves bracket sections intact
333
+func (h *MigrationTestHelper) splitKuboConfigPath(path string) []string {
334
+ var parts []string
335
+ var current strings.Builder
336
+ inBrackets := false
337
+
338
+ for _, r := range path {
339
+ switch r {
340
+ case '[':
341
+ inBrackets = true
342
+ current.WriteRune(r)
343
+ case ']':
344
+ inBrackets = false
345
+ current.WriteRune(r)
346
+ case '.':
347
+ if inBrackets {
348
+ // Inside brackets, preserve the dot
349
+ current.WriteRune(r)
350
+ } else {
351
+ // Outside brackets, split here
352
+ if current.Len() > 0 {
353
+ parts = append(parts, current.String())
354
+ current.Reset()
355
+ }
356
+ }
357
+ default:
358
+ current.WriteRune(r)
359
+ }
360
+ }
361
+
362
+ // Add final part if any
363
+ if current.Len() > 0 {
364
+ parts = append(parts, current.String())
365
+ }
366
+
367
+ return parts
368
+}
369
+
370
+// setupStaticV16Repo creates a test node using static v16 repo fixture from real Kubo 0.36 `ipfs init`
371
+// This ensures tests remain stable regardless of future changes to the IPFS binary
372
+// Each test gets its own copy in a temporary directory to allow modifications
373
+func setupStaticV16Repo(t *testing.T) *harness.Node {
374
+ // Get absolute path to static v16 repo fixture
375
+ v16FixturePath := "testdata/v16-repo"
376
+
377
+ // Create a temporary test directory - each test gets its own copy
378
+ // Use ./tmp.DELETEME/ as requested by user instead of /tmp/
379
+ tmpDir := filepath.Join("tmp.DELETEME", "migration-test-"+t.Name())
380
+ require.NoError(t, os.MkdirAll(tmpDir, 0755))
381
+ t.Cleanup(func() { os.RemoveAll(tmpDir) })
382
+
383
+ // Convert to absolute path for harness
384
+ absTmpDir, err := filepath.Abs(tmpDir)
385
+ require.NoError(t, err)
386
+
387
+ // Use the built binary (should be in PATH)
388
+ node := harness.BuildNode("ipfs", absTmpDir, 0)
389
+
390
+ // Replace IPFS_PATH with static fixture files to test directory (creates independent copy per test)
391
+ cloneStaticRepoFixture(t, v16FixturePath, node.Dir)
392
+
393
+ return node
394
+}
395
+
396
+// cloneStaticRepoFixture recursively copies the v16 repo fixture to the target directory
397
+// It completely removes the target directory contents before copying to ensure no extra files remain
398
+func cloneStaticRepoFixture(t *testing.T, srcPath, dstPath string) {
399
+ srcInfo, err := os.Stat(srcPath)
400
+ require.NoError(t, err)
401
+
402
+ if srcInfo.IsDir() {
403
+ // Completely remove destination directory and all contents
404
+ require.NoError(t, os.RemoveAll(dstPath))
405
+ // Create fresh destination directory
406
+ require.NoError(t, os.MkdirAll(dstPath, srcInfo.Mode()))
407
+
408
+ // Read source directory
409
+ entries, err := os.ReadDir(srcPath)
410
+ require.NoError(t, err)
411
+
412
+ // Copy each entry recursively
413
+ for _, entry := range entries {
414
+ srcEntryPath := filepath.Join(srcPath, entry.Name())
415
+ dstEntryPath := filepath.Join(dstPath, entry.Name())
416
+ cloneStaticRepoFixture(t, srcEntryPath, dstEntryPath)
417
+ }
418
+ } else {
419
+ // Copy file (destination directory should already be clean from parent call)
420
+ srcFile, err := os.Open(srcPath)
421
+ require.NoError(t, err)
422
+ defer srcFile.Close()
423
+
424
+ dstFile, err := os.Create(dstPath)
425
+ require.NoError(t, err)
426
+ defer dstFile.Close()
427
+
428
+ _, err = io.Copy(dstFile, srcFile)
429
+ require.NoError(t, err)
430
+
431
+ // Copy file permissions
432
+ require.NoError(t, dstFile.Chmod(srcInfo.Mode()))
433
+ }
434
+}
435
+
436
+// Placeholder stubs for new test functions - to be implemented
437
+func testDaemonCorruptedConfigHandling(t *testing.T) {
438
+ // TEST: Error handling using 'ipfs daemon --migrate' command with corrupted config (PRIMARY)
439
+ // Test what happens when config file is corrupted during migration
440
+ // NOTE: This test may need to be revised/updated once repo version 18 is released,
441
+ // at that point only keep tests that use 'ipfs repo migrate'
442
+ node := setupStaticV16Repo(t)
443
+
444
+ // Create corrupted config
445
+ configPath := filepath.Join(node.Dir, "config")
446
+ corruptedJson := `{"Bootstrap": [invalid json}`
447
+ require.NoError(t, os.WriteFile(configPath, []byte(corruptedJson), 0644))
448
+
449
+ // Write version file indicating v16
450
+ versionPath := filepath.Join(node.Dir, "version")
451
+ require.NoError(t, os.WriteFile(versionPath, []byte("16"), 0644))
452
+
453
+ // Run daemon with --migrate flag - this should fail gracefully
454
+ result := node.RunIPFS("daemon", "--migrate")
455
+
456
+ // Verify graceful failure handling
457
+ // The daemon should fail but migration error should be clear
458
+ errorOutput := result.Stderr.String() + result.Stdout.String()
459
+ require.True(t, strings.Contains(errorOutput, "json") || strings.Contains(errorOutput, "invalid character"), "Error should mention JSON parsing issue")
460
+
461
+ // Verify atomic failure: version and config should remain unchanged
462
+ versionData, err := os.ReadFile(versionPath)
463
+ require.NoError(t, err)
464
+ require.Equal(t, "16", strings.TrimSpace(string(versionData)), "Version should remain unchanged after failed migration")
465
+
466
+ originalContent, err := os.ReadFile(configPath)
467
+ require.NoError(t, err)
468
+ require.Equal(t, corruptedJson, string(originalContent), "Original config should be unchanged after failed migration")
469
+}
470
+
471
+func testDaemonMissingFieldsHandling(t *testing.T) {
472
+ // TEST: Migration using 'ipfs daemon --migrate' command with minimal config (PRIMARY)
473
+ // Test migration when config is missing expected fields
474
+ // NOTE: This test may need to be revised/updated once repo version 18 is released,
475
+ // at that point only keep tests that use 'ipfs repo migrate'
476
+ node := setupStaticV16Repo(t)
477
+
478
+ // The static fixture already has all required fields, use it as-is
479
+ configPath := filepath.Join(node.Dir, "config")
480
+ versionPath := filepath.Join(node.Dir, "version")
481
+
482
+ // Static fixture already uses port 0 for random port assignment - no config update needed
483
+
484
+ // Run daemon migration
485
+ stdoutOutput, migrationSuccess := runDaemonMigrationWithMonitoring(t, node)
486
+
487
+ // Verify migration was successful
488
+ require.True(t, migrationSuccess, "Migration should have been successful")
489
+ require.Contains(t, stdoutOutput, "applying 16-to-17 repo migration", "Migration should have been triggered")
490
+ require.Contains(t, stdoutOutput, "Migration 16 to 17 succeeded", "Migration should have completed successfully")
491
+
492
+ // Verify version was updated
493
+ versionData, err := os.ReadFile(versionPath)
494
+ require.NoError(t, err)
495
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Version should be updated to 17")
496
+
497
+ // Verify migration adds all required fields to minimal config
498
+ NewMigrationTestHelper(t, configPath).
499
+ RequireAutoConfDefaults().
500
+ RequireAutoFieldsSetToAuto().
501
+ RequireFieldExists("Identity.PeerID") // Original identity preserved from static fixture
502
+}
503
+
504
+// =============================================================================
505
+// COMPARISON TESTS: 'ipfs repo migrate' command
506
+//
507
+// These tests verify that repo migrate produces equivalent results to
508
+// daemon migrate, and test scenarios specific to repo migrate like
509
+// backward migration (which daemon doesn't support).
510
+// =============================================================================
511
+
512
+func testRepoMigrationWithAuto(t *testing.T) {
513
+ // TEST: Forward migration using 'ipfs repo migrate' command (COMPARISON)
514
+ // Simple comparison test to verify repo migrate produces same results as daemon migrate
515
+ node := setupStaticV16Repo(t)
516
+
517
+ // Use static fixture as-is
518
+ configPath := filepath.Join(node.Dir, "config")
519
+
520
+ // Run migration using 'ipfs repo migrate' command
521
+ result := node.RunIPFS("repo", "migrate")
522
+ require.Empty(t, result.Stderr.String(), "Migration should succeed without errors")
523
+
524
+ // Verify same results as daemon migrate
525
+ helper := NewMigrationTestHelper(t, configPath)
526
+ helper.RequireAutoConfDefaults().
527
+ RequireArrayContains("Bootstrap", "auto").
528
+ RequireArrayContains("Routing.DelegatedRouters", "auto").
529
+ RequireArrayContains("Ipns.DelegatedPublishers", "auto").
530
+ RequireFieldEquals("DNS.Resolvers[.]", "auto")
531
+}
532
+
533
+func testRepoBackwardMigration(t *testing.T) {
534
+ // TEST: Backward migration using 'ipfs repo migrate --to=16 --allow-downgrade' command
535
+ // This is kept as repo migrate since daemon doesn't support backward migration
536
+ node := setupStaticV16Repo(t)
537
+
538
+ // Use static fixture as-is
539
+ configPath := filepath.Join(node.Dir, "config")
540
+ versionPath := filepath.Join(node.Dir, "version")
541
+
542
+ // First run forward migration to get to v17
543
+ result := node.RunIPFS("repo", "migrate")
544
+ require.Empty(t, result.Stderr.String(), "Forward migration should succeed")
545
+
546
+ // Verify we're at v17
547
+ versionData, err := os.ReadFile(versionPath)
548
+ require.NoError(t, err)
549
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Should be at version 17 after forward migration")
550
+
551
+ // Now run reverse migration back to v16
552
+ result = node.RunIPFS("repo", "migrate", "--to=16", "--allow-downgrade")
553
+ require.Empty(t, result.Stderr.String(), "Reverse migration should succeed")
554
+
555
+ // Verify version was downgraded to 16
556
+ versionData, err = os.ReadFile(versionPath)
557
+ require.NoError(t, err)
558
+ require.Equal(t, "16", strings.TrimSpace(string(versionData)), "Version should be downgraded to 16")
559
+
560
+ // Verify backward migration results: AutoConf removed and no "auto" values remain
561
+ NewMigrationTestHelper(t, configPath).
562
+ RequireFieldAbsent("AutoConf").
563
+ RequireNoAutoValues()
564
+}
565
+
566
+// runDaemonMigrationWithMonitoring starts daemon --migrate, monitors output until "Daemon is ready",
567
+// then gracefully shuts down the daemon and returns the captured output and success status.
568
+// This is a generic helper that can monitor for any migration patterns.
569
+func runDaemonMigrationWithMonitoring(t *testing.T, node *harness.Node) (string, bool) {
570
+ // Use specific patterns for 16-to-17 migration
571
+ return runDaemonWithMigrationMonitoring(t, node, "applying 16-to-17 repo migration", "Migration 16 to 17 succeeded")
572
+}
573
+
574
+// runDaemonWithMigrationMonitoring is a generic helper for running daemon --migrate and monitoring output.
575
+// It waits for the daemon to be ready, then shuts it down gracefully.
576
+// migrationPattern: pattern to detect migration started (e.g., "applying X-to-Y repo migration")
577
+// successPattern: pattern to detect migration succeeded (e.g., "Migration X to Y succeeded")
578
+// Returns the stdout output and whether both patterns were detected.
579
+func runDaemonWithMigrationMonitoring(t *testing.T, node *harness.Node, migrationPattern, successPattern string) (string, bool) {
580
+ // Create context with timeout as safety net
581
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
582
+ defer cancel()
583
+
584
+ // Set up daemon command with output monitoring
585
+ cmd := exec.CommandContext(ctx, node.IPFSBin, "daemon", "--migrate")
586
+ cmd.Dir = node.Dir
587
+
588
+ // Set environment (especially IPFS_PATH)
589
+ for k, v := range node.Runner.Env {
590
+ cmd.Env = append(cmd.Env, k+"="+v)
591
+ }
592
+
593
+ // Set up pipes for output monitoring
594
+ stdout, err := cmd.StdoutPipe()
595
+ require.NoError(t, err)
596
+ stderr, err := cmd.StderrPipe()
597
+ require.NoError(t, err)
598
+
599
+ // Start the daemon
600
+ err = cmd.Start()
601
+ require.NoError(t, err)
602
+
603
+ var allOutput strings.Builder
604
+ var migrationDetected, migrationSucceeded, daemonReady bool
605
+
606
+ // Monitor stdout for completion signals
607
+ scanner := bufio.NewScanner(stdout)
608
+ go func() {
609
+ for scanner.Scan() {
610
+ line := scanner.Text()
611
+ allOutput.WriteString(line + "\n")
612
+
613
+ // Check for migration messages
614
+ if migrationPattern != "" && strings.Contains(line, migrationPattern) {
615
+ migrationDetected = true
616
+ }
617
+ if successPattern != "" && strings.Contains(line, successPattern) {
618
+ migrationSucceeded = true
619
+ }
620
+ if strings.Contains(line, "Daemon is ready") {
621
+ daemonReady = true
622
+ break // Exit monitoring loop
623
+ }
624
+ }
625
+ }()
626
+
627
+ // Also monitor stderr (but don't use it for completion detection)
628
+ go func() {
629
+ stderrScanner := bufio.NewScanner(stderr)
630
+ for stderrScanner.Scan() {
631
+ line := stderrScanner.Text()
632
+ allOutput.WriteString("STDERR: " + line + "\n")
633
+ }
634
+ }()
635
+
636
+ // Wait for daemon ready signal or timeout
637
+ ticker := time.NewTicker(100 * time.Millisecond)
638
+ defer ticker.Stop()
639
+
640
+ for {
641
+ select {
642
+ case <-ctx.Done():
643
+ // Timeout - kill the process
644
+ if cmd.Process != nil {
645
+ _ = cmd.Process.Kill()
646
+ }
647
+ t.Logf("Daemon migration timed out after 60 seconds")
648
+ return allOutput.String(), false
649
+
650
+ case <-ticker.C:
651
+ if daemonReady {
652
+ // Daemon is ready - shut it down gracefully
653
+ shutdownCmd := exec.Command(node.IPFSBin, "shutdown")
654
+ shutdownCmd.Dir = node.Dir
655
+ for k, v := range node.Runner.Env {
656
+ shutdownCmd.Env = append(shutdownCmd.Env, k+"="+v)
657
+ }
658
+
659
+ if err := shutdownCmd.Run(); err != nil {
660
+ t.Logf("Warning: ipfs shutdown failed: %v", err)
661
+ // Force kill if graceful shutdown fails
662
+ if cmd.Process != nil {
663
+ _ = cmd.Process.Kill()
664
+ }
665
+ }
666
+
667
+ // Wait for process to exit
668
+ _ = cmd.Wait()
669
+
670
+ // Return success if we detected migration
671
+ success := migrationDetected && migrationSucceeded
672
+ return allOutput.String(), success
673
+ }
674
+
675
+ // Check if process has exited (e.g., due to startup failure after migration)
676
+ if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
677
+ // Process exited - migration may have completed but daemon failed to start
678
+ // This is expected for corrupted config tests
679
+ success := migrationDetected && migrationSucceeded
680
+ return allOutput.String(), success
681
+ }
682
+ }
683
+ }
684
+}
test/cli/migrations/migration_legacy_15_to_17_test.go
new
+451
@@ -0,0 +1,451 @@
1
+package migrations
2
+
3
+// NOTE: These legacy migration tests require the local Kubo binary (built with 'make build') to be in PATH.
4
+// The tests migrate from repo version 15 to 17, which requires both external (15→16) and embedded (16→17) migrations.
5
+// This validates the transition from legacy external binaries to modern embedded migrations.
6
+//
7
+// To run these tests successfully:
8
+// export PATH="$(pwd)/cmd/ipfs:$PATH"
9
+// go test ./test/cli/migrations/
10
+
11
+import (
12
+ "bufio"
13
+ "context"
14
+ "encoding/json"
15
+ "fmt"
16
+ "io"
17
+ "os"
18
+ "os/exec"
19
+ "path/filepath"
20
+ "strings"
21
+ "syscall"
22
+ "testing"
23
+ "time"
24
+
25
+ "github.com/ipfs/kubo/test/cli/harness"
26
+ "github.com/stretchr/testify/require"
27
+)
28
+
29
+func TestMigration15To17(t *testing.T) {
30
+ t.Parallel()
31
+
32
+ // Test legacy migration from v15 to v17 (combines external 15→16 + embedded 16→17)
33
+ t.Run("daemon migrate: legacy 15 to 17", testDaemonMigration15To17)
34
+ t.Run("repo migrate: legacy 15 to 17", testRepoMigration15To17)
35
+}
36
+
37
+func TestMigration17To15Downgrade(t *testing.T) {
38
+ t.Parallel()
39
+
40
+ // Test reverse hybrid migration from v17 to v15 (embedded 17→16 + external 16→15)
41
+ t.Run("repo migrate: reverse hybrid 17 to 15", testRepoReverseHybridMigration17To15)
42
+}
43
+
44
+func testDaemonMigration15To17(t *testing.T) {
45
+ // TEST: Migration from v15 to v17 using 'ipfs daemon --migrate'
46
+ // This tests the dual migration path: external binary (15→16) + embedded (16→17)
47
+ // NOTE: This test may need to be revised/updated once repo version 18 is released,
48
+ // at that point only keep tests that use 'ipfs repo migrate'
49
+ node := setupStaticV15Repo(t)
50
+
51
+ // Create mock migration binary for 15→16 (16→17 will use embedded migration)
52
+ createMockMigrationBinary(t, "15", "16")
53
+
54
+ configPath := filepath.Join(node.Dir, "config")
55
+ versionPath := filepath.Join(node.Dir, "version")
56
+
57
+ // Verify starting conditions
58
+ versionData, err := os.ReadFile(versionPath)
59
+ require.NoError(t, err)
60
+ require.Equal(t, "15", strings.TrimSpace(string(versionData)), "Should start at version 15")
61
+
62
+ // Read original config to verify preservation of key fields
63
+ var originalConfig map[string]interface{}
64
+ configData, err := os.ReadFile(configPath)
65
+ require.NoError(t, err)
66
+ require.NoError(t, json.Unmarshal(configData, &originalConfig))
67
+
68
+ originalPeerID := getNestedValue(originalConfig, "Identity.PeerID")
69
+
70
+ // Run dual migration using daemon --migrate
71
+ stdoutOutput, migrationSuccess := runDaemonWithLegacyMigrationMonitoring(t, node)
72
+
73
+ // Debug output
74
+ t.Logf("Daemon output:\n%s", stdoutOutput)
75
+
76
+ // Verify hybrid migration was successful
77
+ require.True(t, migrationSuccess, "Hybrid migration should have been successful")
78
+ require.Contains(t, stdoutOutput, "Phase 1: External migration from v15 to v16", "Should detect external migration phase")
79
+ require.Contains(t, stdoutOutput, "Phase 2: Embedded migration from v16 to v17", "Should detect embedded migration phase")
80
+ require.Contains(t, stdoutOutput, "Hybrid migration completed successfully", "Should confirm hybrid migration completion")
81
+
82
+ // Verify final version is 17
83
+ versionData, err = os.ReadFile(versionPath)
84
+ require.NoError(t, err)
85
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Version should be updated to 17")
86
+
87
+ // Verify config is still valid JSON and key fields preserved
88
+ var finalConfig map[string]interface{}
89
+ configData, err = os.ReadFile(configPath)
90
+ require.NoError(t, err)
91
+ require.NoError(t, json.Unmarshal(configData, &finalConfig), "Config should remain valid JSON")
92
+
93
+ // Verify essential fields preserved
94
+ finalPeerID := getNestedValue(finalConfig, "Identity.PeerID")
95
+ require.Equal(t, originalPeerID, finalPeerID, "Identity.PeerID should be preserved")
96
+
97
+ // Verify bootstrap exists (may be modified by 16→17 migration)
98
+ finalBootstrap := getNestedValue(finalConfig, "Bootstrap")
99
+ require.NotNil(t, finalBootstrap, "Bootstrap should exist after migration")
100
+
101
+ // Verify AutoConf was added by 16→17 migration
102
+ autoConf := getNestedValue(finalConfig, "AutoConf")
103
+ require.NotNil(t, autoConf, "AutoConf should be added by 16→17 migration")
104
+}
105
+
106
+func testRepoMigration15To17(t *testing.T) {
107
+ // TEST: Migration from v15 to v17 using 'ipfs repo migrate'
108
+ // Comparison test to verify repo migrate produces same results as daemon migrate
109
+ node := setupStaticV15Repo(t)
110
+
111
+ // Create mock migration binary for 15→16 (16→17 will use embedded migration)
112
+ createMockMigrationBinary(t, "15", "16")
113
+
114
+ configPath := filepath.Join(node.Dir, "config")
115
+ versionPath := filepath.Join(node.Dir, "version")
116
+
117
+ // Verify starting version
118
+ versionData, err := os.ReadFile(versionPath)
119
+ require.NoError(t, err)
120
+ require.Equal(t, "15", strings.TrimSpace(string(versionData)), "Should start at version 15")
121
+
122
+ // Run migration using 'ipfs repo migrate' with custom PATH
123
+ result := node.Runner.Run(harness.RunRequest{
124
+ Path: node.IPFSBin,
125
+ Args: []string{"repo", "migrate"},
126
+ CmdOpts: []harness.CmdOpt{
127
+ func(cmd *exec.Cmd) {
128
+ // Ensure the command inherits our modified PATH with mock binaries
129
+ cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH"))
130
+ },
131
+ },
132
+ })
133
+ require.Empty(t, result.Stderr.String(), "Migration should succeed without errors")
134
+
135
+ // Verify final version is 17
136
+ versionData, err = os.ReadFile(versionPath)
137
+ require.NoError(t, err)
138
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Version should be updated to 17")
139
+
140
+ // Verify config is valid JSON
141
+ var finalConfig map[string]interface{}
142
+ configData, err := os.ReadFile(configPath)
143
+ require.NoError(t, err)
144
+ require.NoError(t, json.Unmarshal(configData, &finalConfig), "Config should remain valid JSON")
145
+
146
+ // Verify essential fields exist
147
+ require.NotNil(t, getNestedValue(finalConfig, "Identity.PeerID"), "Identity.PeerID should exist")
148
+ require.NotNil(t, getNestedValue(finalConfig, "Bootstrap"), "Bootstrap should exist")
149
+ require.NotNil(t, getNestedValue(finalConfig, "AutoConf"), "AutoConf should be added")
150
+}
151
+
152
+// setupStaticV15Repo creates a test node using static v15 repo fixture
153
+// This ensures tests remain stable and validates migration from very old repos
154
+func setupStaticV15Repo(t *testing.T) *harness.Node {
155
+ // Get path to static v15 repo fixture
156
+ v15FixturePath := "testdata/v15-repo"
157
+
158
+ // Create temporary test directory using Go's testing temp dir
159
+ tmpDir := t.TempDir()
160
+
161
+ // Use the built binary (should be in PATH)
162
+ node := harness.BuildNode("ipfs", tmpDir, 0)
163
+
164
+ // Copy static fixture to test directory
165
+ cloneStaticRepoFixture(t, v15FixturePath, node.Dir)
166
+
167
+ return node
168
+}
169
+
170
+// runDaemonWithLegacyMigrationMonitoring monitors for hybrid migration patterns
171
+func runDaemonWithLegacyMigrationMonitoring(t *testing.T, node *harness.Node) (string, bool) {
172
+ // Monitor for hybrid migration completion - use "Hybrid migration completed successfully" as success pattern
173
+ stdoutOutput, daemonStarted := runDaemonWithMigrationMonitoringCustomEnv(t, node, "Using hybrid migration strategy", "Hybrid migration completed successfully", map[string]string{
174
+ "PATH": os.Getenv("PATH"), // Pass current PATH which includes our mock binaries
175
+ })
176
+
177
+ // Check for hybrid migration patterns in output
178
+ hasHybridStart := strings.Contains(stdoutOutput, "Using hybrid migration strategy")
179
+ hasPhase1 := strings.Contains(stdoutOutput, "Phase 1: External migration from v15 to v16")
180
+ hasPhase2 := strings.Contains(stdoutOutput, "Phase 2: Embedded migration from v16 to v17")
181
+ hasHybridSuccess := strings.Contains(stdoutOutput, "Hybrid migration completed successfully")
182
+
183
+ // Success requires daemon to start and hybrid migration patterns to be detected
184
+ hybridMigrationSuccess := daemonStarted && hasHybridStart && hasPhase1 && hasPhase2 && hasHybridSuccess
185
+
186
+ return stdoutOutput, hybridMigrationSuccess
187
+}
188
+
189
+// runDaemonWithMigrationMonitoringCustomEnv is like runDaemonWithMigrationMonitoring but allows custom environment
190
+func runDaemonWithMigrationMonitoringCustomEnv(t *testing.T, node *harness.Node, migrationPattern, successPattern string, extraEnv map[string]string) (string, bool) {
191
+ // Create context with timeout as safety net
192
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
193
+ defer cancel()
194
+
195
+ // Set up daemon command with output monitoring
196
+ cmd := exec.CommandContext(ctx, node.IPFSBin, "daemon", "--migrate")
197
+ cmd.Dir = node.Dir
198
+
199
+ // Set environment (especially IPFS_PATH)
200
+ for k, v := range node.Runner.Env {
201
+ cmd.Env = append(cmd.Env, k+"="+v)
202
+ }
203
+
204
+ // Add extra environment variables (like PATH with mock binaries)
205
+ for k, v := range extraEnv {
206
+ cmd.Env = append(cmd.Env, k+"="+v)
207
+ }
208
+
209
+ // Set up pipes for output monitoring
210
+ stdout, err := cmd.StdoutPipe()
211
+ require.NoError(t, err)
212
+ stderr, err := cmd.StderrPipe()
213
+ require.NoError(t, err)
214
+
215
+ // Start the daemon
216
+ require.NoError(t, cmd.Start())
217
+
218
+ // Monitor output from both streams
219
+ var outputBuffer strings.Builder
220
+ done := make(chan bool)
221
+ migrationStarted := false
222
+ migrationCompleted := false
223
+
224
+ go func() {
225
+ scanner := bufio.NewScanner(io.MultiReader(stdout, stderr))
226
+ for scanner.Scan() {
227
+ line := scanner.Text()
228
+ outputBuffer.WriteString(line + "\n")
229
+
230
+ // Check for migration start
231
+ if strings.Contains(line, migrationPattern) {
232
+ migrationStarted = true
233
+ }
234
+
235
+ // Check for migration completion
236
+ if strings.Contains(line, successPattern) {
237
+ migrationCompleted = true
238
+ }
239
+
240
+ // Check for daemon ready
241
+ if strings.Contains(line, "Daemon is ready") {
242
+ done <- true
243
+ return
244
+ }
245
+ }
246
+ done <- false
247
+ }()
248
+
249
+ // Wait for daemon to be ready or timeout
250
+ daemonReady := false
251
+ select {
252
+ case ready := <-done:
253
+ daemonReady = ready
254
+ case <-ctx.Done():
255
+ t.Log("Daemon startup timed out")
256
+ }
257
+
258
+ // Stop the daemon
259
+ if cmd.Process != nil {
260
+ _ = cmd.Process.Signal(syscall.SIGTERM)
261
+ _ = cmd.Wait()
262
+ }
263
+
264
+ return outputBuffer.String(), daemonReady && migrationStarted && migrationCompleted
265
+}
266
+
267
+// createMockMigrationBinary creates a platform-agnostic Go binary for migration on PATH
268
+func createMockMigrationBinary(t *testing.T, fromVer, toVer string) {
269
+ // Create bin directory for migration binaries
270
+ binDir := t.TempDir()
271
+
272
+ // Create Go source for mock migration binary
273
+ scriptName := fmt.Sprintf("fs-repo-%s-to-%s", fromVer, toVer)
274
+ sourceFile := filepath.Join(binDir, scriptName+".go")
275
+ binaryPath := filepath.Join(binDir, scriptName)
276
+
277
+ goSource := fmt.Sprintf(`package main
278
+
279
+import (
280
+ "fmt"
281
+ "os"
282
+ "path/filepath"
283
+ "strings"
284
+)
285
+
286
+func main() {
287
+ // Parse command line arguments - real migration binaries expect -path=<repo-path>
288
+ var repoPath string
289
+ var revert bool
290
+ for _, arg := range os.Args[1:] {
291
+ if strings.HasPrefix(arg, "-path=") {
292
+ repoPath = strings.TrimPrefix(arg, "-path=")
293
+ } else if arg == "-revert" {
294
+ revert = true
295
+ }
296
+ }
297
+
298
+ if repoPath == "" {
299
+ fmt.Fprintf(os.Stderr, "Usage: %%s -path=<repo-path> [-verbose=true] [-revert]\n", os.Args[0])
300
+ os.Exit(1)
301
+ }
302
+
303
+ // Determine source and target versions based on revert flag
304
+ var sourceVer, targetVer string
305
+ if revert {
306
+ // When reverting, we go backwards: fs-repo-15-to-16 with -revert goes 16→15
307
+ sourceVer = "%s"
308
+ targetVer = "%s"
309
+ } else {
310
+ // Normal forward migration: fs-repo-15-to-16 goes 15→16
311
+ sourceVer = "%s"
312
+ targetVer = "%s"
313
+ }
314
+
315
+ // Print migration message (same format as real migrations)
316
+ fmt.Printf("fake applying %%s-to-%%s repo migration\n", sourceVer, targetVer)
317
+
318
+ // Update version file
319
+ versionFile := filepath.Join(repoPath, "version")
320
+ err := os.WriteFile(versionFile, []byte(targetVer), 0644)
321
+ if err != nil {
322
+ fmt.Fprintf(os.Stderr, "Error updating version: %%v\n", err)
323
+ os.Exit(1)
324
+ }
325
+}
326
+`, toVer, fromVer, fromVer, toVer)
327
+
328
+ require.NoError(t, os.WriteFile(sourceFile, []byte(goSource), 0644))
329
+
330
+ // Compile the Go binary
331
+ require.NoError(t, os.Setenv("CGO_ENABLED", "0")) // Ensure static binary
332
+ require.NoError(t, exec.Command("go", "build", "-o", binaryPath, sourceFile).Run())
333
+
334
+ // Add bin directory to PATH for this test
335
+ currentPath := os.Getenv("PATH")
336
+ newPath := binDir + string(filepath.ListSeparator) + currentPath
337
+ require.NoError(t, os.Setenv("PATH", newPath))
338
+ t.Cleanup(func() { os.Setenv("PATH", currentPath) })
339
+
340
+ // Verify the binary exists and is executable
341
+ _, err := os.Stat(binaryPath)
342
+ require.NoError(t, err, "Mock binary should exist")
343
+}
344
+
345
+// getNestedValue retrieves a nested value from a config map using dot notation
346
+func getNestedValue(config map[string]interface{}, path string) interface{} {
347
+ parts := strings.Split(path, ".")
348
+ current := interface{}(config)
349
+
350
+ for _, part := range parts {
351
+ switch v := current.(type) {
352
+ case map[string]interface{}:
353
+ current = v[part]
354
+ default:
355
+ return nil
356
+ }
357
+ if current == nil {
358
+ return nil
359
+ }
360
+ }
361
+
362
+ return current
363
+}
364
+
365
+func testRepoReverseHybridMigration17To15(t *testing.T) {
366
+ // TEST: Reverse hybrid migration from v17 to v15 using 'ipfs repo migrate --to=15 --allow-downgrade'
367
+ // This tests reverse hybrid migration: embedded (17→16) + external (16→15)
368
+
369
+ // Start with v15 fixture and migrate forward to v17 to create proper backup files
370
+ node := setupStaticV15Repo(t)
371
+
372
+ // Create mock migration binary for 15→16 (needed for forward migration)
373
+ createMockMigrationBinary(t, "15", "16")
374
+ // Create mock migration binary for 16→15 (needed for downgrade)
375
+ createMockMigrationBinary(t, "16", "15")
376
+
377
+ configPath := filepath.Join(node.Dir, "config")
378
+ versionPath := filepath.Join(node.Dir, "version")
379
+
380
+ // Step 1: Forward migration from v15 to v17 to create backup files
381
+ t.Log("Step 1: Forward migration v15 → v17")
382
+ result := node.Runner.Run(harness.RunRequest{
383
+ Path: node.IPFSBin,
384
+ Args: []string{"repo", "migrate"},
385
+ CmdOpts: []harness.CmdOpt{
386
+ func(cmd *exec.Cmd) {
387
+ // Ensure the command inherits our modified PATH with mock binaries
388
+ cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH"))
389
+ },
390
+ },
391
+ })
392
+
393
+ // Debug: print the output to see what happened
394
+ t.Logf("Forward migration stdout:\n%s", result.Stdout.String())
395
+ t.Logf("Forward migration stderr:\n%s", result.Stderr.String())
396
+
397
+ require.Empty(t, result.Stderr.String(), "Forward migration should succeed without errors")
398
+
399
+ // Verify we're at v17 after forward migration
400
+ versionData, err := os.ReadFile(versionPath)
401
+ require.NoError(t, err)
402
+ require.Equal(t, "17", strings.TrimSpace(string(versionData)), "Should be at version 17 after forward migration")
403
+
404
+ // Read config after forward migration to use as baseline for downgrade
405
+ var v17Config map[string]interface{}
406
+ configData, err := os.ReadFile(configPath)
407
+ require.NoError(t, err)
408
+ require.NoError(t, json.Unmarshal(configData, &v17Config))
409
+
410
+ originalPeerID := getNestedValue(v17Config, "Identity.PeerID")
411
+
412
+ // Step 2: Reverse hybrid migration from v17 to v15
413
+ t.Log("Step 2: Reverse hybrid migration v17 → v15")
414
+ result = node.Runner.Run(harness.RunRequest{
415
+ Path: node.IPFSBin,
416
+ Args: []string{"repo", "migrate", "--to=15", "--allow-downgrade"},
417
+ CmdOpts: []harness.CmdOpt{
418
+ func(cmd *exec.Cmd) {
419
+ // Ensure the command inherits our modified PATH with mock binaries
420
+ cmd.Env = append(cmd.Env, "PATH="+os.Getenv("PATH"))
421
+ },
422
+ },
423
+ })
424
+ require.Empty(t, result.Stderr.String(), "Reverse hybrid migration should succeed without errors")
425
+
426
+ // Debug output
427
+ t.Logf("Downgrade migration output:\n%s", result.Stdout.String())
428
+
429
+ // Verify final version is 15
430
+ versionData, err = os.ReadFile(versionPath)
431
+ require.NoError(t, err)
432
+ require.Equal(t, "15", strings.TrimSpace(string(versionData)), "Version should be updated to 15")
433
+
434
+ // Verify config is still valid JSON and key fields preserved
435
+ var finalConfig map[string]interface{}
436
+ configData, err = os.ReadFile(configPath)
437
+ require.NoError(t, err)
438
+ require.NoError(t, json.Unmarshal(configData, &finalConfig), "Config should remain valid JSON")
439
+
440
+ // Verify essential fields preserved
441
+ finalPeerID := getNestedValue(finalConfig, "Identity.PeerID")
442
+ require.Equal(t, originalPeerID, finalPeerID, "Identity.PeerID should be preserved")
443
+
444
+ // Verify bootstrap exists (may be modified by migrations)
445
+ finalBootstrap := getNestedValue(finalConfig, "Bootstrap")
446
+ require.NotNil(t, finalBootstrap, "Bootstrap should exist after migration")
447
+
448
+ // AutoConf should be removed by the downgrade (was added in 16→17)
449
+ autoConf := getNestedValue(finalConfig, "AutoConf")
450
+ require.Nil(t, autoConf, "AutoConf should be removed by downgrade to v15")
451
+}
test/cli/migrations/testdata/v15-repo/blocks/SHARDING
new
+1
@@ -0,0 +1 @@
1
+/repo/flatfs/shard/v1/next-to-last/2
test/cli/migrations/testdata/v15-repo/blocks/X3/CIQFTFEEHEDF6KLBT32BFAGLXEZL4UWFNWM4LFTLMXQBCERZ6CMLX3Y.data
new
+2
@@ -0,0 +1,2 @@
1
+
2
+
\ No newline at end of file
test/cli/migrations/testdata/v15-repo/blocks/_README
new
+30
@@ -0,0 +1,30 @@
1
+This is a repository of IPLD objects. Each IPLD object is in a single file,
2
+named <base32 encoding of cid>.data. Where <base32 encoding of cid> is the
3
+"base32" encoding of the CID (as specified in
4
+https://github.com/multiformats/multibase) without the 'B' prefix.
5
+All the object files are placed in a tree of directories, based on a
6
+function of the CID. This is a form of sharding similar to
7
+the objects directory in git repositories. Previously, we used
8
+prefixes, we now use the next-to-last two characters.
9
+
10
+ func NextToLast(base32cid string) {
11
+ nextToLastLen := 2
12
+ offset := len(base32cid) - nextToLastLen - 1
13
+ return str[offset : offset+nextToLastLen]
14
+ }
15
+
16
+For example, an object with a base58 CIDv1 of
17
+
18
+ zb2rhYSxw4ZjuzgCnWSt19Q94ERaeFhu9uSqRgjSdx9bsgM6f
19
+
20
+has a base32 CIDv1 of
21
+
22
+ BAFKREIA22FLID5AJ2KU7URG47MDLROZIH6YF2KALU2PWEFPVI37YLKRSCA
23
+
24
+and will be placed at
25
+
26
+ SC/AFKREIA22FLID5AJ2KU7URG47MDLROZIH6YF2KALU2PWEFPVI37YLKRSCA.data
27
+
28
+with 'SC' being the last-to-next two characters and the 'B' at the
29
+beginning of the CIDv1 string is the multibase prefix that is not
30
+stored in the filename.
test/cli/migrations/testdata/v15-repo/blocks/diskUsage.cache
new
+1
@@ -0,0 +1 @@
1
+{"diskUsage":13452,"accuracy":"initial-exact"}
test/cli/migrations/testdata/v15-repo/config
new
+149
@@ -0,0 +1,149 @@
1
+{
2
+ "Identity": {
3
+ "PeerID": "12D3KooWPeo9gaDV6URwwwyWWjEJsCaMeZ7PBE5vpqvR1KFnPv3B",
4
+ "PrivKey": "CAESQGPAQlzI5P/KnsbQ3e7dPNbv5Ztw8YwLv9k1dtS3pkd1zZAOR2796fXBZSKyo8Lw/wOqFb9plijC0iW0vTDuxXI="
5
+ },
6
+ "Datastore": {
7
+ "StorageMax": "10GB",
8
+ "StorageGCWatermark": 90,
9
+ "GCPeriod": "1h",
10
+ "Spec": {
11
+ "mounts": [
12
+ {
13
+ "child": {
14
+ "path": "blocks",
15
+ "shardFunc": "/repo/flatfs/shard/v1/next-to-last/2",
16
+ "sync": true,
17
+ "type": "flatfs"
18
+ },
19
+ "mountpoint": "/blocks",
20
+ "prefix": "flatfs.datastore",
21
+ "type": "measure"
22
+ },
23
+ {
24
+ "child": {
25
+ "compression": "none",
26
+ "path": "datastore",
27
+ "type": "levelds"
28
+ },
29
+ "mountpoint": "/",
30
+ "prefix": "leveldb.datastore",
31
+ "type": "measure"
32
+ }
33
+ ],
34
+ "type": "mount"
35
+ },
36
+ "HashOnRead": false,
37
+ "BloomFilterSize": 0
38
+ },
39
+ "Addresses": {
40
+ "Swarm": [
41
+ "/ip4/0.0.0.0/tcp/4001",
42
+ "/ip6/::/tcp/4001",
43
+ "/ip4/0.0.0.0/udp/4001/quic-v1",
44
+ "/ip4/0.0.0.0/udp/4001/quic-v1/webtransport",
45
+ "/ip6/::/udp/4001/quic-v1",
46
+ "/ip6/::/udp/4001/quic-v1/webtransport"
47
+ ],
48
+ "Announce": [],
49
+ "AppendAnnounce": [],
50
+ "NoAnnounce": [],
51
+ "API": "/ip4/127.0.0.1/tcp/5001",
52
+ "Gateway": "/ip4/127.0.0.1/tcp/8080"
53
+ },
54
+ "Mounts": {
55
+ "IPFS": "/ipfs",
56
+ "IPNS": "/ipns",
57
+ "FuseAllowOther": false
58
+ },
59
+ "Discovery": {
60
+ "MDNS": {
61
+ "Enabled": true
62
+ }
63
+ },
64
+ "Routing": {
65
+ "Routers": null,
66
+ "Methods": null
67
+ },
68
+ "Ipns": {
69
+ "RepublishPeriod": "",
70
+ "RecordLifetime": "",
71
+ "ResolveCacheSize": 128
72
+ },
73
+ "Bootstrap": [
74
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN",
75
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
76
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
77
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
78
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
79
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
80
+ ],
81
+ "Gateway": {
82
+ "HTTPHeaders": {},
83
+ "RootRedirect": "",
84
+ "NoFetch": false,
85
+ "NoDNSLink": false,
86
+ "DeserializedResponses": null,
87
+ "DisableHTMLErrors": null,
88
+ "PublicGateways": null,
89
+ "ExposeRoutingAPI": null
90
+ },
91
+ "API": {
92
+ "HTTPHeaders": {}
93
+ },
94
+ "Swarm": {
95
+ "AddrFilters": null,
96
+ "DisableBandwidthMetrics": false,
97
+ "DisableNatPortMap": false,
98
+ "RelayClient": {},
99
+ "RelayService": {},
100
+ "Transports": {
101
+ "Network": {},
102
+ "Security": {},
103
+ "Multiplexers": {}
104
+ },
105
+ "ConnMgr": {},
106
+ "ResourceMgr": {}
107
+ },
108
+ "AutoNAT": {},
109
+ "Pubsub": {
110
+ "Router": "",
111
+ "DisableSigning": false
112
+ },
113
+ "Peering": {
114
+ "Peers": null
115
+ },
116
+ "DNS": {
117
+ "Resolvers": {}
118
+ },
119
+ "Migration": {
120
+ "DownloadSources": [],
121
+ "Keep": ""
122
+ },
123
+ "Provider": {
124
+ "Strategy": ""
125
+ },
126
+ "Reprovider": {},
127
+ "Experimental": {
128
+ "FilestoreEnabled": false,
129
+ "UrlstoreEnabled": false,
130
+ "Libp2pStreamMounting": false,
131
+ "P2pHttpProxy": false,
132
+ "StrategicProviding": false,
133
+ "OptimisticProvide": false,
134
+ "OptimisticProvideJobsPoolSize": 0
135
+ },
136
+ "Plugins": {
137
+ "Plugins": null
138
+ },
139
+ "Pinning": {
140
+ "RemoteServices": {}
141
+ },
142
+ "Import": {
143
+ "CidVersion": null,
144
+ "UnixFSRawLeaves": null,
145
+ "UnixFSChunker": null,
146
+ "HashFunction": null
147
+ },
148
+ "Internal": {}
149
+}
\ No newline at end of file
test/cli/migrations/testdata/v15-repo/datastore/000001.log
Binary files /dev/null and b/test/cli/migrations/testdata/v15-repo/datastore/000001.log differ
test/cli/migrations/testdata/v15-repo/datastore/CURRENT
new
+1
@@ -0,0 +1 @@
1
+MANIFEST-000000
test/cli/migrations/testdata/v15-repo/datastore/LOCK
test/cli/migrations/testdata/v15-repo/datastore/LOG
new
+8
@@ -0,0 +1,8 @@
1
+=============== Aug 4, 2025 (CEST) ===============
2
+01:47:33.360920 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
3
+01:47:33.384586 db@open opening
4
+01:47:33.385359 version@stat F·[] S·0B[] Sc·[]
5
+01:47:33.397679 db@janitor F·2 G·0
6
+01:47:33.397725 db@open done T·13.097186ms
7
+01:47:33.460539 db@close closing
8
+01:47:33.460679 db@close done T·135.605µs
test/cli/migrations/testdata/v15-repo/datastore/MANIFEST-000000
Binary files /dev/null and b/test/cli/migrations/testdata/v15-repo/datastore/MANIFEST-000000 differ
test/cli/migrations/testdata/v15-repo/datastore_spec
new
+1
@@ -0,0 +1 @@
1
+{"mounts":[{"mountpoint":"/blocks","path":"blocks","shardFunc":"/repo/flatfs/shard/v1/next-to-last/2","type":"flatfs"},{"mountpoint":"/","path":"datastore","type":"levelds"}],"type":"mount"}
\ No newline at end of file
test/cli/migrations/testdata/v15-repo/version
new
+1
@@ -0,0 +1 @@
1
+15
test/cli/migrations/testdata/v16-repo/blocks/SHARDING
new
+1
@@ -0,0 +1 @@
1
+/repo/flatfs/shard/v1/next-to-last/2
test/cli/migrations/testdata/v16-repo/blocks/X3/CIQFTFEEHEDF6KLBT32BFAGLXEZL4UWFNWM4LFTLMXQBCERZ6CMLX3Y.data
new
+2
@@ -0,0 +1,2 @@
1
+
2
+
\ No newline at end of file
test/cli/migrations/testdata/v16-repo/blocks/_README
new
+30
@@ -0,0 +1,30 @@
1
+This is a repository of IPLD objects. Each IPLD object is in a single file,
2
+named <base32 encoding of cid>.data. Where <base32 encoding of cid> is the
3
+"base32" encoding of the CID (as specified in
4
+https://github.com/multiformats/multibase) without the 'B' prefix.
5
+All the object files are placed in a tree of directories, based on a
6
+function of the CID. This is a form of sharding similar to
7
+the objects directory in git repositories. Previously, we used
8
+prefixes, we now use the next-to-last two characters.
9
+
10
+ func NextToLast(base32cid string) {
11
+ nextToLastLen := 2
12
+ offset := len(base32cid) - nextToLastLen - 1
13
+ return str[offset : offset+nextToLastLen]
14
+ }
15
+
16
+For example, an object with a base58 CIDv1 of
17
+
18
+ zb2rhYSxw4ZjuzgCnWSt19Q94ERaeFhu9uSqRgjSdx9bsgM6f
19
+
20
+has a base32 CIDv1 of
21
+
22
+ BAFKREIA22FLID5AJ2KU7URG47MDLROZIH6YF2KALU2PWEFPVI37YLKRSCA
23
+
24
+and will be placed at
25
+
26
+ SC/AFKREIA22FLID5AJ2KU7URG47MDLROZIH6YF2KALU2PWEFPVI37YLKRSCA.data
27
+
28
+with 'SC' being the last-to-next two characters and the 'B' at the
29
+beginning of the CIDv1 string is the multibase prefix that is not
30
+stored in the filename.
test/cli/migrations/testdata/v16-repo/blocks/diskUsage.cache
new
+1
@@ -0,0 +1 @@
1
+{"diskUsage":13452,"accuracy":"initial-exact"}
test/cli/migrations/testdata/v16-repo/config
new
+145
@@ -0,0 +1,145 @@
1
+{
2
+ "Identity": {
3
+ "PeerID": "12D3KooWGU72UzYkzVAiTyNLugX72zoDPTGkRegoKcTfB8oWxSuu",
4
+ "PrivKey": "CAESQNfpGWI4zS+x+HSggBd7qqBai+Je5fopjmBylaTo7uZZYtESGX1PLDr5HmS3NJmrK7glW5kGRuYDvpqwJ2hnC2g="
5
+ },
6
+ "Datastore": {
7
+ "StorageMax": "10GB",
8
+ "StorageGCWatermark": 90,
9
+ "GCPeriod": "1h",
10
+ "Spec": {
11
+ "mounts": [
12
+ {
13
+ "mountpoint": "/blocks",
14
+ "path": "blocks",
15
+ "prefix": "flatfs.datastore",
16
+ "shardFunc": "/repo/flatfs/shard/v1/next-to-last/2",
17
+ "sync": false,
18
+ "type": "flatfs"
19
+ },
20
+ {
21
+ "compression": "none",
22
+ "mountpoint": "/",
23
+ "path": "datastore",
24
+ "prefix": "leveldb.datastore",
25
+ "type": "levelds"
26
+ }
27
+ ],
28
+ "type": "mount"
29
+ },
30
+ "HashOnRead": false,
31
+ "BloomFilterSize": 0,
32
+ "BlockKeyCacheSize": null
33
+ },
34
+ "Addresses": {
35
+ "Swarm": [
36
+ "/ip4/0.0.0.0/tcp/0"
37
+ ],
38
+ "Announce": [],
39
+ "AppendAnnounce": [],
40
+ "NoAnnounce": [],
41
+ "API": "/ip4/127.0.0.1/tcp/0",
42
+ "Gateway": "/ip4/127.0.0.1/tcp/0"
43
+ },
44
+ "Mounts": {
45
+ "IPFS": "/ipfs",
46
+ "IPNS": "/ipns",
47
+ "MFS": "/mfs",
48
+ "FuseAllowOther": false
49
+ },
50
+ "Discovery": {
51
+ "MDNS": {
52
+ "Enabled": true
53
+ }
54
+ },
55
+ "Routing": {},
56
+ "Ipns": {
57
+ "RepublishPeriod": "",
58
+ "RecordLifetime": "",
59
+ "ResolveCacheSize": 128
60
+ },
61
+ "Bootstrap": [
62
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa",
63
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb",
64
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt",
65
+ "/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8",
66
+ "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
67
+ "/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ",
68
+ "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN"
69
+ ],
70
+ "Gateway": {
71
+ "HTTPHeaders": {},
72
+ "RootRedirect": "",
73
+ "NoFetch": false,
74
+ "NoDNSLink": false,
75
+ "DeserializedResponses": null,
76
+ "DisableHTMLErrors": null,
77
+ "PublicGateways": null,
78
+ "ExposeRoutingAPI": null
79
+ },
80
+ "API": {
81
+ "HTTPHeaders": {}
82
+ },
83
+ "Swarm": {
84
+ "AddrFilters": null,
85
+ "DisableBandwidthMetrics": false,
86
+ "DisableNatPortMap": false,
87
+ "RelayClient": {},
88
+ "RelayService": {},
89
+ "Transports": {
90
+ "Network": {},
91
+ "Security": {},
92
+ "Multiplexers": {}
93
+ },
94
+ "ConnMgr": {},
95
+ "ResourceMgr": {}
96
+ },
97
+ "AutoNAT": {},
98
+ "AutoTLS": {},
99
+ "Pubsub": {
100
+ "Router": "",
101
+ "DisableSigning": false
102
+ },
103
+ "Peering": {
104
+ "Peers": null
105
+ },
106
+ "DNS": {
107
+ "Resolvers": {}
108
+ },
109
+ "Migration": {
110
+ "DownloadSources": [],
111
+ "Keep": ""
112
+ },
113
+ "Provider": {},
114
+ "Reprovider": {},
115
+ "HTTPRetrieval": {},
116
+ "Experimental": {
117
+ "FilestoreEnabled": false,
118
+ "UrlstoreEnabled": false,
119
+ "Libp2pStreamMounting": false,
120
+ "P2pHttpProxy": false,
121
+ "OptimisticProvide": false,
122
+ "OptimisticProvideJobsPoolSize": 0
123
+ },
124
+ "Plugins": {
125
+ "Plugins": null
126
+ },
127
+ "Pinning": {
128
+ "RemoteServices": {}
129
+ },
130
+ "Import": {
131
+ "CidVersion": null,
132
+ "UnixFSRawLeaves": null,
133
+ "UnixFSChunker": null,
134
+ "HashFunction": null,
135
+ "UnixFSFileMaxLinks": null,
136
+ "UnixFSDirectoryMaxLinks": null,
137
+ "UnixFSHAMTDirectoryMaxFanout": null,
138
+ "UnixFSHAMTDirectorySizeThreshold": null,
139
+ "BatchMaxNodes": null,
140
+ "BatchMaxSize": null
141
+ },
142
+ "Version": {},
143
+ "Internal": {},
144
+ "Bitswap": {}
145
+}
test/cli/migrations/testdata/v16-repo/datastore/000001.log
Binary files /dev/null and b/test/cli/migrations/testdata/v16-repo/datastore/000001.log differ
test/cli/migrations/testdata/v16-repo/datastore/CURRENT
new
+1
@@ -0,0 +1 @@
1
+MANIFEST-000000
test/cli/migrations/testdata/v16-repo/datastore/LOCK
test/cli/migrations/testdata/v16-repo/datastore/LOG
new
+8
@@ -0,0 +1,8 @@
1
+=============== Jul 23, 2025 (CEST) ===============
2
+19:18:16.721510 log@legend F·NumFile S·FileSize N·Entry C·BadEntry B·BadBlock Ke·KeyError D·DroppedEntry L·Level Q·SeqNum T·TimeElapsed
3
+19:18:16.746720 db@open opening
4
+19:18:16.747562 version@stat F·[] S·0B[] Sc·[]
5
+19:18:16.763409 db@janitor F·2 G·0
6
+19:18:16.763468 db@open done T·16.722352ms
7
+19:18:16.831746 db@close closing
8
+19:18:16.831861 db@close done T·110.694µs
test/cli/migrations/testdata/v16-repo/datastore/MANIFEST-000000
Binary files /dev/null and b/test/cli/migrations/testdata/v16-repo/datastore/MANIFEST-000000 differ
test/cli/migrations/testdata/v16-repo/datastore_spec
new
+1
@@ -0,0 +1 @@
1
+{"mounts":[{"mountpoint":"/blocks","path":"blocks","shardFunc":"/repo/flatfs/shard/v1/next-to-last/2","type":"flatfs"},{"mountpoint":"/","path":"datastore","type":"levelds"}],"type":"mount"}
\ No newline at end of file
test/cli/migrations/testdata/v16-repo/version
new
+1
@@ -0,0 +1 @@
1
+16
test/cli/name_test.go
+1
-1
@@ -150,7 +150,7 @@ func TestName(t *testing.T) {
150
res := node.RunIPFS("name", "publish", "/ipfs/"+fixtureCid)
151
require.Error(t, res.Err)
152
require.Equal(t, 1, res.ExitCode())
153
- require.Contains(t, res.Stderr.String(), `can't publish while offline`)
153
+ require.Contains(t, res.Stderr.String(), "can't publish while offline: pass `--allow-offline` to override or `--allow-delegated` if Ipns.DelegatedPublishers are set up")
154
})
155
156
t.Run("Publish V2-only record", func(t *testing.T) {
test/cli/telemetry_test.go
+125
@@ -1,8 +1,14 @@
1
package cli
2
3
import (
4
+ "encoding/json"
5
+ "io"
6
+ "maps"
7
+ "net/http"
8
+ "net/http/httptest"
9
"os"
10
"path/filepath"
11
+ "slices"
12
"testing"
13
"time"
14
@@ -181,4 +187,123 @@ func TestTelemetry(t *testing.T) {
187
_, err := os.Stat(uuidPath)
188
assert.NoError(t, err, "UUID file should exist when daemon started without telemetry opt-out")
189
})
190
+
191
+ t.Run("telemetry schema regression guard", func(t *testing.T) {
192
+ t.Parallel()
193
+
194
+ // Define the exact set of expected telemetry fields
195
+ // This list must be updated whenever telemetry fields change
196
+ expectedFields := []string{
197
+ "uuid",
198
+ "agent_version",
199
+ "private_network",
200
+ "bootstrappers_custom",
201
+ "repo_size_bucket",
202
+ "uptime_bucket",
203
+ "reprovider_strategy",
204
+ "routing_type",
205
+ "routing_accelerated_dht_client",
206
+ "routing_delegated_count",
207
+ "autonat_service_mode",
208
+ "autonat_reachability",
209
+ "swarm_enable_hole_punching",
210
+ "swarm_circuit_addresses",
211
+ "swarm_ipv4_public_addresses",
212
+ "swarm_ipv6_public_addresses",
213
+ "auto_tls_auto_wss",
214
+ "auto_tls_domain_suffix_custom",
215
+ "autoconf",
216
+ "autoconf_custom",
217
+ "discovery_mdns_enabled",
218
+ "platform_os",
219
+ "platform_arch",
220
+ "platform_containerized",
221
+ "platform_vm",
222
+ }
223
+
224
+ // Channel to receive captured telemetry data
225
+ telemetryChan := make(chan map[string]interface{}, 1)
226
+
227
+ // Create a mock HTTP server to capture telemetry
228
+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
229
+ if r.Method != "POST" {
230
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
231
+ return
232
+ }
233
+
234
+ body, err := io.ReadAll(r.Body)
235
+ if err != nil {
236
+ http.Error(w, "Failed to read body", http.StatusBadRequest)
237
+ return
238
+ }
239
+
240
+ var telemetryData map[string]interface{}
241
+ if err := json.Unmarshal(body, &telemetryData); err != nil {
242
+ http.Error(w, "Invalid JSON", http.StatusBadRequest)
243
+ return
244
+ }
245
+
246
+ // Send captured data through channel
247
+ select {
248
+ case telemetryChan <- telemetryData:
249
+ default:
250
+ }
251
+
252
+ w.WriteHeader(http.StatusOK)
253
+ }))
254
+ defer mockServer.Close()
255
+
256
+ // Create a new node
257
+ node := harness.NewT(t).NewNode().Init()
258
+
259
+ // Configure telemetry with a very short delay for testing
260
+ node.IPFS("config", "Plugins.Plugins.telemetry.Config.Delay", "100ms")
261
+ node.IPFS("config", "Plugins.Plugins.telemetry.Config.Endpoint", mockServer.URL)
262
+
263
+ // Enable debug logging to see what's being sent
264
+ node.Runner.Env["GOLOG_LOG_LEVEL"] = "telemetry=debug"
265
+
266
+ // Start daemon
267
+ node.StartDaemon()
268
+ defer node.StopDaemon()
269
+
270
+ // Wait for telemetry to be sent (configured delay + buffer)
271
+ select {
272
+ case telemetryData := <-telemetryChan:
273
+ receivedFields := slices.Collect(maps.Keys(telemetryData))
274
+ slices.Sort(expectedFields)
275
+ slices.Sort(receivedFields)
276
+
277
+ // Fast path: check if fields match exactly
278
+ if !slices.Equal(expectedFields, receivedFields) {
279
+ var missingFields, unexpectedFields []string
280
+ for _, field := range expectedFields {
281
+ if _, ok := telemetryData[field]; !ok {
282
+ missingFields = append(missingFields, field)
283
+ }
284
+ }
285
+
286
+ expectedSet := make(map[string]struct{}, len(expectedFields))
287
+ for _, f := range expectedFields {
288
+ expectedSet[f] = struct{}{}
289
+ }
290
+ for field := range telemetryData {
291
+ if _, ok := expectedSet[field]; !ok {
292
+ unexpectedFields = append(unexpectedFields, field)
293
+ }
294
+ }
295
+
296
+ t.Fatalf("Telemetry field mismatch:\n"+
297
+ " Missing fields: %v\n"+
298
+ " Unexpected fields: %v\n"+
299
+ " Note: Update expectedFields list in this test when adding/removing telemetry fields",
300
+ missingFields, unexpectedFields)
301
+ }
302
+
303
+ t.Logf("Telemetry field validation passed: %d fields verified", len(expectedFields))
304
+
305
+ case <-time.After(5 * time.Second):
306
+ t.Fatal("Timeout waiting for telemetry data to be sent")
307
+ }
308
+ })
309
}
test/sharness/t0066-migration.sh
+33
-14
@@ -10,6 +10,10 @@ test_description="Test migrations auto update prompt"
10
11
test_init_ipfs
12
13
+# Remove explicit AutoConf.Enabled=false from test profile to use implicit default
14
+# This allows daemon to work with 'auto' values added by v16-to-17 migration
15
+ipfs config --json AutoConf.Enabled null >/dev/null 2>&1
16
+
17
MIGRATION_START=7
18
IPFS_REPO_VER=$(<.ipfs/version)
19
@@ -22,6 +26,12 @@ gen_mock_migrations() {
26
j=$((i+1))
27
echo "#!/bin/bash" > bin/fs-repo-${i}-to-${j}
28
echo "echo fake applying ${i}-to-${j} repo migration" >> bin/fs-repo-${i}-to-${j}
29
+ # Update version file to the target version for hybrid migration system
30
+ echo "if [ \"\$1\" = \"-path\" ] && [ -n \"\$2\" ]; then" >> bin/fs-repo-${i}-to-${j}
31
+ echo " echo $j > \"\$2/version\"" >> bin/fs-repo-${i}-to-${j}
32
+ echo "elif [ -n \"\$IPFS_PATH\" ]; then" >> bin/fs-repo-${i}-to-${j}
33
+ echo " echo $j > \"\$IPFS_PATH/version\"" >> bin/fs-repo-${i}-to-${j}
34
+ echo "fi" >> bin/fs-repo-${i}-to-${j}
35
chmod +x bin/fs-repo-${i}-to-${j}
36
((i++))
37
done
@@ -54,34 +64,42 @@ test_expect_success "manually reset repo version to $MIGRATION_START" '
64
'
65
66
test_expect_success "ipfs daemon --migrate=false fails" '
57
- test_expect_code 1 ipfs daemon --migrate=false > false_out
67
+ test_expect_code 1 ipfs daemon --migrate=false > false_out 2>&1
68
'
69
70
test_expect_success "output looks good" '
61
- grep "Please get fs-repo-migrations from https://dist.ipfs.tech" false_out
71
+ grep "Kubo repository at .* has version .* and needs to be migrated to version" false_out &&
72
+ grep "Error: fs-repo requires migration" false_out
73
'
74
64
-# The migrations will succeed, but the daemon will still exit with 1 because
65
-# the fake migrations do not update the repo version number.
66
-#
67
-# If run with real migrations, the daemon continues running and must be killed.
75
+# The migrations will succeed and the daemon will continue running
76
+# since the mock migrations now properly update the repo version number.
77
test_expect_success "ipfs daemon --migrate=true runs migration" '
69
- test_expect_code 1 ipfs daemon --migrate=true > true_out
78
+ ipfs daemon --migrate=true > true_out 2>&1 &
79
+ DAEMON_PID=$!
80
+ # Wait for daemon to be ready then shutdown gracefully
81
+ sleep 3 && ipfs shutdown 2>/dev/null || kill $DAEMON_PID 2>/dev/null || true
82
+ wait $DAEMON_PID 2>/dev/null || true
83
'
84
85
test_expect_success "output looks good" '
86
check_migration_output true_out &&
74
- grep "Success: fs-repo migrated to version $IPFS_REPO_VER" true_out > /dev/null
87
+ (grep "Success: fs-repo migrated to version $IPFS_REPO_VER" true_out > /dev/null ||
88
+ grep "Hybrid migration completed successfully: v$MIGRATION_START → v$IPFS_REPO_VER" true_out > /dev/null)
89
+'
90
+
91
+test_expect_success "reset repo version for auto-migration test" '
92
+ echo "$MIGRATION_START" > "$IPFS_PATH"/version
93
'
94
95
test_expect_success "'ipfs daemon' prompts to auto migrate" '
78
- test_expect_code 1 ipfs daemon > daemon_out 2> daemon_err
96
+ test_expect_code 1 ipfs daemon > daemon_out 2>&1
97
'
98
99
test_expect_success "output looks good" '
82
- grep "Found outdated fs-repo" daemon_out > /dev/null &&
100
+ grep "Kubo repository at .* has version .* and needs to be migrated to version" daemon_out > /dev/null &&
101
grep "Run migrations now?" daemon_out > /dev/null &&
84
- grep "Please get fs-repo-migrations from https://dist.ipfs.tech" daemon_out > /dev/null
102
+ grep "Error: fs-repo requires migration" daemon_out > /dev/null
103
'
104
105
test_expect_success "ipfs repo migrate succeed" '
@@ -89,8 +107,9 @@ test_expect_success "ipfs repo migrate succeed" '
107
'
108
109
test_expect_success "output looks good" '
92
- grep "Found outdated fs-repo, starting migration." migrate_out > /dev/null &&
93
- grep "Success: fs-repo migrated to version $IPFS_REPO_VER" true_out > /dev/null
110
+ grep "Migrating repository from version" migrate_out > /dev/null &&
111
+ (grep "Success: fs-repo migrated to version $IPFS_REPO_VER" migrate_out > /dev/null ||
112
+ grep "Hybrid migration completed successfully: v$MIGRATION_START → v$IPFS_REPO_VER" migrate_out > /dev/null)
113
'
114
115
test_expect_success "manually reset repo version to latest" '
@@ -102,7 +121,7 @@ test_expect_success "detect repo does not need migration" '
121
'
122
123
test_expect_success "output looks good" '
105
- grep "Repo does not require migration" migrate_out > /dev/null
124
+ grep "Repository is already at version" migrate_out > /dev/null
125
'
126
127
# ensure that we get a lock error if we need to migrate and the daemon is running
test/sharness/t0120-bootstrap.sh
+5
-25
@@ -13,7 +13,10 @@ BP5="/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zk
13
BP6="/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
14
BP7="/ip4/104.131.131.82/udp/4001/quic-v1/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ"
15
16
-test_description="Test ipfs repo operations"
16
+test_description="Test ipfs bootstrap operations"
17
+
18
+# NOTE: For AutoConf bootstrap functionality (add default, --expand-auto, etc.)
19
+# see test/cli/bootstrap_auto_test.go and test/cli/autoconf/expand_test.go
20
21
. lib/test-lib.sh
22
@@ -83,35 +86,12 @@ test_bootstrap_cmd() {
86
87
test_bootstrap_list_cmd $BP2
88
86
- test_expect_success "'ipfs bootstrap add --default' succeeds" '
87
- ipfs bootstrap add --default >add2_actual
88
- '
89
-
90
- test_expect_success "'ipfs bootstrap add --default' output has default BP" '
91
- echo "added $BP1" >add2_expected &&
92
- echo "added $BP2" >>add2_expected &&
93
- echo "added $BP3" >>add2_expected &&
94
- echo "added $BP4" >>add2_expected &&
95
- echo "added $BP5" >>add2_expected &&
96
- echo "added $BP6" >>add2_expected &&
97
- echo "added $BP7" >>add2_expected &&
98
- test_cmp add2_expected add2_actual
99
- '
100
-
101
- test_bootstrap_list_cmd $BP1 $BP2 $BP3 $BP4 $BP5 $BP6 $BP7
102
-
89
test_expect_success "'ipfs bootstrap rm --all' succeeds" '
90
ipfs bootstrap rm --all >rm2_actual
91
'
92
93
test_expect_success "'ipfs bootstrap rm' output looks good" '
108
- echo "removed $BP1" >rm2_expected &&
109
- echo "removed $BP2" >>rm2_expected &&
110
- echo "removed $BP3" >>rm2_expected &&
111
- echo "removed $BP4" >>rm2_expected &&
112
- echo "removed $BP5" >>rm2_expected &&
113
- echo "removed $BP6" >>rm2_expected &&
114
- echo "removed $BP7" >>rm2_expected &&
94
+ echo "removed $BP2" >rm2_expected &&
95
test_cmp rm2_expected rm2_actual
96
'
97
test/sharness/t0181-private-network.sh
+25
-1
@@ -10,6 +10,10 @@ test_description="Test private network feature"
10
11
test_init_ipfs
12
13
+test_expect_success "disable AutoConf for private network tests" '
14
+ ipfs config --json AutoConf.Enabled false
15
+'
16
+
17
export LIBP2P_FORCE_PNET=1
18
19
test_expect_success "daemon won't start with force pnet env but with no key" '
@@ -37,7 +41,8 @@ test_expect_success "set up iptb testbed" '
41
iptb testbed create -type localipfs -count 5 -force -init &&
42
iptb run -- ipfs config --json "Routing.LoopbackAddressesOnLanDHT" true &&
43
iptb run -- ipfs config --json "Swarm.Transports.Network.Websocket" false &&
40
- iptb run -- ipfs config --json Addresses.Swarm '"'"'["/ip4/127.0.0.1/tcp/0"]'"'"'
44
+ iptb run -- ipfs config --json Addresses.Swarm '"'"'["/ip4/127.0.0.1/tcp/0"]'"'"' &&
45
+ iptb run -- ipfs config --json AutoConf.Enabled false
46
'
47
48
set_key() {
@@ -136,4 +141,23 @@ test_expect_success "stop testbed" '
141
142
test_kill_ipfs_daemon
143
144
+# Test that AutoConf with default mainnet URL fails on private networks
145
+test_expect_success "setup test repo with AutoConf enabled and private network" '
146
+ export IPFS_PATH="$(pwd)/.ipfs-autoconf-test" &&
147
+ ipfs init --profile=test > /dev/null &&
148
+ ipfs config --json AutoConf.Enabled true &&
149
+ pnet_key > "${IPFS_PATH}/swarm.key"
150
+'
151
+
152
+test_expect_success "daemon fails with AutoConf + private network error" '
153
+ export IPFS_PATH="$(pwd)/.ipfs-autoconf-test" &&
154
+ test_expect_code 1 ipfs daemon > autoconf_stdout 2> autoconf_stderr
155
+'
156
+
157
+test_expect_success "error message mentions AutoConf and private network conflict" '
158
+ grep "AutoConf cannot use the default mainnet URL" autoconf_stderr > /dev/null &&
159
+ grep "private network.*swarm.key" autoconf_stderr > /dev/null &&
160
+ grep "AutoConf.Enabled=false" autoconf_stderr > /dev/null
161
+'
162
+
163
test_done
version.go
+4
-3
@@ -3,8 +3,6 @@ package ipfs
3
import (
4
"fmt"
5
"runtime"
6
-
7
- "github.com/ipfs/kubo/repo/fsrepo"
6
)
7
8
// CurrentCommit is the current git commit, this is set as a ldflag in the Makefile.
@@ -15,6 +13,9 @@ const CurrentVersionNumber = "0.37.0-dev"
13
14
const ApiVersion = "/kubo/" + CurrentVersionNumber + "/" //nolint
15
16
+// RepoVersion is the version number that we are currently expecting to see.
17
+const RepoVersion = 17
18
+
19
// GetUserAgentVersion is the libp2p user agent used by go-ipfs.
20
//
21
// Note: This will end in `/` when no commit is available. This is expected.
@@ -47,7 +48,7 @@ func GetVersionInfo() *VersionInfo {
48
return &VersionInfo{
49
Version: CurrentVersionNumber,
50
Commit: CurrentCommit,
50
- Repo: fmt.Sprint(fsrepo.RepoVersion),
51
+ Repo: fmt.Sprint(RepoVersion),
52
System: runtime.GOARCH + "/" + runtime.GOOS, // TODO: Precise version here
53
Golang: runtime.Version(),
54
}