1
0
mirror of https://github.com/ipfs/kubo.git synced 2025-05-17 23:16:11 +08:00
Files
kubo/core/commands/config.go
Henrique Dias 01cc5eab57 feat(rpc): Opt-in HTTP RPC API Authorization (#10218)
Context: https://github.com/ipfs/kubo/issues/10187
Co-authored-by: Marcin Rataj <lidel@lidel.org>
2023-11-17 01:29:29 +01:00

590 lines
15 KiB
Go

package commands
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/ipfs/kubo/core/commands/cmdenv"
"github.com/ipfs/kubo/repo"
"github.com/ipfs/kubo/repo/fsrepo"
"github.com/elgris/jsondiff"
cmds "github.com/ipfs/go-ipfs-cmds"
config "github.com/ipfs/kubo/config"
)
// ConfigUpdateOutput is config profile apply command's output
type ConfigUpdateOutput struct {
OldCfg map[string]interface{}
NewCfg map[string]interface{}
}
type ConfigField struct {
Key string
Value interface{}
}
const (
configBoolOptionName = "bool"
configJSONOptionName = "json"
configDryRunOptionName = "dry-run"
)
var ConfigCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Get and set IPFS config values.",
ShortDescription: `
'ipfs config' controls configuration variables. It works like 'git config'.
The configuration values are stored in a config file inside your IPFS_PATH.`,
LongDescription: `
'ipfs config' controls configuration variables. It works
much like 'git config'. The configuration values are stored in a config
file inside your IPFS repository (IPFS_PATH).
Examples:
Get the value of the 'Datastore.Path' key:
$ ipfs config Datastore.Path
Set the value of the 'Datastore.Path' key:
$ ipfs config Datastore.Path ~/.ipfs/datastore
`,
},
Subcommands: map[string]*cmds.Command{
"show": configShowCmd,
"edit": configEditCmd,
"replace": configReplaceCmd,
"profile": configProfileCmd,
},
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."),
},
Options: []cmds.Option{
cmds.BoolOption(configBoolOptionName, "Set a boolean value."),
cmds.BoolOption(configJSONOptionName, "Parse stringified JSON."),
},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
args := req.Arguments
key := args[0]
var output *ConfigField
// This is a temporary fix until we move the private key out of the config file
switch strings.ToLower(key) {
case "identity", "identity.privkey":
return errors.New("cannot show or change private key through API")
default:
}
// 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 := matchesGlobPrefix(key, config.PinningConcealSelector); blocked {
return errors.New("cannot show or change pinning services credentials")
}
cfgRoot, err := cmdenv.GetConfigRoot(env)
if err != nil {
return err
}
r, err := fsrepo.Open(cfgRoot)
if err != nil {
return err
}
defer r.Close()
if len(args) == 2 {
value := args[1]
if parseJSON, _ := req.Options[configJSONOptionName].(bool); parseJSON {
var jsonVal interface{}
if err := json.Unmarshal([]byte(value), &jsonVal); err != nil {
err = fmt.Errorf("failed to unmarshal json. %s", err)
return err
}
output, err = setConfig(r, key, jsonVal)
} else if isbool, _ := req.Options[configBoolOptionName].(bool); isbool {
output, err = setConfig(r, key, value == "true")
} else {
output, err = setConfig(r, key, value)
}
} else {
output, err = getConfig(r, key)
}
if err != nil {
return err
}
return cmds.EmitOnce(res, output)
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigField) error {
if len(req.Arguments) == 2 {
return nil
}
buf, err := config.HumanOutput(out.Value)
if err != nil {
return err
}
buf = append(buf, byte('\n'))
_, err = w.Write(buf)
return err
}),
},
Type: ConfigField{},
}
// matchesGlobPrefix returns true if and only if the key matches the glob.
// The key is a sequence of string "parts", separated by commas.
// The glob is a sequence of string "patterns".
// matchesGlobPrefix tries to match all of the first K parts to the first K patterns, respectively,
// where K is the length of the shorter of key or glob.
// A pattern matches a part if and only if the pattern is "*" or the lowercase pattern equals the lowercase part.
//
// For example:
//
// matchesGlobPrefix("foo.bar", []string{"*", "bar", "baz"}) returns true
// matchesGlobPrefix("foo.bar.baz", []string{"*", "bar"}) returns true
// matchesGlobPrefix("foo.bar", []string{"baz", "*"}) returns false
func matchesGlobPrefix(key string, glob []string) bool {
k := strings.Split(key, ".")
for i, g := range glob {
if i >= len(k) {
break
}
if g == "*" {
continue
}
if !strings.EqualFold(k[i], g) {
return false
}
}
return true
}
var configShowCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Output config file contents.",
ShortDescription: `
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.
`,
},
Type: make(map[string]interface{}),
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
cfgRoot, err := cmdenv.GetConfigRoot(env)
if err != nil {
return err
}
configFileOpt, _ := req.Options[ConfigFileOption].(string)
fname, err := config.Filename(cfgRoot, configFileOpt)
if err != nil {
return err
}
data, err := os.ReadFile(fname)
if err != nil {
return err
}
var cfg map[string]interface{}
err = json.Unmarshal(data, &cfg)
if err != nil {
return err
}
cfg, err = scrubValue(cfg, []string{config.IdentityTag, config.PrivKeyTag})
if err != nil {
return err
}
cfg, err = scrubValue(cfg, []string{config.APITag, config.AuthorizationTag})
if err != nil {
return err
}
cfg, err = scrubOptionalValue(cfg, config.PinningConcealSelector)
if err != nil {
return err
}
return cmds.EmitOnce(res, &cfg)
},
Encoders: cmds.EncoderMap{
cmds.Text: HumanJSONEncoder,
},
}
var HumanJSONEncoder = cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *map[string]interface{}) error {
buf, err := config.HumanOutput(out)
if err != nil {
return err
}
buf = append(buf, byte('\n'))
_, err = w.Write(buf)
return err
})
// Scrubs value and returns error if missing
func scrubValue(m map[string]interface{}, key []string) (map[string]interface{}, error) {
return scrubMapInternal(m, key, false)
}
// Scrubs value and returns no error if missing
func scrubOptionalValue(m map[string]interface{}, key []string) (map[string]interface{}, error) {
return scrubMapInternal(m, key, true)
}
func scrubEither(u interface{}, key []string, okIfMissing bool) (interface{}, error) {
m, ok := u.(map[string]interface{})
if ok {
return scrubMapInternal(m, key, okIfMissing)
}
return scrubValueInternal(m, key, okIfMissing)
}
func scrubValueInternal(v interface{}, key []string, okIfMissing bool) (interface{}, error) {
if v == nil && !okIfMissing {
return nil, errors.New("failed to find specified key")
}
return nil, nil
}
func scrubMapInternal(m map[string]interface{}, key []string, okIfMissing bool) (map[string]interface{}, error) {
if len(key) == 0 {
return make(map[string]interface{}), nil // delete value
}
n := map[string]interface{}{}
for k, v := range m {
if key[0] == "*" || strings.EqualFold(key[0], k) {
u, err := scrubEither(v, key[1:], okIfMissing)
if err != nil {
return nil, err
}
if u != nil {
n[k] = u
}
} else {
n[k] = v
}
}
return n, nil
}
var configEditCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Open the config file for editing in $EDITOR.",
ShortDescription: `
To use 'ipfs config edit', you must have the $EDITOR environment
variable set to your preferred text editor.
`,
},
NoRemote: true,
Extra: CreateCmdExtras(SetDoesNotUseRepo(true)),
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
cfgRoot, err := cmdenv.GetConfigRoot(env)
if err != nil {
return err
}
configFileOpt, _ := req.Options[ConfigFileOption].(string)
filename, err := config.Filename(cfgRoot, configFileOpt)
if err != nil {
return err
}
return editConfig(filename)
},
}
var configReplaceCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Replace the config with <file>.",
ShortDescription: `
Make sure to back up the config file first if necessary, as this operation
can't be undone.
`,
},
Arguments: []cmds.Argument{
cmds.FileArg("file", true, false, "The file to use as the new config."),
},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
cfgRoot, err := cmdenv.GetConfigRoot(env)
if err != nil {
return err
}
r, err := fsrepo.Open(cfgRoot)
if err != nil {
return err
}
defer r.Close()
file, err := cmdenv.GetFileArg(req.Files.Entries())
if err != nil {
return err
}
defer file.Close()
return replaceConfig(r, file)
},
}
var configProfileCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Apply profiles to config.",
ShortDescription: fmt.Sprintf(`
Available profiles:
%s
`, buildProfileHelp()),
},
Subcommands: map[string]*cmds.Command{
"apply": configProfileApplyCmd,
},
}
var configProfileApplyCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Apply profile to config.",
},
Options: []cmds.Option{
cmds.BoolOption(configDryRunOptionName, "print difference between the current config and the config that would be generated"),
},
Arguments: []cmds.Argument{
cmds.StringArg("profile", true, false, "The profile to apply to the config."),
},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
profile, ok := config.Profiles[req.Arguments[0]]
if !ok {
return fmt.Errorf("%s is not a profile", req.Arguments[0])
}
dryRun, _ := req.Options[configDryRunOptionName].(bool)
cfgRoot, err := cmdenv.GetConfigRoot(env)
if err != nil {
return err
}
oldCfg, newCfg, err := transformConfig(cfgRoot, req.Arguments[0], profile.Transform, dryRun)
if err != nil {
return err
}
oldCfgMap, err := scrubPrivKey(oldCfg)
if err != nil {
return err
}
newCfgMap, err := scrubPrivKey(newCfg)
if err != nil {
return err
}
return cmds.EmitOnce(res, &ConfigUpdateOutput{
OldCfg: oldCfgMap,
NewCfg: newCfgMap,
})
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *ConfigUpdateOutput) error {
diff := jsondiff.Compare(out.OldCfg, out.NewCfg)
buf := jsondiff.Format(diff)
_, err := w.Write(buf)
return err
}),
},
Type: ConfigUpdateOutput{},
}
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
}
// 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
}
cfgMap, err = scrubValue(cfgMap, []string{config.IdentityTag, config.PrivKeyTag})
if err != nil {
return nil, err
}
return cfgMap, nil
}
// transformConfig returns old config and new config instead of difference between them,
// 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) {
r, err := fsrepo.Open(configRoot)
if err != nil {
return nil, nil, err
}
defer r.Close()
oldCfg, err := r.Config()
if err != nil {
return nil, nil, err
}
// make a copy to avoid updating repo's config unintentionally
newCfg, err := oldCfg.Clone()
if err != nil {
return nil, nil, err
}
err = transformer(newCfg)
if err != nil {
return nil, nil, err
}
if !dryRun {
_, err = r.BackupConfig("pre-" + configName + "-")
if err != nil {
return nil, nil, err
}
err = r.SetConfig(newCfg)
if err != nil {
return nil, nil, err
}
}
return oldCfg, newCfg, nil
}
func getConfig(r repo.Repo, key string) (*ConfigField, error) {
value, err := r.GetConfigKey(key)
if err != nil {
return nil, fmt.Errorf("failed to get config value: %q", err)
}
return &ConfigField{
Key: key,
Value: value,
}, nil
}
func setConfig(r repo.Repo, key string, value interface{}) (*ConfigField, error) {
err := r.SetConfigKey(key, value)
if err != nil {
return nil, fmt.Errorf("failed to set config value: %s (maybe use --json?)", err)
}
return getConfig(r, key)
}
func editConfig(filename string) error {
editor := os.Getenv("EDITOR")
if editor == "" {
return errors.New("ENV variable $EDITOR not set")
}
cmd := exec.Command(editor, filename)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
func replaceConfig(r repo.Repo, file io.Reader) error {
var newCfg config.Config
if err := json.NewDecoder(file).Decode(&newCfg); err != nil {
return errors.New("failed to decode file as config")
}
// Handle Identity.PrivKey (secret)
if len(newCfg.Identity.PrivKey) != 0 {
return errors.New("setting private key with API is not supported")
}
keyF, err := getConfig(r, config.PrivKeySelector)
if err != nil {
return errors.New("failed to get PrivKey")
}
pkstr, ok := keyF.Value.(string)
if !ok {
return errors.New("private key in config was not a string")
}
newCfg.Identity.PrivKey = pkstr
// Handle Pinning.RemoteServices (API.Key of each service is a secret)
newServices := newCfg.Pinning.RemoteServices
oldServices, err := getRemotePinningServices(r)
if err != nil {
return fmt.Errorf("failed to load remote pinning services info (%v)", err)
}
// fail fast if service lists are obviously different
if len(newServices) != len(oldServices) {
return errors.New("cannot add or remove remote pinning services with 'config replace'")
}
// re-apply API details and confirm every modified service already existed
for name, oldSvc := range oldServices {
if newSvc, hadSvc := newServices[name]; hadSvc {
// fail if input changes any of API details
// (interop with config show: allow Endpoint as long it did not change)
if len(newSvc.API.Key) != 0 || (len(newSvc.API.Endpoint) != 0 && newSvc.API.Endpoint != oldSvc.API.Endpoint) {
return errors.New("cannot change remote pinning services api info with `config replace`")
}
// re-apply API details and store service in updated config
newSvc.API = oldSvc.API
newCfg.Pinning.RemoteServices[name] = newSvc
} else {
// error on service rm attempt
return errors.New("cannot add or remove remote pinning services with 'config replace'")
}
}
return r.SetConfig(&newCfg)
}
func getRemotePinningServices(r repo.Repo) (map[string]config.RemotePinningService, error) {
var oldServices map[string]config.RemotePinningService
if remoteServicesTag, err := getConfig(r, config.RemoteServicesPath); 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 {
jsonString, err := json.Marshal(val)
if err != nil {
return nil, err
}
err = json.Unmarshal(jsonString, &oldServices)
if err != nil {
return nil, err
}
}
}
return oldServices, nil
}