From 2413479bf8989b5b43c4eefeaeb68c00c1fa599a Mon Sep 17 00:00:00 2001 From: Jiri Hnidek Date: Thu, 21 Nov 2024 14:47:32 +0100 Subject: [PATCH] feat: Add support of configuring host to use Satellite server * Card ID: CCT-993 * Introduced new command "configure" and immediately added sub-command "satellite" to this command. "satellite" subcommand has --url CLI option and you can set hostname or URL of server Satellite server. * When valid URL or hostname is provided, then rhc tries to read /api/ping endpoint and it tries to verify that given server is Satellite server * When Satellite server is verified, then it tries to download bootstrap script from /pub/katello-rhsm-consumer * When script is downloaded, then this script is run as root user. * Is little bit risky to run some script downloaded from web server. For this reason we will try to use different approach in the future. Satellite server will provide public REST API endpoints providing CA certs and rendered rhsm.conf. We will simply download these files and install them to system. Unfortunately, these REST API endpoints will be available in some future version of Satellite. * Directory /var/lib/rhc is created during installation of RPM package --- configure_cmd.go | 130 ++++++++++++++++++++++++++++ constants.go | 4 + main.go | 27 ++++++ rhc.spec.in | 2 + satellite.go | 214 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 377 insertions(+) create mode 100644 configure_cmd.go create mode 100644 satellite.go 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/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/rhc.spec.in b/rhc.spec.in index dd80fda..07008e4 100644 --- a/rhc.spec.in +++ b/rhc.spec.in @@ -78,6 +78,7 @@ make PREFIX=%{_prefix} \ 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 @@ -104,6 +105,7 @@ fi %{_mandir}/man1/* %{_unitdir}/* %{_presetdir}/* +%dir %{_sharedstatedir}/rhc %files compat 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 +}