Files
snapd/bootloader/grub.go
2024-03-13 12:41:32 +01:00

693 lines
21 KiB
Go

// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2014-2024 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package bootloader
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/snapcore/snapd/arch"
"github.com/snapcore/snapd/bootloader/assets"
"github.com/snapcore/snapd/bootloader/grubenv"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/osutil/kcmdline"
"github.com/snapcore/snapd/snap"
"github.com/snapcore/snapd/strutil"
)
// grub implements the required interfaces
var (
_ Bootloader = (*grub)(nil)
_ RecoveryAwareBootloader = (*grub)(nil)
_ ExtractedRunKernelImageBootloader = (*grub)(nil)
_ TrustedAssetsBootloader = (*grub)(nil)
)
type grub struct {
rootdir string
basedir string
uefiRunKernelExtraction bool
recovery bool
nativePartitionLayout bool
prepareImageTime bool
}
// newGrub create a new Grub bootloader object
func newGrub(rootdir string, opts *Options) Bootloader {
g := &grub{rootdir: rootdir}
if opts != nil {
// Set the flag to extract the run kernel, only
// for UC20 run mode.
// Both UC16/18 and the recovery mode of UC20 load
// the kernel directly from snaps.
g.uefiRunKernelExtraction = opts.Role == RoleRunMode
g.recovery = opts.Role == RoleRecovery
g.nativePartitionLayout = opts.NoSlashBoot || g.recovery
g.prepareImageTime = opts.PrepareImageTime
}
if g.nativePartitionLayout {
g.basedir = "EFI/ubuntu"
} else {
g.basedir = "boot/grub"
}
return g
}
func (g *grub) Name() string {
return "grub"
}
func (g *grub) dir() string {
if g.rootdir == "" {
panic("internal error: unset rootdir")
}
return filepath.Join(g.rootdir, g.basedir)
}
func (g *grub) installManagedRecoveryBootConfig() error {
assetName := g.Name() + "-recovery.cfg"
systemFile := filepath.Join(g.rootdir, "/EFI/ubuntu/grub.cfg")
return genericSetBootConfigFromAsset(systemFile, assetName)
}
func (g *grub) installManagedBootConfig() error {
assetName := g.Name() + ".cfg"
systemFile := filepath.Join(g.rootdir, "/EFI/ubuntu/grub.cfg")
return genericSetBootConfigFromAsset(systemFile, assetName)
}
func (g *grub) InstallBootConfig(gadgetDir string, opts *Options) error {
if opts != nil && opts.Role == RoleRecovery {
// install managed config for the recovery partition
return g.installManagedRecoveryBootConfig()
}
if opts != nil && opts.Role == RoleRunMode {
// install managed boot config that can handle kernel.efi
return g.installManagedBootConfig()
}
gadgetFile := filepath.Join(gadgetDir, g.Name()+".conf")
systemFile := filepath.Join(g.rootdir, "/boot/grub/grub.cfg")
return genericInstallBootConfig(gadgetFile, systemFile)
}
func (g *grub) SetRecoverySystemEnv(recoverySystemDir string, values map[string]string) error {
if recoverySystemDir == "" {
return fmt.Errorf("internal error: recoverySystemDir unset")
}
recoverySystemGrubEnv := filepath.Join(g.rootdir, recoverySystemDir, "grubenv")
if err := os.MkdirAll(filepath.Dir(recoverySystemGrubEnv), 0755); err != nil {
return err
}
genv := grubenv.NewEnv(recoverySystemGrubEnv)
for k, v := range values {
genv.Set(k, v)
}
return genv.Save()
}
func (g *grub) GetRecoverySystemEnv(recoverySystemDir string, key string) (string, error) {
if recoverySystemDir == "" {
return "", fmt.Errorf("internal error: recoverySystemDir unset")
}
recoverySystemGrubEnv := filepath.Join(g.rootdir, recoverySystemDir, "grubenv")
genv := grubenv.NewEnv(recoverySystemGrubEnv)
if err := genv.Load(); err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
return genv.Get(key), nil
}
func (g *grub) Present() (bool, error) {
return osutil.FileExists(filepath.Join(g.dir(), "grub.cfg")), nil
}
func (g *grub) envFile() string {
return filepath.Join(g.dir(), "grubenv")
}
func (g *grub) GetBootVars(names ...string) (map[string]string, error) {
out := make(map[string]string)
env := grubenv.NewEnv(g.envFile())
if err := env.Load(); err != nil {
return nil, err
}
for _, name := range names {
out[name] = env.Get(name)
}
return out, nil
}
func (g *grub) SetBootVars(values map[string]string) error {
env := grubenv.NewEnv(g.envFile())
if err := env.Load(); err != nil && !os.IsNotExist(err) {
return err
}
for k, v := range values {
env.Set(k, v)
}
return env.Save()
}
func (g *grub) extractedKernelDir(prefix string, s snap.PlaceInfo) string {
return filepath.Join(
prefix,
s.Filename(),
)
}
func (g *grub) ExtractKernelAssets(s snap.PlaceInfo, snapf snap.Container) error {
// default kernel assets are:
// - kernel.img
// - initrd.img
// - dtbs/*
var assets []string
if g.uefiRunKernelExtraction {
assets = []string{"kernel.efi"}
} else {
assets = []string{"kernel.img", "initrd.img", "dtbs/*"}
}
// extraction can be forced through either a special file in the kernel snap
// or through an option in the bootloader
_, err := snapf.ReadFile("meta/force-kernel-extraction")
if g.uefiRunKernelExtraction || err == nil {
return extractKernelAssetsToBootDir(
g.extractedKernelDir(g.dir(), s),
snapf,
assets,
)
}
return nil
}
func (g *grub) RemoveKernelAssets(s snap.PlaceInfo) error {
return removeKernelAssetsFromBootDir(g.dir(), s)
}
// ExtractedRunKernelImageBootloader helper methods
func (g *grub) makeKernelEfiSymlink(s snap.PlaceInfo, name string) error {
// use a relative symlink destination so that it resolves properly, if grub
// is located at /run/mnt/ubuntu-boot or /boot/grub, etc.
target := filepath.Join(
s.Filename(),
"kernel.efi",
)
// the location of the destination symlink as an absolute filepath
source := filepath.Join(g.dir(), name)
// check that the kernel snap has been extracted already so we don't
// inadvertently create a dangling symlink
// expand the relative symlink from g.dir()
if !osutil.FileExists(filepath.Join(g.dir(), target)) {
return fmt.Errorf(
"cannot enable %s at %s: %v",
name,
target,
os.ErrNotExist,
)
}
// the symlink doesn't exist so just create it
return osutil.AtomicSymlink(target, source)
}
// unlinkKernelEfiSymlink will remove the specified symlink if it exists. Note
// that if the symlink is "dangling", it will still remove the symlink without
// returning an error. This is useful for example to disable a try-kernel that
// was incorrectly created.
func (g *grub) unlinkKernelEfiSymlink(name string) error {
symlink := filepath.Join(g.dir(), name)
err := os.Remove(symlink)
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (g *grub) readKernelSymlink(name string) (snap.PlaceInfo, error) {
// read the symlink from <grub-dir>/<name> to
// <grub-dir>/<snap-file-name>/<name> and parse the
// directory (which is supposed to be the name of the snap) into the snap
link := filepath.Join(g.dir(), name)
// check that the symlink is not dangling before continuing
if !osutil.FileExists(link) {
return nil, fmt.Errorf("cannot read dangling symlink %s", name)
}
targetKernelEfi, err := os.Readlink(link)
if err != nil {
return nil, fmt.Errorf("cannot read %s symlink: %v", link, err)
}
kernelSnapFileName := filepath.Base(filepath.Dir(targetKernelEfi))
sn, err := snap.ParsePlaceInfoFromSnapFileName(kernelSnapFileName)
if err != nil {
return nil, fmt.Errorf(
"cannot parse kernel snap file name from symlink target %q: %v",
kernelSnapFileName,
err,
)
}
return sn, nil
}
// actual ExtractedRunKernelImageBootloader methods
// EnableKernel will install a kernel.efi symlink in the bootloader partition,
// pointing to the referenced kernel snap. EnableKernel() will fail if the
// referenced kernel snap does not exist.
func (g *grub) EnableKernel(s snap.PlaceInfo) error {
// add symlink from ubuntuBootPartition/kernel.efi to
// <ubuntu-boot>/EFI/ubuntu/<snap-name>.snap/kernel.efi
// so that we are consistent between uc16/uc18 and uc20 with where we
// extract kernels
return g.makeKernelEfiSymlink(s, "kernel.efi")
}
// EnableTryKernel will install a try-kernel.efi symlink in the bootloader
// partition, pointing towards the referenced kernel snap. EnableTryKernel()
// will fail if the referenced kernel snap does not exist.
func (g *grub) EnableTryKernel(s snap.PlaceInfo) error {
// add symlink from ubuntuBootPartition/kernel.efi to
// <ubuntu-boot>/EFI/ubuntu/<snap-name>.snap/kernel.efi
// so that we are consistent between uc16/uc18 and uc20 with where we
// extract kernels
return g.makeKernelEfiSymlink(s, "try-kernel.efi")
}
// DisableTryKernel will remove the try-kernel.efi symlink if it exists. Note
// that when performing an update, you should probably first use EnableKernel(),
// then DisableTryKernel() for maximum safety.
func (g *grub) DisableTryKernel() error {
return g.unlinkKernelEfiSymlink("try-kernel.efi")
}
// Kernel will return the kernel snap currently installed in the bootloader
// partition, pointed to by the kernel.efi symlink.
func (g *grub) Kernel() (snap.PlaceInfo, error) {
return g.readKernelSymlink("kernel.efi")
}
// TryKernel will return the kernel snap currently being tried if it exists and
// false if there is not currently a try-kernel.efi symlink. Note if the symlink
// exists but does not point to an existing file an error will be returned.
func (g *grub) TryKernel() (snap.PlaceInfo, error) {
// check that the _symlink_ exists, not that it points to something real
// we check for whether it is a dangling symlink inside readKernelSymlink,
// which returns an error when the symlink is dangling
_, err := os.Lstat(filepath.Join(g.dir(), "try-kernel.efi"))
if err == nil {
p, err := g.readKernelSymlink("try-kernel.efi")
// if we failed to read the symlink, then the try kernel isn't usable,
// so return err because the symlink is there
if err != nil {
return nil, err
}
return p, nil
}
return nil, ErrNoTryKernelRef
}
// UpdateBootConfig updates the grub boot config only if it is already managed
// and has a lower edition.
//
// Implements TrustedAssetsBootloader for the grub bootloader.
func (g *grub) UpdateBootConfig() (bool, error) {
// XXX: do we need to take opts here?
bootScriptName := "grub.cfg"
currentBootConfig := filepath.Join(g.dir(), "grub.cfg")
if g.recovery {
// use the recovery asset when asked to do so
bootScriptName = "grub-recovery.cfg"
}
return genericUpdateBootConfigFromAssets(currentBootConfig, bootScriptName)
}
// ManagedAssets returns a list relative paths to boot assets inside the root
// directory of the filesystem.
//
// Implements TrustedAssetsBootloader for the grub bootloader.
func (g *grub) ManagedAssets() []string {
return []string{
filepath.Join(g.basedir, "grub.cfg"),
}
}
func (g *grub) commandLineForEdition(edition uint, pieces CommandLineComponents) (string, error) {
if err := pieces.Validate(); err != nil {
return "", err
}
var nonSnapdCmdline string
if pieces.FullArgs == "" {
staticCmdline := g.defaultCommandLineForEdition(edition)
keepDefaultArgs := kcmdline.RemoveMatchingFilter(staticCmdline, pieces.RemoveArgs)
nonSnapdCmdline = strutil.JoinNonEmpty(append(keepDefaultArgs, pieces.ExtraArgs), " ")
} else {
nonSnapdCmdline = pieces.FullArgs
}
args, err := kcmdline.Split(nonSnapdCmdline)
if err != nil {
return "", fmt.Errorf("cannot use badly formatted kernel command line: %v", err)
}
// join all argument with a single space, see
// grub-core/lib/cmdline.c:grub_create_loader_cmdline() for reference,
// arguments are separated by a single space, the space after last is
// replaced with terminating NULL
snapdArgs := make([]string, 0, 2)
if pieces.ModeArg != "" {
snapdArgs = append(snapdArgs, pieces.ModeArg)
}
if pieces.SystemArg != "" {
snapdArgs = append(snapdArgs, pieces.SystemArg)
}
return strings.Join(append(snapdArgs, args...), " "), nil
}
func (g *grub) assetName() string {
if g.recovery {
return "grub-recovery.cfg"
}
return "grub.cfg"
}
func (g *grub) defaultCommandLineForEdition(edition uint) string {
return staticCommandLineForGrubAssetEdition(g.assetName(), edition)
}
func editionFromDiskConfigAssetFallback(bootConfig string) (uint, error) {
edition, err := editionFromDiskConfigAsset(bootConfig)
if err != nil {
if err != errNoEdition {
return 0, err
}
// we were called using the TrustedAssetsBootloader interface
// meaning the caller expects to us to use the managed assets,
// since one on disk is not managed, use the initial edition of
// the internal boot asset which is compatible with grub.cfg
// used before we started writing out the files ourselves
edition = 1
}
return edition, nil
}
// CommandLine returns the kernel command line composed of mode and
// system arguments, followed by either a built-in bootloader specific
// static arguments corresponding to the on-disk boot asset edition, and
// any extra arguments or a separate set of arguments provided in the
// components. The command line may be different when using a recovery
// bootloader.
//
// Implements TrustedAssetsBootloader for the grub bootloader.
func (g *grub) CommandLine(pieces CommandLineComponents) (string, error) {
currentBootConfig := filepath.Join(g.dir(), "grub.cfg")
edition, err := editionFromDiskConfigAssetFallback(currentBootConfig)
if err != nil {
return "", fmt.Errorf("cannot obtain edition number of current boot config: %v", err)
}
return g.commandLineForEdition(edition, pieces)
}
// CandidateCommandLine is similar to CommandLine, but uses the current
// edition of managed built-in boot assets as reference.
//
// Implements TrustedAssetsBootloader for the grub bootloader.
func (g *grub) CandidateCommandLine(pieces CommandLineComponents) (string, error) {
edition, err := editionFromInternalConfigAsset(g.assetName())
if err != nil {
return "", err
}
return g.commandLineForEdition(edition, pieces)
}
// DefaultCommandLine returns the default kernel command-line used by
// the bootloader excluding the recovery mode and system parameters.
func (g *grub) DefaultCommandLine(candidate bool) (string, error) {
var edition uint
// if "candidate", we look for the managed boot assets
// (current snapd) rather than the ones currently installed on
// the boot/seed disk. This is needed to know the default
// command line before candidate boot assets are installed
if candidate {
var err error
edition, err = editionFromInternalConfigAsset(g.assetName())
if err != nil {
return "", err
}
} else {
currentBootConfig := filepath.Join(g.dir(), "grub.cfg")
var err error
edition, err = editionFromDiskConfigAssetFallback(currentBootConfig)
if err != nil {
return "", fmt.Errorf("cannot obtain edition number of current boot config: %v", err)
}
}
return g.defaultCommandLineForEdition(edition), nil
}
// staticCommandLineForGrubAssetEdition fetches a static command line for given
// grub asset edition
func staticCommandLineForGrubAssetEdition(asset string, edition uint) string {
cmdline := assets.SnippetForEdition(fmt.Sprintf("%s:static-cmdline", asset), edition)
if cmdline == nil {
return ""
}
return string(cmdline)
}
type taggedPath struct {
tag string
path string
}
func (t taggedPath) Id() string {
basename := filepath.Base(t.path)
if t.tag == "" {
return basename
}
return fmt.Sprintf("%s:%s", t.tag, basename)
}
// grubBootAssetPath contains the paths for assets in the boot chain.
type grubBootAssetPath struct {
defaultShimBinary taggedPath
defaultGrubBinary taggedPath
fallbackBinary taggedPath
shimBinary taggedPath
grubBinary taggedPath
}
// grubBootAssetsForArch contains the paths for assets for different
// architectures in a map.
// For backward compliance, we do not have tags
// for asset paths that used to exist before usage of tags.
var grubBootAssetsForArch = map[string]grubBootAssetPath{
"amd64": {
defaultShimBinary: taggedPath{
path: filepath.Join("EFI/boot/", "bootx64.efi"),
},
defaultGrubBinary: taggedPath{
path: filepath.Join("EFI/boot/", "grubx64.efi"),
},
fallbackBinary: taggedPath{
tag: "boot",
path: filepath.Join("EFI/boot/", "fbx64.efi"),
},
shimBinary: taggedPath{
tag: "ubuntu",
path: filepath.Join("EFI/ubuntu/", "shimx64.efi"),
},
grubBinary: taggedPath{
tag: "ubuntu",
path: filepath.Join("EFI/ubuntu/", "grubx64.efi"),
},
},
"arm64": {
defaultShimBinary: taggedPath{
path: filepath.Join("EFI/boot/", "bootaa64.efi"),
},
defaultGrubBinary: taggedPath{
path: filepath.Join("EFI/boot/", "grubaa64.efi"),
},
fallbackBinary: taggedPath{
tag: "boot",
path: filepath.Join("EFI/boot/", "fbaa64.efi"),
},
shimBinary: taggedPath{
tag: "ubuntu",
path: filepath.Join("EFI/ubuntu/", "shimaa64.efi"),
},
grubBinary: taggedPath{
tag: "ubuntu",
path: filepath.Join("EFI/ubuntu/", "grubaa64.efi"),
},
},
}
func (g *grub) getGrubBootAssetsForArch() (*grubBootAssetPath, error) {
if g.prepareImageTime {
return nil, fmt.Errorf("internal error: retrieving boot assets at prepare image time")
}
archi := arch.DpkgArchitecture()
assets, ok := grubBootAssetsForArch[archi]
if !ok {
return nil, fmt.Errorf("cannot find grub assets for %q", archi)
}
return &assets, nil
}
// getGrubRecoveryModeTrustedAssets returns the list of ordered asset
// chain for recovery mode, which are shim and grub from the seed
// partition.
func (g *grub) getGrubRecoveryModeTrustedAssets() ([][]taggedPath, error) {
assets, err := g.getGrubBootAssetsForArch()
if err != nil {
return nil, err
}
return [][]taggedPath{{assets.shimBinary, assets.grubBinary}, {assets.defaultShimBinary, assets.defaultGrubBinary}}, nil
}
// getGrubRunModeTrustedAssets returns the list of ordered asset
// chains for run mode, which is grub from the boot partition.
func (g *grub) getGrubRunModeTrustedAssets() ([][]taggedPath, error) {
assets, err := g.getGrubBootAssetsForArch()
if err != nil {
return nil, err
}
return [][]taggedPath{{assets.defaultGrubBinary}}, nil
}
// TrustedAssets returns the map of relative paths to asset
// identifers. The relative paths are relative to the bootloader's
// rootdir. The asset identifiers correspond to the backward
// compatible names recorded in the modeenv (CurrentTrustedBootAssets
// and CurrentTrustedRecoveryBootAssets).
func (g *grub) TrustedAssets() (map[string]string, error) {
if !g.nativePartitionLayout {
return nil, fmt.Errorf("internal error: trusted assets called without native host-partition layout")
}
ret := make(map[string]string)
var chains [][]taggedPath
var err error
if g.recovery {
chains, err = g.getGrubRecoveryModeTrustedAssets()
} else {
chains, err = g.getGrubRunModeTrustedAssets()
}
if err != nil {
return nil, err
}
for _, chain := range chains {
for _, asset := range chain {
ret[asset.path] = asset.Id()
}
}
return ret, nil
}
// RecoveryBootChains returns the list of load chains for recovery modes.
// It should be called on a RoleRecovery bootloader.
func (g *grub) RecoveryBootChains(kernelPath string) ([][]BootFile, error) {
if !g.recovery {
return nil, fmt.Errorf("not a recovery bootloader")
}
// add trusted assets to the recovery chain
assetsSet, err := g.getGrubRecoveryModeTrustedAssets()
if err != nil {
return nil, err
}
chains := make([][]BootFile, 0, len(assetsSet))
for _, assets := range assetsSet {
chain := make([]BootFile, 0, len(assets)+1)
for _, ta := range assets {
chain = append(chain, NewBootFile("", ta.path, RoleRecovery))
}
// add recovery kernel to the recovery chain
chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRecovery))
chains = append(chains, chain)
}
return chains, nil
}
// BootChains returns the list of load chains for run mode.
// It should be called on a RoleRecovery bootloader passing the
// RoleRunMode bootloader.
func (g *grub) BootChains(runBl Bootloader, kernelPath string) ([][]BootFile, error) {
if !g.recovery {
return nil, fmt.Errorf("not a recovery bootloader")
}
if runBl.Name() != "grub" {
return nil, fmt.Errorf("run mode bootloader must be grub")
}
// add trusted assets to the recovery chain
recoveryModeAssetsSet, err := g.getGrubRecoveryModeTrustedAssets()
if err != nil {
return nil, err
}
runModeAssetsSet, err := g.getGrubRunModeTrustedAssets()
if err != nil {
return nil, err
}
chains := make([][]BootFile, 0, len(recoveryModeAssetsSet)*len(runModeAssetsSet))
for _, recoveryModeAssets := range recoveryModeAssetsSet {
for _, runModeAssets := range runModeAssetsSet {
chain := make([]BootFile, 0, len(recoveryModeAssets)+len(runModeAssets)+1)
for _, ta := range recoveryModeAssets {
chain = append(chain, NewBootFile("", ta.path, RoleRecovery))
}
for _, ta := range runModeAssets {
chain = append(chain, NewBootFile("", ta.path, RoleRunMode))
}
// add kernel to the boot chain
chain = append(chain, NewBootFile(kernelPath, "kernel.efi", RoleRunMode))
chains = append(chains, chain)
}
}
return chains, nil
}