From 974ef6da8f5ecc611fe998203d36ac9832113d4d 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 to directory /var/lib/rhc * When script is downloaded, then this script is run as root user. The "/usr/bin/bash" is used for running script. * When running of script is finished, then this script is deleted unless hidden CLI option --keep-artifacts is used * It is not possible to run this command, when system is connected, because it would not be possible tounregister or disconnect system, if configuring was allowed on connected system. The behavior of rhc/subscription-manager would be strange * It 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 | 144 ++++++++++++++++++++++++++++ constants.go | 4 + dist/srpm/rhc.spec.in | 2 + main.go | 34 +++++++ satellite.go | 212 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 396 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..6b7442e --- /dev/null +++ b/configure_cmd.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "github.com/briandowns/spinner" + "github.com/urfave/cli/v2" + "os" + "os/exec" + "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") + } + + uuid, err := getConsumerUUID() + if err != nil { + return fmt.Errorf("unable to get consumer UUID: %s", err) + } + if uuid != "" { + return fmt.Errorf("cannot configure connected system to use satellite; disconnect system firtst") + } + + 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(fmt.Errorf("could not acquire hostname: %w", 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() }() + } + + if satSpinner != nil { + satSpinner.Suffix = fmt.Sprintf(" Connecting to Satellite server: %v", satelliteUrl.Host) + } + + satClient := NewSatelliteClient(satelliteUrl) + _, err = satClient.Ping() + if err != nil { + return cli.Exit(fmt.Errorf("unable to verify that given server is Satellite server: %v", err), 1) + } + + configureSatelliteResult.IsServerSatellite = true + + if satSpinner != nil { + satSpinner.Suffix = fmt.Sprintf(" Downloading configuration from %v", satelliteUrl.Host) + } + satelliteScriptPath, err := satClient.downloadScript(ctx) + if err != nil { + return cli.Exit(fmt.Errorf("could not download satellite bootstrap script: %w", err), 1) + } + + if ctx.Bool("keep-artifacts") != true { + defer func() { + // TODO: If error happens, then log this error + _ = os.Remove(*satelliteScriptPath) + }() + } + + if satSpinner != nil { + satSpinner.Suffix = fmt.Sprintf( + " Configuring '%v' to use Satellite server: %v", + hostname, + satelliteUrl.Host, + ) + } + + // Run the bash script. It should install CA certificate, change configuration of rhsm.conf. + // In theory, it can do almost anything. + cmd := exec.Command("/usr/bin/bash", *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..51d0c9f 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", "rhc") + } 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..1ebbdc0 100644 --- a/main.go +++ b/main.go @@ -174,6 +174,40 @@ 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"}, + }, + &cli.BoolFlag{ + Name: "keep-artifacts", + Usage: "Keep artifacts (downloaded bootstrap script)", + Hidden: true, + Value: false, + Aliases: []string{"k"}, + }, + }, + Before: beforeSatelliteAction, + Action: satelliteAction, + }, + }, + }, { Name: "disconnect", Flags: []cli.Flag{ diff --git a/satellite.go b/satellite.go new file mode 100644 index 0000000..383f760 --- /dev/null +++ b/satellite.go @@ -0,0 +1,212 @@ +package main + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "github.com/urfave/cli/v2" + "io" + "net/http" + "net/url" + "os" + "path/filepath" +) + +// SatellitePingResponse is used for un-marshaling JSON document +// returned from Satellite server +type SatellitePingResponse struct { + Results struct { + Katello struct { + Services struct { + Candlepin struct { + Status string `json:"status"` + DurationMs string `json:"duration_ms"` + } `json:"candlepin"` + } `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 (result ConfigureSatelliteResult) Error() string { + var msg string + switch result.format { + case "json": + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err.Error() + } + msg = string(data) + case "": + break + default: + msg = "error: unsupported document format: " + result.format + } + return msg +} + +// SatelliteHTTPClient represents http httpClient used for communication with +// satellite server +type SatelliteHTTPClient struct { + httpClient *http.Client + satelliteURL *url.URL +} + +// NewSatelliteClient creates instance of SatelliteHTTPClient and +// configure it to use HTTPS +func NewSatelliteClient(satelliteURL *url.URL) *SatelliteHTTPClient { + satClient := SatelliteHTTPClient{} + // 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 + satClient.httpClient = &http.Client{Transport: transport} + satClient.satelliteURL = satelliteURL + return &satClient +} + +// Ping 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 change rhc behavior according detected Satellite version +// https://satellite.sat.engineering.redhat.com/apidoc/v2/ping/server_status.en.html +func (client *SatelliteHTTPClient) Ping() (*SatellitePingResponse, error) { + // Copy URL from struct URL and modify scheme, path and query + satellitePingUrl := *client.satelliteURL + satellitePingUrl.Scheme = "https" + satellitePingUrl.Path = "/api/ping" + satellitePingUrl.RawQuery = "" + + response, err := client.httpClient.Get(satellitePingUrl.String()) + if err != nil { + return nil, fmt.Errorf("ping satellite server failed with error: %v", err) + } + if response.StatusCode != 200 { + return nil, fmt.Errorf("ping satellite server failed with status code %d", response.StatusCode) + } + defer func() { + // TODO: If error happens, then log this error + _ = response.Body.Close() + }() + resBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("unable to read body of ping satellite server response: %v", err) + } + + satellitePingResponse := SatellitePingResponse{} + err = json.Unmarshal(resBody, &satellitePingResponse) + if err != nil { + return nil, fmt.Errorf("unable to parse ping satellite server response: %v", err) + } + + return &satellitePingResponse, nil +} + +// downloadSatelliteScript tries to download script from provided URL to given file +func (client *SatelliteHTTPClient) downloadScript(ctx *cli.Context) (*string, error) { + var scriptFile *os.File + // Create file for script file + satelliteScriptPath := filepath.Join(VarLibDir, "katello-rhsm-consumer") + + scriptFile, err := os.Create(satelliteScriptPath) + if err != nil { + return nil, cli.Exit(fmt.Errorf("could not create %v file: %w", satelliteScriptPath, err), 1) + } + defer func() { + // TODO: If error happens, then log this error + _ = scriptFile.Close() + }() + err = os.Chmod(satelliteScriptPath, 0700) + if err != nil { + return nil, cli.Exit(fmt.Errorf("could not set permissions on %v file: %w", satelliteScriptPath, err), 1) + } + + // Try to get script from Satellite server + response, err := http.Get(client.satelliteURL.String()) + if err != nil { + return nil, fmt.Errorf("could not download file %v : %w", client.satelliteURL.String(), err) + } + + defer func() { + // TODO: If error happens, then log this error + _ = response.Body.Close() + }() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "downloading %v terminated with status: %v", + client.satelliteURL.String(), + response.Status, + ) + } + + numberBytesWritten, err := io.Copy(scriptFile, response.Body) + if err != nil { + return nil, fmt.Errorf("could not write response body to file %v: %w", scriptFile.Name(), err) + } + if numberBytesWritten == 0 { + return nil, fmt.Errorf("zero bytes written from response body to file %v", scriptFile.Name()) + } + + // 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 &satelliteScriptPath, 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 +}