diff --git a/.github/workflows/generate-desktop-targets.yml b/.github/workflows/generate-desktop-targets.yml index 4233c7291f..d3854175ef 100644 --- a/.github/workflows/generate-desktop-targets.yml +++ b/.github/workflows/generate-desktop-targets.yml @@ -63,7 +63,7 @@ jobs: path: desktop.app.tar.gz desktop-windows: - runs-on: macos-latest + runs-on: ubuntu-latest steps: - name: Install Go @@ -83,4 +83,27 @@ jobs: uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535 # v2 with: name: fleet-desktop.exe - path: fleet-desktop.exe \ No newline at end of file + path: fleet-desktop.exe + + desktop-linux: + runs-on: ubuntu-latest + steps: + + - name: Install Go + uses: actions/setup-go@f6164bd8c8acb4a71fb2791a8b6c4024ff038dab # v2 + with: + go-version: '^1.17.0' + + - name: Checkout + uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 + + - name: Generate desktop.tar.gz + run: | + FLEET_DESKTOP_VERSION=$FLEET_DESKTOP_VERSION \ + make desktop-linux + + - name: Upload desktop.tar.gz + uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535 # v2 + with: + name: desktop.tar.gz + path: desktop.tar.gz \ No newline at end of file diff --git a/.gitignore b/.gitignore index e1a87bf011..57e5768caf 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ terraform.tfstate* # generated installers fleet-osquery* desktop.app.tar.gz +desktop.tar.gz # residual files when running the cpe command cmd/cpe/etagenv diff --git a/Dockerfile-desktop-linux b/Dockerfile-desktop-linux new file mode 100644 index 0000000000..be2ffd4b9a --- /dev/null +++ b/Dockerfile-desktop-linux @@ -0,0 +1,19 @@ +FROM golang:1.17.8-bullseye +LABEL maintainer="Fleet Developers " + +RUN apt-get update && apt-get install -y \ + gcc \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /usr/src/fleet +RUN mkdir -p /output + +WORKDIR /usr/src/fleet + +COPY orbit ./orbit +COPY pkg ./pkg +COPY go.mod go.sum ./ + +CMD /bin/bash diff --git a/Makefile b/Makefile index 620cfb1d91..a29c17335e 100644 --- a/Makefile +++ b/Makefile @@ -175,6 +175,10 @@ deps-js: deps-go: go mod download +ifeq ($(shell uname -s),Linux) + # Dependency required for Linux Fleet Desktop. + sudo apt-get install gcc libgtk-3-dev libayatana-appindicator3-dev -y +endif migration: go run github.com/fleetdm/goose/cmd/goose -dir server/datastore/mysql/migrations/tables create $(name) @@ -306,6 +310,8 @@ endif # # Usage: # FLEET_DESKTOP_APPLE_AUTHORITY=foo FLEET_DESKTOP_VERSION=0.0.1 make desktop-app-tar-gz +# +# Output: desktop.app.tar.gz desktop-app-tar-gz: ifneq ($(shell uname), Darwin) @echo "Makefile target desktop-app-tar-gz is only supported on macOS" @@ -313,12 +319,37 @@ ifneq ($(shell uname), Darwin) endif go run ./tools/desktop macos +FLEET_DESKTOP_VERSION ?= unknown + # Build desktop executable for Windows. # # Usage: # FLEET_DESKTOP_VERSION=0.0.1 make desktop-windows +# +# Output: fleet-desktop.exe desktop-windows: - GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o fleet-desktop.exe ./orbit/cmd/desktop + GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui -X=main.version=$(FLEET_DESKTOP_VERSION)" -o fleet-desktop.exe ./orbit/cmd/desktop + +# Build desktop executable for Linux. +# +# Usage: +# FLEET_DESKTOP_VERSION=0.0.1 make desktop-linux +# +# Output: desktop.tar.gz +desktop-linux: + docker build -f Dockerfile-desktop-linux -t desktop-linux-builder . + docker run --rm -v $(shell pwd):/output desktop-linux-builder /bin/bash -c "\ + mkdir /output/fleet-desktop && \ + go build -o /output/fleet-desktop/fleet-desktop -ldflags "-X=main.version=$(FLEET_DESKTOP_VERSION)" /usr/src/fleet/orbit/cmd/desktop && \ + cp /usr/lib/x86_64-linux-gnu/libayatana-appindicator3.so.1 \ + /usr/lib/x86_64-linux-gnu/libayatana-ido3-0.4.so.0 \ + /usr/lib/x86_64-linux-gnu/libayatana-indicator3.so.7 \ + /lib/x86_64-linux-gnu/libm.so.6 \ + /usr/lib/x86_64-linux-gnu/libdbusmenu-gtk3.so.4 \ + /usr/lib/x86_64-linux-gnu/libdbusmenu-glib.so.4 \ + /output/fleet-desktop && cd /output && \ + tar czf desktop.tar.gz fleet-desktop && \ + rm -r fleet-desktop" # db-replica-setup setups one main and one read replica MySQL instance for dev/testing. # - Assumes the docker containers are already running (tools/mysql-replica-testing/docker-compose.yml) diff --git a/changes/issue-4925-fleet-desktop-linux b/changes/issue-4925-fleet-desktop-linux new file mode 100644 index 0000000000..04ed91f9d8 --- /dev/null +++ b/changes/issue-4925-fleet-desktop-linux @@ -0,0 +1 @@ +* Add (beta) support for Fleet Desktop to linux. diff --git a/ee/fleetctl/updates.go b/ee/fleetctl/updates.go index 3efc8d1a06..7c2d7d08b1 100644 --- a/ee/fleetctl/updates.go +++ b/ee/fleetctl/updates.go @@ -251,6 +251,9 @@ func updatesAddFunc(c *cli.Context) error { case name == "desktop" && platform == "windows": // This is a special case for the desktop target on Windows. dstPath = filepath.Join(filepath.Dir(dstPath), constant.DesktopAppExecName+".exe") + case name == "desktop" && platform == "linux": + // This is a special case for the desktop target on Linux. + dstPath += ".tar.gz" case strings.HasSuffix(target, ".exe"): dstPath += ".exe" case strings.HasSuffix(target, ".app.tar.gz"): diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index f20a4e46f6..0b7884088d 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -1,6 +1,3 @@ -//go:build darwin || windows -// +build darwin windows - package main import ( @@ -17,12 +14,15 @@ import ( "github.com/getlantern/systray" ) +var version = "unknown" + func main() { // Our TUF provided targets must support launching with "--help". if len(os.Args) > 1 && os.Args[1] == "--help" { fmt.Println("Fleet Desktop application executable") return } + log.Printf("fleet-desktop version=%s\n", version) devURL := os.Getenv("FLEET_DESKTOP_DEVICE_URL") if devURL == "" { diff --git a/orbit/cmd/desktop/desktop_unix.go b/orbit/cmd/desktop/desktop_unix.go index 5fe818a7ab..45bcc0dbdb 100644 --- a/orbit/cmd/desktop/desktop_unix.go +++ b/orbit/cmd/desktop/desktop_unix.go @@ -1,7 +1,5 @@ -//go:build darwin -// +build darwin - -// TODO(lucas): Once we support Linux, amend the above build tags. +//go:build darwin || linux +// +build darwin linux package main diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 562c370f2b..8ba56e42d0 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -229,6 +229,8 @@ func main() { opt.Targets["desktop"] = update.DesktopMacOSTarget case "windows": opt.Targets["desktop"] = update.DesktopWindowsTarget + case "linux": + opt.Targets["desktop"] = update.DesktopLinuxTarget default: log.Fatal().Str("GOOS", runtime.GOOS).Msg("unsupported GOOS for desktop target") } @@ -321,7 +323,7 @@ func main() { if !c.Bool("disable-updates") { targets := []string{"orbit", "osqueryd"} - if c.Bool("fleet-desktop") && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { + if c.Bool("fleet-desktop") { targets = append(targets, "desktop") } updateRunner, err := update.NewRunner(updater, update.RunnerOptions{ @@ -483,7 +485,7 @@ func main() { })) g.Add(ext.Execute, ext.Interrupt) - if c.Bool("fleet-desktop") && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { + if c.Bool("fleet-desktop") { desktopRunner := newDesktopRunner(desktopPath, fleetURL, deviceAuthToken, c.Bool("insecure")) g.Add(desktopRunner.actor()) } @@ -557,7 +559,7 @@ func (d *desktopRunner) execute() error { for { // First retry logic to start fleet-desktop. - if done := retry(30*time.Second, d.interruptCh, func() bool { + if done := retry(30*time.Second, false, d.interruptCh, func() bool { // Orbit runs as root user on Unix and as SYSTEM (Windows Service) user on Windows. // To be able to run the desktop application (mostly to register the icon in the system tray) // we need to run the application as the login user. @@ -572,7 +574,8 @@ func (d *desktopRunner) execute() error { } // Second retry logic to monitor fleet-desktop. - if done := retry(30*time.Second, d.interruptCh, func() bool { + // Call with waitFirst=true to give some time for the process to start. + if done := retry(30*time.Second, true, d.interruptCh, func() bool { switch _, err := getProcessByName(constant.DesktopAppExecName); { case err == nil: return true // all good, process is running, retry. @@ -589,14 +592,17 @@ func (d *desktopRunner) execute() error { } } -func retry(d time.Duration, done chan struct{}, fn func() bool) bool { +func retry(d time.Duration, waitFirst bool, done chan struct{}, fn func() bool) bool { ticker := time.NewTicker(d) defer ticker.Stop() for { - if retry := fn(); !retry { - return false + if !waitFirst { + if retry := fn(); !retry { + return false + } } + waitFirst = false select { case <-done: return true diff --git a/orbit/pkg/execuser/execuser_linux.go b/orbit/pkg/execuser/execuser_linux.go index 77fe5aa6a9..df245b40b6 100644 --- a/orbit/pkg/execuser/execuser_linux.go +++ b/orbit/pkg/execuser/execuser_linux.go @@ -1,5 +1,114 @@ package execuser +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/rs/zerolog/log" +) + +// run uses sudo to run the given path as login user. func run(path string, opts eopts) error { - panic("unimplemented") + user, err := getLoginUID() + if err != nil { + return fmt.Errorf("get user: %w", err) + } + + log.Info(). + Str("user", user.name). + Int64("id", user.id). + Msg("running sudo") + + arg := []string{"-u", user.name, "-H"} + for _, nv := range opts.env { + arg = append(arg, fmt.Sprintf("%s=%s", nv[0], nv[1])) + } + arg = append(arg, + // TODO(lucas): Default to display 0, revisit when working on + // multi-user/multi-session support. This assumes there's only + // one desktop session and belongs to the user returned in `getLoginUID'. + "DISPLAY=:0", + // DBUS_SESSION_BUS_ADDRESS sets the location of the user login session bus. + // Required by the libayatana-appindicator3 library to display a tray icon + // on the desktop session. + fmt.Sprintf("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%d/bus", user.id), + fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(path), os.ExpandEnv("$LD_LIBRARY_PATH")), + path, + ) + + cmd := exec.Command("sudo", arg...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + log.Printf("cmd=%s", cmd.String()) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("open path %q: %w", path, err) + } + return nil +} + +type user struct { + name string + id int64 +} + +// getLoginUID returns the name and uid of the first login user +// as reported by the `users' command. +// +// NOTE(lucas): It is always picking first login user as returned +// by `users', revisit when working on multi-user/multi-session support. +func getLoginUID() (*user, error) { + out, err := exec.Command("users").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("users exec failed: %w", err) + } + usernames := parseUsersOutput(string(out)) + username := usernames[0] + if username == "" { + return nil, fmt.Errorf("no user session found") + } + out, err = exec.Command("id", "-u", username).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("id exec failed: %w", err) + } + uid, err := parseIDOutput(string(out)) + if err != nil { + return nil, err + } + return &user{ + name: username, + id: uid, + }, nil +} + +// parseUsersOutput parses the output of the `users' command. +// +// `users' command prints on a single line a blank-separated list of user names of +// users currently logged in to the current host. Each user name +// corresponds to a login session, so if a user has more than one login +// session, that user's name will appear the same number of times in the +// output. +// +// Returns the list of usernames. +func parseUsersOutput(s string) []string { + var users []string + for _, userCol := range strings.Split(strings.TrimSpace(s), " ") { + users = append(users, userCol) + } + return users +} + +// parseIDOutput parses the output of the `id' command. +// +// Returns the parsed uid. +func parseIDOutput(s string) (int64, error) { + uid, err := strconv.ParseInt(strings.TrimSpace(string(s)), 10, 0) + if err != nil { + return 0, fmt.Errorf("failed to parse uid: %w", err) + } + return uid, nil } diff --git a/orbit/pkg/packaging/linux_shared.go b/orbit/pkg/packaging/linux_shared.go index 76579b9946..1d25047161 100644 --- a/orbit/pkg/packaging/linux_shared.go +++ b/orbit/pkg/packaging/linux_shared.go @@ -40,6 +40,12 @@ func buildNFPM(opt Options, pkger nfpm.Packager) (string, error) { updateOpt.RootDirectory = orbitRoot updateOpt.Targets = update.LinuxTargets + if opt.Desktop { + updateOpt.Targets["desktop"] = update.DesktopLinuxTarget + // Override default channel with the provided value. + updateOpt.Targets.SetTargetChannel("desktop", opt.DesktopChannel) + } + // Override default channels with the provided values. updateOpt.Targets.SetTargetChannel("orbit", opt.OrbitChannel) updateOpt.Targets.SetTargetChannel("osqueryd", opt.OsquerydChannel) @@ -235,6 +241,10 @@ ORBIT_UPDATE_URL={{ .UpdateURL }} ORBIT_ORBIT_CHANNEL={{ .OrbitChannel }} ORBIT_OSQUERYD_CHANNEL={{ .OsquerydChannel }} ORBIT_UPDATE_INTERVAL={{ .OrbitUpdateInterval }} +{{ if .Desktop }} +ORBIT_FLEET_DESKTOP=true +ORBIT_DESKTOP_CHANNEL={{ .DesktopChannel }} +{{ end }} {{ if .Insecure }}ORBIT_INSECURE=true{{ end }} {{ if .DisableUpdates }}ORBIT_DISABLE_UPDATES=true{{ end }} {{ if .FleetURL }}ORBIT_FLEET_URL={{.FleetURL}}{{ end }} @@ -295,12 +305,13 @@ func writePostInstall(opt Options, path string) error { } func writePreRemove(opt Options, path string) error { + // We add `|| true` in case the service is not running + // or has been manually disabled already. Otherwise, + // uninstallation fails. if err := ioutil.WriteFile(path, []byte(`#!/bin/sh -set -e - -systemctl stop orbit.service -systemctl disable orbit.service +systemctl stop orbit.service || true +systemctl disable orbit.service || true `), constant.DefaultFileMode); err != nil { return fmt.Errorf("write file: %w", err) } diff --git a/orbit/pkg/update/options.go b/orbit/pkg/update/options.go index 4d716ed367..73b8252e81 100644 --- a/orbit/pkg/update/options.go +++ b/orbit/pkg/update/options.go @@ -1,6 +1,13 @@ package update -import "github.com/fleetdm/fleet/v4/orbit/pkg/constant" +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" +) // DefaultOptions are the default options to use when creating an update // client. @@ -59,4 +66,19 @@ var ( Channel: "stable", TargetFile: constant.DesktopAppExecName + ".exe", } + + DesktopLinuxTarget = TargetInfo{ + Platform: "linux", + Channel: "stable", + TargetFile: "desktop.tar.gz", + ExtractedExecSubPath: []string{"fleet-desktop", constant.DesktopAppExecName}, + CustomCheckExec: func(execPath string) error { + cmd := exec.Command(execPath, "--help") + cmd.Env = append(cmd.Env, fmt.Sprintf("LD_LIBRARY_PATH=%s:%s", filepath.Dir(execPath), os.ExpandEnv("$LD_LIBRARY_PATH"))) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("exec new version: %s: %w", string(out), err) + } + return nil + }, + } ) diff --git a/orbit/pkg/update/update.go b/orbit/pkg/update/update.go index 2f31c7de45..113d0f7f2c 100644 --- a/orbit/pkg/update/update.go +++ b/orbit/pkg/update/update.go @@ -85,6 +85,8 @@ type TargetInfo struct { // ExtractedExecSubPath is the path to the executable in case the // target is a compressed file. ExtractedExecSubPath []string + // CustomCheckExec allows for a custom method for checking a downloaded executable. + CustomCheckExec func(execPath string) error } // New creates a new updater given the provided options. All the necessary @@ -286,7 +288,7 @@ func (u *Updater) Get(target string) (*LocalTarget, error) { } if err := checkFileHash(meta, localTarget.Path); err != nil { log.Debug().Str("info", err.Error()).Msg("change detected") - if err := u.download(target, repoPath, localTarget.Path); err != nil { + if err := u.download(target, repoPath, localTarget.Path, localTarget.Info.CustomCheckExec); err != nil { return nil, fmt.Errorf("download %q: %w", repoPath, err) } if strings.HasSuffix(localTarget.Path, ".tar.gz") { @@ -299,7 +301,7 @@ func (u *Updater) Get(target string) (*LocalTarget, error) { } case errors.Is(err, os.ErrNotExist): log.Debug().Err(err).Msg("stat file") - if err := u.download(target, repoPath, localTarget.Path); err != nil { + if err := u.download(target, repoPath, localTarget.Path, localTarget.Info.CustomCheckExec); err != nil { return nil, fmt.Errorf("download %q: %w", repoPath, err) } default: @@ -375,7 +377,7 @@ func (u *Updater) CopyDevBuild(target, devBuildPath string) { // download downloads the target to the provided path. The file is deleted and // an error is returned if the hash does not match. -func (u *Updater) download(target, repoPath, localPath string) error { +func (u *Updater) download(target, repoPath, localPath string, customCheckExec func(execPath string) error) error { staging := filepath.Join(u.opt.RootDirectory, stagingDir) if err := secure.MkdirAll(staging, constant.DefaultDirMode); err != nil { @@ -422,7 +424,7 @@ func (u *Updater) download(target, repoPath, localPath string) error { return fmt.Errorf("close tmp file: %w", err) } - if err := u.checkExec(target, tmp.Name()); err != nil { + if err := u.checkExec(target, tmp.Name(), customCheckExec); err != nil { return fmt.Errorf("exec check failed %q: %w", tmp.Name(), err) } @@ -452,7 +454,7 @@ func goosFromPlatform(platform string) (string, error) { } // checkExec checks/verifies a downloaded executable target by executing it. -func (u *Updater) checkExec(target, tmpPath string) error { +func (u *Updater) checkExec(target, tmpPath string, customCheckExec func(execPath string) error) error { localTarget, err := u.localTarget(target) if err != nil { return err @@ -477,11 +479,18 @@ func (u *Updater) checkExec(target, tmpPath string) error { tmpPath = filepath.Join(append([]string{filepath.Dir(tmpPath)}, localTarget.Info.ExtractedExecSubPath...)...) } - // Note that this would fail for any binary that returns nonzero for --help. - out, err := exec.Command(tmpPath, "--help").CombinedOutput() - if err != nil { - return fmt.Errorf("exec new version: %s: %w", string(out), err) + if customCheckExec != nil { + if err := customCheckExec(tmpPath); err != nil { + return fmt.Errorf("custom exec new version failed: %w", err) + } + } else { + // Note that this would fail for any binary that returns nonzero for --help. + cmd := exec.Command(tmpPath, "--help") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("exec new version: %s: %w", string(out), err) + } } + return nil } diff --git a/tools/desktop/desktop.go b/tools/desktop/desktop.go index cbbe22bc12..7109f62b6a 100644 --- a/tools/desktop/desktop.go +++ b/tools/desktop/desktop.go @@ -147,7 +147,11 @@ func createMacOSApp(version, authority string, notarize bool) error { } /* #nosec G204 -- arguments are actually well defined */ - buildExec := exec.Command("go", "build", "-o", filepath.Join(macOSDir, constant.DesktopAppExecName), "./"+filepath.Join("orbit", "cmd", "desktop")) + buildExec := exec.Command("go", "build", + "-o", filepath.Join(macOSDir, constant.DesktopAppExecName), + "-ldflags", os.ExpandEnv("-X=main.version=$FLEET_DESKTOP_VERSION"), + "./"+filepath.Join("orbit", "cmd", "desktop"), + ) buildExec.Env = append(os.Environ(), "CGO_ENABLED=1") buildExec.Stderr = os.Stderr buildExec.Stdout = os.Stdout diff --git a/tools/tuf/init_tuf.sh b/tools/tuf/init_tuf.sh index 75e412d1c9..5f8cb538a8 100755 --- a/tools/tuf/init_tuf.sh +++ b/tools/tuf/init_tuf.sh @@ -89,6 +89,19 @@ function create_repository() { rm fleet-desktop.exe fi + # Add Fleet Desktop application on (if enabled). + if [[ $system == "linux" && -n "$FLEET_DESKTOP" ]]; then + FLEET_DESKTOP_VERSION=42.0.0 \ + make desktop-linux + ./build/fleetctl updates add \ + --path $TUF_PATH \ + --target desktop.tar.gz \ + --platform linux \ + --name desktop \ + --version 42.0.0 -t 42.0 -t 42 -t stable + rm desktop.tar.gz + fi + done # Generate and add osqueryd .app bundle for macos-app. @@ -146,6 +159,7 @@ if [ -n "$GENERATE_PKGS" ]; then echo "Generating deb..." ./build/fleetctl package \ --type=deb \ + ${FLEET_DESKTOP:+--fleet-desktop} \ --fleet-url=https://$DEB_HOSTNAME:8080 \ --enroll-secret=$ENROLL_SECRET \ --insecure \ @@ -158,6 +172,7 @@ if [ -n "$GENERATE_PKGS" ]; then echo "Generating rpm..." ./build/fleetctl package \ --type=rpm \ + ${FLEET_DESKTOP:+--fleet-desktop} \ --fleet-url=https://$RPM_HOSTNAME:8080 \ --enroll-secret=$ENROLL_SECRET \ --insecure \