add tons of shit to enroll
This commit is contained in:
parent
475a444129
commit
712df4c190
8 changed files with 519 additions and 44 deletions
|
@ -3,3 +3,6 @@
|
||||||
build:
|
build:
|
||||||
mkdir -p build/
|
mkdir -p build/
|
||||||
go build -o build/
|
go build -o build/
|
||||||
|
|
||||||
|
push-client:
|
||||||
|
scp build/config root@192.168.122.2:/usr/bin/efi-http-config
|
||||||
|
|
104
config/efivar/efivar.go
Normal file
104
config/efivar/efivar.go
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
package efivar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// const efiVarDir = "/sys/firmware/efi/efivars"
|
||||||
|
const execPath = "/usr/bin/efivar"
|
||||||
|
|
||||||
|
var VendorID = uuid.MustParse("638a24ce-a0ce-4908-8152-b2a4e8b2f29d")
|
||||||
|
|
||||||
|
const (
|
||||||
|
VarBootUUID = "HTTP_BOOT_UUID"
|
||||||
|
VarBootPort = "HTTP_BOOT_MCAST_PORT"
|
||||||
|
VarBootGroup = "HTTP_BOOT_MCAST_GROUP"
|
||||||
|
VarBootIP = "HTTP_BOOT_IP"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("variable not found")
|
||||||
|
|
||||||
|
func toFullVarName(gid uuid.UUID, varName string) string {
|
||||||
|
return strings.Join([]string{gid.String(), varName}, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func numSliceToBytes(in []string) ([]byte, error) {
|
||||||
|
res := []byte{}
|
||||||
|
for i, n := range in {
|
||||||
|
strippedN := strings.TrimSpace(n)
|
||||||
|
if len(strippedN) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intN, err := strconv.Atoi(strippedN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid number %s at index %d", n, i)
|
||||||
|
}
|
||||||
|
if intN < 0 || intN > 255 {
|
||||||
|
return nil, fmt.Errorf("value %d at index %d out of range", intN, i)
|
||||||
|
}
|
||||||
|
res = append(res, byte(intN))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVar(gid uuid.UUID, varName string) ([]byte, error) {
|
||||||
|
cmd := exec.Command(execPath, "-n", toFullVarName(gid, varName), "-d")
|
||||||
|
var outBuf, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &outBuf
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
var exitErr exec.ExitError
|
||||||
|
e := &exitErr
|
||||||
|
if err != nil && !errors.As(err, &e) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
stderr := errBuf.String()
|
||||||
|
if strings.Contains(stderr, "No such file or directory") {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("error getting variable, returncode %d, stderr %q", cmd.ProcessState.ExitCode(), stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
nums := strings.Split(outBuf.String(), " ")
|
||||||
|
return numSliceToBytes(nums)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetVar(gid uuid.UUID, varName string, value []byte) error {
|
||||||
|
tmpVarFile, err := os.CreateTemp("/tmp", "efitmpvar")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create tmp file to write variable: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpVarFile.Close()
|
||||||
|
defer os.Remove(tmpVarFile.Name())
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(tmpVarFile.Name(), value, 0o500); err != nil {
|
||||||
|
return fmt.Errorf("failed to write variable value to tmp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "-n", toFullVarName(gid, varName), "-w", "-f", tmpVarFile.Name())
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
var exitErr exec.ExitError
|
||||||
|
e := &exitErr
|
||||||
|
if err != nil && !errors.As(err, &e) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
stderr := errBuf.String()
|
||||||
|
return fmt.Errorf("error setting variable, returncode %d, stderr %q", cmd.ProcessState.ExitCode(), stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
32
config/efivar/efivar_test.go
Normal file
32
config/efivar/efivar_test.go
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
package efivar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNumSliceToBytes(t *testing.T) {
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
expected := []byte{12, 53, 124, 84}
|
||||||
|
input := []string{"12", "53", "124", "84"}
|
||||||
|
res, err := numSliceToBytes(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, expected, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Err - invalid byte", func(t *testing.T) {
|
||||||
|
input := []string{"12", "11453", "124"}
|
||||||
|
res, err := numSliceToBytes(input)
|
||||||
|
require.ErrorContains(t, err, "value 11453 at index 1 out of range")
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Err - not numbers", func(t *testing.T) {
|
||||||
|
input := []string{"12", "toto", "124"}
|
||||||
|
res, err := numSliceToBytes(input)
|
||||||
|
require.ErrorContains(t, err, "invalid number toto at index 1")
|
||||||
|
assert.Nil(t, res)
|
||||||
|
})
|
||||||
|
}
|
235
config/main.go
235
config/main.go
|
@ -1,13 +1,20 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"io/ioutil"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"git.faercol.me/faercol/http-boot-config/config/efivar"
|
||||||
"git.faercol.me/faercol/http-boot-config/config/logger"
|
"git.faercol.me/faercol/http-boot-config/config/logger"
|
||||||
"git.faercol.me/faercol/http-boot-config/config/prober"
|
"git.faercol.me/faercol/http-boot-config/config/prober"
|
||||||
"git.faercol.me/faercol/http-boot-config/config/remote"
|
"git.faercol.me/faercol/http-boot-config/config/remote"
|
||||||
|
@ -20,9 +27,16 @@ const (
|
||||||
actionList action = iota
|
actionList action = iota
|
||||||
actionGetRemote
|
actionGetRemote
|
||||||
actionEnroll
|
actionEnroll
|
||||||
|
actionStatus
|
||||||
actionUnknown
|
actionUnknown
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultEFIDest = "/EFI/httpboot/"
|
||||||
|
defaultEFIMount = "/boot"
|
||||||
|
defaultEFISrc = "httpboot.efi"
|
||||||
|
)
|
||||||
|
|
||||||
type cliArgs struct {
|
type cliArgs struct {
|
||||||
debug bool
|
debug bool
|
||||||
colour bool
|
colour bool
|
||||||
|
@ -31,6 +45,12 @@ type cliArgs struct {
|
||||||
remoteAddr string
|
remoteAddr string
|
||||||
prettyPrint bool
|
prettyPrint bool
|
||||||
name string
|
name string
|
||||||
|
efiDest string
|
||||||
|
efiMountPoint string
|
||||||
|
efiDisk string
|
||||||
|
efiSrc string
|
||||||
|
ifaceName string
|
||||||
|
install bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultArgs cliArgs = cliArgs{
|
var defaultArgs cliArgs = cliArgs{
|
||||||
|
@ -45,6 +65,9 @@ func parseArgs() (cliArgs, error) {
|
||||||
|
|
||||||
listFlagSet := flag.NewFlagSet("list", flag.ExitOnError)
|
listFlagSet := flag.NewFlagSet("list", flag.ExitOnError)
|
||||||
|
|
||||||
|
statusFlagSet := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
|
hostStatusFlag := statusFlagSet.String("remote-host", "http://localhost:5000", "Address for the remote boot server")
|
||||||
|
|
||||||
getRemoteFlagSet := flag.NewFlagSet("get-remote", flag.ExitOnError)
|
getRemoteFlagSet := flag.NewFlagSet("get-remote", flag.ExitOnError)
|
||||||
uuidFlag := getRemoteFlagSet.String("uuid", "", "Client UUID")
|
uuidFlag := getRemoteFlagSet.String("uuid", "", "Client UUID")
|
||||||
hostGetRemoteFlag := getRemoteFlagSet.String("remote-host", "http://localhost:5000", "Address for the remote boot server")
|
hostGetRemoteFlag := getRemoteFlagSet.String("remote-host", "http://localhost:5000", "Address for the remote boot server")
|
||||||
|
@ -53,6 +76,12 @@ func parseArgs() (cliArgs, error) {
|
||||||
enrollFlagSet := flag.NewFlagSet("enroll", flag.ExitOnError)
|
enrollFlagSet := flag.NewFlagSet("enroll", flag.ExitOnError)
|
||||||
hostEnrollFlag := enrollFlagSet.String("remote-host", "http://localhost:5000", "Address for the remote boot server")
|
hostEnrollFlag := enrollFlagSet.String("remote-host", "http://localhost:5000", "Address for the remote boot server")
|
||||||
nameFlag := enrollFlagSet.String("name", "default", "Name for the client on the remote boot server")
|
nameFlag := enrollFlagSet.String("name", "default", "Name for the client on the remote boot server")
|
||||||
|
efiSrcFlag := enrollFlagSet.String("src", defaultEFISrc, "HTTP EFI app to install")
|
||||||
|
efiDestFlag := enrollFlagSet.String("dest", defaultEFIDest, "Directory in which to store the EFI app")
|
||||||
|
efiMountPointFlag := enrollFlagSet.String("mountpoint", defaultEFIMount, "EFI partition mountpoint")
|
||||||
|
installFlag := enrollFlagSet.Bool("install", false, "Install the EFI app in the system")
|
||||||
|
efiDiskFlag := enrollFlagSet.String("disk", "/dev/sda1", "Partition in which to install the EFI app")
|
||||||
|
ifaceNameFlag := enrollFlagSet.String("iface", "eth0", "Iface name to use for the boot loader")
|
||||||
|
|
||||||
debugFlag := flag.Bool("debug", false, "Display debug logs")
|
debugFlag := flag.Bool("debug", false, "Display debug logs")
|
||||||
noColourFlag := flag.Bool("no-colour", false, "Disable colour logs")
|
noColourFlag := flag.Bool("no-colour", false, "Disable colour logs")
|
||||||
|
@ -65,6 +94,8 @@ func parseArgs() (cliArgs, error) {
|
||||||
args.action = actionGetRemote
|
args.action = actionGetRemote
|
||||||
case "enroll":
|
case "enroll":
|
||||||
args.action = actionEnroll
|
args.action = actionEnroll
|
||||||
|
case "status":
|
||||||
|
args.action = actionStatus
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -74,6 +105,9 @@ func parseArgs() (cliArgs, error) {
|
||||||
switch args.action {
|
switch args.action {
|
||||||
case actionList:
|
case actionList:
|
||||||
listFlagSet.Parse(os.Args[firstArg:])
|
listFlagSet.Parse(os.Args[firstArg:])
|
||||||
|
case actionStatus:
|
||||||
|
statusFlagSet.Parse(os.Args[firstArg:])
|
||||||
|
args.remoteAddr = *hostStatusFlag
|
||||||
case actionGetRemote:
|
case actionGetRemote:
|
||||||
getRemoteFlagSet.Parse(os.Args[firstArg:])
|
getRemoteFlagSet.Parse(os.Args[firstArg:])
|
||||||
parsedID, err := uuid.Parse(*uuidFlag)
|
parsedID, err := uuid.Parse(*uuidFlag)
|
||||||
|
@ -87,6 +121,12 @@ func parseArgs() (cliArgs, error) {
|
||||||
enrollFlagSet.Parse(os.Args[firstArg:])
|
enrollFlagSet.Parse(os.Args[firstArg:])
|
||||||
args.remoteAddr = *hostEnrollFlag
|
args.remoteAddr = *hostEnrollFlag
|
||||||
args.name = *nameFlag
|
args.name = *nameFlag
|
||||||
|
args.efiSrc = *efiSrcFlag
|
||||||
|
args.efiDest = *efiDestFlag
|
||||||
|
args.install = *installFlag
|
||||||
|
args.efiDisk = *efiDiskFlag
|
||||||
|
args.efiMountPoint = *efiMountPointFlag
|
||||||
|
args.ifaceName = *ifaceNameFlag
|
||||||
default:
|
default:
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
return cliArgs{}, errors.New("missing an action")
|
return cliArgs{}, errors.New("missing an action")
|
||||||
|
@ -100,7 +140,7 @@ func parseArgs() (cliArgs, error) {
|
||||||
|
|
||||||
func displayAppList(l *logger.SimpleLogger) {
|
func displayAppList(l *logger.SimpleLogger) {
|
||||||
l.Info("Checking EFI directory for available boot images...")
|
l.Info("Checking EFI directory for available boot images...")
|
||||||
apps, err := prober.GetEFIApps(l)
|
apps, err := prober.GetEFIApps()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, fs.ErrPermission) {
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
l.Fatal("Permission error, try to run the command as sudo")
|
l.Fatal("Permission error, try to run the command as sudo")
|
||||||
|
@ -131,11 +171,196 @@ func getRemoteConfig(l *logger.SimpleLogger, host string, id uuid.UUID, pretty b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func enroll(l *logger.SimpleLogger, host, name string) {
|
func enroll(l *logger.SimpleLogger, host, name, src, dest, destPart, mount, iface string, install bool) {
|
||||||
|
if install {
|
||||||
|
l.Debug("Installing boot application")
|
||||||
|
if err := installApp(l, src, dest, destPart, mount); err != nil {
|
||||||
|
l.Fatalf("Failed to install boot application: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
l.Info("Enrolling client")
|
l.Info("Enrolling client")
|
||||||
if err := remote.Enroll(context.Background(), host, name, l); err != nil {
|
clientConf, err := remote.Enroll(context.Background(), host, name, l)
|
||||||
|
if err != nil {
|
||||||
l.Fatal(err.Error())
|
l.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if install {
|
||||||
|
l.Debug("Setting EFI vars")
|
||||||
|
if err := installVars(l, clientConf.ID, clientConf.MulticastPort, clientConf.MulticastGroup, iface); err != nil {
|
||||||
|
l.Fatalf("Failed to install efi vars: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEFIConf() (clientID uuid.UUID, port int, group net.IP, ip net.IP, err error) {
|
||||||
|
idRaw, err := efivar.GetVar(efivar.VendorID, efivar.VarBootUUID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientID, err = uuid.ParseBytes(idRaw)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("invalid uuid %q: %w", string(idRaw), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
portRaw, err := efivar.GetVar(efivar.VendorID, efivar.VarBootPort)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(portRaw) == 1 {
|
||||||
|
portRaw = append(portRaw, 0)
|
||||||
|
}
|
||||||
|
port = int(binary.LittleEndian.Uint16(portRaw))
|
||||||
|
|
||||||
|
groupRaw, err := efivar.GetVar(efivar.VendorID, efivar.VarBootGroup)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group = net.IP(groupRaw)
|
||||||
|
|
||||||
|
ipRaw, err := efivar.GetVar(efivar.VendorID, efivar.VarBootIP)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip = net.IP(ipRaw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStatus(l *logger.SimpleLogger, host string) {
|
||||||
|
l.Debug("Checking EFI vars")
|
||||||
|
clientID, port, group, ip, err := getEFIConf()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, efivar.ErrNotFound) {
|
||||||
|
l.Info("Boot client is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.Fatalf("Failed to get EFI configuration: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Infof("Registered client ID %s", clientID.String())
|
||||||
|
l.Infof("Registered multicast group %s:%d", group.String(), port)
|
||||||
|
l.Infof("HTTP_BOOT client using local ip address %s", ip.String())
|
||||||
|
|
||||||
|
l.Debug("Getting remote config")
|
||||||
|
remoteRaw, err := remote.GetRemoteConfig(context.Background(), host, clientID, l)
|
||||||
|
if err != nil {
|
||||||
|
l.Fatalf("Failed to get remote config: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteConf remote.ClientConfig
|
||||||
|
if err := json.Unmarshal(remoteRaw, &remoteConf); err != nil {
|
||||||
|
l.Fatalf("Invalid remote config: %s", err.Error())
|
||||||
|
}
|
||||||
|
l.Infof("Remote HTTP Boot configured and set to boot to %s", remoteConf.EFIConfig.Options[remoteConf.EFIConfig.SelectedOption].Name)
|
||||||
|
|
||||||
|
l.Debug("Checking selected EFI image")
|
||||||
|
apps, err := prober.GetEFIApps()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrPermission) {
|
||||||
|
l.Fatal("Permission error, try to run the command as sudo")
|
||||||
|
}
|
||||||
|
l.Fatalf("Failed to check EFI directory: %s", err.Error())
|
||||||
|
}
|
||||||
|
installed := false
|
||||||
|
for _, a := range apps {
|
||||||
|
if a.Name == prober.HTTPBootLabel {
|
||||||
|
installed = true
|
||||||
|
l.Info("HTTP_BOOT efi application found in the EFI partitions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !installed {
|
||||||
|
l.Warning("HTTP_BOOT efi application is not installed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpNext, err := prober.IsHTTPBootNext()
|
||||||
|
if err != nil {
|
||||||
|
l.Fatalf("Failed to check EFI app selected for next boot: %s", err.Error())
|
||||||
|
}
|
||||||
|
if httpNext {
|
||||||
|
l.Info("HTTP_BOOT set for next boot")
|
||||||
|
} else {
|
||||||
|
l.Warning("HTTP_BOOT is not set for next boot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIPAddr(ifaceName string) (net.IP, error) {
|
||||||
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get iface: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get addresses for iface: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
parsedIP, _, err := net.ParseCIDR(a.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid addr %s: %w", a.String(), err)
|
||||||
|
}
|
||||||
|
if parsedIP.IsLinkLocalUnicast() && parsedIP.To4() == nil {
|
||||||
|
return parsedIP, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("no link-local ipv6 found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func installApp(l *logger.SimpleLogger, srcPath, destDir, destPart, mount string) error {
|
||||||
|
destFullDir := path.Join(mount, destDir)
|
||||||
|
l.Debug("Creating dest directory")
|
||||||
|
if err := os.MkdirAll(destFullDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create dest directory: %w", err)
|
||||||
|
}
|
||||||
|
l.Debugf("Opening source file %s for copy", srcPath)
|
||||||
|
input, err := ioutil.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open source file for copy: %w", err)
|
||||||
|
}
|
||||||
|
dest := path.Join(destFullDir, path.Base(srcPath))
|
||||||
|
l.Debugf("Copying efi image to %s", dest)
|
||||||
|
if err := ioutil.WriteFile(dest, input, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy efi image: %w", err)
|
||||||
|
}
|
||||||
|
l.Debug("Deleting source file after copy")
|
||||||
|
if err := os.Remove(srcPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove source file after copy: %w", err)
|
||||||
|
}
|
||||||
|
l.Debug("Installing app in efibootmgr")
|
||||||
|
if err := prober.Install(path.Join(destDir, path.Base(srcPath)), destPart); err != nil {
|
||||||
|
return fmt.Errorf("failed to install EFI app in efibootmgr: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installVars(l *logger.SimpleLogger, clientID uuid.UUID, port int, group net.IP, ifaceName string) error {
|
||||||
|
l.Debug("Setting multicast port")
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := binary.Write(buf, binary.LittleEndian, uint16(port)); err != nil {
|
||||||
|
return fmt.Errorf("invalid port format: %w", err)
|
||||||
|
}
|
||||||
|
if err := efivar.SetVar(efivar.VendorID, efivar.VarBootPort, buf.Bytes()); err != nil {
|
||||||
|
return fmt.Errorf("failed to set port number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug("Setting multicast group")
|
||||||
|
if err := efivar.SetVar(efivar.VendorID, efivar.VarBootGroup, group); err != nil {
|
||||||
|
return fmt.Errorf("failed to set multicast group: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug("Setting UUID")
|
||||||
|
if err := efivar.SetVar(efivar.VendorID, efivar.VarBootUUID, []byte(clientID.String())); err != nil {
|
||||||
|
return fmt.Errorf("failed to set client UUID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Debug("Setting link local IPv6")
|
||||||
|
ipAddr, err := getIPAddr(ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get ipv6 link local address: %w", err)
|
||||||
|
}
|
||||||
|
if err := efivar.SetVar(efivar.VendorID, efivar.VarBootIP, ipAddr); err != nil {
|
||||||
|
return fmt.Errorf("failed to set ip address: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -150,10 +375,12 @@ func main() {
|
||||||
switch args.action {
|
switch args.action {
|
||||||
case actionList:
|
case actionList:
|
||||||
displayAppList(l)
|
displayAppList(l)
|
||||||
|
case actionStatus:
|
||||||
|
getStatus(l, args.remoteAddr)
|
||||||
case actionGetRemote:
|
case actionGetRemote:
|
||||||
getRemoteConfig(l, args.remoteAddr, args.id, args.prettyPrint)
|
getRemoteConfig(l, args.remoteAddr, args.id, args.prettyPrint)
|
||||||
case actionEnroll:
|
case actionEnroll:
|
||||||
enroll(l, args.remoteAddr, args.name)
|
enroll(l, args.remoteAddr, args.name, args.efiSrc, args.efiDest, args.efiDisk, args.efiMountPoint, args.ifaceName, args.install)
|
||||||
default:
|
default:
|
||||||
l.Fatal("Unknown action")
|
l.Fatal("Unknown action")
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,15 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.faercol.me/faercol/http-boot-config/config/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var efiBootmgrRegexp = regexp.MustCompile(`Boot(?P<id>\d+)\* (?P<name>[\w ]+)\t(HD\(.+,.+,(?P<disk_id>[0-9a-f-]+),.+,.+\))/File\((?P<filepath>.+)\)`)
|
var efiBootmgrRegexp = regexp.MustCompile(`Boot(?P<id>[0-9A-F]+)\* (?P<name>.+)\t(.+\(.+,.+,(?P<disk_id>[0-9a-f-]+),.+,.+\))/File\((?P<filepath>.+)\)`)
|
||||||
var activeRegexp = regexp.MustCompile(`BootCurrent: (\d+)`)
|
var activeRegexp = regexp.MustCompile(`BootCurrent: (\d+)`)
|
||||||
|
var bootOrderRegexp = regexp.MustCompile(`BootOrder: ((?:[0-9A-F]{4},?)+)`)
|
||||||
|
|
||||||
|
const HTTPBootName = "httpboot.efi"
|
||||||
|
const HTTPBootLabel = "HTTP_BOOT"
|
||||||
|
const execPath = "/usr/bin/efibootmgr"
|
||||||
|
|
||||||
type EfiApp struct {
|
type EfiApp struct {
|
||||||
ID int
|
ID int
|
||||||
|
@ -38,7 +41,7 @@ func efiAppFromBootMgrOutput(rawVal string, activeId int) (app EfiApp, ok bool)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idInt, err := strconv.Atoi(id)
|
idInt, err := strconv.ParseInt(id, 16, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app, false
|
return app, false
|
||||||
}
|
}
|
||||||
|
@ -55,10 +58,10 @@ func efiAppFromBootMgrOutput(rawVal string, activeId int) (app EfiApp, ok bool)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return EfiApp{
|
return EfiApp{
|
||||||
ID: idInt,
|
ID: int(idInt),
|
||||||
Name: strings.TrimSpace(name),
|
Name: strings.TrimSpace(name),
|
||||||
Path: strings.TrimSpace(filepath),
|
Path: strings.TrimSpace(filepath),
|
||||||
Active: idInt == activeId,
|
Active: int(idInt) == activeId,
|
||||||
DiskID: disk_id,
|
DiskID: disk_id,
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
@ -76,7 +79,7 @@ func getActiveEFIApp(output string) (int, error) {
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetEFIApps(log *logger.SimpleLogger) (apps []EfiApp, err error) {
|
func GetEFIApps() (apps []EfiApp, err error) {
|
||||||
cmd := exec.Command("/usr/bin/efibootmgr")
|
cmd := exec.Command("/usr/bin/efibootmgr")
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
|
@ -96,13 +99,94 @@ func GetEFIApps(log *logger.SimpleLogger) (apps []EfiApp, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, l := range strings.Split(outStr, "\n") {
|
for _, l := range strings.Split(outStr, "\n") {
|
||||||
log.Debugf("Parsing line %q", l)
|
|
||||||
app, ok := efiAppFromBootMgrOutput(l, activeBootID)
|
app, ok := efiAppFromBootMgrOutput(l, activeBootID)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug("Line is not a valid EFI application")
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
apps = append(apps, app)
|
apps = append(apps, app)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Installed() (bool, error) {
|
||||||
|
apps, err := GetEFIApps()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get list of EFI applications: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range apps {
|
||||||
|
if a.Name == HTTPBootLabel {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHTTPBoot() (EfiApp, error) {
|
||||||
|
apps, err := GetEFIApps()
|
||||||
|
if err != nil {
|
||||||
|
return EfiApp{}, fmt.Errorf("failed to get installed EFI applications: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range apps {
|
||||||
|
if a.Name == HTTPBootLabel {
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return EfiApp{}, errors.New("HTTP_BOOT not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHTTPBootNext() (present bool, err error) {
|
||||||
|
httpBootApp, err := getHTTPBoot()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get HTTP_BOOT config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("/usr/bin/efibootmgr")
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
return false, fmt.Errorf("error running efibootmgr, returncode %d", cmd.ProcessState.ExitCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
outStr := out.String()
|
||||||
|
match := bootOrderRegexp.FindStringSubmatch(outStr)
|
||||||
|
if len(match) == 0 {
|
||||||
|
return false, errors.New("no boot order found")
|
||||||
|
}
|
||||||
|
orderedBootOptionsStr := strings.Split(match[1], ",")
|
||||||
|
var orderedBootOptionsInt []int
|
||||||
|
for _, o := range orderedBootOptionsStr {
|
||||||
|
if oInt, convEerr := strconv.ParseInt(o, 16, 32); err != nil {
|
||||||
|
return false, fmt.Errorf("invalid value for boot option: %w", convEerr)
|
||||||
|
} else {
|
||||||
|
orderedBootOptionsInt = append(orderedBootOptionsInt, int(oInt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orderedBootOptionsInt[0] == httpBootApp.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Install(path, device string) (err error) {
|
||||||
|
partNum := device[len(device)-1:]
|
||||||
|
diskDev := device[:len(device)-1]
|
||||||
|
|
||||||
|
cmd := exec.Command(execPath, "--create", "--label", HTTPBootLabel, "--loader", path, "--part", partNum, "--disk", diskDev)
|
||||||
|
var errBuf bytes.Buffer
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
var exitErr exec.ExitError
|
||||||
|
e := &exitErr
|
||||||
|
if err != nil && !errors.As(err, &e) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cmd.ProcessState.ExitCode() != 0 {
|
||||||
|
stderr := errBuf.String()
|
||||||
|
return fmt.Errorf("error setting variable, returncode %d, stderr %q", cmd.ProcessState.ExitCode(), stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -24,6 +24,22 @@ func TestEfiFomBootMgr(t *testing.T) {
|
||||||
assert.Equal(t, expected, app)
|
assert.Equal(t, expected, app)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("OK - SystemdBoot VM", func(t *testing.T) {
|
||||||
|
rawVal := `Boot0001* SYSTEMD_BOOT PciRoot(0x0)/Pci(0x1f,0x2)/Sata(0,65535,0)/HD(1,GPT,c4540877-4592-494f-bd8a-2a23abb22c3f,0x800,0x100000)/File(\EFI\systemd\systemd-bootx64.efi)`
|
||||||
|
expected := EfiApp{ID: 1, Name: "SYSTEMD_BOOT", Path: `\EFI\systemd\systemd-bootx64.efi`, Active: false, DiskID: "c4540877-4592-494f-bd8a-2a23abb22c3f"}
|
||||||
|
app, ok := efiAppFromBootMgrOutput(rawVal, 0)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, expected, app)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OK - HTTP_BOOT hexa", func(t *testing.T) {
|
||||||
|
rawVal := `Boot000A* httpboot.efi HD(1,GPT,c4540877-4592-494f-bd8a-2a23abb22c3f,0x800,0x100000)/File(\EFI\httpboot\httpboot.efi)`
|
||||||
|
expected := EfiApp{ID: 10, Name: "httpboot.efi", Path: `\EFI\httpboot\httpboot.efi`, Active: false, DiskID: "c4540877-4592-494f-bd8a-2a23abb22c3f"}
|
||||||
|
app, ok := efiAppFromBootMgrOutput(rawVal, 0)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, expected, app)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("OK - EFI Base", func(t *testing.T) {
|
t.Run("OK - EFI Base", func(t *testing.T) {
|
||||||
rawVal := `Boot0002* UEFI OS HD(1,GPT,16f06d01-50da-6544-86bd-f3457f980086,0x1000,0x96000)/File(\EFI\BOOT\BOOTX64.EFI)0000424f`
|
rawVal := `Boot0002* UEFI OS HD(1,GPT,16f06d01-50da-6544-86bd-f3457f980086,0x1000,0x96000)/File(\EFI\BOOT\BOOTX64.EFI)0000424f`
|
||||||
expected := EfiApp{ID: 2, Name: "UEFI OS", Path: `\EFI\BOOT\BOOTX64.EFI`, Active: false, DiskID: "16f06d01-50da-6544-86bd-f3457f980086"}
|
expected := EfiApp{ID: 2, Name: "UEFI OS", Path: `\EFI\BOOT\BOOTX64.EFI`, Active: false, DiskID: "16f06d01-50da-6544-86bd-f3457f980086"}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.faercol.me/faercol/http-boot-config/config/logger"
|
"git.faercol.me/faercol/http-boot-config/config/logger"
|
||||||
|
@ -27,11 +28,19 @@ type enrollPayload struct {
|
||||||
SelectedOption string `json:"selected_option"`
|
SelectedOption string `json:"selected_option"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type enrollRespPayload struct {
|
type enrollRespPayloadJSON struct {
|
||||||
ID string `json:"ID"`
|
ID string `json:"ID"`
|
||||||
|
MulticastGroup string `json:"multicast_group"`
|
||||||
|
MulticastPort int `json:"multicast_port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func enrollToServer(ctx context.Context, host string, name string, apps []prober.EfiApp) (uuid.UUID, error) {
|
type EnrollConfig struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
MulticastGroup net.IP
|
||||||
|
MulticastPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrollToServer(ctx context.Context, host string, name string, apps []prober.EfiApp) (EnrollConfig, error) {
|
||||||
payload := enrollPayload{
|
payload := enrollPayload{
|
||||||
Name: name,
|
Name: name,
|
||||||
Options: make(map[string]enrollEFIOption),
|
Options: make(map[string]enrollEFIOption),
|
||||||
|
@ -46,49 +55,49 @@ func enrollToServer(ctx context.Context, host string, name string, apps []prober
|
||||||
|
|
||||||
dat, err := json.Marshal(payload)
|
dat, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("failed to serialize payload: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to serialize payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
subCtx, cancel := context.WithTimeout(ctx, reqTimeout)
|
subCtx, cancel := context.WithTimeout(ctx, reqTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req, err := http.NewRequestWithContext(subCtx, http.MethodPost, host+enrollURL, bytes.NewBuffer(dat))
|
req, err := http.NewRequestWithContext(subCtx, http.MethodPost, host+enrollURL, bytes.NewBuffer(dat))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("failed to initialize request: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to initialize request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("failed to do query to remote boot server: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to do query to remote boot server: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return uuid.Nil, fmt.Errorf("unexpected returncode %d", resp.StatusCode)
|
return EnrollConfig{}, fmt.Errorf("unexpected returncode %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
var respPayload enrollRespPayload
|
var respPayload enrollRespPayloadJSON
|
||||||
respDat, err := io.ReadAll(resp.Body)
|
respDat, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("failed to read response from server: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to read response from server: %w", err)
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respDat, &respPayload); err != nil {
|
if err := json.Unmarshal(respDat, &respPayload); err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("failed to deserialize data from server: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to deserialize data from server: %w", err)
|
||||||
}
|
}
|
||||||
newID, err := uuid.Parse(respPayload.ID)
|
newID, err := uuid.Parse(respPayload.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("invalid UUID %q from server: %w", respPayload.ID, err)
|
return EnrollConfig{}, fmt.Errorf("invalid UUID %q from server: %w", respPayload.ID, err)
|
||||||
}
|
}
|
||||||
return newID, nil
|
return EnrollConfig{ID: newID, MulticastGroup: net.ParseIP(respPayload.MulticastGroup), MulticastPort: respPayload.MulticastPort}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Enroll(ctx context.Context, host, name string, l *logger.SimpleLogger) error {
|
func Enroll(ctx context.Context, host, name string, l *logger.SimpleLogger) (EnrollConfig, error) {
|
||||||
apps, err := prober.GetEFIApps(l)
|
apps, err := prober.GetEFIApps()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get list of available EFI applications: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to get list of available EFI applications: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newID, err := enrollToServer(ctx, host, name, apps)
|
newConf, err := enrollToServer(ctx, host, name, apps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to enroll client to remote host: %w", err)
|
return EnrollConfig{}, fmt.Errorf("failed to enroll client to remote host: %w", err)
|
||||||
}
|
}
|
||||||
l.Infof("Successfully enrolled new client, ID is %q", newID.String())
|
l.Infof("Successfully enrolled new client, ID is %q", newConf.ID.String())
|
||||||
return nil
|
return newConf, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ var ErrNotEnrolled = errors.New("client not enrolled")
|
||||||
const getBootRoute = "/config"
|
const getBootRoute = "/config"
|
||||||
const reqTimeout = 5 * time.Second
|
const reqTimeout = 5 * time.Second
|
||||||
|
|
||||||
type clientConfig struct {
|
type ClientConfig struct {
|
||||||
EFIConfig struct {
|
EFIConfig struct {
|
||||||
ID string `json:"ID"`
|
ID string `json:"ID"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
@ -36,7 +36,7 @@ type clientConfig struct {
|
||||||
} `json:"net_config"`
|
} `json:"net_config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteConfig(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) ([]byte, error) {
|
func GetRemoteConfig(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) ([]byte, error) {
|
||||||
subCtx, cancel := context.WithTimeout(ctx, reqTimeout)
|
subCtx, cancel := context.WithTimeout(ctx, reqTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
req, err := http.NewRequestWithContext(subCtx, http.MethodGet, host+getBootRoute, nil)
|
req, err := http.NewRequestWithContext(subCtx, http.MethodGet, host+getBootRoute, nil)
|
||||||
|
@ -69,7 +69,7 @@ func getRemoteConfig(ctx context.Context, host string, id uuid.UUID, l *logger.S
|
||||||
}
|
}
|
||||||
|
|
||||||
func DisplayRemoteConfigJSON(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) error {
|
func DisplayRemoteConfigJSON(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) error {
|
||||||
dat, err := getRemoteConfig(ctx, host, id, l)
|
dat, err := GetRemoteConfig(ctx, host, id, l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -85,12 +85,12 @@ func DisplayRemoteConfigJSON(ctx context.Context, host string, id uuid.UUID, l *
|
||||||
}
|
}
|
||||||
|
|
||||||
func DisplayRemoteConfigPretty(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) error {
|
func DisplayRemoteConfigPretty(ctx context.Context, host string, id uuid.UUID, l *logger.SimpleLogger) error {
|
||||||
dat, err := getRemoteConfig(ctx, host, id, l)
|
dat, err := GetRemoteConfig(ctx, host, id, l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsedConf clientConfig
|
var parsedConf ClientConfig
|
||||||
if err := json.Unmarshal(dat, &parsedConf); err != nil {
|
if err := json.Unmarshal(dat, &parsedConf); err != nil {
|
||||||
return fmt.Errorf("invalid JSON format for result: %w", err)
|
return fmt.Errorf("invalid JSON format for result: %w", err)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue