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 }