From 2e8da551d04aaf0305087f4c1871f9def1ce94fa Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Thu, 21 Dec 2023 12:22:59 -0500 Subject: [PATCH] Custom email device-mapping: implement the CLI (fleetd + fleetctl) changes (#15763) Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com> --- .../issue-15057-custom-email-device-mapping | 1 + cmd/fleetctl/package.go | 17 ++++ orbit/cmd/orbit/orbit.go | 30 +++++++ orbit/pkg/packaging/packaging.go | 3 + orbit/pkg/packaging/windows_templates.go | 90 +++++++++---------- server/datastore/mysql/mysql.go | 14 +-- server/datastore/mysql/mysql_test.go | 21 ----- server/fleet/capabilities.go | 4 + server/fleet/emails.go | 19 ++++ server/fleet/emails_test.go | 28 ++++++ server/service/orbit_client.go | 14 +++ 11 files changed, 162 insertions(+), 79 deletions(-) create mode 100644 server/fleet/emails_test.go diff --git a/changes/issue-15057-custom-email-device-mapping b/changes/issue-15057-custom-email-device-mapping index 71572f6659..c022b035b2 100644 --- a/changes/issue-15057-custom-email-device-mapping +++ b/changes/issue-15057-custom-email-device-mapping @@ -1 +1,2 @@ * Added the `PUT /api/fleet/orbit/device_mapping` and `PUT /api/v1/fleet/hosts/{id}/device_mapping` endpoints (orbit-authenticated and user-authenticated) to set or replace the custom email address associated with a host. +* Added the experimental `--end-user-email` flag to `fleetctl package` so that the email address associated with the host can be bundled in the `.msi` installer for Windows. diff --git a/cmd/fleetctl/package.go b/cmd/fleetctl/package.go index 770beb2b1f..29c97203b2 100644 --- a/cmd/fleetctl/package.go +++ b/cmd/fleetctl/package.go @@ -13,6 +13,7 @@ import ( eefleetctl "github.com/fleetdm/fleet/v4/ee/fleetctl" "github.com/fleetdm/fleet/v4/orbit/pkg/packaging" + "github.com/fleetdm/fleet/v4/server/fleet" "github.com/rs/zerolog" zlog "github.com/rs/zerolog/log" "github.com/skratchdot/open-golang/open" @@ -227,6 +228,13 @@ func packageCommand() *cli.Command { EnvVars: []string{"FLEETCTL_HOST_IDENTIFIER"}, Destination: &opt.HostIdentifier, }, + &cli.StringFlag{ + Name: "end-user-email", + Hidden: true, // experimental feature, we don't want to show it for now + Usage: "Sets the email address of the user associated with the host when enrolling to Fleet. (requires Fleet >= v4.43.0)", + EnvVars: []string{"FLEETCTL_END_USER_EMAIL"}, + Destination: &opt.EndUserEmail, + }, }, Action: func(c *cli.Context) error { if opt.FleetURL != "" || opt.EnrollSecret != "" { @@ -280,6 +288,15 @@ func packageCommand() *cli.Command { Visit https://wixtoolset.org/ for more information about how to use WiX.`) } + if opt.EndUserEmail != "" && c.String("type") != "msi" { + return errors.New("Can only set --end-user-email when building an MSI package.") + } + if opt.EndUserEmail != "" { + if !fleet.IsLooseEmail(opt.EndUserEmail) { + return errors.New("Invalid email address specified for --end-user-email.") + } + } + if opt.FleetCertificate != "" { err := checkPEMCertificate(opt.FleetCertificate) if err != nil { diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 02f0ad9f30..e15b4ff830 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -183,6 +183,12 @@ func main() { EnvVars: []string{"ORBIT_HOST_IDENTIFIER"}, Value: "uuid", }, + &cli.StringFlag{ + Name: "end-user-email", + Hidden: true, // experimental feature, we don't want to show it for now + Usage: "Sets the email address of the user associated with the host when enrolling to Fleet. (requires Fleet >= v4.43.0)", + EnvVars: []string{"ORBIT_END_USER_EMAIL"}, + }, } app.Before = func(c *cli.Context) error { // handle old installations, which had default root dir set to /var/lib/orbit @@ -268,6 +274,10 @@ func main() { return fmt.Errorf("--host-identifier=%s is not supported, currently supported values are 'uuid' and 'instance'", hostIdentifier) } + if email := c.String("end-user-email"); email != "" && !fleet.IsLooseEmail(email) { + return fmt.Errorf("the provided end-user email address %q is not a valid email address", email) + } + if err := secure.MkdirAll(c.String("root-dir"), constant.DefaultDirMode); err != nil { return fmt.Errorf("initialize root dir: %w", err) } @@ -705,6 +715,8 @@ func main() { RootDir: c.String("root-dir"), }) // Try performing a flags update to use latest configured osquery flags from get-go. + // This also takes care of populating the server's capabilities as it calls the orbit + // config endpoint. if _, err := flagRunner.DoFlagsUpdate(); err != nil { // Just log, OK to continue, since flagRunner will retry // in flagRunner.Execute. @@ -957,6 +969,19 @@ func main() { g.Add(desktopRunner.actor()) } + // --end-user-email is only supported on Windows (for macOS it gets the + // email from the enrollment profile) + if runtime.GOOS == "windows" && c.String("end-user-email") != "" { + if orbitClient.GetServerCapabilities().Has(fleet.CapabilityEndUserEmail) { + log.Debug().Msg("sending end-user email to Fleet") + if err := orbitClient.SetOrUpdateDeviceMappingEmail(c.String("end-user-email")); err != nil { + log.Error().Err(err).Msg("error sending end-user email to Fleet") + } + } else { + log.Info().Msg("an end-user email is provided, but the Fleet server doesn't have the capability to set it.") + } + } + // Install a signal handler ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1300,6 +1325,11 @@ func (f *capabilitiesChecker) execute() error { log.Info().Msgf("%s capability changed, restarting", fleet.CapabilityTokenRotation) return nil } + if oldCapabilities.Has(fleet.CapabilityEndUserEmail) != + newCapabilities.Has(fleet.CapabilityEndUserEmail) { + log.Info().Msgf("%s capability changed, restarting", fleet.CapabilityEndUserEmail) + return nil + } case <-f.interruptCh: return nil diff --git a/orbit/pkg/packaging/packaging.go b/orbit/pkg/packaging/packaging.go index a4ca6f0742..363a573a91 100644 --- a/orbit/pkg/packaging/packaging.go +++ b/orbit/pkg/packaging/packaging.go @@ -117,6 +117,9 @@ type Options struct { LocalWixDir string // HostIdentifier is the host identifier to use in osquery. HostIdentifier string + // EndUserEmail is the email address of the end user that uses the host on + // which the agent is going to be installed. + EndUserEmail string } func initializeTempDir() (string, error) { diff --git a/orbit/pkg/packaging/windows_templates.go b/orbit/pkg/packaging/windows_templates.go index 04f72a1e76..d234c3a14e 100644 --- a/orbit/pkg/packaging/windows_templates.go +++ b/orbit/pkg/packaging/windows_templates.go @@ -99,7 +99,7 @@ var windowsWixTemplate = template.Must(template.New("").Option("missingkey=error Start="auto" Type="ownProcess" Description="This service runs Fleet's osquery runtime and autoupdater (Orbit)." - Arguments='--root-dir "[ORBITROOT]." --log-file "[System64Folder]config\systemprofile\AppData\Local\FleetDM\Orbit\Logs\orbit-osquery.log" --fleet-url "[FLEET_URL]"{{ if .FleetCertificate }} --fleet-certificate "[ORBITROOT]fleet.pem"{{ end }}{{ if .EnrollSecret }} --enroll-secret-path "[ORBITROOT]secret.txt"{{ end }}{{if .Insecure }} --insecure{{ end }}{{ if .Debug }} --debug{{ end }}{{ if .UpdateURL }} --update-url "{{ .UpdateURL }}"{{ end }}{{ if .UpdateTLSServerCertificate }} --update-tls-certificate "[ORBITROOT]update.pem"{{ end }}{{ if .DisableUpdates }} --disable-updates{{ end }}{{ if .Desktop }} --fleet-desktop --desktop-channel {{ .DesktopChannel }}{{ if .FleetDesktopAlternativeBrowserHost }} --fleet-desktop-alternative-browser-host {{ .FleetDesktopAlternativeBrowserHost }}{{ end }}{{ end }} --orbit-channel "{{ .OrbitChannel }}" --osqueryd-channel "{{ .OsquerydChannel }}" {{ if .EnableScripts }} --enable-scripts{{ end }}{{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}--host-identifier={{ .HostIdentifier }}{{ end }}' + Arguments='--root-dir "[ORBITROOT]." --log-file "[System64Folder]config\systemprofile\AppData\Local\FleetDM\Orbit\Logs\orbit-osquery.log" --fleet-url "[FLEET_URL]"{{ if .FleetCertificate }} --fleet-certificate "[ORBITROOT]fleet.pem"{{ end }}{{ if .EnrollSecret }} --enroll-secret-path "[ORBITROOT]secret.txt"{{ end }}{{if .Insecure }} --insecure{{ end }}{{ if .Debug }} --debug{{ end }}{{ if .UpdateURL }} --update-url "{{ .UpdateURL }}"{{ end }}{{ if .UpdateTLSServerCertificate }} --update-tls-certificate "[ORBITROOT]update.pem"{{ end }}{{ if .DisableUpdates }} --disable-updates{{ end }}{{ if .Desktop }} --fleet-desktop --desktop-channel {{ .DesktopChannel }}{{ if .FleetDesktopAlternativeBrowserHost }} --fleet-desktop-alternative-browser-host {{ .FleetDesktopAlternativeBrowserHost }}{{ end }}{{ end }} --orbit-channel "{{ .OrbitChannel }}" --osqueryd-channel "{{ .OsquerydChannel }}" {{ if .EnableScripts }} --enable-scripts{{ end }}{{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}--host-identifier={{ .HostIdentifier }}{{ end }}{{ if .EndUserEmail }} --end-user-email "{{ .EndUserEmail }}"{{ end }}' > + Impersonate="no" /> Update Orbit secret" + Write-Host " -updateSecret Update Orbit secret" Write-Host " -help Shows this help screen" - + Exit 1 } @@ -510,10 +510,10 @@ function Resolve-Error-Detailed($ErrorRecord = $Error[0]) { function Stop-Osquery { $kServiceName = "osqueryd" - + # Stop Service - Stop-Service -Name $kServiceName -ErrorAction "Continue" - Start-Sleep -Milliseconds 1000 + Stop-Service -Name $kServiceName -ErrorAction "Continue" + Start-Sleep -Milliseconds 1000 # Ensure that no process left running Get-Process -Name $kServiceName -ErrorAction "SilentlyContinue" | Stop-Process -Force @@ -539,11 +539,11 @@ function Update-OrbitSecret { # Ensuring secret file is not empty if (-not ([string]::IsNullOrEmpty($updateSecret)) -and ($updateSecret -ne "dummy")) { - Write-Host "Updating secret" - $targetSecretFile = $Env:Programfiles + "\\Orbit\\secret.txt" + Write-Host "Updating secret" + $targetSecretFile = $Env:Programfiles + "\\Orbit\\secret.txt" Set-Content -NoNewline -Path $targetSecretFile -Value $updateSecret - Start-Sleep -Milliseconds 1000 + Start-Sleep -Milliseconds 1000 } } @@ -568,11 +568,11 @@ function Force-Remove-Orbit { #Remove HKLM registry entries Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -Recurse -ErrorAction "SilentlyContinue" | Where-Object {($_.ValueCount -gt 0)} | ForEach-Object { - + # Filter for osquery entries - $properties = Get-ItemProperty $_.PSPath -ErrorAction "SilentlyContinue" | Where-Object {($_.DisplayName -eq "Fleet osquery")} + $properties = Get-ItemProperty $_.PSPath -ErrorAction "SilentlyContinue" | Where-Object {($_.DisplayName -eq "Fleet osquery")} if ($properties) { - + #Remove Registry Entries $regKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + $_.PSChildName @@ -582,14 +582,14 @@ function Force-Remove-Orbit { } } } - catch { + catch { Write-Host "There was a problem running Force-Remove-Orbit" -ForegroundColor Red Write-Host "=====================================" Write-Host "$(Resolve-Error-Detailed)" Write-Host "=====================================" return $false } - + return $true } @@ -610,28 +610,28 @@ function Force-Remove-Osquery { #Remove HKLM registry entries and disk footprint Get-ChildItem "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" -Recurse -ErrorAction "SilentlyContinue" | Where-Object {($_.ValueCount -gt 0)} | ForEach-Object { - + # Filter for osquery entries - $properties = Get-ItemProperty $_.PSPath -ErrorAction "SilentlyContinue" | Where-Object {($_.DisplayName -eq "osquery")} + $properties = Get-ItemProperty $_.PSPath -ErrorAction "SilentlyContinue" | Where-Object {($_.DisplayName -eq "osquery")} if ($properties) { - #Remove files from osquery location + #Remove files from osquery location if ($properties.InstallLocation){ Remove-Item -LiteralPath $properties.InstallLocation -Force -Recurse -ErrorAction "SilentlyContinue" } - + #Remove Registry Entries $regKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\" + $_.PSChildName Get-Item $regKey -ErrorAction "SilentlyContinue" | Remove-Item -Force -ErrorAction "SilentlyContinue" return } - } + } #Remove user entries if present [RegistryUtils]::RemoveOsqueryInstallationFromUserHives() } - catch { + catch { Write-Host "There was a problem running Force-Remove-Osquery" -ForegroundColor Red Write-Host "=====================================" Write-Host "$(Resolve-Error-Detailed)" @@ -657,40 +657,40 @@ function Graceful-Product-Uninstall($productName) { } elseif ($productName -eq "osquery") { Stop-Osquery } - + # Grabbing the location of msiexec.exe $targetBinPath = Resolve-Path "$env:windir\system32\msiexec.exe" if (!(Test-Path $targetBinPath)) { Write-Host "msiexec.exe cannot be located." -foregroundcolor Yellow return $false } - + # Creating a COM instance of the WindowsInstaller.Installer COM object $Installer = New-Object -ComObject WindowsInstaller.Installer if (!$Installer) { Write-Host "There was a problem retrieving the installed packages." -foregroundcolor Yellow return $false } - + # Enumerating the installed packages $ProductEnumFlag = 7 #installed packaged enumeration flag - $InstallerProducts = $Installer.ProductsEx("", "", $ProductEnumFlag); + $InstallerProducts = $Installer.ProductsEx("", "", $ProductEnumFlag); if (!$InstallerProducts) { Write-Host "Installed packages cannot be retrieved." -foregroundcolor Yellow return $false } - + # Iterating over the installed packages results and checking for osquery package ForEach ($Product in $InstallerProducts) { - + $ProductCode = $null $VersionString = $null $ProductPath = $null - + $ProductCode = $Product.ProductCode() $VersionString = $Product.InstallProperty("VersionString") $ProductPath = $Product.InstallProperty("ProductName") - + if ($ProductPath -like $productName) { Write-Host "Graceful uninstall of $ProductPath version $VersionString." -foregroundcolor Cyan $InstallProcess = Start-Process $targetBinPath -ArgumentList "/quiet /x $ProductCode" -PassThru -Verb RunAs -Wait @@ -703,12 +703,12 @@ function Graceful-Product-Uninstall($productName) { } } } - catch { + catch { Write-Host "There was a problem running Graceful-Product-Uninstall" -ForegroundColor Red Write-Host "=====================================" Write-Host "$(Resolve-Error-Detailed)" Write-Host "=====================================" - } + } return $false } @@ -727,30 +727,30 @@ function Main { if ($help) { Do-Help Exit -1 - } - + } + if ($uninstallOsquery) { Write-Host "About to uninstall Osquery." -foregroundcolor Yellow - + #if (Graceful-Product-Uninstall("osquery")) { if ($false) { Force-Remove-Osquery #best effort action to ensure cleanup after graceful uninstall Write-Host "Osquery was gracefully uninstalled." -foregroundcolor Cyan Exit 0 - } else { + } else { if (Force-Remove-Osquery) { Write-Host "Osquery was uninstalled." -foregroundcolor Cyan Exit 0 } else { Write-Host "There was a problem uninstalling Osquery" -foregroundcolor Cyan Exit -1 - } + } } - + } elseif ($uninstallOrbit) { Write-Host "About to uninstall Orbit." -foregroundcolor Yellow - + #if (Graceful-Product-Uninstall("Fleet osquery")) { if ($false) { Force-Remove-Orbit #best effort action to ensure cleanup after graceful uninstall @@ -764,7 +764,7 @@ function Main { } else { Write-Host "There was a problem uninstalling Orbit" -foregroundcolor Cyan Exit -1 - } + } } } elseif ($stopOrbit) { @@ -782,14 +782,14 @@ function Main { Write-Host "Orbit secret update was called." -foregroundcolor Cyan Exit 0 - + } else { Write-Host "Invalid option selected: please see -help for usage details." -foregroundcolor Red Do-Help Exit -1 } } catch { - Write-Host "There was a problem running installer entry point logic" -ForegroundColor Red + Write-Host "There was a problem running installer entry point logic" -ForegroundColor Red Write-Host "=====================================" Write-Host "$(Resolve-Error-Detailed)" Write-Host "=====================================" diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 2fb9876240..c1f08b6ac2 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1106,18 +1106,6 @@ func searchLikePattern(sql string, params []interface{}, match string, replacer return sql, params } -// very loosely checks that a string looks like an email: -// has no spaces, a single @ character, a part before the @, -// a part after the @, the part after has at least one dot -// with something after the dot. I don't think this is perfectly -// correct as the email format allows any chars including spaces -// when inside double quotes, but this is an edge case that is -// unlikely to matter much in practice. Another option that would -// definitely not cut out any valid address is to just check for -// the presence of @, which is arguably the most important check -// in this. -var rxLooseEmail = regexp.MustCompile(`^[^\s@]+@[^\s@\.]+\..+$`) - /* This regex matches any occurrence of a character from the ASCII character set followed by one or more characters that are not from the ASCII character set. The first part `[[:ascii:]]` matches any character that is within the ASCII range (0 to 127 in the ASCII table), @@ -1136,7 +1124,7 @@ func hostSearchLike(sql string, params []interface{}, match string, columns ...s // special-case for hosts: if match looks like an email address, add searching // in host_emails table as an option, in addition to the provided columns. - if rxLooseEmail.MatchString(match) { + if fleet.IsLooseEmail(match) { matchesEmail = true // remove the closing paren and add the email condition to the list base = strings.TrimSuffix(base, ")") + " OR (" + ` EXISTS (SELECT 1 FROM host_emails he WHERE he.host_id = h.id AND he.email LIKE ?)))` diff --git a/server/datastore/mysql/mysql_test.go b/server/datastore/mysql/mysql_test.go index b661aa6115..ebaba64554 100644 --- a/server/datastore/mysql/mysql_test.go +++ b/server/datastore/mysql/mysql_test.go @@ -959,27 +959,6 @@ func TestCompareVersions(t *testing.T) { } } -func TestRxLooseEmail(t *testing.T) { - testCases := []struct { - str string - match bool - }{ - {"foo", false}, - {"", false}, - {"foo@example", false}, - {"foo@example.com", true}, - {"foo+bar@example.com", true}, - {"foo.bar@example.com", true}, - {"foo.bar@baz.example.com", true}, - } - - for _, tc := range testCases { - t.Run(tc.str, func(t *testing.T) { - assert.Equal(t, tc.match, rxLooseEmail.MatchString(tc.str)) - }) - } -} - func TestDebugs(t *testing.T) { ds := CreateMySQLDS(t) diff --git a/server/fleet/capabilities.go b/server/fleet/capabilities.go index 922031c3e1..36bcd32dab 100644 --- a/server/fleet/capabilities.go +++ b/server/fleet/capabilities.go @@ -75,12 +75,16 @@ const ( // periodic rotation of device tokens CapabilityTokenRotation Capability = "token_rotation" CapabilityErrorReporting Capability = "error_reporting" + // CapabilityEndUserEmail denotes the ability of the server to support + // receiving the end-user email from orbit. + CapabilityEndUserEmail Capability = "end_user_email" ) func GetServerOrbitCapabilities() CapabilityMap { return CapabilityMap{ CapabilityOrbitEndpoints: {}, CapabilityTokenRotation: {}, + CapabilityEndUserEmail: {}, } } diff --git a/server/fleet/emails.go b/server/fleet/emails.go index 4f94f46e75..3e9bb31b06 100644 --- a/server/fleet/emails.go +++ b/server/fleet/emails.go @@ -1,6 +1,7 @@ package fleet import ( + "regexp" "time" ) @@ -32,3 +33,21 @@ type PasswordResetRequest struct { UserID uint `db:"user_id"` Token string } + +// very loosely checks that a string looks like an email: +// has no spaces, a single @ character, a part before the @, +// a part after the @, the part after has at least one dot +// with something after the dot. I don't think this is perfectly +// correct as the email format allows any chars including spaces +// when inside double quotes, but this is an edge case that is +// unlikely to matter much in practice. Another option that would +// definitely not cut out any valid address is to just check for +// the presence of @, which is arguably the most important check +// in this. +var rxLooseEmail = regexp.MustCompile(`^[^\s@]+@[^\s@\.]+\..+$`) + +// IsLooseEmail loosely checks that the provided string looks like +// an email. +func IsLooseEmail(email string) bool { + return rxLooseEmail.MatchString(email) +} diff --git a/server/fleet/emails_test.go b/server/fleet/emails_test.go new file mode 100644 index 0000000000..d8979b7b78 --- /dev/null +++ b/server/fleet/emails_test.go @@ -0,0 +1,28 @@ +package fleet + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsLooseEmail(t *testing.T) { + testCases := []struct { + str string + match bool + }{ + {"foo", false}, + {"", false}, + {"foo@example", false}, + {"foo@example.com", true}, + {"foo+bar@example.com", true}, + {"foo.bar@example.com", true}, + {"foo.bar@baz.example.com", true}, + } + + for _, tc := range testCases { + t.Run(tc.str, func(t *testing.T) { + assert.Equal(t, tc.match, IsLooseEmail(tc.str)) + }) + } +} diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index c2b33818cd..5220eaf96a 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -146,6 +146,20 @@ func (oc *OrbitClient) SetOrUpdateDeviceToken(deviceAuthToken string) error { return nil } +// SetOrUpdateDeviceMappingEmail sends a request to the server to set or update the +// device mapping email with the given value. +func (oc *OrbitClient) SetOrUpdateDeviceMappingEmail(email string) error { + verb, path := "PUT", "/api/fleet/orbit/device_mapping" + params := orbitPutDeviceMappingRequest{ + Email: email, + } + var resp orbitPutDeviceMappingResponse + if err := oc.authenticatedRequest(verb, path, ¶ms, &resp); err != nil { + return err + } + return nil +} + // GetHostScript returns the script fetched from Fleet server to run on this // host. func (oc *OrbitClient) GetHostScript(execID string) (*fleet.HostScriptResult, error) {