Skip to content

Commit

Permalink
feat: support iPXE direct booting in talosctl cluster create
Browse files Browse the repository at this point in the history
This embeds a tiny TFTP server which serves UEFI iPXE which embeds a
script that chainloads a given iPXE script.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
  • Loading branch information
smira committed Dec 19, 2023
1 parent 3ba8470 commit 01f0cbe
Show file tree
Hide file tree
Showing 16 changed files with 263 additions and 36 deletions.
8 changes: 8 additions & 0 deletions Dockerfile
Expand Up @@ -36,6 +36,9 @@ FROM --platform=arm64 ghcr.io/siderolabs/sd-boot:${PKGS} AS pkg-sd-boot-arm64
FROM --platform=amd64 ghcr.io/siderolabs/iptables:${PKGS} AS pkg-iptables-amd64
FROM --platform=arm64 ghcr.io/siderolabs/iptables:${PKGS} AS pkg-iptables-arm64

FROM --platform=amd64 ghcr.io/siderolabs/ipxe:${PKGS} AS pkg-ipxe-amd64
FROM --platform=arm64 ghcr.io/siderolabs/ipxe:${PKGS} AS pkg-ipxe-arm64

FROM --platform=amd64 ghcr.io/siderolabs/libinih:${PKGS} AS pkg-libinih-amd64
FROM --platform=arm64 ghcr.io/siderolabs/libinih:${PKGS} AS pkg-libinih-arm64

Expand Down Expand Up @@ -275,6 +278,10 @@ COPY --from=embed-abbrev-generate /src/pkg/machinery/gendata/data /pkg/machinery
COPY --from=embed-abbrev-generate /src/_out/talos-metadata /_out/talos-metadata
COPY --from=embed-abbrev-generate /src/_out/signing_key.x509 /_out/signing_key.x509

FROM scratch AS ipxe-generate
COPY --from=pkg-ipxe-amd64 /usr/libexec/snp.efi /amd64/snp.efi
COPY --from=pkg-ipxe-arm64 /usr/libexec/snp.efi /arm64/snp.efi

FROM --platform=${BUILDPLATFORM} scratch AS generate
COPY --from=proto-format-build /src/api /api/
COPY --from=generate-build /api/common/*.pb.go /pkg/machinery/api/common/
Expand All @@ -295,6 +302,7 @@ COPY --from=go-generate /src/pkg/machinery/resources/ /pkg/machinery/resources/
COPY --from=go-generate /src/pkg/machinery/config/types/ /pkg/machinery/config/types/
COPY --from=go-generate /src/pkg/machinery/nethelpers/ /pkg/machinery/nethelpers/
COPY --from=go-generate /src/pkg/machinery/extensions/ /pkg/machinery/extensions/
COPY --from=ipxe-generate / /pkg/provision/providers/vm/internal/ipxe/data/ipxe/
COPY --from=embed-abbrev / /

# The base target provides a container that can be used to build all Talos
Expand Down
13 changes: 8 additions & 5 deletions cmd/talosctl/cmd/mgmt/cluster/create.go
Expand Up @@ -99,6 +99,7 @@ var (
nodeInitramfsPath string
nodeISOPath string
nodeDiskImagePath string
nodeIPXEBootScript string
applyConfigEnabled bool
bootloaderEnabled bool
uefiEnabled bool
Expand Down Expand Up @@ -322,11 +323,12 @@ func create(ctx context.Context, flags *pflag.FlagSet) (err error) {
Bandwidth: bandwidth,
},

Image: nodeImage,
KernelPath: nodeVmlinuzPath,
InitramfsPath: nodeInitramfsPath,
ISOPath: nodeISOPath,
DiskImagePath: nodeDiskImagePath,
Image: nodeImage,
KernelPath: nodeVmlinuzPath,
InitramfsPath: nodeInitramfsPath,
ISOPath: nodeISOPath,
IPXEBootScript: nodeIPXEBootScript,
DiskImagePath: nodeDiskImagePath,

SelfExecutable: os.Args[0],
StateDirectory: stateDir,
Expand Down Expand Up @@ -958,6 +960,7 @@ func init() {
createCmd.Flags().StringVar(&nodeISOPath, "iso-path", "", "the ISO path to use for the initial boot (VM only)")
createCmd.Flags().StringVar(&nodeInitramfsPath, "initrd-path", helpers.ArtifactPath(constants.InitramfsAssetWithArch), "initramfs image to use")
createCmd.Flags().StringVar(&nodeDiskImagePath, "disk-image-path", "", "disk image to use")
createCmd.Flags().StringVar(&nodeIPXEBootScript, "ipxe-boot-script", "", "iPXE boot script (URL) to use")
createCmd.Flags().BoolVar(&applyConfigEnabled, "with-apply-config", false, "enable apply config when the VM is starting in maintenance mode")
createCmd.Flags().BoolVar(&bootloaderEnabled, bootloaderEnabledFlag, true, "enable bootloader to load kernel and initramfs from disk image after install")
createCmd.Flags().BoolVar(&uefiEnabled, "with-uefi", true, "enable UEFI on x86_64 architecture")
Expand Down
25 changes: 20 additions & 5 deletions cmd/talosctl/cmd/mgmt/dhcpd_launch_linux.go
Expand Up @@ -9,17 +9,19 @@ import (
"strings"

"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"

"github.com/siderolabs/talos/pkg/provision/providers/vm"
)

var dhcpdLaunchCmdFlags struct {
addr string
ifName string
statePath string
addr string
ifName string
statePath string
ipxeNextHandler string
}

// dhcpdLaunchCmd represents the loadbalancer-launch command.
// dhcpdLaunchCmd represents the dhcpd-launch command.
var dhcpdLaunchCmd = &cobra.Command{
Use: "dhcpd-launch",
Short: "Internal command used by VM provisioners",
Expand All @@ -33,13 +35,26 @@ var dhcpdLaunchCmd = &cobra.Command{
ips = append(ips, net.ParseIP(ip))
}

return vm.DHCPd(dhcpdLaunchCmdFlags.ifName, ips, dhcpdLaunchCmdFlags.statePath)
var eg errgroup.Group

eg.Go(func() error {
return vm.DHCPd(dhcpdLaunchCmdFlags.ifName, ips, dhcpdLaunchCmdFlags.statePath)
})

if dhcpdLaunchCmdFlags.ipxeNextHandler != "" {
eg.Go(func() error {
return vm.TFTPd(ips, dhcpdLaunchCmdFlags.ipxeNextHandler)
})
}

return eg.Wait()
},
}

func init() {
dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.addr, "addr", "localhost", "IP addresses to listen on")
dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.ifName, "interface", "", "interface to listen on")
dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.statePath, "state-path", "", "path to state directory")
dhcpdLaunchCmd.Flags().StringVar(&dhcpdLaunchCmdFlags.ipxeNextHandler, "ipxe-next-handler", "", "iPXE script to chain load")
addCommand(dhcpdLaunchCmd)
}
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -90,6 +90,7 @@ require (
github.com/opencontainers/runtime-spec v1.1.0-rc.1
github.com/packethost/packngo v0.30.0
github.com/pelletier/go-toml v1.9.5
github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible
github.com/pin/tftp/v3 v3.1.0
github.com/pmorjan/kmod v1.1.0
github.com/prometheus/procfs v0.12.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Expand Up @@ -587,6 +587,8 @@ github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+v
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible h1:zQDvVdw4rn2smQfJZwbD5FboCiiTgw/1lpER60easPM=
github.com/pin/tftp v2.1.1-0.20200117065540-2f79be2dba4e+incompatible/go.mod h1:xVpZOMCXTy+A5QMjEVN0Glwa1sUvaJhFXbr/aAxuxGY=
github.com/pin/tftp/v3 v3.1.0 h1:rQaxd4pGwcAJnpId8zC+O2NX3B2/NscjDZQaqEjuE7c=
github.com/pin/tftp/v3 v3.1.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
Expand Down
7 changes: 6 additions & 1 deletion pkg/provision/providers/qemu/node.go
Expand Up @@ -139,6 +139,11 @@ func (p *provisioner) createNode(state *vm.State, clusterReq provision.ClusterRe
APIPort: apiPort,
}

if clusterReq.IPXEBootScript != "" {
launchConfig.TFTPServer = clusterReq.Network.GatewayAddrs[0].String()
launchConfig.IPXEBootFileName = fmt.Sprintf("ipxe/%s/snp.efi", string(arch))
}

nodeInfo := provision.NodeInfo{
ID: pidPath,
UUID: nodeUUID,
Expand Down Expand Up @@ -168,7 +173,7 @@ func (p *provisioner) createNode(state *vm.State, clusterReq provision.ClusterRe
launchConfig.Hostname = nodeReq.Name
}

if !nodeReq.PXEBooted {
if !(nodeReq.PXEBooted || launchConfig.IPXEBootFileName != "") {
launchConfig.KernelImagePath = strings.ReplaceAll(clusterReq.KernelPath, constants.ArchVariable, opts.TargetArch)
launchConfig.InitrdPath = strings.ReplaceAll(clusterReq.InitramfsPath, constants.ArchVariable, opts.TargetArch)
launchConfig.ISOPath = strings.ReplaceAll(clusterReq.ISOPath, constants.ArchVariable, opts.TargetArch)
Expand Down
7 changes: 5 additions & 2 deletions pkg/provision/providers/vm/dhcpd.go
Expand Up @@ -91,12 +91,14 @@ func handlerDHCP4(serverIP net.IP, statePath string) server4.Handler {

if m.IsOptionRequested(dhcpv4.OptionBootfileName) {
log.Printf("received PXE boot request from %s", m.ClientHWAddr)
log.Printf("sending PXE response to %s: %s/%s", m.ClientHWAddr, match.TFTPServer, match.IPXEBootFilename)

if match.TFTPServer != "" {
log.Printf("sending PXE response to %s: %s/%s", m.ClientHWAddr, match.TFTPServer, match.IPXEBootFilename)

resp.ServerIPAddr = net.ParseIP(match.TFTPServer)
resp.UpdateOption(dhcpv4.OptTFTPServerName(match.TFTPServer))
}

if match.IPXEBootFilename != "" {
resp.UpdateOption(dhcpv4.OptBootFileName(match.IPXEBootFilename))
}
}
Expand Down Expand Up @@ -293,6 +295,7 @@ func (p *Provisioner) CreateDHCPd(state *State, clusterReq provision.ClusterRequ
"--state-path", statePath,
"--addr", strings.Join(gatewayAddrs, ","),
"--interface", state.BridgeName,
"--ipxe-next-handler", clusterReq.IPXEBootScript,
}

cmd := exec.Command(clusterReq.SelfExecutable, args...)
Expand Down
Binary file not shown.
Binary file not shown.
154 changes: 154 additions & 0 deletions pkg/provision/providers/vm/internal/ipxe/ipxe.go
@@ -0,0 +1,154 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Package ipxe provides utility to deliver iPXE images and build iPXE scripts.
package ipxe

import (
"bytes"
"embed"
"fmt"
"io"
"log"
"path/filepath"
"text/template"
)

//go:embed "data/*"
var ipxeFiles embed.FS

// TFTPHandler is called when client starts file download from the TFTP server.
//
// TFTP handler also patches the iPXE binary on the fly with the script
// which chainloads next handler.
func TFTPHandler(next string) func(filename string, rf io.ReaderFrom) error {
return func(filename string, rf io.ReaderFrom) error {
log.Printf("tftp request: %s", filename)

file, err := ipxeFiles.Open(filepath.Join("data", filename))
if err != nil {
return err
}

defer file.Close() //nolint:errcheck

contents, err := io.ReadAll(file)
if err != nil {
return err
}

var script bytes.Buffer

if err = scriptTemplate.Execute(&script, struct {
Next string
}{
Next: next,
}); err != nil {
return err
}

contents, err = patchScript(contents, script.Bytes())
if err != nil {
return fmt.Errorf("error patching %q: %w", filename, err)
}

_, err = rf.ReadFrom(bytes.NewReader(contents))

return err
}
}

// scriptTemplate to run DHCP and chain the boot to the .Next endpoint.
var scriptTemplate = template.Must(template.New("iPXE embedded").Parse(`#!ipxe
prompt --key 0x02 --timeout 2000 Press Ctrl-B for the iPXE command line... && shell ||
{{/* print interfaces */}}
ifstat
{{/* retry 10 times overall */}}
set attempts:int32 10
set x:int32 0
:retry_loop
set idx:int32 0
:loop
{{/* try DHCP on each interface */}}
isset ${net${idx}/mac} || goto exhausted
ifclose
iflinkwait --timeout 5000 net${idx} || goto next_iface
dhcp net${idx} || goto next_iface
goto boot
:next_iface
inc idx && goto loop
:boot
{{/* attempt boot, if fails try next iface */}}
route
chain --replace {{ .Next }} || goto next_iface
:exhausted
echo
echo Failed to iPXE boot successfully via all interfaces
iseq ${x} ${attempts} && goto fail ||
echo Retrying...
echo
inc x
goto retry_loop
:fail
echo
echo Failed to get a valid response after ${attempts} attempts
echo
echo Rebooting in 5 seconds...
sleep 5
reboot
`))

var (
placeholderStart = []byte("# *PLACEHOLDER START*")
placeholderEnd = []byte("# *PLACEHOLDER END*")
)

// patchScript patches the iPXE script into the iPXE binary.
//
// The iPXE binary should be built uncompressed with an embedded
// script stub which contains abovementioned placeholders.
func patchScript(contents, script []byte) ([]byte, error) {
start := bytes.Index(contents, placeholderStart)
if start == -1 {
return nil, fmt.Errorf("placeholder start not found")
}

end := bytes.Index(contents, placeholderEnd)
if end == -1 {
return nil, fmt.Errorf("placeholder end not found")
}

if end < start {
return nil, fmt.Errorf("placeholder end before start")
}

end += len(placeholderEnd)

length := end - start

if len(script) > length {
return nil, fmt.Errorf("script size %d is larger than placeholder space %d", len(script), length)
}

script = append(script, bytes.Repeat([]byte{'\n'}, length-len(script))...)

copy(contents[start:end], script)

return contents, nil
}
34 changes: 34 additions & 0 deletions pkg/provision/providers/vm/tftpd.go
@@ -0,0 +1,34 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package vm

import (
"net"
"time"

"github.com/pin/tftp"
"golang.org/x/sync/errgroup"

"github.com/siderolabs/talos/pkg/provision/providers/vm/internal/ipxe"
)

// TFTPd starts a TFTP server on the given IPs.
func TFTPd(ips []net.IP, nextHandler string) error {
server := tftp.NewServer(ipxe.TFTPHandler(nextHandler), nil)

server.SetTimeout(5 * time.Second)

var eg errgroup.Group

for _, ip := range ips {
ip := ip

eg.Go(func() error {
return server.ListenAndServe(net.JoinHostPort(ip.String(), "69"))
})
}

return eg.Wait()
}
18 changes: 12 additions & 6 deletions pkg/provision/request.go
Expand Up @@ -23,12 +23,18 @@ type ClusterRequest struct {
Network NetworkRequest
Nodes NodeRequests

Image string
KernelPath string
InitramfsPath string
ISOPath string
DiskImagePath string
KMSEndpoint string
// Docker specific parameters.
Image string

// Boot options (QEMU).
KernelPath string
InitramfsPath string
ISOPath string
DiskImagePath string
IPXEBootScript string

// Encryption
KMSEndpoint string

// Path to talosctl executable to re-execute itself as needed.
SelfExecutable string
Expand Down

0 comments on commit 01f0cbe

Please sign in to comment.