master
go 661 lines 17.4 KB
Raw
1 package commands
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "maps"
9 "os"
10 "os/exec"
11 "slices"
12 "strings"
13
14 "github.com/anmitsu/go-shlex"
15 "github.com/elgris/jsondiff"
16 cmds "github.com/ipfs/go-ipfs-cmds"
17 config "github.com/ipfs/kubo/config"
18 "github.com/ipfs/kubo/core/commands/cmdenv"
19 "github.com/ipfs/kubo/repo"
20 "github.com/ipfs/kubo/repo/fsrepo"
21 )
22
23 // ConfigUpdateOutput is config profile apply command's output
24 type ConfigUpdateOutput struct {
25 OldCfg map[string]any
26 NewCfg map[string]any
27 }
28
29 type ConfigField struct {
30 Key string
31 Value any
32 }
33
34 const (
35 configBoolOptionName = "bool"
36 configJSONOptionName = "json"
37 configDryRunOptionName = "dry-run"
38 configExpandAutoName = "expand-auto"
39 )
40
41 var ConfigCmd = &cmds.Command{
42 Helptext: cmds.HelpText{
43 Tagline: "Get and set IPFS config values.",
44 ShortDescription: `
45 'ipfs config' controls configuration variables. It works like 'git config'.
46 The configuration values are stored in a config file inside your IPFS_PATH.`,
47 LongDescription: `
48 'ipfs config' controls configuration variables. It works
49 much like 'git config'. The configuration values are stored in a config
50 file inside your IPFS repository (IPFS_PATH).
51
52 Examples:
53
54 Get the value of the 'Routing.Type' key:
55
56 $ ipfs config Routing.Type
57
58 Set the value of the 'Routing.Type' key:
59
60 $ ipfs config Routing.Type auto
61
62 Set multiple values in the 'Addresses.AppendAnnounce' array:
63
64 $ ipfs config Addresses.AppendAnnounce --json \
65 '["/dns4/a.example.com/tcp/4001", "/dns4/b.example.com/tcp/4002"]'
66 `,
67 },
68 Subcommands: map[string]*cmds.Command{
69 "show": configShowCmd,
70 "edit": configEditCmd,
71 "replace": configReplaceCmd,
72 "profile": configProfileCmd,
73 },
74 Arguments: []cmds.Argument{
75 cmds.StringArg("key", true, false, "The key of the config entry (e.g. \"Addresses.API\")."),
76 cmds.StringArg("value", false, false, "The value to set the config entry to."),
77 },
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
85 key := args[0]
86
87 var output *ConfigField
88
89 // This is a temporary fix until we move the private key out of the config file
90 switch strings.ToLower(key) {
91 case "identity", "identity.privkey":
92 return errors.New("cannot show or change private key through API")
93 default:
94 }
95
96 // Temporary fix until we move ApiKey secrets out of the config file
97 // (remote services are a map, so more advanced blocking is required)
98 if blocked := matchesGlobPrefix(key, config.PinningConcealSelector); blocked {
99 return errors.New("cannot show or change pinning services credentials")
100 }
101
102 cfgRoot, err := cmdenv.GetConfigRoot(env)
103 if err != nil {
104 return err
105 }
106 r, err := fsrepo.Open(cfgRoot)
107 if err != nil {
108 return err
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 {
120 var jsonVal any
121 if err := json.Unmarshal([]byte(value), &jsonVal); err != nil {
122 err = fmt.Errorf("failed to unmarshal json. %s", err)
123 return err
124 }
125
126 output, err = setConfig(r, key, jsonVal)
127 } else if isbool, _ := req.Options[configBoolOptionName].(bool); isbool {
128 output, err = setConfig(r, key, value == "true")
129 } else {
130 output, err = setConfig(r, key, value)
131 }
132 } else {
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 {
143 return err
144 }
145
146 return cmds.EmitOnce(res, output)
147 },
148 Encoders: cmds.EncoderMap{
149 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigField) error {
150 if len(req.Arguments) == 2 {
151 return nil
152 }
153
154 buf, err := config.HumanOutput(out.Value)
155 if err != nil {
156 return err
157 }
158 buf = append(buf, byte('\n'))
159
160 _, err = w.Write(buf)
161 return err
162 }),
163 },
164 Type: ConfigField{},
165 }
166
167 // matchesGlobPrefix returns true if and only if the key matches the glob.
168 // The key is a sequence of string "parts", separated by commas.
169 // The glob is a sequence of string "patterns".
170 // matchesGlobPrefix tries to match all of the first K parts to the first K patterns, respectively,
171 // where K is the length of the shorter of key or glob.
172 // A pattern matches a part if and only if the pattern is "*" or the lowercase pattern equals the lowercase part.
173 //
174 // For example:
175 //
176 // matchesGlobPrefix("foo.bar", []string{"*", "bar", "baz"}) returns true
177 // matchesGlobPrefix("foo.bar.baz", []string{"*", "bar"}) returns true
178 // matchesGlobPrefix("foo.bar", []string{"baz", "*"}) returns false
179 func matchesGlobPrefix(key string, glob []string) bool {
180 k := strings.Split(key, ".")
181 for i, g := range glob {
182 if i >= len(k) {
183 break
184 }
185 if g == "*" {
186 continue
187 }
188 if !strings.EqualFold(k[i], g) {
189 return false
190 }
191 }
192 return true
193 }
194
195 var configShowCmd = &cmds.Command{
196 Helptext: cmds.HelpText{
197 Tagline: "Output config file contents.",
198 ShortDescription: `
199 NOTE: For security reasons, this command will omit your private key and remote services. If you would like to make a full backup of your config (private key included), you must copy the config file from your repo.
200 `,
201 },
202 Type: make(map[string]any),
203 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
204 cfgRoot, err := cmdenv.GetConfigRoot(env)
205 if err != nil {
206 return err
207 }
208
209 configFileOpt, _ := req.Options[ConfigFileOption].(string)
210 fname, err := config.Filename(cfgRoot, configFileOpt)
211 if err != nil {
212 return err
213 }
214
215 data, err := os.ReadFile(fname)
216 if err != nil {
217 return err
218 }
219
220 var cfg map[string]any
221 err = json.Unmarshal(data, &cfg)
222 if err != nil {
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
246 }
247
248 cfg, err = scrubValue(cfg, []string{config.APITag, config.AuthorizationTag})
249 if err != nil {
250 return err
251 }
252
253 cfg, err = scrubOptionalValue(cfg, config.PinningConcealSelector)
254 if err != nil {
255 return err
256 }
257
258 return cmds.EmitOnce(res, &cfg)
259 },
260 Encoders: cmds.EncoderMap{
261 cmds.Text: HumanJSONEncoder,
262 },
263 }
264
265 var HumanJSONEncoder = cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *map[string]any) error {
266 buf, err := config.HumanOutput(out)
267 if err != nil {
268 return err
269 }
270 buf = append(buf, byte('\n'))
271 _, err = w.Write(buf)
272 return err
273 })
274
275 // Scrubs value and returns error if missing
276 func scrubValue(m map[string]any, key []string) (map[string]any, error) {
277 return scrubMapInternal(m, key, false)
278 }
279
280 // Scrubs value and returns no error if missing
281 func scrubOptionalValue(m map[string]any, key []string) (map[string]any, error) {
282 return scrubMapInternal(m, key, true)
283 }
284
285 func scrubEither(u any, key []string, okIfMissing bool) (any, error) {
286 m, ok := u.(map[string]any)
287 if ok {
288 return scrubMapInternal(m, key, okIfMissing)
289 }
290 return scrubValueInternal(m, key, okIfMissing)
291 }
292
293 func scrubValueInternal(v any, key []string, okIfMissing bool) (any, error) {
294 if v == nil && !okIfMissing {
295 return nil, errors.New("failed to find specified key")
296 }
297 return nil, nil
298 }
299
300 func scrubMapInternal(m map[string]any, key []string, okIfMissing bool) (map[string]any, error) {
301 if len(key) == 0 {
302 return make(map[string]any), nil // delete value
303 }
304 n := map[string]any{}
305 for k, v := range m {
306 if key[0] == "*" || strings.EqualFold(key[0], k) {
307 u, err := scrubEither(v, key[1:], okIfMissing)
308 if err != nil {
309 return nil, err
310 }
311 if u != nil {
312 n[k] = u
313 }
314 } else {
315 n[k] = v
316 }
317 }
318 return n, nil
319 }
320
321 var configEditCmd = &cmds.Command{
322 Helptext: cmds.HelpText{
323 Tagline: "Open the config file for editing in $EDITOR.",
324 ShortDescription: `
325 To use 'ipfs config edit', you must have the $EDITOR environment
326 variable set to your preferred text editor.
327 `,
328 },
329 NoRemote: true,
330 Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
331 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
332 cfgRoot, err := cmdenv.GetConfigRoot(env)
333 if err != nil {
334 return err
335 }
336
337 configFileOpt, _ := req.Options[ConfigFileOption].(string)
338 filename, err := config.Filename(cfgRoot, configFileOpt)
339 if err != nil {
340 return err
341 }
342
343 return editConfig(filename)
344 },
345 }
346
347 var configReplaceCmd = &cmds.Command{
348 Helptext: cmds.HelpText{
349 Tagline: "Replace the config with <file>.",
350 ShortDescription: `
351 Make sure to back up the config file first if necessary, as this operation
352 can't be undone.
353 `,
354 },
355
356 Arguments: []cmds.Argument{
357 cmds.FileArg("file", true, false, "The file to use as the new config."),
358 },
359 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
360 cfgRoot, err := cmdenv.GetConfigRoot(env)
361 if err != nil {
362 return err
363 }
364
365 r, err := fsrepo.Open(cfgRoot)
366 if err != nil {
367 return err
368 }
369 defer r.Close()
370
371 file, err := cmdenv.GetFileArg(req.Files.Entries())
372 if err != nil {
373 return err
374 }
375 defer file.Close()
376
377 return replaceConfig(r, file)
378 },
379 }
380
381 var configProfileCmd = &cmds.Command{
382 Helptext: cmds.HelpText{
383 Tagline: "Apply profiles to config.",
384 ShortDescription: fmt.Sprintf(`
385 Available profiles:
386 %s
387 `, buildProfileHelp()),
388 },
389
390 Subcommands: map[string]*cmds.Command{
391 "apply": configProfileApplyCmd,
392 },
393 }
394
395 var configProfileApplyCmd = &cmds.Command{
396 Helptext: cmds.HelpText{
397 Tagline: "Apply profile to config.",
398 },
399 Options: []cmds.Option{
400 cmds.BoolOption(configDryRunOptionName, "print difference between the current config and the config that would be generated"),
401 },
402 Arguments: []cmds.Argument{
403 cmds.StringArg("profile", true, false, "The profile to apply to the config."),
404 },
405 Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
406 profile, ok := config.Profiles[req.Arguments[0]]
407 if !ok {
408 return fmt.Errorf("%s is not a profile", req.Arguments[0])
409 }
410
411 dryRun, _ := req.Options[configDryRunOptionName].(bool)
412 cfgRoot, err := cmdenv.GetConfigRoot(env)
413 if err != nil {
414 return err
415 }
416
417 oldCfg, newCfg, err := transformConfig(cfgRoot, req.Arguments[0], profile.Transform, dryRun)
418 if err != nil {
419 return err
420 }
421
422 oldCfgMap, err := scrubPrivKey(oldCfg)
423 if err != nil {
424 return err
425 }
426
427 newCfgMap, err := scrubPrivKey(newCfg)
428 if err != nil {
429 return err
430 }
431
432 return cmds.EmitOnce(res, &ConfigUpdateOutput{
433 OldCfg: oldCfgMap,
434 NewCfg: newCfgMap,
435 })
436 },
437 Encoders: cmds.EncoderMap{
438 cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigUpdateOutput) error {
439 diff := jsondiff.Compare(out.OldCfg, out.NewCfg)
440 buf := jsondiff.Format(diff)
441
442 _, err := w.Write(buf)
443 return err
444 }),
445 },
446 Type: ConfigUpdateOutput{},
447 }
448
449 func buildProfileHelp() string {
450 var out string
451
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]
457 }
458
459 out = out + fmt.Sprintf(" '%s':\n%s\n", name, strings.Join(dlines, "\n"))
460 }
461
462 return out
463 }
464
465 // scrubPrivKey scrubs private key for security reasons.
466 func scrubPrivKey(cfg *config.Config) (map[string]any, error) {
467 cfgMap, err := config.ToMap(cfg)
468 if err != nil {
469 return nil, err
470 }
471
472 cfgMap, err = scrubValue(cfgMap, []string{config.IdentityTag, config.PrivKeyTag})
473 if err != nil {
474 return nil, err
475 }
476
477 return cfgMap, nil
478 }
479
480 // transformConfig returns old config and new config instead of difference between them,
481 // because apply command can provide stable API through this way.
482 // If dryRun is true, repo's config should not be updated and persisted
483 // to storage. Otherwise, repo's config should be updated and persisted
484 // to storage.
485 func transformConfig(configRoot string, configName string, transformer config.Transformer, dryRun bool) (*config.Config, *config.Config, error) {
486 r, err := fsrepo.Open(configRoot)
487 if err != nil {
488 return nil, nil, err
489 }
490 defer r.Close()
491
492 oldCfg, err := r.Config()
493 if err != nil {
494 return nil, nil, err
495 }
496
497 // make a copy to avoid updating repo's config unintentionally
498 newCfg, err := oldCfg.Clone()
499 if err != nil {
500 return nil, nil, err
501 }
502
503 err = transformer(newCfg)
504 if err != nil {
505 return nil, nil, err
506 }
507
508 if !dryRun {
509 _, err = r.BackupConfig("pre-" + configName + "-")
510 if err != nil {
511 return nil, nil, err
512 }
513
514 err = r.SetConfig(newCfg)
515 if err != nil {
516 return nil, nil, err
517 }
518 }
519
520 return oldCfg, newCfg, nil
521 }
522
523 func getConfig(r repo.Repo, key string) (*ConfigField, error) {
524 value, err := r.GetConfigKey(key)
525 if err != nil {
526 return nil, fmt.Errorf("failed to get config value: %q", err)
527 }
528 return &ConfigField{
529 Key: key,
530 Value: value,
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 any) (*ConfigField, error) {
557 err := r.SetConfigKey(key, value)
558 if err != nil {
559 return nil, fmt.Errorf("failed to set config value: %s (maybe use --json?)", err)
560 }
561 return getConfig(r, key)
562 }
563
564 // parseEditorCommand parses the EDITOR environment variable into command and arguments
565 func parseEditorCommand(editor string) ([]string, error) {
566 return shlex.Split(editor, true)
567 }
568
569 func editConfig(filename string) error {
570 editor := os.Getenv("EDITOR")
571 if editor == "" {
572 return errors.New("ENV variable $EDITOR not set")
573 }
574
575 editorAndArgs, err := parseEditorCommand(editor)
576 if err != nil {
577 return fmt.Errorf("cannot parse $EDITOR value: %s", err)
578 }
579 editor = editorAndArgs[0]
580 args := append(editorAndArgs[1:], filename)
581
582 cmd := exec.Command(editor, args...)
583 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
584 return cmd.Run()
585 }
586
587 func replaceConfig(r repo.Repo, file io.Reader) error {
588 var newCfg config.Config
589 if err := json.NewDecoder(file).Decode(&newCfg); err != nil {
590 return errors.New("failed to decode file as config")
591 }
592
593 // Handle Identity.PrivKey (secret)
594
595 if len(newCfg.Identity.PrivKey) != 0 {
596 return errors.New("setting private key with API is not supported")
597 }
598
599 keyF, err := getConfig(r, config.PrivKeySelector)
600 if err != nil {
601 return errors.New("failed to get PrivKey")
602 }
603
604 pkstr, ok := keyF.Value.(string)
605 if !ok {
606 return errors.New("private key in config was not a string")
607 }
608
609 newCfg.Identity.PrivKey = pkstr
610
611 // Handle Pinning.RemoteServices (API.Key of each service is a secret)
612
613 newServices := newCfg.Pinning.RemoteServices
614 oldServices, err := getRemotePinningServices(r)
615 if err != nil {
616 return fmt.Errorf("failed to load remote pinning services info (%v)", err)
617 }
618
619 // fail fast if service lists are obviously different
620 if len(newServices) != len(oldServices) {
621 return errors.New("cannot add or remove remote pinning services with 'config replace'")
622 }
623
624 // re-apply API details and confirm every modified service already existed
625 for name, oldSvc := range oldServices {
626 if newSvc, hadSvc := newServices[name]; hadSvc {
627 // fail if input changes any of API details
628 // (interop with config show: allow Endpoint as long it did not change)
629 if len(newSvc.API.Key) != 0 || (len(newSvc.API.Endpoint) != 0 && newSvc.API.Endpoint != oldSvc.API.Endpoint) {
630 return errors.New("cannot change remote pinning services api info with `config replace`")
631 }
632 // re-apply API details and store service in updated config
633 newSvc.API = oldSvc.API
634 newCfg.Pinning.RemoteServices[name] = newSvc
635 } else {
636 // error on service rm attempt
637 return errors.New("cannot add or remove remote pinning services with 'config replace'")
638 }
639 }
640
641 return r.SetConfig(&newCfg)
642 }
643
644 func getRemotePinningServices(r repo.Repo) (map[string]config.RemotePinningService, error) {
645 var oldServices map[string]config.RemotePinningService
646 if remoteServicesTag, err := getConfig(r, config.RemoteServicesPath); err == nil {
647 // seems that golang cannot type assert map[string]interface{} to map[string]config.RemotePinningService
648 // so we have to manually copy the data :-|
649 if val, ok := remoteServicesTag.Value.(map[string]any); ok {
650 jsonString, err := json.Marshal(val)
651 if err != nil {
652 return nil, err
653 }
654 err = json.Unmarshal(jsonString, &oldServices)
655 if err != nil {
656 return nil, err
657 }
658 }
659 }
660 return oldServices, nil
661 }