Custom email device-mapping: implement the CLI (fleetd + fleetctl) changes (#15763)

Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
This commit is contained in:
Martin Angers
2023-12-21 11:22:59 -06:00
committed by GitHub
co-authored by Sarah Gillespie
parent 235d2cf2dc
commit 2e8da551d0
11 changed files with 162 additions and 79 deletions
@@ -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.
+17
View File
@@ -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 {
+30
View File
@@ -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
+3
View File
@@ -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) {
+45 -45
View File
@@ -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 }}'
>
<util:ServiceConfig
FirstFailureActionType="restart"
@@ -157,7 +157,7 @@ var windowsWixTemplate = template.Must(template.New("").Option("missingkey=error
DllEntry="WixQuietExec64"
Execute="deferred"
Return="check"
Impersonate="no" />
Impersonate="no" />
<SetProperty Id="CA_WaitOrbit"
Before ="CA_WaitOrbit"
@@ -265,7 +265,7 @@ var windowsPSInstallerUtils = template.Must(template.New("").Option("missingkey=
[switch] $uninstallOsquery = $false,
[switch] $uninstallOrbit = $false,
[switch] $stopOrbit = $false,
[string] $updateSecret = "",
[string] $updateSecret = "",
[switch] $help = $false
)
@@ -464,8 +464,8 @@ public class RegistryUtils
Add-Type -TypeDefinition $code -Language CSharp
function Test-Administrator
{
function Test-Administrator
{
[OutputType([bool])]
param()
process {
@@ -476,7 +476,7 @@ function Test-Administrator
function Do-Help {
$programName = (Get-Item $PSCommandPath ).Name
Write-Host "Usage: $programName (-uninstallOsquery|-uninstallOrbit|-stopOrbit|-updateSecret|-help)" -foregroundcolor Yellow
Write-Host ""
Write-Host " Only one of the following options can be used. Using multiple will result in "
@@ -484,9 +484,9 @@ function Do-Help {
Write-Host " -uninstallOsquery Uninstall Osquery"
Write-Host " -uninstallOrbit Uninstall Orbit"
Write-Host " -stopOrbit Stop Orbit"
Write-Host " -updateSecret <secret> Update Orbit secret"
Write-Host " -updateSecret <secret> 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 "====================================="
+1 -13
View File
@@ -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 ?)))`
-21
View File
@@ -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)
+4
View File
@@ -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: {},
}
}
+19
View File
@@ -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)
}
+28
View File
@@ -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))
})
}
}
+14
View File
@@ -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, &params, &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) {