github.com/containers/libpod@v1.9.4-0.20220419124438-4284fd425507/pkg/util/utils.go (about) 1 package util 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "os" 7 "os/user" 8 "path/filepath" 9 "strconv" 10 "strings" 11 "sync" 12 "syscall" 13 "time" 14 15 "github.com/BurntSushi/toml" 16 "github.com/containers/image/v5/types" 17 "github.com/containers/libpod/cmd/podman/cliconfig" 18 "github.com/containers/libpod/pkg/errorhandling" 19 "github.com/containers/libpod/pkg/namespaces" 20 "github.com/containers/libpod/pkg/rootless" 21 "github.com/containers/libpod/pkg/signal" 22 "github.com/containers/storage" 23 "github.com/containers/storage/pkg/idtools" 24 v1 "github.com/opencontainers/image-spec/specs-go/v1" 25 "github.com/pkg/errors" 26 "github.com/sirupsen/logrus" 27 "github.com/spf13/pflag" 28 "golang.org/x/crypto/ssh/terminal" 29 ) 30 31 // Helper function to determine the username/password passed 32 // in the creds string. It could be either or both. 33 func parseCreds(creds string) (string, string) { 34 if creds == "" { 35 return "", "" 36 } 37 up := strings.SplitN(creds, ":", 2) 38 if len(up) == 1 { 39 return up[0], "" 40 } 41 return up[0], up[1] 42 } 43 44 // ParseRegistryCreds takes a credentials string in the form USERNAME:PASSWORD 45 // and returns a DockerAuthConfig 46 func ParseRegistryCreds(creds string) (*types.DockerAuthConfig, error) { 47 username, password := parseCreds(creds) 48 if username == "" { 49 fmt.Print("Username: ") 50 fmt.Scanln(&username) 51 } 52 if password == "" { 53 fmt.Print("Password: ") 54 termPassword, err := terminal.ReadPassword(0) 55 if err != nil { 56 return nil, errors.Wrapf(err, "could not read password from terminal") 57 } 58 password = string(termPassword) 59 } 60 61 return &types.DockerAuthConfig{ 62 Username: username, 63 Password: password, 64 }, nil 65 } 66 67 // StringInSlice determines if a string is in a string slice, returns bool 68 func StringInSlice(s string, sl []string) bool { 69 for _, i := range sl { 70 if i == s { 71 return true 72 } 73 } 74 return false 75 } 76 77 // ImageConfig is a wrapper around the OCIv1 Image Configuration struct exported 78 // by containers/image, but containing additional fields that are not supported 79 // by OCIv1 (but are by Docker v2) - notably OnBuild. 80 type ImageConfig struct { 81 v1.ImageConfig 82 OnBuild []string 83 } 84 85 // GetImageConfig produces a v1.ImageConfig from the --change flag that is 86 // accepted by several Podman commands. It accepts a (limited subset) of 87 // Dockerfile instructions. 88 func GetImageConfig(changes []string) (ImageConfig, error) { 89 // Valid changes: 90 // USER 91 // EXPOSE 92 // ENV 93 // ENTRYPOINT 94 // CMD 95 // VOLUME 96 // WORKDIR 97 // LABEL 98 // STOPSIGNAL 99 // ONBUILD 100 101 config := ImageConfig{} 102 103 for _, change := range changes { 104 // First, let's assume proper Dockerfile format - space 105 // separator between instruction and value 106 split := strings.SplitN(change, " ", 2) 107 108 if len(split) != 2 { 109 split = strings.SplitN(change, "=", 2) 110 if len(split) != 2 { 111 return ImageConfig{}, errors.Errorf("invalid change %q - must be formatted as KEY VALUE", change) 112 } 113 } 114 115 outerKey := strings.ToUpper(strings.TrimSpace(split[0])) 116 value := strings.TrimSpace(split[1]) 117 switch outerKey { 118 case "USER": 119 // Assume literal contents are the user. 120 if value == "" { 121 return ImageConfig{}, errors.Errorf("invalid change %q - must provide a value to USER", change) 122 } 123 config.User = value 124 case "EXPOSE": 125 // EXPOSE is either [portnum] or 126 // [portnum]/[proto] 127 // Protocol must be "tcp" or "udp" 128 splitPort := strings.Split(value, "/") 129 if len(splitPort) > 2 { 130 return ImageConfig{}, errors.Errorf("invalid change %q - EXPOSE port must be formatted as PORT[/PROTO]", change) 131 } 132 portNum, err := strconv.Atoi(splitPort[0]) 133 if err != nil { 134 return ImageConfig{}, errors.Wrapf(err, "invalid change %q - EXPOSE port must be an integer", change) 135 } 136 if portNum > 65535 || portNum <= 0 { 137 return ImageConfig{}, errors.Errorf("invalid change %q - EXPOSE port must be a valid port number", change) 138 } 139 proto := "tcp" 140 if len(splitPort) > 1 { 141 testProto := strings.ToLower(splitPort[1]) 142 switch testProto { 143 case "tcp", "udp": 144 proto = testProto 145 default: 146 return ImageConfig{}, errors.Errorf("invalid change %q - EXPOSE protocol must be TCP or UDP", change) 147 } 148 } 149 if config.ExposedPorts == nil { 150 config.ExposedPorts = make(map[string]struct{}) 151 } 152 config.ExposedPorts[fmt.Sprintf("%d/%s", portNum, proto)] = struct{}{} 153 case "ENV": 154 // Format is either: 155 // ENV key=value 156 // ENV key=value key=value ... 157 // ENV key value 158 // Both keys and values can be surrounded by quotes to group them. 159 // For now: we only support key=value 160 // We will attempt to strip quotation marks if present. 161 162 var ( 163 key, val string 164 ) 165 166 splitEnv := strings.SplitN(value, "=", 2) 167 key = splitEnv[0] 168 // We do need a key 169 if key == "" { 170 return ImageConfig{}, errors.Errorf("invalid change %q - ENV must have at least one argument", change) 171 } 172 // Perfectly valid to not have a value 173 if len(splitEnv) == 2 { 174 val = splitEnv[1] 175 } 176 177 if strings.HasPrefix(key, `"`) && strings.HasSuffix(key, `"`) { 178 key = strings.TrimPrefix(strings.TrimSuffix(key, `"`), `"`) 179 } 180 if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { 181 val = strings.TrimPrefix(strings.TrimSuffix(val, `"`), `"`) 182 } 183 config.Env = append(config.Env, fmt.Sprintf("%s=%s", key, val)) 184 case "ENTRYPOINT": 185 // Two valid forms. 186 // First, JSON array. 187 // Second, not a JSON array - we interpret this as an 188 // argument to `sh -c`, unless empty, in which case we 189 // just use a blank entrypoint. 190 testUnmarshal := []string{} 191 if err := json.Unmarshal([]byte(value), &testUnmarshal); err != nil { 192 // It ain't valid JSON, so assume it's an 193 // argument to sh -c if not empty. 194 if value != "" { 195 config.Entrypoint = []string{"/bin/sh", "-c", value} 196 } else { 197 config.Entrypoint = []string{} 198 } 199 } else { 200 // Valid JSON 201 config.Entrypoint = testUnmarshal 202 } 203 case "CMD": 204 // Same valid forms as entrypoint. 205 // However, where ENTRYPOINT assumes that 'ENTRYPOINT ' 206 // means no entrypoint, CMD assumes it is 'sh -c' with 207 // no third argument. 208 testUnmarshal := []string{} 209 if err := json.Unmarshal([]byte(value), &testUnmarshal); err != nil { 210 // It ain't valid JSON, so assume it's an 211 // argument to sh -c. 212 // Only include volume if it's not "" 213 config.Cmd = []string{"/bin/sh", "-c"} 214 if value != "" { 215 config.Cmd = append(config.Cmd, value) 216 } 217 } else { 218 // Valid JSON 219 config.Cmd = testUnmarshal 220 } 221 case "VOLUME": 222 // Either a JSON array or a set of space-separated 223 // paths. 224 // Acts rather similar to ENTRYPOINT and CMD, but always 225 // appends rather than replacing, and no sh -c prepend. 226 testUnmarshal := []string{} 227 if err := json.Unmarshal([]byte(value), &testUnmarshal); err != nil { 228 // Not valid JSON, so split on spaces 229 testUnmarshal = strings.Split(value, " ") 230 } 231 if len(testUnmarshal) == 0 { 232 return ImageConfig{}, errors.Errorf("invalid change %q - must provide at least one argument to VOLUME", change) 233 } 234 for _, vol := range testUnmarshal { 235 if vol == "" { 236 return ImageConfig{}, errors.Errorf("invalid change %q - VOLUME paths must not be empty", change) 237 } 238 if config.Volumes == nil { 239 config.Volumes = make(map[string]struct{}) 240 } 241 config.Volumes[vol] = struct{}{} 242 } 243 case "WORKDIR": 244 // This can be passed multiple times. 245 // Each successive invocation is treated as relative to 246 // the previous one - so WORKDIR /A, WORKDIR b, 247 // WORKDIR c results in /A/b/c 248 // Just need to check it's not empty... 249 if value == "" { 250 return ImageConfig{}, errors.Errorf("invalid change %q - must provide a non-empty WORKDIR", change) 251 } 252 config.WorkingDir = filepath.Join(config.WorkingDir, value) 253 case "LABEL": 254 // Same general idea as ENV, but we no longer allow " " 255 // as a separator. 256 // We didn't do that for ENV either, so nice and easy. 257 // Potentially problematic: LABEL might theoretically 258 // allow an = in the key? If people really do this, we 259 // may need to investigate more advanced parsing. 260 var ( 261 key, val string 262 ) 263 264 splitLabel := strings.SplitN(value, "=", 2) 265 // Unlike ENV, LABEL must have a value 266 if len(splitLabel) != 2 { 267 return ImageConfig{}, errors.Errorf("invalid change %q - LABEL must be formatted key=value", change) 268 } 269 key = splitLabel[0] 270 val = splitLabel[1] 271 272 if strings.HasPrefix(key, `"`) && strings.HasSuffix(key, `"`) { 273 key = strings.TrimPrefix(strings.TrimSuffix(key, `"`), `"`) 274 } 275 if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) { 276 val = strings.TrimPrefix(strings.TrimSuffix(val, `"`), `"`) 277 } 278 // Check key after we strip quotations 279 if key == "" { 280 return ImageConfig{}, errors.Errorf("invalid change %q - LABEL must have a non-empty key", change) 281 } 282 if config.Labels == nil { 283 config.Labels = make(map[string]string) 284 } 285 config.Labels[key] = val 286 case "STOPSIGNAL": 287 // Check the provided signal for validity. 288 killSignal, err := ParseSignal(value) 289 if err != nil { 290 return ImageConfig{}, errors.Wrapf(err, "invalid change %q - KILLSIGNAL must be given a valid signal", change) 291 } 292 config.StopSignal = fmt.Sprintf("%d", killSignal) 293 case "ONBUILD": 294 // Onbuild always appends. 295 if value == "" { 296 return ImageConfig{}, errors.Errorf("invalid change %q - ONBUILD must be given an argument", change) 297 } 298 config.OnBuild = append(config.OnBuild, value) 299 default: 300 return ImageConfig{}, errors.Errorf("invalid change %q - invalid instruction %s", change, outerKey) 301 } 302 } 303 304 return config, nil 305 } 306 307 // ParseSignal parses and validates a signal name or number. 308 func ParseSignal(rawSignal string) (syscall.Signal, error) { 309 // Strip off leading dash, to allow -1 or -HUP 310 basename := strings.TrimPrefix(rawSignal, "-") 311 312 sig, err := signal.ParseSignal(basename) 313 if err != nil { 314 return -1, err 315 } 316 // 64 is SIGRTMAX; wish we could get this from a standard Go library 317 if sig < 1 || sig > 64 { 318 return -1, errors.Errorf("valid signals are 1 through 64") 319 } 320 return sig, nil 321 } 322 323 // ParseIDMapping takes idmappings and subuid and subgid maps and returns a storage mapping 324 func ParseIDMapping(mode namespaces.UsernsMode, uidMapSlice, gidMapSlice []string, subUIDMap, subGIDMap string) (*storage.IDMappingOptions, error) { 325 options := storage.IDMappingOptions{ 326 HostUIDMapping: true, 327 HostGIDMapping: true, 328 } 329 330 if mode.IsAuto() { 331 var err error 332 options.HostUIDMapping = false 333 options.HostGIDMapping = false 334 options.AutoUserNs = true 335 opts, err := mode.GetAutoOptions() 336 if err != nil { 337 return nil, err 338 } 339 options.AutoUserNsOpts = *opts 340 return &options, nil 341 } 342 if mode.IsKeepID() { 343 if len(uidMapSlice) > 0 || len(gidMapSlice) > 0 { 344 return nil, errors.New("cannot specify custom mappings with --userns=keep-id") 345 } 346 if len(subUIDMap) > 0 || len(subGIDMap) > 0 { 347 return nil, errors.New("cannot specify subuidmap or subgidmap with --userns=keep-id") 348 } 349 if rootless.IsRootless() { 350 min := func(a, b int) int { 351 if a < b { 352 return a 353 } 354 return b 355 } 356 357 uid := rootless.GetRootlessUID() 358 gid := rootless.GetRootlessGID() 359 360 uids, gids, err := rootless.GetConfiguredMappings() 361 if err != nil { 362 return nil, errors.Wrapf(err, "cannot read mappings") 363 } 364 maxUID, maxGID := 0, 0 365 for _, u := range uids { 366 maxUID += u.Size 367 } 368 for _, g := range gids { 369 maxGID += g.Size 370 } 371 372 options.UIDMap, options.GIDMap = nil, nil 373 374 options.UIDMap = append(options.UIDMap, idtools.IDMap{ContainerID: 0, HostID: 1, Size: min(uid, maxUID)}) 375 options.UIDMap = append(options.UIDMap, idtools.IDMap{ContainerID: uid, HostID: 0, Size: 1}) 376 if maxUID > uid { 377 options.UIDMap = append(options.UIDMap, idtools.IDMap{ContainerID: uid + 1, HostID: uid + 1, Size: maxUID - uid}) 378 } 379 380 options.GIDMap = append(options.GIDMap, idtools.IDMap{ContainerID: 0, HostID: 1, Size: min(gid, maxGID)}) 381 options.GIDMap = append(options.GIDMap, idtools.IDMap{ContainerID: gid, HostID: 0, Size: 1}) 382 if maxGID > gid { 383 options.GIDMap = append(options.GIDMap, idtools.IDMap{ContainerID: gid + 1, HostID: gid + 1, Size: maxGID - gid}) 384 } 385 386 options.HostUIDMapping = false 387 options.HostGIDMapping = false 388 } 389 // Simply ignore the setting and do not setup an inner namespace for root as it is a no-op 390 return &options, nil 391 } 392 393 if subGIDMap == "" && subUIDMap != "" { 394 subGIDMap = subUIDMap 395 } 396 if subUIDMap == "" && subGIDMap != "" { 397 subUIDMap = subGIDMap 398 } 399 if len(gidMapSlice) == 0 && len(uidMapSlice) != 0 { 400 gidMapSlice = uidMapSlice 401 } 402 if len(uidMapSlice) == 0 && len(gidMapSlice) != 0 { 403 uidMapSlice = gidMapSlice 404 } 405 if len(uidMapSlice) == 0 && subUIDMap == "" && os.Getuid() != 0 { 406 uidMapSlice = []string{fmt.Sprintf("0:%d:1", os.Getuid())} 407 } 408 if len(gidMapSlice) == 0 && subGIDMap == "" && os.Getuid() != 0 { 409 gidMapSlice = []string{fmt.Sprintf("0:%d:1", os.Getgid())} 410 } 411 412 if subUIDMap != "" && subGIDMap != "" { 413 mappings, err := idtools.NewIDMappings(subUIDMap, subGIDMap) 414 if err != nil { 415 return nil, err 416 } 417 options.UIDMap = mappings.UIDs() 418 options.GIDMap = mappings.GIDs() 419 } 420 parsedUIDMap, err := idtools.ParseIDMap(uidMapSlice, "UID") 421 if err != nil { 422 return nil, err 423 } 424 parsedGIDMap, err := idtools.ParseIDMap(gidMapSlice, "GID") 425 if err != nil { 426 return nil, err 427 } 428 options.UIDMap = append(options.UIDMap, parsedUIDMap...) 429 options.GIDMap = append(options.GIDMap, parsedGIDMap...) 430 if len(options.UIDMap) > 0 { 431 options.HostUIDMapping = false 432 } 433 if len(options.GIDMap) > 0 { 434 options.HostGIDMapping = false 435 } 436 return &options, nil 437 } 438 439 var ( 440 rootlessConfigHomeDirOnce sync.Once 441 rootlessConfigHomeDir string 442 rootlessRuntimeDirOnce sync.Once 443 rootlessRuntimeDir string 444 ) 445 446 type tomlOptionsConfig struct { 447 MountProgram string `toml:"mount_program"` 448 } 449 450 type tomlConfig struct { 451 Storage struct { 452 Driver string `toml:"driver"` 453 RunRoot string `toml:"runroot"` 454 GraphRoot string `toml:"graphroot"` 455 Options struct{ tomlOptionsConfig } `toml:"options"` 456 } `toml:"storage"` 457 } 458 459 func getTomlStorage(storeOptions *storage.StoreOptions) *tomlConfig { 460 config := new(tomlConfig) 461 462 config.Storage.Driver = storeOptions.GraphDriverName 463 config.Storage.RunRoot = storeOptions.RunRoot 464 config.Storage.GraphRoot = storeOptions.GraphRoot 465 for _, i := range storeOptions.GraphDriverOptions { 466 s := strings.Split(i, "=") 467 if s[0] == "overlay.mount_program" { 468 config.Storage.Options.MountProgram = s[1] 469 } 470 } 471 472 return config 473 } 474 475 // WriteStorageConfigFile writes the configuration to a file 476 func WriteStorageConfigFile(storageOpts *storage.StoreOptions, storageConf string) error { 477 if err := os.MkdirAll(filepath.Dir(storageConf), 0755); err != nil { 478 return err 479 } 480 storageFile, err := os.OpenFile(storageConf, os.O_RDWR|os.O_TRUNC, 0600) 481 if err != nil { 482 return errors.Wrapf(err, "cannot open %s", storageConf) 483 } 484 tomlConfiguration := getTomlStorage(storageOpts) 485 defer errorhandling.CloseQuiet(storageFile) 486 enc := toml.NewEncoder(storageFile) 487 if err := enc.Encode(tomlConfiguration); err != nil { 488 if err := os.Remove(storageConf); err != nil { 489 logrus.Errorf("unable to remove file %s", storageConf) 490 } 491 return err 492 } 493 return nil 494 } 495 496 // ParseInputTime takes the users input and to determine if it is valid and 497 // returns a time format and error. The input is compared to known time formats 498 // or a duration which implies no-duration 499 func ParseInputTime(inputTime string) (time.Time, error) { 500 timeFormats := []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02T15:04:05.999999999", 501 "2006-01-02Z07:00", "2006-01-02"} 502 // iterate the supported time formats 503 for _, tf := range timeFormats { 504 t, err := time.Parse(tf, inputTime) 505 if err == nil { 506 return t, nil 507 } 508 } 509 510 // input might be a duration 511 duration, err := time.ParseDuration(inputTime) 512 if err != nil { 513 return time.Time{}, errors.Errorf("unable to interpret time value") 514 } 515 return time.Now().Add(-duration), nil 516 } 517 518 // GetGlobalOpts checks all global flags and generates the command string 519 // FIXME: Port input to config.Config 520 // TODO: Is there a "better" way to reverse values to flags? This seems brittle. 521 func GetGlobalOpts(c *cliconfig.RunlabelValues) string { 522 globalFlags := map[string]bool{ 523 "cgroup-manager": true, "cni-config-dir": true, "conmon": true, "default-mounts-file": true, 524 "hooks-dir": true, "namespace": true, "root": true, "runroot": true, 525 "runtime": true, "storage-driver": true, "storage-opt": true, "syslog": true, 526 "trace": true, "network-cmd-path": true, "config": true, "cpu-profile": true, 527 "log-level": true, "tmpdir": true} 528 const stringSliceType string = "stringSlice" 529 530 var optsCommand []string 531 c.PodmanCommand.Command.Flags().VisitAll(func(f *pflag.Flag) { 532 if !f.Changed { 533 return 534 } 535 if _, exist := globalFlags[f.Name]; exist { 536 if f.Value.Type() == stringSliceType { 537 flagValue := strings.TrimSuffix(strings.TrimPrefix(f.Value.String(), "["), "]") 538 for _, value := range strings.Split(flagValue, ",") { 539 optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, value)) 540 } 541 } else { 542 optsCommand = append(optsCommand, fmt.Sprintf("--%s %s", f.Name, f.Value.String())) 543 } 544 } 545 }) 546 return strings.Join(optsCommand, " ") 547 } 548 549 // OpenExclusiveFile opens a file for writing and ensure it doesn't already exist 550 func OpenExclusiveFile(path string) (*os.File, error) { 551 baseDir := filepath.Dir(path) 552 if baseDir != "" { 553 if _, err := os.Stat(baseDir); err != nil { 554 return nil, err 555 } 556 } 557 return os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) 558 } 559 560 // PullType whether to pull new image 561 type PullType int 562 563 const ( 564 // PullImageAlways always try to pull new image when create or run 565 PullImageAlways PullType = iota 566 // PullImageMissing pulls image if it is not locally 567 PullImageMissing 568 // PullImageNever will never pull new image 569 PullImageNever 570 ) 571 572 // ValidatePullType check if the pullType from CLI is valid and returns the valid enum type 573 // if the value from CLI is invalid returns the error 574 func ValidatePullType(pullType string) (PullType, error) { 575 switch pullType { 576 case "always": 577 return PullImageAlways, nil 578 case "missing": 579 return PullImageMissing, nil 580 case "never": 581 return PullImageNever, nil 582 case "": 583 return PullImageMissing, nil 584 default: 585 return PullImageMissing, errors.Errorf("invalid pull type %q", pullType) 586 } 587 } 588 589 // ExitCode reads the error message when failing to executing container process 590 // and then returns 0 if no error, 126 if command does not exist, or 127 for 591 // all other errors 592 func ExitCode(err error) int { 593 if err == nil { 594 return 0 595 } 596 e := strings.ToLower(err.Error()) 597 if strings.Contains(e, "file not found") || 598 strings.Contains(e, "no such file or directory") { 599 return 127 600 } 601 602 return 126 603 } 604 605 // HomeDir returns the home directory for the current user. 606 func HomeDir() (string, error) { 607 home := os.Getenv("HOME") 608 if home == "" { 609 usr, err := user.LookupId(fmt.Sprintf("%d", rootless.GetRootlessUID())) 610 if err != nil { 611 return "", errors.Wrapf(err, "unable to resolve HOME directory") 612 } 613 home = usr.HomeDir 614 } 615 return home, nil 616 } 617 618 func Tmpdir() string { 619 tmpdir := os.Getenv("TMPDIR") 620 if tmpdir == "" { 621 tmpdir = "/var/tmp" 622 } 623 624 return tmpdir 625 } 626 627 // ValidateSysctls validates a list of sysctl and returns it. 628 func ValidateSysctls(strSlice []string) (map[string]string, error) { 629 sysctl := make(map[string]string) 630 validSysctlMap := map[string]bool{ 631 "kernel.msgmax": true, 632 "kernel.msgmnb": true, 633 "kernel.msgmni": true, 634 "kernel.sem": true, 635 "kernel.shmall": true, 636 "kernel.shmmax": true, 637 "kernel.shmmni": true, 638 "kernel.shm_rmid_forced": true, 639 } 640 validSysctlPrefixes := []string{ 641 "net.", 642 "fs.mqueue.", 643 } 644 645 for _, val := range strSlice { 646 foundMatch := false 647 arr := strings.Split(val, "=") 648 if len(arr) < 2 { 649 return nil, errors.Errorf("%s is invalid, sysctl values must be in the form of KEY=VALUE", val) 650 } 651 if validSysctlMap[arr[0]] { 652 sysctl[arr[0]] = arr[1] 653 continue 654 } 655 656 for _, prefix := range validSysctlPrefixes { 657 if strings.HasPrefix(arr[0], prefix) { 658 sysctl[arr[0]] = arr[1] 659 foundMatch = true 660 break 661 } 662 } 663 if !foundMatch { 664 return nil, errors.Errorf("sysctl '%s' is not whitelisted", arr[0]) 665 } 666 } 667 return sysctl, nil 668 }