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) {