Skip to content

Commit

Permalink
feat: Add support of configuring host to use Satellite server
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
jirihnidek committed Nov 21, 2024
1 parent 2d11822 commit 48699ce
Show file tree
Hide file tree
Showing 3 changed files with 374 additions and 0 deletions.
133 changes: 133 additions & 0 deletions configure_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package main

import (
"fmt"
"github.com/briandowns/spinner"
"github.com/urfave/cli/v2"
"os"
"os/exec"
"path"
"time"
)

const (
varLibDir = "/var/lib/rhc"
)

// beforeSatelliteAction
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: %s", 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 fmt.Errorf("could not parse satellite url: %w", err)
} else {
configureSatelliteResult.SatelliteServerHostname = satelliteUrl.Hostname()
configureSatelliteResult.SatelliteServerScriptUrl = satelliteUrl.String()
}

var s *spinner.Spinner = nil
if uiSettings.isRich {
s = spinner.New(spinner.CharSets[9], 100*time.Millisecond)
s.Suffix = fmt.Sprintf(" Configuring '%v' to use Satellite %v", hostname, satelliteUrl.Host)
s.Start()
// Stop spinner after running function
defer func() { s.Stop() }()
}

err = pingSatelliteServer(satelliteUrl, s)
if err != nil {
return fmt.Errorf("unable to verify that given server is Satellite server: %v", err)
} else {
configureSatelliteResult.IsServerSatellite = true
}

// Create file for script file
satelliteScriptPath := path.Join(varLibDir, "katello-rhsm-consumer")
defer os.Remove(satelliteScriptPath)
scriptFile, err := os.Create(satelliteScriptPath)
if err != nil {
return fmt.Errorf("could not create %v file: %w", satelliteScriptPath, err)
}
defer scriptFile.Close()
err = os.Chmod(satelliteScriptPath, 0700)
if err != nil {
return fmt.Errorf("could not set permissions on %v file: %w", satelliteScriptPath, err)
}

err = downloadSatelliteScript(scriptFile, satelliteUrl, s)
if err != nil {
return fmt.Errorf("could not download satellite bootstrap script: %w", err)
}

if s != nil {
s.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 fmt.Errorf("execution of %v script failed: %w", satelliteScriptPath, err)
} else {
configureSatelliteResult.HostConfigured = true
}

if uiSettings.isRich {
s.Suffix = ""
s.Stop()
}

interactivePrintf("Host '%v' configured to use Satellite server: %v\n", hostname, satelliteUrl.Host)

return cli.Exit(configureSatelliteResult, 0)
}
27 changes: 27 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 host to use 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{
Expand Down
214 changes: 214 additions & 0 deletions satellite.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit 48699ce

Please sign in to comment.