config.go 14.1 KB
Newer Older
1 2 3
package commands

import (
4
	"encoding/json"
5 6 7
	"errors"
	"fmt"
	"io"
8
	"io/ioutil"
9 10
	"os"
	"os/exec"
11
	"strings"
12

13 14 15
	"github.com/ipfs/go-ipfs/core/commands/cmdenv"
	"github.com/ipfs/go-ipfs/repo"
	"github.com/ipfs/go-ipfs/repo/fsrepo"
16

Jakub Sztandera's avatar
Jakub Sztandera committed
17
	"github.com/elgris/jsondiff"
18 19
	cmds "github.com/ipfs/go-ipfs-cmds"
	config "github.com/ipfs/go-ipfs-config"
20 21
)

22 23
// ConfigUpdateOutput is config profile apply command's output
type ConfigUpdateOutput struct {
24 25
	OldCfg map[string]interface{}
	NewCfg map[string]interface{}
26 27
}

28 29 30 31 32
type ConfigField struct {
	Key   string
	Value interface{}
}

Kejie Zhang's avatar
Kejie Zhang committed
33
const (
34 35 36
	configBoolOptionName   = "bool"
	configJSONOptionName   = "json"
	configDryRunOptionName = "dry-run"
Kejie Zhang's avatar
Kejie Zhang committed
37 38
)

39 40
var tryRemoteServiceApiErr = errors.New("cannot show or change pinning services through this API (try: ipfs pin remote service --help)")

41
var ConfigCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
42
	Helptext: cmds.HelpText{
43
		Tagline: "Get and set ipfs config values.",
44
		ShortDescription: `
Richard Littauer's avatar
Richard Littauer committed
45
'ipfs config' controls configuration variables. It works like 'git config'.
46
The configuration values are stored in a config file inside your ipfs
47 48
repository.`,
		LongDescription: `
Richard Littauer's avatar
Richard Littauer committed
49
'ipfs config' controls configuration variables. It works
50
much like 'git config'. The configuration values are stored in a config
51
file inside your IPFS repository.
52

53
Examples:
54

David Brennan's avatar
David Brennan committed
55
Get the value of the 'Datastore.Path' key:
56

David Brennan's avatar
David Brennan committed
57
  $ ipfs config Datastore.Path
58

David Brennan's avatar
David Brennan committed
59
Set the value of the 'Datastore.Path' key:
60

David Brennan's avatar
David Brennan committed
61
  $ ipfs config Datastore.Path ~/.ipfs/datastore
62
`,
63
	},
64 65 66 67 68 69
	Subcommands: map[string]*cmds.Command{
		"show":    configShowCmd,
		"edit":    configEditCmd,
		"replace": configReplaceCmd,
		"profile": configProfileCmd,
	},
Steven Allen's avatar
Steven Allen committed
70 71 72
	Arguments: []cmds.Argument{
		cmds.StringArg("key", true, false, "The key of the config entry (e.g. \"Addresses.API\")."),
		cmds.StringArg("value", false, false, "The value to set the config entry to."),
73
	},
Steven Allen's avatar
Steven Allen committed
74 75 76
	Options: []cmds.Option{
		cmds.BoolOption(configBoolOptionName, "Set a boolean value."),
		cmds.BoolOption(configJSONOptionName, "Parse stringified JSON."),
77
	},
78 79
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		args := req.Arguments
80
		key := args[0]
81

Jan Winkelmann's avatar
Jan Winkelmann committed
82 83
		var output *ConfigField

84
		// This is a temporary fix until we move the private key out of the config file
85 86
		switch strings.ToLower(key) {
		case "identity", "identity.privkey":
87
			return errors.New("cannot show or change private key through API")
88 89 90
		default:
		}

91 92 93 94 95 96
		// Temporary fix until we move ApiKey secrets out of the config file
		// (remote services are a map, so more advanced blocking is required)
		if blocked := inBlockedScope(key, config.RemoteServicesSelector); blocked {
			return tryRemoteServiceApiErr
		}

Overbool's avatar
Overbool committed
97 98 99 100 101
		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}
		r, err := fsrepo.Open(cfgRoot)
102
		if err != nil {
103
			return err
104
		}
105
		defer r.Close()
106
		if len(args) == 2 {
107
			value := args[1]
108

109
			if parseJSON, _ := req.Options[configJSONOptionName].(bool); parseJSON {
110 111 112
				var jsonVal interface{}
				if err := json.Unmarshal([]byte(value), &jsonVal); err != nil {
					err = fmt.Errorf("failed to unmarshal json. %s", err)
113
					return err
114 115 116
				}

				output, err = setConfig(r, key, jsonVal)
117
			} else if isbool, _ := req.Options[configBoolOptionName].(bool); isbool {
118 119 120 121
				output, err = setConfig(r, key, value == "true")
			} else {
				output, err = setConfig(r, key, value)
			}
122
		} else {
123 124
			output, err = getConfig(r, key)
		}
125

126
		if err != nil {
127
			return err
128
		}
Jan Winkelmann's avatar
Jan Winkelmann committed
129

130
		return cmds.EmitOnce(res, output)
131 132 133 134 135
	},
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigField) error {
			if len(req.Arguments) == 2 {
				return nil
Juan Batiz-Benet's avatar
Juan Batiz-Benet committed
136 137
			}

138
			buf, err := config.HumanOutput(out.Value)
139
			if err != nil {
140
				return err
141
			}
Overbool's avatar
Overbool committed
142 143
			buf = append(buf, byte('\n'))

144 145
			_, err = w.Write(buf)
			return err
146
		}),
147
	},
148
	Type: ConfigField{},
149 150
}

151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168
// Returns bool to indicate if tested key is in the blocked scope.
// (scope includes parent, direct, and child match)
func inBlockedScope(testKey string, blockedScope string) bool {
	blockedScope = strings.ToLower(blockedScope)
	roots := strings.Split(strings.ToLower(testKey), ".")
	var scope []string
	for _, name := range roots {
		scope := append(scope, name)
		impactedKey := strings.Join(scope, ".")
		// blockedScope=foo.bar.BLOCKED should return true
		// for parent and child impactedKeys: foo.bar and foo.bar.BLOCKED.subkey
		if strings.HasPrefix(impactedKey, blockedScope) || strings.HasPrefix(blockedScope, impactedKey) {
			return true
		}
	}
	return false
}

169
var configShowCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
170
	Helptext: cmds.HelpText{
171
		Tagline: "Output config file contents.",
172
		ShortDescription: `
173
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.
174
`,
175
	},
176
	Type: map[string]interface{}{},
177
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
Overbool's avatar
Overbool committed
178 179 180 181 182 183
		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		fname, err := config.Filename(cfgRoot)
184
		if err != nil {
185
			return err
186 187
		}

188
		data, err := ioutil.ReadFile(fname)
189
		if err != nil {
190
			return err
191
		}
192 193 194 195

		var cfg map[string]interface{}
		err = json.Unmarshal(data, &cfg)
		if err != nil {
196
			return err
197 198
		}

199 200
		err = scrubValue(cfg, []string{config.IdentityTag, config.PrivKeyTag})
		if err != nil {
201
			return err
Jeromy's avatar
Jeromy committed
202 203
		}

204 205 206 207 208
		err = scrubOptionalValue(cfg, []string{config.PinningTag, config.RemoteServicesTag})
		if err != nil {
			return err
		}

209
		return cmds.EmitOnce(res, &cfg)
210 211 212 213
	},
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *map[string]interface{}) error {
			buf, err := config.HumanOutput(out)
214
			if err != nil {
215
				return err
216
			}
Overbool's avatar
Overbool committed
217
			buf = append(buf, byte('\n'))
218 219
			_, err = w.Write(buf)
			return err
220
		}),
221 222 223
	},
}

224
// Scrubs value and returns error if missing
225
func scrubValue(m map[string]interface{}, key []string) error {
226 227 228 229 230 231 232 233 234
	return scrub(m, key, false)
}

// Scrubs value and returns no error if missing
func scrubOptionalValue(m map[string]interface{}, key []string) error {
	return scrub(m, key, true)
}

func scrub(m map[string]interface{}, key []string, okIfMissing bool) error {
235 236 237 238 239 240 241 242 243 244 245 246 247 248
	find := func(m map[string]interface{}, k string) (string, interface{}, bool) {
		lckey := strings.ToLower(k)
		for mkey, val := range m {
			lcmkey := strings.ToLower(mkey)
			if lckey == lcmkey {
				return mkey, val, true
			}
		}
		return "", nil, false
	}

	cur := m
	for _, k := range key[:len(key)-1] {
		foundk, val, ok := find(cur, k)
249
		if !ok && !okIfMissing {
250
			return errors.New("failed to find specified key")
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
		}

		if foundk != k {
			// case mismatch, calling this an error
			return fmt.Errorf("case mismatch in config, expected %q but got %q", k, foundk)
		}

		mval, mok := val.(map[string]interface{})
		if !mok {
			return fmt.Errorf("%s was not a map", foundk)
		}

		cur = mval
	}

	todel, _, ok := find(cur, key[len(key)-1])
267
	if !ok && !okIfMissing {
268 269 270 271 272 273 274
		return fmt.Errorf("%s, not found", strings.Join(key, "."))
	}

	delete(cur, todel)
	return nil
}

275
var configEditCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
276
	Helptext: cmds.HelpText{
277
		Tagline: "Open the config file for editing in $EDITOR.",
278 279
		ShortDescription: `
To use 'ipfs config edit', you must have the $EDITOR environment
280 281
variable set to your preferred text editor.
`,
282
	},
283 284
	NoRemote: true,
	Extra:    CreateCmdExtras(SetDoesNotUseRepo(true)),
285
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
Overbool's avatar
Overbool committed
286 287 288 289 290 291
		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		filename, err := config.Filename(cfgRoot)
292
		if err != nil {
293
			return err
294 295
		}

296
		return editConfig(filename)
297 298 299
	},
}

300
var configReplaceCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
301
	Helptext: cmds.HelpText{
302
		Tagline: "Replace the config with <file>.",
303
		ShortDescription: `
304
Make sure to back up the config file first if necessary, as this operation
305 306 307 308
can't be undone.
`,
	},

Steven Allen's avatar
Steven Allen committed
309 310
	Arguments: []cmds.Argument{
		cmds.FileArg("file", true, false, "The file to use as the new config."),
311
	},
312
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
Overbool's avatar
Overbool committed
313 314 315 316 317 318
		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		r, err := fsrepo.Open(cfgRoot)
319
		if err != nil {
320
			return err
321 322 323
		}
		defer r.Close()

324 325 326
		file, err := cmdenv.GetFileArg(req.Files.Entries())
		if err != nil {
			return err
327 328 329
		}
		defer file.Close()

330
		return replaceConfig(r, file)
331 332 333
	},
}

334
var configProfileCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
335
	Helptext: cmds.HelpText{
336
		Tagline: "Apply profiles to config.",
Łukasz Magiera's avatar
Łukasz Magiera committed
337 338 339 340
		ShortDescription: fmt.Sprintf(`
Available profiles:
%s
`, buildProfileHelp()),
341 342 343
	},

	Subcommands: map[string]*cmds.Command{
344
		"apply": configProfileApplyCmd,
345 346 347 348
	},
}

var configProfileApplyCmd = &cmds.Command{
Steven Allen's avatar
Steven Allen committed
349
	Helptext: cmds.HelpText{
350 351
		Tagline: "Apply profile to config.",
	},
Steven Allen's avatar
Steven Allen committed
352 353
	Options: []cmds.Option{
		cmds.BoolOption(configDryRunOptionName, "print difference between the current config and the config that would be generated"),
354
	},
Steven Allen's avatar
Steven Allen committed
355 356
	Arguments: []cmds.Argument{
		cmds.StringArg("profile", true, false, "The profile to apply to the config."),
357
	},
358 359
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		profile, ok := config.Profiles[req.Arguments[0]]
360
		if !ok {
361
			return fmt.Errorf("%s is not a profile", req.Arguments[0])
362 363
		}

364
		dryRun, _ := req.Options[configDryRunOptionName].(bool)
Overbool's avatar
Overbool committed
365 366 367 368 369 370
		cfgRoot, err := cmdenv.GetConfigRoot(env)
		if err != nil {
			return err
		}

		oldCfg, newCfg, err := transformConfig(cfgRoot, req.Arguments[0], profile.Transform, dryRun)
371
		if err != nil {
372
			return err
373
		}
374 375 376

		oldCfgMap, err := scrubPrivKey(oldCfg)
		if err != nil {
377
			return err
378 379 380 381
		}

		newCfgMap, err := scrubPrivKey(newCfg)
		if err != nil {
382
			return err
383 384
		}

385
		return cmds.EmitOnce(res, &ConfigUpdateOutput{
386 387
			OldCfg: oldCfgMap,
			NewCfg: newCfgMap,
388 389
		})
	},
390 391 392
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigUpdateOutput) error {
			diff := jsondiff.Compare(out.OldCfg, out.NewCfg)
393 394
			buf := jsondiff.Format(diff)

395 396
			_, err := w.Write(buf)
			return err
397
		}),
398
	},
399
	Type: ConfigUpdateOutput{},
400
}
401

Łukasz Magiera's avatar
Łukasz Magiera committed
402 403 404 405 406 407 408 409 410 411 412 413 414 415 416
func buildProfileHelp() string {
	var out string

	for name, profile := range config.Profiles {
		dlines := strings.Split(profile.Description, "\n")
		for i := range dlines {
			dlines[i] = "    " + dlines[i]
		}

		out = out + fmt.Sprintf("  '%s':\n%s\n", name, strings.Join(dlines, "\n"))
	}

	return out
}

417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
// scrubPrivKey scrubs private key for security reasons.
func scrubPrivKey(cfg *config.Config) (map[string]interface{}, error) {
	cfgMap, err := config.ToMap(cfg)
	if err != nil {
		return nil, err
	}

	err = scrubValue(cfgMap, []string{config.IdentityTag, config.PrivKeyTag})
	if err != nil {
		return nil, err
	}

	return cfgMap, nil
}

432 433 434 435 436 437
// transformConfig returns old config and new config instead of difference between they,
// because apply command can provide stable API through this way.
// If dryRun is true, repo's config should not be updated and persisted
// to storage. Otherwise, repo's config should be updated and persisted
// to storage.
func transformConfig(configRoot string, configName string, transformer config.Transformer, dryRun bool) (*config.Config, *config.Config, error) {
438 439
	r, err := fsrepo.Open(configRoot)
	if err != nil {
440
		return nil, nil, err
441 442 443
	}
	defer r.Close()

Steven Allen's avatar
Steven Allen committed
444
	oldCfg, err := r.Config()
445
	if err != nil {
446
		return nil, nil, err
447 448
	}

449
	// make a copy to avoid updating repo's config unintentionally
Steven Allen's avatar
Steven Allen committed
450 451 452 453 454 455
	newCfg, err := oldCfg.Clone()
	if err != nil {
		return nil, nil, err
	}

	err = transformer(newCfg)
456
	if err != nil {
457
		return nil, nil, err
458 459
	}

460 461 462 463 464 465
	if !dryRun {
		_, err = r.BackupConfig("pre-" + configName + "-")
		if err != nil {
			return nil, nil, err
		}

Steven Allen's avatar
Steven Allen committed
466
		err = r.SetConfig(newCfg)
467 468 469
		if err != nil {
			return nil, nil, err
		}
Łukasz Magiera's avatar
Łukasz Magiera committed
470 471
	}

Steven Allen's avatar
Steven Allen committed
472
	return oldCfg, newCfg, nil
473 474
}

475
func getConfig(r repo.Repo, key string) (*ConfigField, error) {
476
	value, err := r.GetConfigKey(key)
477
	if err != nil {
Łukasz Magiera's avatar
Łukasz Magiera committed
478
		return nil, fmt.Errorf("failed to get config value: %q", err)
479 480 481 482 483 484 485
	}
	return &ConfigField{
		Key:   key,
		Value: value,
	}, nil
}

486
func setConfig(r repo.Repo, key string, value interface{}) (*ConfigField, error) {
487
	err := r.SetConfigKey(key, value)
488
	if err != nil {
489
		return nil, fmt.Errorf("failed to set config value: %s (maybe use --json?)", err)
490
	}
491
	return getConfig(r, key)
492 493 494 495 496 497 498 499 500 501 502 503
}

func editConfig(filename string) error {
	editor := os.Getenv("EDITOR")
	if editor == "" {
		return errors.New("ENV variable $EDITOR not set")
	}

	cmd := exec.Command("sh", "-c", editor+" "+filename)
	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
	return cmd.Run()
}
504 505 506 507

func replaceConfig(r repo.Repo, file io.Reader) error {
	var cfg config.Config
	if err := json.NewDecoder(file).Decode(&cfg); err != nil {
508 509
		return errors.New("failed to decode file as config")
	}
510 511 512

	// Handle Identity.PrivKey (secret)

513 514
	if len(cfg.Identity.PrivKey) != 0 {
		return errors.New("setting private key with API is not supported")
515 516
	}

517
	keyF, err := getConfig(r, config.PrivKeySelector)
518
	if err != nil {
519
		return errors.New("failed to get PrivKey")
520
	}
521 522 523

	pkstr, ok := keyF.Value.(string)
	if !ok {
524
		return errors.New("private key in config was not a string")
525 526 527
	}

	cfg.Identity.PrivKey = pkstr
528

529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554
	// Handle Pinning.RemoteServices (ApiKey of each service is secret)
	// Note: these settings are opt-in and may be missing

	if len(cfg.Pinning.RemoteServices) != 0 {
		return tryRemoteServiceApiErr
	}

	// detect if existing config has any remote services defined..
	if remoteServicesTag, err := getConfig(r, config.RemoteServicesSelector); err == nil {
		// seems that golang cannot type assert map[string]interface{} to map[string]config.RemotePinningService
		// so we have to manually copy the data :-|
		if val, ok := remoteServicesTag.Value.(map[string]interface{}); ok {
			var services map[string]config.RemotePinningService
			jsonString, err := json.Marshal(val)
			if err != nil {
				return fmt.Errorf("failed to replace config while preserving %s: %s", config.RemoteServicesSelector, err)
			}
			err = json.Unmarshal(jsonString, &services)
			if err != nil {
				return fmt.Errorf("failed to replace config while preserving %s: %s", config.RemoteServicesSelector, err)
			}
			// .. if so, apply them on top of the new config
			cfg.Pinning.RemoteServices = services
		}
	}

555 556
	return r.SetConfig(&cfg)
}