github.com/canonical/ubuntu-image@v0.0.0-20240430122802-2202fe98b290/internal/ppa/ppa.go (about)

     1  // Package ppa manages Private Package Archives sources list.
     2  // It enables adding and removing a PPA on a system.
     3  package ppa
     4  
     5  import (
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"os/exec"
    12  	"path/filepath"
    13  	"strings"
    14  
    15  	"github.com/canonical/ubuntu-image/internal/helper"
    16  	"github.com/canonical/ubuntu-image/internal/imagedefinition"
    17  )
    18  
    19  var (
    20  	httpGet       = http.Get
    21  	ioReadAll     = io.ReadAll
    22  	jsonUnmarshal = json.Unmarshal
    23  	osRemove      = os.Remove
    24  	osRemoveAll   = os.RemoveAll
    25  	osMkdirAll    = os.MkdirAll
    26  	osOpenFile    = os.OpenFile
    27  	execCommand   = exec.Command
    28  
    29  	sourcesListDPath = filepath.Join("etc", "apt", "sources.list.d")
    30  	trustedGPGDPath  = filepath.Join("etc", "apt", "trusted.gpg.d")
    31  	lpBaseURL        = "https://api.launchpad.net"
    32  )
    33  
    34  // PPAInterface is the only interface that should be used outside of this package.
    35  // It defines the behavior of a PPA.
    36  type PPAInterface interface {
    37  	Add(basePath string, debug bool) error
    38  	Remove(basePath string) error
    39  }
    40  
    41  // PPAPrivateInterface defines internal behavior expected from a PPAInterface implementer.
    42  // Even though methods are exporter, they are not meant to be used outside of this package.
    43  type PPAPrivateInterface interface {
    44  	FullName() string
    45  	FileName() string
    46  	FileContent() (string, error)
    47  	ImportKey(basePath string, debug bool) error
    48  	Remove(basePath string) error
    49  }
    50  
    51  // New instantiates the proper PPA implementation based on the deb822 flag
    52  func New(imageDefPPA *imagedefinition.PPA, deb822 bool, series string) PPAInterface {
    53  	basePPA := BasePPA{
    54  		PPA:    imageDefPPA,
    55  		series: series,
    56  	}
    57  
    58  	if deb822 {
    59  		return &PPA{
    60  			PPAPrivateInterface: &Deb822PPA{
    61  				BasePPA: basePPA,
    62  			},
    63  		}
    64  	}
    65  
    66  	return &PPA{
    67  		PPAPrivateInterface: &LegacyPPA{
    68  			BasePPA: basePPA,
    69  		},
    70  	}
    71  }
    72  
    73  // BasePPA holds fields and methods common to every PPAPrivateInterface implementation
    74  type BasePPA struct {
    75  	*imagedefinition.PPA
    76  	series     string
    77  	signingKey string
    78  }
    79  
    80  func (p *BasePPA) FullName() string {
    81  	return p.Name
    82  }
    83  
    84  func (p *BasePPA) name() string {
    85  	return strings.Split(p.Name, "/")[1]
    86  }
    87  
    88  func (p *BasePPA) user() string {
    89  	return strings.Split(p.Name, "/")[0]
    90  }
    91  
    92  func (p *BasePPA) url() string {
    93  	var baseURL string
    94  	if p.Auth == "" {
    95  		baseURL = "https://ppa.launchpadcontent.net"
    96  	} else {
    97  		baseURL = fmt.Sprintf("https://%s@private-ppa.launchpadcontent.net", p.Auth)
    98  	}
    99  	return fmt.Sprintf("%s/%s/%s/ubuntu", baseURL, p.user(), p.name())
   100  }
   101  
   102  // removePPAFile removes the PPA file from the sources.list.d directory
   103  func (p *BasePPA) removePPAFile(basePath string, fileName string) error {
   104  	sourcesListD := filepath.Join(basePath, sourcesListDPath)
   105  	if p.KeepEnabled == nil {
   106  		return imagedefinition.ErrKeepEnabledNil
   107  	}
   108  
   109  	if *p.KeepEnabled {
   110  		return nil
   111  	}
   112  
   113  	ppaFile := filepath.Join(sourcesListD, fileName)
   114  	err := osRemove(ppaFile)
   115  	if err != nil {
   116  		return fmt.Errorf("Error removing %s: %s", ppaFile, err.Error())
   117  	}
   118  	return nil
   119  }
   120  
   121  // importKey fetches and imports the public key of a PPA.
   122  // This function relies on gpg to fetch the key from the keyserver. We cannot reliably get this key
   123  // from Launchpad because it is not publicly accessible for private PPAs.
   124  // If the ascii arg is set to true, the key is also stored dearmored in the signingKey field of p.
   125  func (p *BasePPA) importKey(basePath string, ppaFileName string, ascii bool, debug bool) (err error) {
   126  	trustedGPGD := filepath.Join(basePath, trustedGPGDPath)
   127  	keyFileName := strings.Replace(ppaFileName, ".list", ".gpg", 1)
   128  	keyFilePath := filepath.Join(trustedGPGD, keyFileName)
   129  
   130  	err = p.ensureFingerprint(lpBaseURL)
   131  	if err != nil {
   132  		return err
   133  	}
   134  
   135  	tmpGPGDir, err := p.createTmpGPGDir(basePath)
   136  	if err != nil {
   137  		return err
   138  	}
   139  
   140  	defer func() {
   141  		tmpErr := osRemoveAll(tmpGPGDir)
   142  		if tmpErr != nil {
   143  			if err != nil {
   144  				err = fmt.Errorf("%s after previous error: %w", tmpErr.Error(), err)
   145  			} else {
   146  				err = fmt.Errorf("Error removing temporary gpg directory \"%s\": %s", tmpGPGDir, tmpErr.Error())
   147  			}
   148  		}
   149  	}()
   150  
   151  	tmpASCIIKeyFilName := keyFileName + ".asc"
   152  	tmpASCIIKeyFilePath := filepath.Join(tmpGPGDir, tmpASCIIKeyFilName)
   153  
   154  	commonGPGArgs := []string{
   155  		"--no-default-keyring",
   156  		"--no-options",
   157  		"--batch",
   158  		"--homedir",
   159  		tmpGPGDir,
   160  		"--secret-keyring",
   161  		filepath.Join(tmpGPGDir, "tempring.gpg"),
   162  		"--keyserver",
   163  		"hkp://keyserver.ubuntu.com:80",
   164  	}
   165  	recvKeyArgs := append(commonGPGArgs, "--recv-keys", p.Fingerprint)
   166  
   167  	exportKeyArgs := make([]string, 0)
   168  	exportKeyArgs = append(exportKeyArgs, commonGPGArgs...)
   169  
   170  	if ascii {
   171  		exportKeyArgs = append(exportKeyArgs, "-a", "--output", tmpASCIIKeyFilePath)
   172  	} else {
   173  		exportKeyArgs = append(exportKeyArgs, "--output", keyFilePath)
   174  	}
   175  
   176  	exportKeyArgs = append(exportKeyArgs, "--export", p.Fingerprint)
   177  
   178  	gpgCmds := []*exec.Cmd{
   179  		execCommand(
   180  			"gpg",
   181  			recvKeyArgs...,
   182  		),
   183  		execCommand(
   184  			"gpg",
   185  			exportKeyArgs...,
   186  		),
   187  	}
   188  
   189  	for _, gpgCmd := range gpgCmds {
   190  		gpgOutput := helper.SetCommandOutput(gpgCmd, debug)
   191  		err := gpgCmd.Run()
   192  		if err != nil {
   193  			err = fmt.Errorf("Error running gpg command \"%s\". Error is \"%s\". Full output below:\n%s",
   194  				gpgCmd.String(), err.Error(), gpgOutput.String())
   195  			return err
   196  		}
   197  	}
   198  
   199  	keyBytes := []byte{}
   200  	if ascii {
   201  		keyBytes, err = os.ReadFile(tmpASCIIKeyFilePath)
   202  		if err != nil {
   203  			return err
   204  		}
   205  	}
   206  
   207  	p.signingKey = string(keyBytes)
   208  
   209  	return nil
   210  }
   211  
   212  // ensureFingerprint ensures a non empty fingerprint is set on the PPA object
   213  // Fingerprint for private PPA cannot be fetched, so they have to be provided in
   214  // the configuration.
   215  func (p *BasePPA) ensureFingerprint(baseURL string) error {
   216  	if p.PPA.Fingerprint != "" {
   217  		return nil
   218  	}
   219  	// The YAML schema has already been validated that if no fingerprint is
   220  	// provided, then this is a public PPA. We will get the fingerprint
   221  	// from the Launchpad API
   222  	type lpResponse struct {
   223  		SigningKeyFingerprint string `json:"signing_key_fingerprint"`
   224  		// plus many other fields that aren't needed at the moment
   225  	}
   226  	lpRespContent := &lpResponse{}
   227  
   228  	lpURL := fmt.Sprintf("%s/devel/~%s/+archive/ubuntu/%s", baseURL,
   229  		p.user(), p.name())
   230  
   231  	resp, err := httpGet(lpURL)
   232  	if err != nil {
   233  		return fmt.Errorf("Error getting signing key for ppa \"%s\": %s",
   234  			p.name(), err.Error())
   235  	}
   236  	defer resp.Body.Close()
   237  
   238  	body, err := ioReadAll(resp.Body)
   239  	if err != nil {
   240  		return fmt.Errorf("Error reading signing key for ppa \"%s\": %s",
   241  			p.name(), err.Error())
   242  	}
   243  
   244  	err = jsonUnmarshal(body, lpRespContent)
   245  	if err != nil {
   246  		return fmt.Errorf("Error unmarshalling launchpad API response: %s", err.Error())
   247  	}
   248  
   249  	p.Fingerprint = lpRespContent.SigningKeyFingerprint
   250  
   251  	return nil
   252  }
   253  
   254  func (p *BasePPA) createTmpGPGDir(basePath string) (string, error) {
   255  	tmpGPGDir := filepath.Join(basePath, "tmp", "u-i-gpg")
   256  
   257  	// dirmngr cannot handle a homedir path length of 100 or above
   258  	// Until this is fixed, return a user-friendly error.
   259  	// See LP: #2057885
   260  	if len(tmpGPGDir) >= 100 {
   261  		return "", fmt.Errorf("dirmngr cannot handle a homedir path length of 100 or above. Please move your workdir somewhere else to have a shorter path. Current path: %s", tmpGPGDir)
   262  	}
   263  
   264  	err := osMkdirAll(tmpGPGDir, 0755)
   265  	if err != nil && !os.IsExist(err) {
   266  		return "", fmt.Errorf("Error creating temp dir for gpg imports: %s", err.Error())
   267  	}
   268  	return tmpGPGDir, nil
   269  }
   270  
   271  // PPA is a basic implementation of the PPAPrivateInterface enabling
   272  // the implementation of common behaviors between LegacyPPA and Deb822PPA
   273  type PPA struct {
   274  	PPAPrivateInterface
   275  }
   276  
   277  // Add adds the PPA to the sources.list.d directory and imports the signing key.
   278  func (p *PPA) Add(basePath string, debug bool) error {
   279  	sourcesListD := filepath.Join(basePath, sourcesListDPath)
   280  	err := osMkdirAll(sourcesListD, 0755)
   281  	if err != nil && !os.IsExist(err) {
   282  		return fmt.Errorf("Failed to create apt sources.list.d: %s", err.Error())
   283  	}
   284  
   285  	err = p.ImportKey(basePath, debug)
   286  	if err != nil {
   287  		return fmt.Errorf("Error retrieving signing key for ppa \"%s\": %s",
   288  			p.FullName(), err.Error())
   289  	}
   290  
   291  	var ppaIO *os.File
   292  	ppaFile := filepath.Join(sourcesListD, p.FileName())
   293  	ppaIO, err = osOpenFile(ppaFile, os.O_CREATE|os.O_WRONLY, 0644)
   294  	if err != nil {
   295  		return fmt.Errorf("Error creating %s: %s", ppaFile, err.Error())
   296  	}
   297  	defer ppaIO.Close()
   298  
   299  	content, err := p.FileContent()
   300  	if err != nil {
   301  		return err
   302  	}
   303  
   304  	_, err = ppaIO.Write([]byte(content))
   305  	if err != nil {
   306  		return fmt.Errorf("unable to write ppa file %s: %w", ppaFile, err)
   307  	}
   308  
   309  	return nil
   310  }
   311  
   312  // LegacyPPA implements behaviors to manage PPA the legacy way, specifically:
   313  // - write in a sources.list file
   314  // - manage signing key with gpg in /etc/apt/trusted.gpg.d
   315  type LegacyPPA struct {
   316  	BasePPA
   317  }
   318  
   319  func (p *LegacyPPA) FileName() string {
   320  	return fmt.Sprintf("%s-ubuntu-%s-%s.list", p.user(), p.name(), p.series)
   321  }
   322  
   323  func (p *LegacyPPA) FileContent() (string, error) {
   324  	return fmt.Sprintf("deb %s %s main", p.url(), p.series), nil
   325  }
   326  
   327  func (p *LegacyPPA) ImportKey(basePath string, debug bool) error {
   328  	return p.BasePPA.importKey(basePath, p.FileName(), false, debug)
   329  }
   330  
   331  func (p *LegacyPPA) Remove(basePath string) error {
   332  	err := p.removePPAFile(basePath, p.FileName())
   333  	if err != nil {
   334  		return err
   335  	}
   336  
   337  	trustedGPGD := filepath.Join(basePath, trustedGPGDPath)
   338  	keyFileName := strings.Replace(p.FileName(), ".list", ".gpg", 1)
   339  	keyFilePath := filepath.Join(trustedGPGD, keyFileName)
   340  
   341  	err = osRemove(keyFilePath)
   342  	if err != nil {
   343  		return fmt.Errorf("Error removing %s: %s", keyFilePath, err.Error())
   344  	}
   345  
   346  	return nil
   347  }
   348  
   349  // Deb822PPA implements behaviors to manage PPA in the deb822 format, specifically:
   350  // - write in a <ppa>.sources file, in the deb822 format
   351  // - embed the signing key in the file itself
   352  type Deb822PPA struct {
   353  	BasePPA
   354  }
   355  
   356  func (p *Deb822PPA) FileName() string {
   357  	return fmt.Sprintf("%s-ubuntu-%s-%s.sources", p.user(), p.name(), p.series)
   358  }
   359  
   360  func (p *Deb822PPA) FileContent() (string, error) {
   361  	key, err := p.formatKey(p.BasePPA.signingKey)
   362  	if err != nil {
   363  		return "", err
   364  	}
   365  
   366  	return fmt.Sprintf("Types: deb\n"+
   367  		"URIS: %s\nSuites: %s\nComponents: main\nSigned-By:\n%s\n",
   368  		p.url(), p.series, key), nil
   369  }
   370  
   371  func (p *Deb822PPA) ImportKey(basePath string, debug bool) error {
   372  	return p.BasePPA.importKey(basePath, p.FileName(), true, debug)
   373  }
   374  
   375  func (p *Deb822PPA) Remove(basePath string) error {
   376  	return p.removePPAFile(basePath, p.FileName())
   377  }
   378  
   379  // formatKey formats the signing key for a PPA to be set in a deb822
   380  // formatted Signed-By field.
   381  func (p *Deb822PPA) formatKey(rawKey string) (string, error) {
   382  	rawKey = strings.TrimSpace(rawKey)
   383  	if len(rawKey) == 0 {
   384  		return "", fmt.Errorf("received an empty signing key for PPA %s", p.Name)
   385  	}
   386  
   387  	lines := make([]string, 0)
   388  	for _, l := range strings.Split(rawKey, "\n") {
   389  		if l == "" {
   390  			lines = append(lines, " .")
   391  		} else {
   392  			lines = append(lines, " "+l)
   393  		}
   394  	}
   395  
   396  	return strings.Join(lines, "\n"), nil
   397  }