diff --git a/configure_cmd.go b/configure_cmd.go new file mode 100644 index 0000000..3aeb45f --- /dev/null +++ b/configure_cmd.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "github.com/briandowns/spinner" + "github.com/urfave/cli/v2" + "os" + "os/exec" + "path/filepath" + "time" +) + +// beforeSatelliteAction is run before satelliteAction is run. It is used +// for checking CLI options. +func beforeSatelliteAction(ctx *cli.Context) error { + // First check if machine-readable format is used + err := setupFormatOption(ctx) + if err != nil { + return err + } + + satelliteUrlStr := ctx.String("url") + if satelliteUrlStr == "" { + return fmt.Errorf("no url provided using --url CLI option") + } + + return checkForUnknownArgs(ctx) +} + +// satelliteAction tries to get bootstrap script from Satellite server and run it. +// When it is not possible to download the script or running script returns +// non-zero exit code, then error is returned. +// +// It is really risky to download to run some script downloaded from the URL as a +// root user without any restriction. For this reason, we at least check that +// provided URL is URL of Satellite server. +// +// We would like to use different approach in the future. We would like to use +// some API endpoints not restricted by username & password for getting CA certs +// and rendered rhsm.conf, because it would be more secure, but it is not possible ATM +func satelliteAction(ctx *cli.Context) error { + var configureSatelliteResult ConfigureSatelliteResult + configureSatelliteResult.format = ctx.String("format") + satelliteUrlStr := ctx.String("url") + + uid := os.Getuid() + if uid != 0 { + errMsg := "non-root user cannot connect system" + exitCode := 1 + return cli.Exit(fmt.Errorf("error: %satSpinner", errMsg), exitCode) + } + + hostname, err := os.Hostname() + if uiSettings.isMachineReadable { + configureSatelliteResult.Hostname = hostname + } + if err != nil { + exitCode := 1 + if uiSettings.isMachineReadable { + configureSatelliteResult.HostnameError = err.Error() + return cli.Exit(configureSatelliteResult, exitCode) + } else { + return cli.Exit(err, exitCode) + } + } + + satelliteUrl, err := normalizeSatelliteScriptUrl(satelliteUrlStr) + if err != nil { + return cli.Exit(fmt.Errorf("could not parse satellite url: %w", err), 1) + } + + configureSatelliteResult.SatelliteServerHostname = satelliteUrl.Hostname() + configureSatelliteResult.SatelliteServerScriptUrl = satelliteUrl.String() + + var satSpinner *spinner.Spinner = nil + if uiSettings.isRich { + satSpinner = spinner.New(spinner.CharSets[9], 100*time.Millisecond) + satSpinner.Suffix = fmt.Sprintf(" Configuring '%v' to use Satellite %v", hostname, satelliteUrl.Host) + satSpinner.Start() + // Stop spinner after running function + defer func() { satSpinner.Stop() }() + } + + err = pingSatelliteServer(satelliteUrl, satSpinner) + if err != nil { + return cli.Exit(fmt.Errorf("unable to verify that given server is Satellite server: %v", err), 1) + } + + configureSatelliteResult.IsServerSatellite = true + + // Create file for script file + satelliteScriptPath := filepath.Join(VarLibDir, "katello-rhsm-consumer") + defer os.Remove(satelliteScriptPath) + scriptFile, err := os.Create(satelliteScriptPath) + if err != nil { + return cli.Exit(fmt.Errorf("could not create %v file: %w", satelliteScriptPath, err), 1) + } + defer scriptFile.Close() + err = os.Chmod(satelliteScriptPath, 0700) + if err != nil { + return cli.Exit(fmt.Errorf("could not set permissions on %v file: %w", satelliteScriptPath, err), 1) + } + + err = downloadSatelliteScript(scriptFile, satelliteUrl, satSpinner) + if err != nil { + return cli.Exit(fmt.Errorf("could not download satellite bootstrap script: %w", err), 1) + } + + if satSpinner != nil { + satSpinner.Suffix = fmt.Sprintf(" Configuring '%v' to use Satellite server: %v", hostname, satelliteUrl.Host) + } + // Run the script. It should install CA certificate, change configuration of rhsm.conf. + // In theory, it can do almost anything. + cmd := exec.Command(satelliteScriptPath) + err = cmd.Run() + if err != nil { + return cli.Exit(fmt.Errorf("execution of %v script failed: %w", satelliteScriptPath, err), 1) + } + + configureSatelliteResult.HostConfigured = true + + if uiSettings.isRich { + satSpinner.Suffix = "" + satSpinner.Stop() + } + + interactivePrintf("Host '%v' configured to use Satellite server: %v\n", hostname, satelliteUrl.Host) + + return cli.Exit(configureSatelliteResult, 0) +} diff --git a/constants.go b/constants.go index d131d90..21d03fe 100644 --- a/constants.go +++ b/constants.go @@ -39,6 +39,7 @@ var ( SysconfDir string LocalstateDir string DbusInterfacesDir string + VarLibDir string ) func init() { @@ -75,6 +76,9 @@ func init() { if DbusInterfacesDir == "" { DbusInterfacesDir = filepath.Join(DataDir, "dbus-1", "interfaces") } + if VarLibDir == "" { + VarLibDir = filepath.Join(LocalstateDir, "lib") + } if ShortName == "" { ShortName = "rhc" diff --git a/dist/srpm/rhc.spec.in b/dist/srpm/rhc.spec.in index ddf8184..fd13c29 100644 --- a/dist/srpm/rhc.spec.in +++ b/dist/srpm/rhc.spec.in @@ -108,6 +108,7 @@ export %gomodulesmode %meson_install install --directory %{buildroot}%{_unitdir} install --directory %{buildroot}%{_sysconfdir}/rhc +mkdir -p %{buildroot}%{_sharedstatedir}/rhc ln -sf yggdrasil.service %{buildroot}%{_unitdir}/rhcd.service ln -sf ../yggdrasil/config.toml %{buildroot}%{_sysconfdir}/rhc/config.toml @@ -144,6 +145,7 @@ fi %{_mandir}/man1/* %{_unitdir}/rhc-canonical-facts.* %{_presetdir}/* +%dir %{_sharedstatedir}/rhc %files compat %{_unitdir}/rhcd.service diff --git a/main.go b/main.go index 18fd2e2..333fe36 100644 --- a/main.go +++ b/main.go @@ -174,6 +174,33 @@ func main() { Before: beforeConnectAction, Action: connectAction, }, + { + Name: "configure", + Usage: "Configure the host", + UsageText: fmt.Sprintf("%v configure [sub-command]", app.Name), + Subcommands: []*cli.Command{ + { + Name: "satellite", + Usage: "Configure the host to use with Satellite server", + UsageText: fmt.Sprintf("%v configure satellite [command options]", app.Name), + Description: "The satellite sub-command configure the system for connection to a Satellite server", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Usage: "URL of the Satellite server", + Aliases: []string{"u"}, + }, + &cli.StringFlag{ + Name: "format", + Usage: "prints output of satellite configure in machine-readable format (supported formats: \"json\")", + Aliases: []string{"f"}, + }, + }, + Before: beforeSatelliteAction, + Action: satelliteAction, + }, + }, + }, { Name: "disconnect", Flags: []cli.Flag{ diff --git a/satellite.go b/satellite.go new file mode 100644 index 0000000..e1f27a0 --- /dev/null +++ b/satellite.go @@ -0,0 +1,214 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "github.com/briandowns/spinner" + "io" + "net/http" + "net/url" + "os" +) + +type SatellitePingResponse struct { + Results struct { + Foreman struct { + Database struct { + Active bool `json:"active"` + DurationMs string `json:"duration_ms"` + } `json:"database"` + Cache struct { + Servers []struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"servers"` + } `json:"cache"` + } `json:"foreman"` + Katello struct { + Services struct { + Candlepin struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"candlepin"` + CandlepinAuth struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"candlepin_auth"` + ForemanTasks struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"foreman_tasks"` + KatelloEvents struct { + Status string `json:"status"` + Message string `json:"message"` + DurationMs string `json:"duration_ms"` + } `json:"katello_events"` + CandlepinEvents struct { + Status string `json:"status"` + Message string `json:"message"` + DurationMs string `json:"duration_ms"` + } `json:"candlepin_events"` + Pulp3 struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"pulp3"` + Pulp3Content struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"pulp3_content"` + } `json:"services"` + Status string `json:"status"` + } `json:"katello"` + } `json:"results"` +} + +// ConfigureSatelliteResult is structure holding information about results +// of configuring host from Satellite server. The result could be printed +// in machine-readable format. +type ConfigureSatelliteResult struct { + Hostname string `json:"hostname"` + HostnameError string `json:"hostname_error,omitempty"` + IsServerSatellite bool `json:"is_server_satellite_running"` + SatelliteServerHostname string `json:"satellite_server_hostname"` + SatelliteServerScriptUrl string `json:"satellite_server_script_url"` + HostConfigured bool `json:"host_configured"` + format string +} + +// Error implement error interface for structure ConfigureSatelliteResult +func (configureSatelliteResult ConfigureSatelliteResult) Error() string { + var result string + switch configureSatelliteResult.format { + case "json": + data, err := json.MarshalIndent(configureSatelliteResult, "", " ") + if err != nil { + return err.Error() + } + result = string(data) + case "": + break + default: + result = "error: unsupported document format: " + configureSatelliteResult.format + } + return result +} + +// pingSatelliteServer tries to ping Satellite server to be sure that user +// tries to reach get and run bootstrap script from Satellite server. +// We use following endpoint that is available for unauthenticated users: +// https://satellite.sat.engineering.redhat.com/apidoc/v2/ping/ping.en.html +// It is not necessary to try to reach this endpoint, but download and run +// some bash script downloaded from the internet. It is risky action. For +// this reason we do this extra step to minimize risk that some non-intentional +// code is downloaded and run as root user. +// +// In the future we could use following endpoint to determine version of +// Satellite (Katello) and +// https://satellite.sat.engineering.redhat.com/apidoc/v2/ping/server_status.en.html +func pingSatelliteServer(satelliteScriptUrl *url.URL, s *spinner.Spinner) error { + if s != nil { + s.Suffix = fmt.Sprintf(" Connecting Satellite server: %v", satelliteScriptUrl.Host) + } + + // Copy URL from struct URL and modify scheme, path and query + satellitePingUrl := *satelliteScriptUrl + satellitePingUrl.Scheme = "https" + satellitePingUrl.Path = "/api/ping" + satellitePingUrl.RawQuery = "" + + // We have to use insecure HTTPs connection, because most of the customers use + // self-signed certificates + tlsConfig := tls.Config{ + InsecureSkipVerify: true, + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tlsConfig + client := &http.Client{Transport: transport} + + response, err := client.Get(satellitePingUrl.String()) + if err != nil { + return fmt.Errorf("ping satellite server failed with error: %v", err) + } + if response.StatusCode != 200 { + return fmt.Errorf("ping satellite server failed with status code %d", response.StatusCode) + } + defer response.Body.Close() + resBody, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("unable to read body of ping satellite server response: %v", err) + } + + satellitePingResponse := SatellitePingResponse{} + err = json.Unmarshal(resBody, &satellitePingResponse) + if err != nil { + return fmt.Errorf("unable to parse ping satellite server response: %v", err) + } + + // In theory, we could check for the status of candlepin and candlepin auth, etc., + // but we only try to configure host. We do not care about status of candlepin ATM. + // if satellitePingResponse.Results.Katello.Services.Candlepin.Status != "ok" { + // return fmt.Errorf("wrong status of candlepin: %v", + // satellitePingResponse.Results.Katello.Services.Candlepin.Status) + // } + + return nil +} + +// downloadSatelliteScript tries to download script from provided URL to given +// file +func downloadSatelliteScript(scriptFile *os.File, satelliteUrl *url.URL, s *spinner.Spinner) error { + if s != nil { + s.Suffix = fmt.Sprintf(" Downloading configuration from %v", satelliteUrl.Host) + } + + // Try to get script from Satellite server + response, err := http.Get(satelliteUrl.String()) + if err != nil { + return fmt.Errorf("could not download file %v : %w", satelliteUrl.String(), err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("downloading %v terminated with status: %v", satelliteUrl.String(), response.Status) + } + + numberBytesWritten, err := io.Copy(scriptFile, response.Body) + if err != nil { + return fmt.Errorf("could not response body to file %v: %w", scriptFile.Name(), err) + } + if numberBytesWritten == 0 { + return fmt.Errorf("zero bytes written from response body to file %v", scriptFile.Name()) + } + + return nil +} + +// normalizeSatelliteScriptUrl normalize URL of bootstrap script, and it returns +// URL structure, when parsing of URL is successful +func normalizeSatelliteScriptUrl(satelliteUrlStr string) (*url.URL, error) { + satelliteUrl, err := url.Parse(satelliteUrlStr) + if err != nil { + return nil, fmt.Errorf("could not parse satellite url: %w", err) + } + + // It would be better to use "https", but the most of the customers use + // self-signed certificates. + if satelliteUrl.Scheme == "" { + satelliteUrl.Scheme = "http" + } + + // When path is not set the path to standard path to bootstrap script + if satelliteUrl.Path == "" || satelliteUrl.Path == "/" { + satelliteUrl.Path = "/pub/katello-rhsm-consumer" + } else { + // When CLI argument is provided like this --url satellite.company.com, + // then url.Parse parses hostname as a path + if satelliteUrl.Path == satelliteUrlStr { + satelliteUrl.Host = satelliteUrlStr + satelliteUrl.Path = "/pub/katello-rhsm-consumer" + } + } + + return satelliteUrl, nil +}