From 09ff3c7ad36e994020f96b020e8493077bbf7428 Mon Sep 17 00:00:00 2001 From: Frank Febbraro Date: Thu, 8 Feb 2018 06:42:17 -0800 Subject: [PATCH 1/2] Release 2.1.2 (#143) * Add explicit privilege prompt to improve sudo UX (#138) * Explicitly prompt for privilege escallation * Remove password prompt part of privilege message * Expand sudo detection. * Tidy up timing issues. * Consolidate messaging and avoid newline in verbose. * Cleanup ToString, sudo contains, cover more exec methods. * Lint does not catch all of fmt. * Remove unnecessary password prompt from networking cleanup. * Remove color reset and cat /dev/null to clear route text. * trying a different approach to requesting for admin privs (#144) --- commands/stop.go | 5 ++--- util/logger.go | 39 +++++++++++++++++++++++++++++++-------- util/shell_exec.go | 39 ++++++++++++++++++++++++++++++++++++--- util/slices.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 util/slices.go diff --git a/commands/stop.go b/commands/stop.go index 39807d7..31fde50 100644 --- a/commands/stop.go +++ b/commands/stop.go @@ -3,7 +3,6 @@ package commands import ( "fmt" - "github.com/fatih/color" "github.com/phase2/rig/util" "github.com/urfave/cli" ) @@ -58,15 +57,15 @@ func (cmd *Stop) StopOutrigger() error { } cmd.out.Info("Stopped machine '%s'", cmd.machine.Name) - cmd.out.Spin("Cleaning up local networking (may require your admin password)") + cmd.out.Spin("Cleaning up local networking...") if util.IsWindows() { util.Command("runas", "/noprofile", "/user:Administrator", "route", "DELETE", "172.17.0.0").Run() util.Command("runas", "/noprofile", "/user:Administrator", "route", "DELETE", "172.17.42.1").Run() } else { + util.EscalatePrivilege() util.Command("sudo", "route", "-n", "delete", "-net", "172.17.0.0").Run() util.Command("sudo", "route", "-n", "delete", "-net", "172.17.42.1").Run() } - color.Unset() cmd.out.Info("Networking cleanup completed") return cmd.Success(fmt.Sprintf("Machine '%s' stopped", cmd.machine.Name)) diff --git a/util/logger.go b/util/logger.go index 0095225..0fa30fb 100644 --- a/util/logger.go +++ b/util/logger.go @@ -1,11 +1,11 @@ package util import ( + "fmt" "io/ioutil" "log" "os" - "fmt" "github.com/fatih/color" spun "github.com/slok/gospinner" ) @@ -24,10 +24,11 @@ type logChannels struct { // RigLogger is the global logger object type RigLogger struct { - Channel logChannels - Progress *RigSpinner - IsVerbose bool - Spinning bool + Channel logChannels + Progress *RigSpinner + IsVerbose bool + Spinning bool + Privileged bool } // RigSpinner object wrapper to facilitate our spinner service @@ -51,9 +52,10 @@ func LoggerInit(verbose bool) { Error: log.New(os.Stderr, color.RedString("[ERROR] "), 0), Verbose: log.New(verboseWriter, "[VERBOSE] ", 0), }, - IsVerbose: verbose, - Progress: &RigSpinner{s}, - Spinning: false, + IsVerbose: verbose, + Progress: &RigSpinner{s}, + Spinning: false, + Privileged: false, } } @@ -125,3 +127,24 @@ func (log *RigLogger) Verbose(format string, a ...interface{}) { func (log *RigLogger) Note(format string, a ...interface{}) { log.Channel.Info.Println(fmt.Sprintf(format, a...)) } + +// PrivilegeEscallationPrompt interrupts a running spinner to ensure clear +// prompting to the user for sudo password entry. It is up to the caller to know +// that privilege is needed. This prompt is only displayed on the first privilege +// escallation of a given rig process. +func (log *RigLogger) PrivilegeEscallationPrompt() { + defer func() { log.Privileged = true }() + + if log.Privileged { + return + } + + // This newline ensures the last status before escallation is preserved + // on-screen. It creates extraneous space in verbose mode. + if !log.IsVerbose { + fmt.Println() + } + message := "Administrative privileges needed..." + log.Spin(message) + log.Warning(message) +} diff --git a/util/shell_exec.go b/util/shell_exec.go index 45bf785..2318ad5 100644 --- a/util/shell_exec.go +++ b/util/shell_exec.go @@ -39,6 +39,13 @@ func Convert(cmd *exec.Cmd) Executor { return Executor{cmd} } +// EscalatePrivilege attempts to gain administrative privilege +// @todo identify administrative escallation on Windows. +// E.g., "runas", "/noprofile", "/user:Administrator +func EscalatePrivilege() error { + return Command("sudo", "-v").Run() +} + // PassthruCommand is similar to ForceStreamCommand in that it will issue all output // regardless of verbose mode. Further, this version of the command captures the // exit status of any executed command. This function is intended to simulate @@ -92,36 +99,53 @@ func (x Executor) Execute(forceOutput bool) error { // CombinedOutput runs a command via exec.CombinedOutput() without modification or output of the underlying command. func (x Executor) CombinedOutput() ([]byte, error) { x.Log("Executing") + if out := Logger(); out != nil && x.IsPrivileged() { + out.PrivilegeEscallationPrompt() + defer out.Spin("Resuming operation...") + } return x.cmd.CombinedOutput() } // Run runs a command via exec.Run() without modification or output of the underlying command. func (x Executor) Run() error { x.Log("Executing") + if out := Logger(); out != nil && x.IsPrivileged() { + out.PrivilegeEscallationPrompt() + defer out.Spin("Resuming operation...") + } return x.cmd.Run() } // Output runs a command via exec.Output() without modification or output of the underlying command. func (x Executor) Output() ([]byte, error) { x.Log("Executing") + if out := Logger(); out != nil && x.IsPrivileged() { + out.PrivilegeEscallationPrompt() + defer out.Spin("Resuming operation...") + } return x.cmd.Output() } // Start runs a command via exec.Start() without modification or output of the underlying command. func (x Executor) Start() error { x.Log("Executing") + if out := Logger(); out != nil && x.IsPrivileged() { + out.PrivilegeEscallationPrompt() + defer out.Spin("Resuming operation...") + } return x.cmd.Start() } // Log verbosely logs the command. func (x Executor) Log(tag string) { color.Set(color.FgMagenta) - Logger().Verbose("%s: %s", tag, x.ToString()) + Logger().Verbose("%s: %s", tag, x) color.Unset() } -// ToString converts a Command to a human-readable string with key context details. -func (x Executor) ToString() string { +// String converts a Command to a human-readable string with key context details. +// It is automatically applied in contexts such as fmt functions. +func (x Executor) String() string { context := "" if x.cmd.Dir != "" { context = fmt.Sprintf("(WD: %s", x.cmd.Dir) @@ -137,3 +161,12 @@ func (x Executor) ToString() string { return fmt.Sprintf("%s %s %s", x.cmd.Path, strings.Join(x.cmd.Args[1:], " "), context) } + +// IsPrivileged evaluates the command to determine if administrative privilege +// is required. +// @todo identify administrative escallation on Windows. +// E.g., "runas", "/noprofile", "/user:Administrator +func (x Executor) IsPrivileged() bool { + _, privileged := IndexOfSubstring(x.cmd.Args, "sudo") + return privileged +} diff --git a/util/slices.go b/util/slices.go new file mode 100644 index 0000000..30aa68d --- /dev/null +++ b/util/slices.go @@ -0,0 +1,30 @@ +package util + +import ( + "strings" +) + +// IndexOfString is a general utility function that can find the index of a value +// present in a string slice. The second value is true if the item is found. +func IndexOfString(slice []string, search string) (int, bool) { + for index, elem := range slice { + if elem == search { + return index, true + } + } + + return 0, false +} + +// IndexOfSubstring is a variation on IndexOfString which checks to see if a +// given slice value matches our search string, or if that search string is +// a substring of the element. The second value is true if the item is found. +func IndexOfSubstring(slice []string, search string) (int, bool) { + for index, elem := range slice { + if strings.Contains(elem, search) { + return index, true + } + } + + return 0, false +} From ddf80ffa398789789403be96fe24b45f851c2bfc Mon Sep 17 00:00:00 2001 From: Dave Murray Date: Sun, 10 Jun 2018 14:15:03 -0400 Subject: [PATCH 2/2] Add dns-records --save and --remove option (unix only so far) --- commands/dns-records.go | 216 +++++++++++++++++++++++++++++++++++++--- commands/doctor.go | 4 +- util/filesystem.go | 26 +++++ 3 files changed, 229 insertions(+), 17 deletions(-) diff --git a/commands/dns-records.go b/commands/dns-records.go index 0380617..ea9f15f 100644 --- a/commands/dns-records.go +++ b/commands/dns-records.go @@ -1,9 +1,11 @@ package commands import ( + "encoding/json" "fmt" "io/ioutil" "net/http" + "os" "strings" "github.com/bitly/go-simplejson" @@ -17,12 +19,61 @@ type DNSRecords struct { BaseCommand } +// DNSRecord is the struct for a single DNS entry +type DNSRecord struct { + Id string // nolint + Name string + Image string + IPs []string + TTL int64 + Aliases []string +} + +// DNSRecordsList is an array of DNSRecords +type DNSRecordsList []*DNSRecord + +const ( + unixHostsPreamble = "##+++ added by rig" + unixHostsPostamble = "##--- end rig additions" +) + +func (record *DNSRecord) String() string { + result := "" + for _, ip := range record.IPs { + result += fmt.Sprintf("%s\t%s.%s.%s\n", ip, record.Name, record.Image, "vm") + // attach any aliases too + for _, a := range record.Aliases { + result += fmt.Sprintf("%s\t%s\n", ip, a) + } + } + return result +} + +// String converts a list of DNSRecords to a formatted string +func (hosts DNSRecordsList) String() string { + result := "" + for _, host := range hosts { + result += host.String() + } + return result +} + // Commands returns the operations supported by this command func (cmd *DNSRecords) Commands() []cli.Command { return []cli.Command{ { - Name: "dns-records", - Usage: "List all DNS records for running containers", + Name: "dns-records", + Usage: "List all DNS records for running containers", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "save", + Usage: "Save the DNS records to /etc/hosts or {FIX insert Windows desscription}", + }, + cli.BoolFlag{ + Name: "remove", + Usage: "Remove the DNS records from /etc/hosts or {FIX insert Windows desscription}", + }, + }, Before: cmd.Before, Action: cmd.Run, }, @@ -31,27 +82,31 @@ func (cmd *DNSRecords) Commands() []cli.Command { // Run executes the `rig dns-records` command func (cmd *DNSRecords) Run(c *cli.Context) error { + // Don't require rig to be started to remove records + if c.Bool("remove") { + if c.Bool("save") { + return cmd.Failure("--remove and --save are mutually exclusive", "COMMAND-ERROR", 13) + } + // TODO The VM might have to be up for Windows + return cmd.removeDNSRecords() + } records, err := cmd.LoadRecords() if err != nil { return cmd.Failure(err.Error(), "COMMAND-ERROR", 13) } - for _, record := range records { - for _, ip := range record["IPs"].([]interface{}) { - fmt.Printf("%s\t%s.%s.%s\n", ip, record["Name"], record["Image"], "vm") - // attach any aliases too - for _, a := range record["Aliases"].([]interface{}) { - fmt.Printf("%s\t%s\n", ip, a) - } - } + if c.Bool("save") { + return cmd.saveDNSRecords(records) } + printDNSRecords(records) + return cmd.Success("") } // LoadRecords retrieves the records from DNSDock and processes/return them -func (cmd *DNSRecords) LoadRecords() ([]map[string]interface{}, error) { +func (cmd *DNSRecords) LoadRecords() ([]*DNSRecord, error) { ip, err := util.Command("docker", "inspect", "--format", "{{.NetworkSettings.IPAddress}}", "dnsdock").Output() if err != nil { return nil, fmt.Errorf("failed to discover dnsdock IP address: %s", err) @@ -74,11 +129,142 @@ func (cmd *DNSRecords) LoadRecords() ([]map[string]interface{}, error) { } dnsdockMap, _ := js.Map() - records := []map[string]interface{}{} - for id, value := range dnsdockMap { - record := value.(map[string]interface{}) - record["Id"] = id + records := make([]*DNSRecord, 0, 20) + for id, rawValue := range dnsdockMap { + // Cast rawValue to its actual type + value := rawValue.(map[string]interface{}) + ttl, _ := value["TTL"].(json.Number).Int64() + record := &DNSRecord{ + Id: id, + Name: value["Name"].(string), + Image: value["Image"].(string), + TTL: ttl, + } + record.IPs = make([]string, 0, 10) + for _, ip := range value["IPs"].([]interface{}) { + record.IPs = append(record.IPs, ip.(string)) + } + record.Aliases = make([]string, 0, 10) + for _, alias := range value["Aliases"].([]interface{}) { + record.Aliases = append(record.Aliases, alias.(string)) + } records = append(records, record) } return records, nil } + +func printDNSRecords(records []*DNSRecord) { + for _, record := range records { + fmt.Print(record) + } +} + +// Write the records to /etc/hosts or FIX Windows? +func (cmd *DNSRecords) saveDNSRecords(records []*DNSRecord) error { + if util.IsMac() || util.IsLinux() { + return cmd.saveDNSRecordsUnix(records) + } else if util.IsWindows() { + return cmd.saveDNSRecordsWindows(records) + } + return cmd.Success("Not implemented") +} + +// ⚠ Administrative privileges needed... + +func (cmd *DNSRecords) saveDNSRecordsUnix(records []*DNSRecord) error { + // Both of these are []string + oldHostEntries := util.LoadFile("/etc/hosts") + newHostEntries := stripDNS(oldHostEntries) + // records.String does the formatting, so convert both to a string + oldHosts := strings.Join(oldHostEntries, "\n") + newHosts := strings.Join(newHostEntries, "\n") + "\n" + + unixHostsPreamble + "\n" + + DNSRecordsList(records).String() + + unixHostsPostamble + "\n" + if oldHosts == newHosts { + return cmd.Success("No changes made") + } + return cmd.writeEtcHosts(newHosts) +} + +func (cmd *DNSRecords) saveDNSRecordsWindows(records []*DNSRecord) error { + return cmd.Failure("Not Implemented", "COMMAND-ERROR", 13) +} + +func (cmd *DNSRecords) removeDNSRecords() error { + if util.IsMac() || util.IsLinux() { + return cmd.removeDNSRecordsUnix() + } else if util.IsWindows() { + return cmd.removeDNSRecordsWindows() + } + return cmd.Success("Not implemented") +} + +func (cmd *DNSRecords) removeDNSRecordsUnix() error { + oldHostsEntries := util.LoadFile("/etc/hosts") + newHostsEntries := stripDNS(oldHostsEntries) + oldHosts := strings.Join(oldHostsEntries, "\n") + newHosts := strings.Join(newHostsEntries, "\n") + if oldHosts == newHosts { + return cmd.Success("No changes made") + } + return cmd.writeEtcHosts(newHosts) +} + +func (cmd *DNSRecords) removeDNSRecordsWindows() error { + return cmd.Failure("Not Implemented", "COMMAND-ERROR", 13) +} + +// Save a new version of /etc/hosts, arg is the full text to save +func (cmd *DNSRecords) writeEtcHosts(hostsText string) error { + // Make sure it ends in a newline + if hostsText[len(hostsText)-1] != '\n' { + hostsText += "\n" + } + // Write new version to a temp file + tmpfile, err := ioutil.TempFile("", "rig-hosts") + if err != nil { + return cmd.Failure("Unable to create hosts tempfile: "+err.Error(), "COMMAND-ERROR", 13) + } + tmpname := tmpfile.Name() + defer os.Remove(tmpname) + if _, err := tmpfile.Write([]byte(hostsText)); err != nil { + return cmd.Failure("Unable to write hosts tempfile: ("+tmpname+") "+err.Error(), "COMMAND-ERROR", 13) + } + if err := tmpfile.Close(); err != nil { + return cmd.Failure("Unable to close hosts tempfile: ("+tmpname+") "+err.Error(), "COMMAND-ERROR", 13) + } + // mv it into place. This is safer than trying to write /etc/hosts on the fly. + if err := util.EscalatePrivilege(); err != nil { + return cmd.Failure("Unable to obtain privileges to replace: "+err.Error(), "COMMAND-ERROR", 13) + } + if err := util.Command("sudo", "mv", "-f", tmpname, "/etc/hosts").Run(); err != nil { + return cmd.Failure("Unable to replace /etc/hosts: "+err.Error(), "COMMAND-ERROR", 13) + } + return cmd.Success("/etc/hosts updated") +} + +// Remove a section of the hosts file we previously added +func stripDNS(hosts []string) []string { + const ( + looking = iota + found + ) + results := make([]string, 0, 1000) + state := looking + for _, host := range hosts { + switch state { + case looking: + if host == unixHostsPreamble { + state = found + } else { + results = append(results, host) + } + case found: + if host == unixHostsPostamble { + state = looking + } + } + } + return results +} diff --git a/commands/doctor.go b/commands/doctor.go index fba4675..a607ae1 100644 --- a/commands/doctor.go +++ b/commands/doctor.go @@ -135,9 +135,9 @@ func (cmd *Doctor) Run(c *cli.Context) error { if records, err := dnsRecords.LoadRecords(); err == nil { resolved := false for _, record := range records { - if record["Name"] == "dnsdock" { + if record.Name == "dnsdock" { resolved = true - cmd.out.Info("DNS and routing services are working. DNSDock resolves to %s", record["IPs"]) + cmd.out.Info("DNS and routing services are working. DNSDock resolves to %s", record.IPs) break } } diff --git a/util/filesystem.go b/util/filesystem.go index 28c16af..b03385a 100644 --- a/util/filesystem.go +++ b/util/filesystem.go @@ -1,9 +1,12 @@ package util import ( + "bufio" "fmt" + "io" "os" "path/filepath" + "strings" "github.com/kardianos/osext" ) @@ -51,3 +54,26 @@ func TouchFile(pathToFile string, workingDir string) error { f.Close() return nil } + +// LoadFile loads a file into an array, without the newlines +func LoadFile(filename string) []string { + lines := make([]string, 0, 1000) + f, err := os.Open(filename) + if err != nil { + return lines + } + defer f.Close() + r := bufio.NewReader(f) + for { + switch ln, err := r.ReadString('\n'); err { + case nil: + ln = strings.Replace(ln, "\r", "", -1) + ln = strings.Replace(ln, "\n", "", -1) + lines = append(lines, ln) + case io.EOF: + return lines + default: + fmt.Println(err) + } + } +}