From ed7ab1e42871905a0e509d7650cfc2cc5e47c19e Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Tue, 30 Jan 2024 11:08:21 -0600 Subject: [PATCH] Fixed macOS MSI package -- using local wine and wix (#16307) New flow for `fleetctl --package --type=msi` on macOS using arm64 processor (M1, M2, etc.) - wine must be installed locally. See ./orbit/tools/build/install-wine-macos.sh and https://wiki.winehq.org/MacOS for reference. - --local-wix-dir can be used to point to a local Wix3 installation (using this switch requires a current Fleet EE subscription) #15463 PR for docs: https://github.com/fleetdm/fleet/pull/16459 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> --- .github/workflows/test-packaging.yml | 16 ++- changes/15463-msi-on-mac | 3 + cmd/fleetctl/package.go | 46 +------- ee/fleetctl/local_wix.go | 2 +- orbit/pkg/packaging/windows.go | 158 ++++++++++++++++++++++++++- orbit/pkg/packaging/windows_test.go | 13 +++ orbit/pkg/packaging/wix/wix.go | 44 ++++++-- scripts/macos-install-wine.sh | 15 +++ 8 files changed, 242 insertions(+), 55 deletions(-) create mode 100644 changes/15463-msi-on-mac create mode 100755 scripts/macos-install-wine.sh diff --git a/.github/workflows/test-packaging.yml b/.github/workflows/test-packaging.yml index e0fb8db825..bdf5940aab 100644 --- a/.github/workflows/test-packaging.yml +++ b/.github/workflows/test-packaging.yml @@ -77,7 +77,17 @@ jobs: - name: Checkout Code uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - # It seems faster not to cache Go dependencies + - name: Install wine and wix + if: matrix.os == 'macos-latest' + run: | + ./scripts/macos-install-wine.sh + wget https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip -nv -O wix.zip + mkdir wix + unzip wix.zip -d wix + rm -f wix.zip + echo wix installed at $(pwd)/wix + + # It seems faster not to cache Go dependencies - name: Install Go Dependencies run: make deps-go @@ -107,3 +117,7 @@ jobs: - name: Build PKG with Fleet Desktop run: ./build/fleetctl package --type pkg --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop + + - name: Build MSI (using local Wix) + if: matrix.os == 'macos-latest' + run: ./build/fleetctl package --type msi --enroll-secret=foo --fleet-url=https://localhost:8080 --fleet-desktop --local-wix-dir ./wix diff --git a/changes/15463-msi-on-mac b/changes/15463-msi-on-mac new file mode 100644 index 0000000000..efd1b37838 --- /dev/null +++ b/changes/15463-msi-on-mac @@ -0,0 +1,3 @@ +New flow for `fleetctl package --type=msi` on macOS using arm64 processor (M1, M2, etc.) +- wine must be installed locally. See ./orbit/tools/build/install-wine-macos.sh and https://wiki.winehq.org/MacOS for reference. +- --local-wix-dir can be used to point to a local Wix3 installation (using this switch requires a current Fleet EE subscription) diff --git a/cmd/fleetctl/package.go b/cmd/fleetctl/package.go index 837974a16c..d0a49c138f 100644 --- a/cmd/fleetctl/package.go +++ b/cmd/fleetctl/package.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "runtime" - "strings" "time" eefleetctl "github.com/fleetdm/fleet/v4/ee/fleetctl" @@ -289,8 +288,9 @@ func packageCommand() *cli.Command { return errors.New("native tooling is only available in Linux") } - if opt.LocalWixDir != "" && runtime.GOOS != "windows" { - return errors.New(`Could not use local WiX to generate an osquery installer. This option is only available on Windows. + if opt.LocalWixDir != "" && runtime.GOOS != "windows" && runtime.GOOS != "darwin" { + return errors.New( + `Could not use local WiX to generate an osquery installer. This option is only available on Windows and macOS. Visit https://wixtoolset.org/ for more information about how to use WiX.`) } @@ -340,25 +340,8 @@ func packageCommand() *cli.Command { zlog.Logger = zerolog.Nop() } - const maxAttempts = 9 // see #5732 - var ( - attempts int - path string - err error - ) - for attempts < maxAttempts { - attempts++ - - if attempts > 1 { - fmt.Printf("Generating your osquery installer [attempt %d/%d]...\n\n", attempts, maxAttempts) - } else { - fmt.Println("Generating your osquery installer...") - } - path, err = buildFunc(opt) - if err == nil || !shouldRetry(c.String("type"), opt, err) { - break - } - } + fmt.Println("Generating your osquery installer...") + path, err := buildFunc(opt) if err != nil { return err } @@ -379,25 +362,6 @@ To add other devices to Fleet, distribute this installer using Chef, Ansible, Ja } } -func shouldRetry(pkgType string, opt packaging.Options, err error) bool { - if pkgType != "msi" || runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { - return false - } - - // building an MSI on macos M1, check if the error is one that should be retried - errStr := err.Error() - switch { - case strings.Contains(errStr, "package root files: heat failed"): - return true - case strings.Contains(errStr, "build package: candle failed"): - return true - case strings.Contains(errStr, "build package: light failed"): - return true - default: - return false - } -} - func checkPEMCertificate(path string) error { cert, err := os.ReadFile(path) if err != nil { diff --git a/ee/fleetctl/local_wix.go b/ee/fleetctl/local_wix.go index 1d1564fb8f..6cc9733bfa 100644 --- a/ee/fleetctl/local_wix.go +++ b/ee/fleetctl/local_wix.go @@ -5,7 +5,7 @@ import "github.com/urfave/cli/v2" func LocalWixDirFlag(dest *string) *cli.StringFlag { return &cli.StringFlag{ Name: "local-wix-dir", - Usage: "Use local install of WiX instead of Docker Hub (only available on Windows w/ WiX v3). This functionality is licensed under the Fleet EE License. Usage requires a current Fleet EE subscription.", + Usage: "Use local install of WiX instead of Docker Hub (only available on Windows and macOS w/ WiX v3). This functionality is licensed under the Fleet EE License. Usage requires a current Fleet EE subscription.", Destination: dest, } } diff --git a/orbit/pkg/packaging/windows.go b/orbit/pkg/packaging/windows.go index 3d9afe67fd..731e57efe0 100644 --- a/orbit/pkg/packaging/windows.go +++ b/orbit/pkg/packaging/windows.go @@ -1,10 +1,13 @@ package packaging import ( + "archive/zip" "bytes" "crypto/tls" "errors" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -17,11 +20,14 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/packaging/wix" "github.com/fleetdm/fleet/v4/orbit/pkg/update" "github.com/fleetdm/fleet/v4/pkg/file" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/secure" "github.com/josephspurrier/goversioninfo" "github.com/rs/zerolog/log" ) +const wixDownload = "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311-binaries.zip" + // BuildMSI builds a Windows .msi. // Note: this function is not safe for concurrent use func BuildMSI(opt Options) (string, error) { @@ -149,7 +155,40 @@ func BuildMSI(opt Options) (string, error) { } } - if err := wix.Heat(tmpDir, opt.NativeTooling, opt.LocalWixDir); err != nil { + absWixDir := opt.LocalWixDir + wineChecked := false + + // Download wix for macOS running on arm64, unless a local-wix-dir is provided. + // We are using native MSI build on macOS arm64, instead of Docker, because the current fleetdm/wix Docker image is unreliable on macOS arm64. + // We are looking into creating a new Docker image for macOS arm64. + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && absWixDir == "" { + fmt.Println("Detected macOS arm64. fleetctl must use locally installed wine and wix to build the MSI package.") + + // Ensure wine is installed before downloading wix + if err = checkWine(false); err != nil { + return "", err + } + wineChecked = true + + fmt.Printf("Downloading wix from %s\n", wixDownload) + client := fleethttp.NewClient() + absWixDir = filepath.Join(tmpDir, "wix") + err = downloadAndExtractZip(client, wixDownload, absWixDir) + if err != nil { + return "", err + } + } + + if absWixDir != "" { + absWixDir, err = filepath.Abs(absWixDir) + if err != nil { + return "", fmt.Errorf("could not get filepath from local-wix-dir %s: %w", opt.LocalWixDir, err) + } + if err = checkWine(wineChecked); err != nil { + return "", err + } + } + if err := wix.Heat(tmpDir, opt.NativeTooling, absWixDir); err != nil { return "", fmt.Errorf("package root files: %w", err) } @@ -157,11 +196,11 @@ func BuildMSI(opt Options) (string, error) { return "", fmt.Errorf("transform heat: %w", err) } - if err := wix.Candle(tmpDir, opt.NativeTooling, opt.LocalWixDir); err != nil { + if err := wix.Candle(tmpDir, opt.NativeTooling, absWixDir); err != nil { return "", fmt.Errorf("build package: %w", err) } - if err := wix.Light(tmpDir, opt.NativeTooling, opt.LocalWixDir); err != nil { + if err := wix.Light(tmpDir, opt.NativeTooling, absWixDir); err != nil { return "", fmt.Errorf("build package: %w", err) } @@ -177,6 +216,20 @@ func BuildMSI(opt Options) (string, error) { return filename, nil } +func checkWine(wineChecked bool) error { + if !wineChecked && runtime.GOOS == "darwin" { + // Ensure wine is installed + cmd := exec.Command(wix.WineCmd, "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf( + "%s failed. Is Wine installed? Creating a fleetd agent for Windows (.msi) requires Wine. To install Wine see the script here: https://github.com/fleetdm/fleet/blob/fleet-v4.44.0/scripts/macos-install-wine.sh %w", + wix.WineCmd, err, + ) + } + } + return nil +} + func writeWixFile(opt Options, rootPath string) error { // PackageInfo is metadata for the pkg path := filepath.Join(rootPath, "main.wxs") @@ -378,3 +431,102 @@ func writeResourceSyso(opt Options, orbitPath string) error { return nil } + +func downloadAndExtractZip(client *http.Client, urlPath string, destPath string) error { + zipFile, err := os.CreateTemp("", "file.zip") + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer zipFile.Close() + defer os.Remove(zipFile.Name()) + + req, err := http.NewRequest(http.MethodGet, urlPath, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("could not download %s: %w", urlPath, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("could not download %s: received http status code %s", urlPath, resp.Status) + } + _, err = io.Copy(zipFile, resp.Body) + if err != nil { + return fmt.Errorf("could not write %s: %w", zipFile.Name(), err) + } + + // Open the downloaded file for reading. With zip, we cannot unzip directly from resp.Body + zipReader, err := zip.OpenReader(zipFile.Name()) + if err != nil { + return fmt.Errorf("could not open %s: %w", zipFile.Name(), err) + } + defer zipReader.Close() + + err = os.MkdirAll(filepath.Dir(destPath), 0755) + if err != nil { + return fmt.Errorf("could not create directory %s: %w", filepath.Dir(destPath), err) + } + + // Extract each file in the archive + for _, archiveReader := range zipReader.File { + err = extractZipFile(archiveReader, destPath) + if err != nil { + return err + } + } + + return nil + +} + +func extractZipFile(archiveReader *zip.File, destPath string) error { + if archiveReader.FileInfo().Mode()&os.ModeSymlink != 0 { + // Skip symlinks for security reasons + return nil + } + + // Open the file in the archive + archiveFile, err := archiveReader.Open() + if err != nil { + return fmt.Errorf("could not open archive %s: %w", archiveReader.Name, err) + } + defer archiveFile.Close() + + // Clean the archive path to prevent extracting files outside the destination. + archivePath := filepath.Clean(archiveReader.Name) + if strings.HasPrefix(archivePath, ".."+string(filepath.Separator)) { + // Skip relative paths for security reasons + return nil + } + // Prepare to write the file + finalPath := filepath.Join(destPath, archivePath) + + // Check if the file to extract is just a directory + if archiveReader.FileInfo().IsDir() { + err = os.MkdirAll(finalPath, 0755) + if err != nil { + return fmt.Errorf("could not create directory %s: %w", finalPath, err) + } + } else { + // Create all needed directories + if os.MkdirAll(filepath.Dir(finalPath), 0755) != nil { + return fmt.Errorf("could not create directory %s: %w", filepath.Dir(finalPath), err) + } + + // Prepare to write the destination file + destinationFile, err := os.OpenFile(finalPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, archiveReader.Mode()) + if err != nil { + return fmt.Errorf("could not open file %s: %w", finalPath, err) + } + defer destinationFile.Close() + + // Write the destination file + if _, err = io.Copy(destinationFile, archiveFile); err != nil { + return fmt.Errorf("could not write file %s: %w", finalPath, err) + } + } + return nil +} diff --git a/orbit/pkg/packaging/windows_test.go b/orbit/pkg/packaging/windows_test.go index 1199be4209..cd1ee4e54c 100644 --- a/orbit/pkg/packaging/windows_test.go +++ b/orbit/pkg/packaging/windows_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,3 +91,14 @@ func TestSanitizeVersion(t *testing.T) { require.Equal(t, tC.Parts, result) } } + +func TestDownloadAndExtractZip(t *testing.T) { + t.Parallel() + path := t.TempDir() + client := fleethttp.NewClient() + err := downloadAndExtractZip(client, wixDownload, path) + require.NoError(t, err) + assert.FileExists(t, filepath.Join(path, "heat.exe")) + assert.FileExists(t, filepath.Join(path, "candle.exe")) + assert.FileExists(t, filepath.Join(path, "light.exe")) +} diff --git a/orbit/pkg/packaging/wix/wix.go b/orbit/pkg/packaging/wix/wix.go index 06b8871bc2..37d7a7a8dd 100644 --- a/orbit/pkg/packaging/wix/wix.go +++ b/orbit/pkg/packaging/wix/wix.go @@ -7,10 +7,15 @@ import ( "fmt" "os" "os/exec" + "path/filepath" + "runtime" ) const ( directoryReference = "ORBITROOT" + imageName = "fleetdm/wix:latest" + dockerPlatform = "linux/amd64" + WineCmd = "wine64" ) // Heat runs the WiX Heat command on the provided directory. @@ -24,15 +29,18 @@ func Heat(path string, native bool, localWixDir string) error { if !native && localWixDir == "" { args = append( args, - "docker", "run", "--rm", "--platform", "linux/amd64", + "docker", "run", "--rm", "--platform", dockerPlatform, "--volume", path+":/wix", // mount volume - "fleetdm/wix:latest", // image name + imageName, // image name ) } heatPath := `heat` if localWixDir != "" { - heatPath = localWixDir + `\heat.exe` + heatPath = filepath.Join(localWixDir, `heat.exe`) + if runtime.GOOS == "darwin" { + args = append(args, WineCmd) + } } args = append(args, @@ -48,6 +56,10 @@ func Heat(path string, native bool, localWixDir string) error { cmd := exec.Command(args[0], args[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if args[0] == WineCmd { + cmd.Env = append(os.Environ(), "WINEDEBUG=-all") + } + if native || localWixDir != "" { cmd.Dir = path } @@ -69,15 +81,18 @@ func Candle(path string, native bool, localWixDir string) error { if !native && localWixDir == "" { args = append( args, - "docker", "run", "--rm", "--platform", "linux/amd64", + "docker", "run", "--rm", "--platform", dockerPlatform, "--volume", path+":/wix", // mount volume - "fleetdm/wix:latest", // image name + imageName, // image name ) } candlePath := `candle` if localWixDir != "" { - candlePath = localWixDir + `\candle.exe` + candlePath = filepath.Join(localWixDir, `candle.exe`) + if runtime.GOOS == "darwin" { + args = append(args, WineCmd) + } } args = append(args, candlePath, "heat.wxs", "main.wxs", // command @@ -88,6 +103,10 @@ func Candle(path string, native bool, localWixDir string) error { cmd := exec.Command(args[0], args[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if args[0] == WineCmd { + cmd.Env = append(os.Environ(), "WINEDEBUG=-all") + } + if native || localWixDir != "" { cmd.Dir = path } @@ -109,15 +128,18 @@ func Light(path string, native bool, localWixDir string) error { if !native && localWixDir == "" { args = append( args, - "docker", "run", "--rm", "--platform", "linux/amd64", + "docker", "run", "--rm", "--platform", dockerPlatform, "--volume", path+":/wix", // mount volume - "fleetdm/wix:latest", // image name + imageName, // image name ) } lightPath := `light` if localWixDir != "" { - lightPath = localWixDir + `\light.exe` + lightPath = filepath.Join(localWixDir, `light.exe`) + if runtime.GOOS == "darwin" { + args = append(args, WineCmd) + } } args = append(args, lightPath, "heat.wixobj", "main.wixobj", // command @@ -130,6 +152,10 @@ func Light(path string, native bool, localWixDir string) error { cmd := exec.Command(args[0], args[1:]...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if args[0] == WineCmd { + cmd.Env = append(os.Environ(), "WINEDEBUG=-all") + } + if native || localWixDir != "" { cmd.Dir = path } diff --git a/scripts/macos-install-wine.sh b/scripts/macos-install-wine.sh new file mode 100755 index 0000000000..fd5c55532b --- /dev/null +++ b/scripts/macos-install-wine.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -eo pipefail + +# Reference: https://wiki.winehq.org/MacOS +# NOTE: Wine can be installed without brew via a distribution such as https://github.com/Gcenx/macOS_Wine_builds/releases/tag/9.0, or by building from source. + +# Check if brew is installed +if ! command -v brew >/dev/null 2>&1 ; then + echo "Homebrew is not installed. Please install Homebrew first. For instructions, see https://brew.sh/" + exit 1 +fi + +# Install wine via brew +brew install --cask --no-quarantine https://raw.githubusercontent.com/Homebrew/homebrew-cask/1ecfe82f84e0f3c3c6b741d3ddc19a164c2cb18d/Casks/w/wine-stable.rb