diff --git a/docs/checks/commands/check_drivesize.md b/docs/checks/commands/check_drivesize.md index e2dea9fc..5d00eceb 100644 --- a/docs/checks/commands/check_drivesize.md +++ b/docs/checks/commands/check_drivesize.md @@ -68,16 +68,19 @@ Naemon Config ## Check Specific Arguments -| Argument | Description | -| ------------------------- | ----------------------------------------------------------------------------------------- | -| drive | The drives to check, e.g. C:\ or / | -| exclude | List of drives to exclude from check | -| folder | The folders to check (parent mountpoint) | -| freespace-ignore-reserved | When false, root-reserved space is subtracted from the total size. Default: true | -| ignore-unreadable | Deprecated, use filter instead | -| magic | Magic number for use with scaling drive sizes. Note there is also a more generic magic factor in the perf-config option. | -| mounted | Deprecated, use filter instead | -| total | Include the total of all matching drives | +| Argument | Description | +| ----------------------------- | ------------------------------------------------------------------------------------- | +| add-persistent-network-drives | Include persistent network drives (net use /persistent), even if currently disconnected, in the all/all-shares listing | +| drive | The drives to check, e.g. C:\ or / | +| exclude | List of drives to exclude from check | +| folder | The folders to check (parent mountpoint) | +| freespace-ignore-reserved | When false, root-reserved space is subtracted from the total size. Default: true | +| ignore-unreadable | Deprecated, use filter instead | +| magic | Magic number for use with scaling drive sizes. Note there is also a more generic magic factor in the perf-config option. | +| mounted | Deprecated, use filter instead | +| share-password | Windows only: password used to authenticate to the network shares given in this check. Note: the password is transmitted as part of the check request. | +| share-user | Windows only: username used to authenticate to the network shares given in this check. The connection is established on demand and removed again after the check. | +| total | Include the total of all matching drives | ## Attributes @@ -125,4 +128,6 @@ these can be used in filters and thresholds (along with the default attributes): | hotplug | Windows only: flag drive is hotplugable (0/1) | | remote_name | Windows only: the remote name of the drive, if it uses a network name | | persistent | Windows only: if the network drive is mounted as persistent (0/1) | +| connected | Windows only: if the network drive is currently connected (0/1) | +| hidden | Windows only: if the network share is a hidden share, i.e. the share name ends with a dollar sign like C\$ (0/1) | | localised_remote_path | Windows only: If the path is given as a remote path, and that remote path has an assigned logical drive, this is the replaced path under that logical drive. | diff --git a/docs/configuration/_index.md b/docs/configuration/_index.md index dbeb89e1..84910167 100644 --- a/docs/configuration/_index.md +++ b/docs/configuration/_index.md @@ -150,6 +150,56 @@ This is the order of inheritance for the example above: The first defined value will be used. +## Network Share Credentials + +On Windows, network shares are accessed with the credentials of the account the snclient +service runs under. Shares that were never opened before need credentials before they can +be queried. These can be provided in the `[/settings/credentials]` section, they are stored +in the Windows Credential Manager of the snclient account, without mounting anything. + +```ini +[/settings/credentials] +[[share1]] +type = windows-share +target = 192.168.178.21 +username = svc +password = secret +strategy = on-demand + +[[share2]] +type = windows-share +target = fileserver +username = CORP\svc +password = secret2 +strategy = on-start +``` + +The credential is added for the target server and is automatically used by the SMB +redirector (NTLM/Kerberos) when the snclient connects to that server. + +### Keys + +| Key | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| type | Type of the credential. Only `windows-share` is supported for now. | +| target | Server to store the credential for, e.g. the host name or IP as used in the UNC path. A full UNC path like `\\server\share` works as well. | +| username | Account used to connect, e.g. `CORP\svc`. If it contains no domain, the domain of the account the snclient runs as is added automatically. | +| password | Plaintext password of the account. Required because the Windows authentication packages need the real secret to authenticate. | +| strategy | When to load the credential, see below. Default: `on-demand` | + +### Loading Strategies + +| Strategy | Description | +| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| on-start | The credential is loaded once when the snclient starts and stays for the lifetime of the snclient logon session. It is gone after a reboot. | +| on-demand | The credential is loaded right before a share is queried and removed again immediately after the check finished. This minimizes the time the credential exists. | + +When a credential is loaded on demand and a credential for the same target already exists +in the Credential Manager, it is left untouched and removed again after the check. The +credentials never survive a reboot. Note that an established SMB session stays cached in +the snclient logon session even after the credential was removed, so other processes in the +same session could still reuse that connection. + ## Includes It is possible and encouraged to include other ini files to organize your settings. diff --git a/pkg/convert/convert.go b/pkg/convert/convert.go index b61b03ef..a36fa77a 100644 --- a/pkg/convert/convert.go +++ b/pkg/convert/convert.go @@ -324,6 +324,15 @@ func Num2StringE(raw any) (string, error) { } } +// converts a bool into a "1"/"0" string, used for flag attributes like connected and hidden +func BoolTo01String(value bool) string { + if value { + return "1" + } + + return "0" +} + // StateString returns the string corresponding to a monitoring plugin exit code func StateString(state int64) string { switch state { diff --git a/pkg/snclient/check_drivesize.go b/pkg/snclient/check_drivesize.go index 34ed0a48..dcbe5690 100644 --- a/pkg/snclient/check_drivesize.go +++ b/pkg/snclient/check_drivesize.go @@ -23,7 +23,7 @@ func init() { } const ( - DiskDetailsTimeout = 30 * time.Second + DiskDetailsTimeout = 10 * time.Second ) func defaultExcludedFsTypes() []string { @@ -61,15 +61,18 @@ func defaultExcludedFsTypes() []string { } type CheckDrivesize struct { - drives []string - folders []string - excludes []string - total bool - magic float64 - mounted bool - ignoreUnreadable bool - hasCustomPath bool - freespaceIgnoreReserved bool + drives []string + folders []string + excludes []string + total bool + magic float64 + mounted bool + ignoreUnreadable bool + hasCustomPath bool + freespaceIgnoreReserved bool + addPersistentNetworkDrives bool + shareUser string + sharePassword string } func NewCheckDrivesize() CheckHandler { @@ -81,6 +84,7 @@ func NewCheckDrivesize() CheckHandler { } } +//nolint:funlen // there are lots of attributes in this check func (l *CheckDrivesize) Build() *CheckData { return &CheckData{ name: "check_drivesize", @@ -100,6 +104,17 @@ func (l *CheckDrivesize) Build() *CheckData { "mounted": {value: &l.mounted, description: "Deprecated, use filter instead"}, // deprecated and unused, but should not result in unknown argument "ignore-unreadable": {value: &l.ignoreUnreadable, description: "Deprecated, use filter instead"}, // same "freespace-ignore-reserved": {value: &l.freespaceIgnoreReserved, description: "When false, root-reserved space is subtracted from the total size. Default: true"}, + "add-persistent-network-drives": { + value: &l.addPersistentNetworkDrives, description: "Include persistent network drives (net use /persistent), even if currently disconnected, in the all/all-shares listing", + }, + "share-user": { + value: &l.shareUser, description: "Windows only: username used to authenticate to the network shares given in this check. " + + "The connection is established on demand and removed again after the check.", + }, + "share-password": { + value: &l.sharePassword, description: "Windows only: password used to authenticate to the network shares given in this check. " + + "Note: the password is transmitted as part of the check request.", + }, }, defaultFilter: l.getDefaultFilter(), defaultWarning: "used_pct > 80", @@ -152,6 +167,8 @@ func (l *CheckDrivesize) Build() *CheckData { {name: "remote_name", description: "Windows only: the remote name of the drive, if it uses a network name"}, {name: "persistent", description: "Windows only: if the network drive is mounted as persistent (0/1)", unit: UBool}, + {name: "connected", description: "Windows only: if the network drive is currently connected (0/1)", unit: UBool}, + {name: "hidden", description: "Windows only: if the network share is a hidden share, i.e. the share name ends with a dollar sign like C$ (0/1)", unit: UBool}, {name: "localised_remote_path", description: "Windows only: If the path is given as a remote path, and that remote path has an assigned logical drive," + " this is the replaced path under that logical drive."}, }, @@ -165,7 +182,7 @@ func (l *CheckDrivesize) Build() *CheckData { } } -//nolint:funlen // no need to split the function, it is simple as is +//nolint:funlen,gocyclo,maintidx,contextcheck,nolintlint // no need to split the function, it is simple as is , context is constructed when needed func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData, _ []Argument) (*CheckResult, error) { enabled, _, _ := snc.config.Section("/modules").GetBool("CheckDisk") if !enabled { @@ -234,6 +251,77 @@ func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData l.tidyThresholdDriveValues(check) + // resolve the credential to use for each UNC share in this check. + // share-user / share-password override any credentials from the config section. + shareCredentials := map[string]Credential{} + for _, k := range keys { + drive := requiredDisks[k] + if !isNetworkSharePath(drive["drive_or_id"]) { + continue + } + root := shareRoot(drive["drive_or_id"]) + if root == "" { + continue + } + if _, ok := shareCredentials[root]; ok { + continue + } + + if l.shareUser != "" { + // user is always needed, but password can be empty for a valid login + shareCredentials[root] = Credential{ + Type: CredentialTypeWindowsShare, + Target: shareTargetFromUNCPath(root), + Username: qualifyUsername(l.shareUser, currentUserDomain()), + Password: l.sharePassword, + Strategy: CredentialStrategyOnDemand, + } + + continue + } + + if cred, ok := findOnDemandCredential(snc.config, shareTargetFromUNCPath(root)); ok { + shareCredentials[root] = cred + } + } + + // keep track of the connections snclient established, so the cleanup only tears down the ones it added itself + addedConnections := map[string]bool{} + + for root, cred := range shareCredentials { + // drop a stale session first, otherwise SMB redirector keeps reusing it and the new credential would not take effect + if err := deleteShareConnection(root); err != nil { + log.Debugf("credentials: could not drop existing connection for %s: %s", root, err.Error()) + } + + if err := addShareConnection(&cred, root); err != nil { + // a connection with different credentials may still be around, force it away and try once more + if errors.Is(err, errSessionCredentialConflict) { + _ = deleteShareConnection(root) + err = addShareConnection(&cred, root) + } + if err != nil { + log.Errorf("credentials: failed to connect to %s: %s", root, err.Error()) + + continue + } + } + log.Debugf("credentials: established connection for %s", root) + addedConnections[root] = true + } + + // remove all newly added connections again after the check finished + defer func() { + for root := range addedConnections { + if err := deleteShareConnection(root); err != nil { + log.Errorf("credentials: failed to remove connection for %s: %s", root, err.Error()) + + continue + } + log.Debugf("credentials: removed connection for %s", root) + } + }() + for _, k := range keys { if ctxErr := ctx.Err(); ctxErr != nil { return nil, fmt.Errorf("disk scan canceled: %w", ctxErr) @@ -267,10 +355,14 @@ func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData // remove errored paths unless custom path is specified if !l.hasCustomPath { - for i, entry := range check.listData { + for idx, entry := range check.listData { if errMsg, ok := entry["_error"]; ok { + // persistent network drives added via add-persistent-network-drives are treated like custom paths, so surface their errors instead of skipping them + if l.addPersistentNetworkDrives && entry["persistent"] == "1" { + continue + } log.Debugf("drivesize failed for %s: %s", entry["drive_or_id"], errMsg) - check.listData[i]["_skip"] = "1" + check.listData[idx]["_skip"] = "1" } } } diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index 6d4f41c4..f40c1e9d 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -40,15 +40,42 @@ func (l *CheckDrivesize) getDefaultFilter() string { func (l *CheckDrivesize) getExample() string { return ` check_drivesize drive=c: show-all - OK - c: 36.801 GiB/63.075 GiB (58.3%) |... + OK - C: 36.801 GiB/63.075 GiB (58.3%) |... - check_drivesize folder=c:\Temp show-all - OK - c: 36.801 GiB/63.075 GiB (58.3%) |... + check_drivesize folder=C:\Windows show-all + OK - C:\Windows 36.801 GiB/63.075 GiB (58.3%) |... + +Check a network share directly via its UNC path, no drive letter mapping required. This also works for hidden shares like the administrative share C$: + + check_drivesize drive=\\server\C$ show-all + OK - \\server\C$ 100.000 GiB/500.000 GiB (20.0%) |'\\server\C$ used'=... + +Shares in general are only accessible if the snclient has sufficient credentials on the remote server. Otherwise the check reports an error for that drive. + +If the UNC path is mapped to a drive letter, the mapped drive is used and the localised path is available in the localised_remote_path attribute: + + check_drivesize drive=\\server\share\folder show-all + OK - Z:\ 100.000 GiB/500.000 GiB (20.0%) |... + +Include persistent network drives (net use /persistent), even if they are currently disconnected, when specifying drive=all or drive=all-shares: + + check_drivesize all-shares add-persistent-network-drives + +Hidden shares can be accessed if their path is specified. + + check_drivesize drive='\\192.168.178.21\TestHidden$' + +Credentials for shares that were never opened before can be provided via share-user / share-password. +A connection is established with the given credentials for the duration of the check and removed afterwards. + + check_drivesize drive='\\192.168.178.21\TestHidden$' share-user='CORP\svc' share-password='secret' + +Alternatively, credentials can be configured globally in the [/settings/credentials] section, see the snclient documentation for details. ` } // Terminology -// Disk : Physical Hardware like a HDD, SSD, Usb Stick. A block device that ca be used to store raw bytes -> \\.\PhysicalDrive0 +// Disk : Physical Hardware like a HDD, SSD, Usb Stick. A block device that ca be used to store raw bytes -> \\.\PhysicalDritve0 // Partition: Is written into the disk in a partition table. It exists independently of volumes, may not be used by Windows // Volume: A logical abstraction of a storage, formatted with a file system. It can be a virtual file, a RAID disk or one partition -> \\?\Volume{GUID}\ // Drive: This term is not defined well. Here, it means a logical access point with an assigned drive letter. @@ -114,7 +141,8 @@ func (l *CheckDrivesize) addDiskDetails(ctx context.Context, check *CheckData, d l.setDeviceInfo(drive) - if drive["type"] != "remote" { + // device flags and media type need a handle to the device, which does not work for UNC paths or remote drives + if drive["type"] != "remote" && !strings.HasPrefix(drive["drive_or_id"], "\\\\") { if err := l.setDeviceFlags(drive); err != nil { log.Debugf("device flags: %s", err.Error()) } @@ -126,8 +154,9 @@ func (l *CheckDrivesize) addDiskDetails(ctx context.Context, check *CheckData, d timeoutContext, cancel := context.WithTimeout(ctx, DiskDetailsTimeout) defer cancel() - // Uses gopsutil to check disk usage - usage, err := disk.UsageWithContext(timeoutContext, drive["drive_or_id"]) + // Uses gopsutil to check disk usage, which calls GetDiskFreeSpaceExW on the given path. + // GetDiskFreeSpaceExW requires UNC names to end with a trailing backslash e.g. \\server\share\ , so make sure the path is in that form. + usage, err := disk.UsageWithContext(timeoutContext, l.ensureTrailingBackslash(drive["drive_or_id"])) if err != nil { switch { case drive["type"] == "cdrom": @@ -265,9 +294,15 @@ func (l *CheckDrivesize) setMediaType(drive map[string]string) error { // //nolint:funlen //no need to split this func (l *CheckDrivesize) setDeviceInfo(drive map[string]string) { - driveType, err := GetDriveType(drive["drive_or_id"]) - if err != nil { - log.Warnf("Error when getting the drive type of drive, drive: '%s' , error: %s", drive["drive_or_id"], err.Error()) + // GetDriveType is meant for drive roots and is unreliable for UNC paths + // SMB does not support volume management functions + // Assume that an UNC path is always a remote share. + if strings.HasPrefix(drive["drive_or_id"], "\\\\") { + drive["type"] = "remote" + } else { + driveType, err := GetDriveType(drive["drive_or_id"]) + if err != nil { + log.Warnf("Error when getting the drive type of drive, drive: '%s' , error: %s", drive["drive_or_id"], err.Error()) return } @@ -286,6 +321,10 @@ func (l *CheckDrivesize) setDeviceInfo(drive map[string]string) { if !strings.HasSuffix(drivePath, "\\") { drivePath += "\\" } + + // drivePath needs to be in form 'X:\' or '\\server\share\', + // GetVolumeInformation requires a trailing backslash. + drivePath := strings.ToUpper(l.ensureTrailingBackslash(drive["drive_or_id"])) driveUTF16, err := syscall.UTF16PtrFromString(drivePath) if err != nil { log.Warnf("Cannot convert drive to UTF16 : %s: %s", drive["drive_or_id"], err.Error()) @@ -369,7 +408,9 @@ func (l *CheckDrivesize) setDeviceInfo(drive map[string]string) { // gopsutil disk.Partition had an issue with Bitlocker, but a fix was upstreamed func (l *CheckDrivesize) setDrives(requiredDrives map[string]map[string]string) (err error) { - partitions, err := disk.Partitions(true) + timeoutContext, cancel := context.WithTimeout(context.Background(), DiskDetailsTimeout) + defer cancel() + partitions, err := disk.PartitionsWithContext(timeoutContext, true) if err != nil && len(partitions) == 0 { return fmt.Errorf("disk partitions failed: %s", err.Error()) } @@ -482,7 +523,7 @@ func (l *CheckDrivesize) setVolume(requiredDrives map[string]map[string]string, name = volumeGUIDPath } - drive, isDrive, _ := cleanupPathString(volumePathName) + drive, isDrive, _ := l.cleanupPathString(volumePathName) if !isDrive { drive = "" } @@ -577,7 +618,7 @@ func getPerflabelPrefix(path string) (perflabelPrefix string, err error) { // changes the drive letter to be uppercase, if present // adds a colon after the drive letter if its not present // adds a backward slash after colon if not present -func cleanupPathString(path string) (cleanedPath string, isDrive bool, err error) { +func (l *CheckDrivesize) cleanupPathString(path string) (cleanedPath string, isDrive bool, err error) { if path == "" { return "", false, fmt.Errorf("path to cleanup is empty") } @@ -636,33 +677,48 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma // if its a network share path, discover existing shares and match it with a drive[remote_path] // then we replace path argument in-place, replacing the network path with the logical drive it is assigned to if isNetworkSharePath(path) { + // isNetworkSharePath also accepts forward slashes, + // remote names returned by WNetGetConnection always use backslashes + normalizedPath := strings.ReplaceAll(path, "/", "\\") + discoveredNetworkShares := map[string]map[string]string{} l.setShares(discoveredNetworkShares) - for key := range discoveredNetworkShares { - networkShare := discoveredNetworkShares[key] - remoteName, hasRemoteName := networkShare["remote_name"] - if hasRemoteName && strings.HasPrefix(path, remoteName) { - requiredDrives[key] = utils.CloneStringMap(discoveredNetworkShares[key]) - - // drive["remote_name"] = \\SERVER\SHARENAME - // drive["drive"] = x: - // pathExample1 = \\SERVER\SHARENAME -> x: - // pathExample2 = \\SERVER\SHARENAME\FOO\BAR -> x:\FOO\BAR - pathReplaced := strings.Replace(path, networkShare["remote_name"], networkShare["drive"], 1) - // It is better to let users set their own detailSyntax or okSyntax, we give them the attributes for it - // requiredDrives[key]["drive_or_name"] = fmt.Sprintf("%s - (%s)", path, pathReplaced) - requiredDrives[key]["localised_remote_path"] = pathReplaced - - return nil + if key, _, matched := l.matchNetworkShare(normalizedPath, discoveredNetworkShares); matched { + requiredDrives[key] = utils.CloneStringMap(discoveredNetworkShares[key]) + + // drive["remote_name"] = \\SERVER\SHARENAME + // drive["drive"] = x: + // pathExample1 = \\SERVER\SHARENAME -> x: + // pathExample2 = \\SERVER\SHARENAME\FOO\BAR -> x:\FOO\BAR + remoteName := strings.TrimRight(discoveredNetworkShares[key]["remote_name"], "\\") + trimmedPath := strings.TrimRight(normalizedPath, "\\") + remainder := "" + if len(trimmedPath) >= len(remoteName) { + remainder = trimmedPath[len(remoteName):] } + pathReplaced := strings.TrimRight(discoveredNetworkShares[key]["drive"], "\\") + "\\" + strings.TrimPrefix(remainder, "\\") + // It is better to let users set their own detailSyntax or okSyntax, we give them the attributes for it + // requiredDrives[key]["drive_or_name"] = fmt.Sprintf("%s - (%s)", path, pathReplaced) + requiredDrives[key]["localised_remote_path"] = pathReplaced + + return nil } + + // no connected mapping exists, e.g. hidden shares like \\server\C$ + // these may not be mapped to a drive letter, so add them with UNC path directly + entry := l.driveEntry(normalizedPath) + entry["remote_name"] = shareRoot(normalizedPath) + entry["hidden"] = convert.BoolTo01String(l.isHiddenSharePath(normalizedPath)) + requiredDrives[normalizedPath] = entry + + return nil } // Important: UNC network paths have slashes next to each other e.g: \\ServerName\SharedFolder\ResourcePath // This gets cleaned up using cleanupPathString , as it is meant for absolute paths inside a drive. // Not cleaning up the path beforehand is intentional - cleanedPath, isDrivePath, err := cleanupPathString(path) + cleanedPath, isDrivePath, err := l.cleanupPathString(path) if err != nil { return fmt.Errorf("error when cleaning up path: %w", err) } @@ -774,7 +830,9 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma // adds all network shares to requiredDrives func (l *CheckDrivesize) setShares(requiredDrives map[string]map[string]string) { - partitions, err := disk.Partitions(true) + timeoutContext, cancel := context.WithTimeout(context.Background(), DiskDetailsTimeout) + defer cancel() + partitions, err := disk.PartitionsWithContext(timeoutContext, true) if err != nil { log.Debugf("Error when discovering partitions: %s", err.Error()) } @@ -814,6 +872,8 @@ func (l *CheckDrivesize) setShares(requiredDrives map[string]map[string]string) drive["letter"] = fmt.Sprintf("%c", logicalDrive[0]) drive["remote_name"] = remoteName + drive["connected"] = "1" + drive["hidden"] = convert.BoolTo01String(l.isHiddenSharePath(remoteName)) if isNetworkDrivePersistent(logicalDrive) { drive["persistent"] = "1" } else { @@ -822,6 +882,38 @@ func (l *CheckDrivesize) setShares(requiredDrives map[string]map[string]string) requiredDrives[logicalDrive] = drive } } + + // when opted in, also add persistent network drives from the registry, + // even if they are currently disconnected + if l.addPersistentNetworkDrives { + persistentNetworkDrives, err := discoverPersistentNetworkDrives() + if err != nil { + log.Debugf("Error when discovering persistent network drives: %s", err.Error()) + + return + } + for _, networkDrive := range persistentNetworkDrives { + logicalDrive := strings.ToUpper(networkDrive.DriveLetter) + ":\\" + if _, ok := requiredDrives[logicalDrive]; ok { + // drive is already listed as a currently connected drive + continue + } + drive := map[string]string{ + "id": networkDrive.RemotePath, + "drive": logicalDrive, + "drive_or_id": logicalDrive, + "drive_or_name": logicalDrive, + "drive_or_name_or_id": logicalDrive, + "letter": strings.ToUpper(networkDrive.DriveLetter), + "remote_name": networkDrive.RemotePath, + "persistent": "1", + "connected": "0", + "mounted": "0", + "hidden": convert.BoolTo01String(l.isHiddenSharePath(networkDrive.RemotePath)), + } + requiredDrives[logicalDrive] = drive + } + } } // driveLetter is assumed to be like 'X:\' @@ -841,21 +933,42 @@ func isNetworkDrivePersistent(driveLetter string) (isPersistent bool) { return false } -// returns if the path looks like an UNC path -func isNetworkSharePath(path string) (isNetworkSharePath bool) { - // Example 1: \\FileServer01\PublicDocs - // Example 2: \\BackupServer\Data\Archive\2025-01-14.zip - // Example 3: \\192.168.1.50\SharedData\Images| - // But modern programs also generally accept forward slash definitions - // //192.168.1.50/Shareddata/Images - - if len(path) < 2 { +// returns if the given UNC path points to a hidden share, i.e. the share name ends with a dollar sign +func (l *CheckDrivesize) isHiddenSharePath(path string) bool { + parts := strings.Split(path, "\\") + // UNC paths are in the form \\server\share\... + // share name is the 4th element + if len(parts) < 4 || parts[0] != "" || parts[1] != "" { return false } - if !strings.HasPrefix(path, "\\\\") && !strings.HasPrefix(path, "//") { - return false + return strings.HasSuffix(parts[3], "$") +} + +// returns the path with exactly one trailing backslash. +// Windows file system APIs like GetVolumeInformation and GetDiskFreeSpaceExW require a trailing backslash when the path is a UNC name e.g. \\server\share\ or a root path to a drive +func (l *CheckDrivesize) ensureTrailingBackslash(path string) string { + return strings.TrimRight(path, "\\") + "\\" +} + +// tries to find a mounted network share that the given UNC path belongs to. +// matching is case-insensitive and checks the share name as well +// disconnected drives (connected = 0) are skipped, such entries may be added when adding persistent drives +func (l *CheckDrivesize) matchNetworkShare(path string, shares map[string]map[string]string) (key string, entry map[string]string, matched bool) { + upperPath := strings.ToUpper(strings.TrimRight(path, "\\")) + for shareKey, share := range shares { + if share["connected"] == "0" { + continue + } + remoteName, hasRemoteName := share["remote_name"] + if !hasRemoteName || remoteName == "" { + continue + } + upperRemote := strings.ToUpper(strings.TrimRight(remoteName, "\\")) + if upperPath == upperRemote || strings.HasPrefix(upperPath, upperRemote+"\\") { + return shareKey, share, true + } } - return true + return "", nil, false } diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index 879d52c9..2c63ffdc 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestCheckDrivesize(t *testing.T) { @@ -154,3 +155,151 @@ func TestNonexistingDrive(t *testing.T) { StopTestAgent(t, snc) } + +func TestIsHiddenSharePath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {`\\server\C$`, true}, + {`\\server\ADMIN$`, true}, + {`\\server\share$`, true}, + {`\\server\share$\folder`, true}, + {`\\server\share`, false}, + {`\\server\share\folder`, false}, + {`\\server`, false}, + {`C:\folder`, false}, + {``, false}, + } + cd := CheckDrivesize{} + for _, test := range tests { + assert.Equalf(t, test.want, cd.isHiddenSharePath(test.path), "isHiddenSharePath(%q)", test.path) + } +} + +func TestShareRoot(t *testing.T) { + tests := []struct { + path string + want string + }{ + {`\\server\share`, `\\server\share`}, + {`\\server\share\folder`, `\\server\share`}, + {`\\server\share\folder\file.txt`, `\\server\share`}, + {`\\server`, `\\server`}, + {`C:\folder`, `C:\folder`}, + } + for _, test := range tests { + assert.Equalf(t, test.want, shareRoot(test.path), "shareRoot(%q)", test.path) + } +} + +func TestMatchNetworkShare(t *testing.T) { + shares := map[string]map[string]string{ + `Z:\`: { + "remote_name": `\\server\share`, + "drive": `Z:\`, + "connected": "1", + }, + `Y:\`: { + "remote_name": `\\server\share2`, + "drive": `Y:\`, + "connected": "1", + }, + `X:\`: { + "remote_name": `\\offline\share`, + "drive": `X:\`, + "connected": "0", + }, + } + checkDrivesize := CheckDrivesize{} + + // exact match + key, entry, matched := checkDrivesize.matchNetworkShare(`\\server\share`, shares) + assert.Truef(t, matched, "exact match") + assert.Equalf(t, `Z:\`, key, "exact match key") + assert.Equalf(t, `Z:\`, entry["drive"], "exact match entry") + + // subfolder match + key, _, matched = checkDrivesize.matchNetworkShare(`\\server\share\folder`, shares) + assert.Truef(t, matched, "subfolder match") + assert.Equalf(t, `Z:\`, key, "subfolder match key") + + // trailing backslash + key, _, matched = checkDrivesize.matchNetworkShare(`\\server\share\`, shares) + assert.Truef(t, matched, "trailing backslash match") + assert.Equalf(t, `Z:\`, key, "trailing backslash match key") + + key, _, matched = checkDrivesize.matchNetworkShare(`\\server\share\\\\`, shares) + assert.Truef(t, matched, "trailing backslash match") + assert.Equalf(t, `Z:\`, key, "multiple trailing backslash match key") + + // case-insensitive + key, _, matched = checkDrivesize.matchNetworkShare(`\\SERVER\SHARE\Folder`, shares) + assert.Truef(t, matched, "case-insensitive match") + assert.Equalf(t, `Z:\`, key, "case-insensitive match key") + + // prefix without share name boundary must not match + _, _, matched = checkDrivesize.matchNetworkShare(`\\server\shareX`, shares) + assert.Falsef(t, matched, "no match for share without boundary") + + key, _, matched = checkDrivesize.matchNetworkShare(`\\server\share2`, shares) + assert.Truef(t, matched, "share2 matches its own remote name") + assert.Equalf(t, `Y:\`, key, "share2 key") + + key, _, matched = checkDrivesize.matchNetworkShare(`\\server\share2\folder`, shares) + assert.Truef(t, matched, "share2 subfolder matches") + assert.Equalf(t, `Y:\`, key, "share2 subfolder key") + + // disconnected persistent drives are skipped + _, _, matched = checkDrivesize.matchNetworkShare(`\\offline\share`, shares) + assert.Falsef(t, matched, "disconnected drive skipped") + + // no match at all + //nolint:misspell // it thinks that '\\other' is actually 'ther' due to backslash + _, _, matched = checkDrivesize.matchNetworkShare(`\\other\share`, shares) + assert.Falsef(t, matched, "no match") +} + +func TestCleanupPathString(t *testing.T) { + tests := []struct { + path string + cleaned string + isDrive bool + }{ + {`c`, `C:\`, true}, + {`c:`, `C:\`, true}, + {`c:\`, `C:\`, true}, + {`C:\`, `C:\`, true}, + {`c:/`, `C:\`, true}, + {`c:\\`, `C:\`, true}, + {`C://///`, `C:\`, true}, + {`\\server\share`, `\server\share`, false}, + } + cd := CheckDrivesize{} + for _, test := range tests { + cleaned, isDrive, err := cd.cleanupPathString(test.path) + require.NoErrorf(t, err, "cleanupPathString(%q)", test.path) + assert.Equalf(t, test.cleaned, cleaned, "cleanupPathString(%q)", test.path) + assert.Equalf(t, test.isDrive, isDrive, "cleanupPathString(%q) isDrive", test.path) + } +} + +func TestEnsureTrailingBackslash(t *testing.T) { + tests := []struct { + path string + want string + }{ + {`\\server\share`, `\\server\share\`}, + {`\\server\share\`, `\\server\share\`}, + {`\\server\share\\`, `\\server\share\`}, + {`\\server\share\\\\\\\\`, `\\server\share\`}, + {`\\server\share$\folder`, `\\server\share$\folder\`}, + {`C:`, `C:\`}, + {`C:\`, `C:\`}, + {`Z:\`, `Z:\`}, + } + cd := CheckDrivesize{} + for _, test := range tests { + assert.Equalf(t, test.want, cd.ensureTrailingBackslash(test.path), "ensureTrailingBackslash(%q)", test.path) + } +} diff --git a/pkg/snclient/check_drivesize_windows_win32api.go b/pkg/snclient/check_drivesize_windows_win32api.go index a1840a36..20f16fea 100644 --- a/pkg/snclient/check_drivesize_windows_win32api.go +++ b/pkg/snclient/check_drivesize_windows_win32api.go @@ -3,7 +3,6 @@ package snclient import ( - "errors" "fmt" "unsafe" @@ -55,14 +54,6 @@ var ( // [in, optional] lpRootPathName The root directory for the drive. A trailing backslash is required. // If this parameter is NULL, the function uses the root of the current directory. getDriveTypeW = kernel32Dll.NewProc("GetDriveTypeW") - - winnetwkDll = windows.NewLazySystemDLL("Mpr.dll") - - // [in] lpLocalName Pointer to a constant null-terminated string that specifies the name of the local device to get the network name for. - // [out] lpRemoteName Pointer to a null-terminated string that receives the remote name used to make the connection. - // [in, out] lpnLength Pointer to a variable that specifies the size of the buffer pointed to by the lpRemoteName parameter, - // in characters. If the function fails because the buffer is not large enough, this parameter returns the required buffer size. - wNetGetConnectionW = winnetwkDll.NewProc("WNetGetConnectionW") ) func GetDriveType(lpRootPathName string) (returnValue GetDriveTypeReturnValuePrimitive, err error) { @@ -88,68 +79,3 @@ func GetDriveType(lpRootPathName string) (returnValue GetDriveTypeReturnValuePri return GetDriveTypeReturnValuePrimitive(rvU), nil } - -func NetGetConnection(lpLocalName string) (lpRemoteName string, err error) { - if lpLocalName == "" { - return "", fmt.Errorf("lpLocalName cannot be empty") - } - - lpLocalNameW16 := windows.StringToUTF16(lpLocalName) - - var lpnLength uint32 = 32768 - lpRemoteNameW16 := make([]uint16, lpnLength) - returnValue, _, err := wNetGetConnectionW.Call( - uintptr(unsafe.Pointer(&lpLocalNameW16[0])), - uintptr(unsafe.Pointer(&lpRemoteNameW16[0])), - uintptr(unsafe.Pointer(&lpnLength)), - ) - - switch { - case returnValue == windows.NO_ERROR: - // this is what we want - case errors.Is(err, windows.ERROR_BAD_DEVICE): - return "", fmt.Errorf("the string pointed to by the lpLocalName parameter is invalid : %s", lpLocalName) - case errors.Is(err, windows.ERROR_NOT_CONNECTED): - return "", fmt.Errorf("the device specified by lpLocalName is not a redirected device. For more information, see the following Remarks section") - case errors.Is(err, windows.ERROR_MORE_DATA): - return "", fmt.Errorf("the buffer is too small. The lpnLength parameter points to a variable that contains the required buffer size. More entries are available with subsequent calls") - case errors.Is(err, windows.ERROR_CONNECTION_UNAVAIL): - return "", fmt.Errorf("the device is not currently connected, but it is a persistent connection. For more information, see the following Remarks section") - case errors.Is(err, windows.ERROR_NO_NETWORK): - return "", fmt.Errorf("the network is unavailable") - case errors.Is(err, windows.ERROR_EXTENDED_ERROR): - return "", fmt.Errorf("a network-specific error occurred. To obtain a description of the error, call the WNetGetLastError function."+ - "WNetGetLastError returned: %w", handleWNetError(returnValue, winnetwkDll)) - case errors.Is(err, windows.ERROR_NO_NET_OR_BAD_PATH): - return "", fmt.Errorf("none of the providers recognize the local name as having a connection. However, the network is not available for at least one provider to whom the connection may belong") - default: - return "", fmt.Errorf("mNetGetConnectionW returned an unrecognized error with value: %d", returnValue) - } - - lpRemoteName = windows.UTF16ToString(lpRemoteNameW16) - - return lpRemoteName, nil -} - -func handleWNetError(errorCode uintptr, winnetwkDll *windows.LazyDLL) (err error) { - wNetGetLastErrorAFunc := winnetwkDll.NewProc("WNetGetLastErrorA") - const lpErrorBufLength = uint32(1024) - lpErrorBuf := make([]byte, lpErrorBufLength) - const lpNameBufLength = uint32(256) - lpNameBuf := make([]byte, lpNameBufLength) - ret, _, _ := wNetGetLastErrorAFunc.Call( - errorCode, - uintptr(unsafe.Pointer(&lpErrorBuf)), - uintptr(lpErrorBufLength), - uintptr(unsafe.Pointer(&lpNameBuf)), - uintptr(lpNameBufLength), - ) - if ret != windows.NO_ERROR { - return fmt.Errorf("got en error while getting the extended network error") - } - if ret == uintptr(windows.ERROR_INVALID_ADDRESS) { - return fmt.Errorf("provided an invalid buffer while getting the extended network error") - } - - return nil -} diff --git a/pkg/snclient/credentials.go b/pkg/snclient/credentials.go new file mode 100644 index 00000000..22ae34c3 --- /dev/null +++ b/pkg/snclient/credentials.go @@ -0,0 +1,139 @@ +package snclient + +import ( + "errors" + "sort" + "strings" +) + +const ( + // CredentialTypeWindowsShare stores a domain password credential in the Windows + // Credential Manager that is automatically used by the SMB redirector when + // connecting to the given target server. + CredentialTypeWindowsShare = "windows-share" + + // CredentialStrategyOnStart loads the credential once at agent startup. + // It stays for the lifetime of the agent logon session and is gone after a reboot. + CredentialStrategyOnStart = "on-start" + + // CredentialStrategyOnDemand loads the credential right before it is needed + // and removes it again as soon as the check finished. + CredentialStrategyOnDemand = "on-demand" +) + +// errSessionCredentialConflict is returned when a connection to the target server +// already exists with a different user name. +var errSessionCredentialConflict = errors.New("a connection to the server already exists with different credentials") + +// Credential describes a single entry in the [/settings/credentials] section. +type Credential struct { + Type string + Target string + Username string + Password string + Strategy string +} + +// parseCredentials reads the [/settings/credentials] section and returns all entries. +// Invalid or unsupported entries are skipped with a warning, so one broken entry does not break the whole configuration. +func parseCredentials(config *Config) (credentials []Credential) { + sections := config.SectionsByPrefix("/settings/credentials/") + names := make([]string, 0, len(sections)) + for name := range sections { + names = append(names, name) + } + sort.Strings(names) + + domain := currentUserDomain() + + for _, name := range names { + section := sections[name] + + cred := Credential{ + Type: CredentialTypeWindowsShare, + Strategy: CredentialStrategyOnDemand, + } + if val, ok := section.GetString("type"); ok && val != "" { + cred.Type = strings.ToLower(strings.TrimSpace(val)) + } + if val, ok := section.GetString("target"); ok { + cred.Target = strings.TrimSpace(val) + } + if val, ok := section.GetString("username"); ok { + cred.Username = strings.TrimSpace(val) + } + if val, ok := section.GetString("password"); ok { + cred.Password = val + } + if val, ok := section.GetString("strategy"); ok && val != "" { + cred.Strategy = strings.ToLower(strings.TrimSpace(val)) + } + + switch cred.Type { + case CredentialTypeWindowsShare: + default: + log.Warnf("credentials: unsupported type %q in %s, only %q is supported, skipping", cred.Type, name, CredentialTypeWindowsShare) + + continue + } + + if cred.Target == "" { + log.Warnf("credentials: missing target in %s, skipping", name) + + continue + } + + switch cred.Strategy { + case CredentialStrategyOnStart, CredentialStrategyOnDemand: + default: + log.Warnf("credentials: unsupported strategy %q in %s, skipping", cred.Strategy, name) + + continue + } + + if cred.Username == "" { + log.Warnf("credentials: missing username in %s, skipping", name) + + continue + } + + cred.Username = qualifyUsername(cred.Username, domain) + + credentials = append(credentials, cred) + } + + return credentials +} + +// applyCredentialsOnStart loads all credentials configured with the on-start strategy. +// It is called once at agent startup and again on config reloads. +func applyCredentialsOnStart(config *Config) { + for _, cred := range parseCredentials(config) { + if cred.Strategy != CredentialStrategyOnStart { + continue + } + + if cred.Type == CredentialTypeWindowsShare { + if err := addShareCredential(&cred); err != nil { + log.Errorf("credentials: failed to add on-start credential for %s: %s", cred.Target, err.Error()) + + continue + } + log.Debugf("credentials: added on-start windows share credential for %s", cred.Target) + } + } +} + +// findOnDemandCredential returns the on-demand credential matching the given target. +func findOnDemandCredential(config *Config, target string) (Credential, bool) { + for _, cred := range parseCredentials(config) { + if cred.Strategy != CredentialStrategyOnDemand { + continue + } + if strings.EqualFold(normalizeCredentialTargetFromUNCPath(cred.Target), normalizeCredentialTargetFromUNCPath(target)) { + return cred, true + } + } + + return Credential{}, false +} diff --git a/pkg/snclient/credentials_test.go b/pkg/snclient/credentials_test.go new file mode 100644 index 00000000..bd183c01 --- /dev/null +++ b/pkg/snclient/credentials_test.go @@ -0,0 +1,150 @@ +package snclient + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQualifyUsername(t *testing.T) { + tests := []struct { + username string + domain string + want string + }{ + {`svc`, `CORP`, `CORP\svc`}, + {`DOMAIN\svc`, `CORP`, `DOMAIN\svc`}, + {`svc@corp.example.com`, `CORP`, `svc@corp.example.com`}, + {`corp.example.com\svc`, `CORP`, `corp.example.com\svc`}, + {`svc`, ``, `svc`}, + } + for _, test := range tests { + assert.Equalf(t, test.want, qualifyUsername(test.username, test.domain), "qualifyUsername(%q, %q)", test.username, test.domain) + } +} + +func TestShareTargetFromUNC(t *testing.T) { + tests := []struct { + path string + want string + }{ + {`\\server\share`, `server`}, + {`\\server\share\folder`, `server`}, + {`\\server\C$`, `server`}, + {`\\192.168.178.21\TestHidden$`, `192.168.178.21`}, + {`//server/share`, `server`}, + {`C:\folder`, ``}, + {`server\share`, ``}, + {``, ``}, + } + for _, test := range tests { + assert.Equalf(t, test.want, shareTargetFromUNCPath(test.path), "shareTargetFromUNC(%q)", test.path) + } +} + +func TestIsNetworkSharePath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {`\\server\share`, true}, + {`//server/share`, true}, + {`\\server`, true}, + {`C:\folder`, false}, + {`C:`, false}, + {`/`, false}, + {``, false}, + } + for _, test := range tests { + assert.Equalf(t, test.want, isNetworkSharePath(test.path), "isNetworkSharePath(%q)", test.path) + } +} + +func TestNormalizeCredentialTarget(t *testing.T) { + tests := []struct { + target string + want string + }{ + {`\\server\share`, `server`}, + {`\\server\C$\folder`, `server`}, + {`//server/share`, `server`}, + {`server`, `server`}, + {`server2`, `server2`}, + {``, ``}, + } + for _, test := range tests { + assert.Equalf(t, test.want, normalizeCredentialTargetFromUNCPath(test.target), "normalizeCredentialTarget(%q)", test.target) + } +} + +func TestParseCredentials(t *testing.T) { + config := NewConfig(true) + parent := config.Section("/settings/credentials") + parent.Set("strategy", "on-start") + + // explicit entry, all keys set + share1 := config.Section("/settings/credentials/share1") + share1.Set("type", "windows-share") + share1.Set("target", `\\server1`) + share1.Set("username", `CORP\svc`) + share1.Set("password", "secret") + share1.Set("strategy", "on-demand") + + // type and strategy default to windows-share / parent strategy + share2 := config.Section("/settings/credentials/share2") + share2.Set("target", "server2") + share2.Set("username", "svc@corp.example.com") + share2.Set("password", "secret2") + + // unsupported type, must be skipped + share3 := config.Section("/settings/credentials/share3") + share3.Set("type", "generic") + share3.Set("target", "server3") + share3.Set("username", `CORP\svc`) + + // missing username, must be skipped + share4 := config.Section("/settings/credentials/share4") + share4.Set("target", "server4") + share4.Set("password", "secret4") + + // unsupported strategy, must be skipped + share5 := config.Section("/settings/credentials/share5") + share5.Set("target", "server5") + share5.Set("username", `CORP\svc`) + share5.Set("strategy", "sometimes") + + credentials := parseCredentials(config) + require.Lenf(t, credentials, 2, "two valid credentials expected") + + assert.Equal(t, CredentialTypeWindowsShare, credentials[0].Type) + assert.Equal(t, `\\server1`, credentials[0].Target) + assert.Equal(t, `CORP\svc`, credentials[0].Username) + assert.Equal(t, "secret", credentials[0].Password) + assert.Equal(t, CredentialStrategyOnDemand, credentials[0].Strategy) + + assert.Equal(t, CredentialTypeWindowsShare, credentials[1].Type) + assert.Equal(t, "server2", credentials[1].Target) + assert.Equal(t, "svc@corp.example.com", credentials[1].Username) + assert.Equal(t, CredentialStrategyOnStart, credentials[1].Strategy) +} + +func TestFindOnDemandCredential(t *testing.T) { + config := NewConfig(true) + share := config.Section("/settings/credentials/share1") + share.Set("target", `\\server1\C$`) + share.Set("username", `CORP\svc`) + share.Set("strategy", "on-demand") + + cred, ok := findOnDemandCredential(config, `server1`) + require.True(t, ok) + assert.Equal(t, `\\server1\C$`, cred.Target) + + // case-insensitive match + cred, ok = findOnDemandCredential(config, `SERVER1`) + require.True(t, ok) + assert.Equal(t, `\\server1\C$`, cred.Target) + + _, ok = findOnDemandCredential(config, `otherserver`) + assert.False(t, ok) +} diff --git a/pkg/snclient/credentials_windows.go b/pkg/snclient/credentials_windows.go new file mode 100644 index 00000000..7a6a30fb --- /dev/null +++ b/pkg/snclient/credentials_windows.go @@ -0,0 +1,92 @@ +//go:build windows + +package snclient + +import ( + "fmt" + "syscall" + "unsafe" + + "github.com/consol-monitoring/snclient/pkg/convert" + "golang.org/x/sys/windows" +) + +const ( + // https://learn.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentialw + credTypeDomainPassword = 2 + + // the credential persists for the life of the logon session, it does not survive a reboot + credPersistSession = 1 +) + +var ( + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + + credWriteW = advapi32.NewProc("CredWriteW") +) + +// credentialW is the CREDENTIALW structure from wincred.h. +// do not reorder, the fields must match the Windows layout exactly. +// https://learn.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentialw +type credentialW struct { + Flags uint32 + Type uint32 + TargetName *uint16 + Comment *uint16 + LastWritten windows.Filetime + CredentialBlobSize uint32 + CredentialBlob *byte + Persist uint32 + AttributeCount uint32 + Attributes uintptr + TargetAlias *uint16 + UserName *uint16 +} + +// addShareCredential stores the credential in the Credential Manager of the current user. +// domain password credential is automatically used by the SMB redirector (NTLM/Kerberos) when connecting to the given target server. +// uses credWriteW syscall +func addShareCredential(cred *Credential) error { + if cred.Type != CredentialTypeWindowsShare { + return fmt.Errorf("unsupported credential type %q", cred.Type) + } + + target := normalizeCredentialTargetFromUNCPath(cred.Target) + if target == "" { + return fmt.Errorf("empty credential target") + } + + targetUTF16, err := syscall.UTF16PtrFromString(target) + if err != nil { + return fmt.Errorf("target to utf16: %s", err.Error()) + } + userUTF16, err := syscall.UTF16PtrFromString(cred.Username) + if err != nil { + return fmt.Errorf("username to utf16: %s", err.Error()) + } + passwordUTF16, err := syscall.UTF16FromString(cred.Password) + if err != nil { + return fmt.Errorf("password to utf16: %s", err.Error()) + } + // the credential blob contains the plaintext unicode password, no trailing null character + passwordBlobSize, err := convert.UInt32E((len(passwordUTF16) - 1) * 2) + if err != nil { + return fmt.Errorf("password length to large for credential blob: %s", err.Error()) + } + + credential := credentialW{ + Type: credTypeDomainPassword, + TargetName: targetUTF16, + UserName: userUTF16, + CredentialBlobSize: passwordBlobSize, + CredentialBlob: (*byte)(unsafe.Pointer(&passwordUTF16[0])), + Persist: credPersistSession, + } + + ret, _, err := credWriteW.Call(uintptr(unsafe.Pointer(&credential)), 0) + if ret == 0 { + return fmt.Errorf("credWriteW failed: %s", err.Error()) + } + + return nil +} diff --git a/pkg/snclient/network_shares.go b/pkg/snclient/network_shares.go new file mode 100644 index 00000000..db20a25e --- /dev/null +++ b/pkg/snclient/network_shares.go @@ -0,0 +1,69 @@ +package snclient + +import ( + "strings" +) + +// qualifyUsername adds the current users domain to the username if it does not already contain a domain or UPN. +func qualifyUsername(username, domain string) string { + if strings.Contains(username, "\\") || strings.Contains(username, "@") { + return username + } + if domain == "" { + return username + } + + return domain + "\\" + username +} + +// shareTargetFromUNCPath returns the server name of a UNC path, e.g. \\server\share -> server +func shareTargetFromUNCPath(path string) string { + normalized := strings.ReplaceAll(path, "/", "\\") + parts := strings.Split(normalized, "\\") + // UNC paths look like \\server\share, split parts: ["", "", "server", "share"] + if len(parts) >= 3 && parts[0] == "" && parts[1] == "" { + return parts[2] + } + + return "" +} + +// normalizeCredentialTargetFromUNCPath turns a UNC path into a plain server name, the target name format expected by the SMB redirector. +func normalizeCredentialTargetFromUNCPath(target string) string { + target = strings.TrimSpace(target) + if strings.HasPrefix(target, "\\\\") || strings.HasPrefix(target, "//") { + return shareTargetFromUNCPath(target) + } + + return target +} + +// shareRoot returns the share root of a UNC path, e.g. \\server\share for \\server\share\folder\file. +func shareRoot(path string) string { + parts := strings.Split(path, "\\") + // UNC paths are in the form \\server\share\... + // share root is the 4th element + if len(parts) < 4 { + return path + } + + return strings.Join(parts[:4], "\\") +} + +// isNetworkSharePath returns if the given path looks like an UNC path. +// Example 1: \\FileServer01\PublicDocs +// Example 2: \\BackupServer\Data\Archive\2025-01-14.zip +// Example 3: \\192.168.1.50\SharedData\Images +// Modern programs also generally accept forward slash definitions, +// e.g. //192.168.1.50/Shareddata/Images +func isNetworkSharePath(path string) bool { + if len(path) < 2 { + return false + } + + if !strings.HasPrefix(path, "\\\\") && !strings.HasPrefix(path, "//") { + return false + } + + return true +} diff --git a/pkg/snclient/network_shares_other.go b/pkg/snclient/network_shares_other.go new file mode 100644 index 00000000..ddc12bca --- /dev/null +++ b/pkg/snclient/network_shares_other.go @@ -0,0 +1,26 @@ +//go:build !windows + +package snclient + +// Credentials can only be stored in the Windows Credential Manager. +// On other platforms all credential functions are no-ops. + +func addShareCredential(cred *Credential) error { + log.Debugf("credentials: storing credentials is only supported on windows, skipping target %s", cred.Target) + + return nil +} + +func addShareConnection(_ *Credential, shareRoot string) error { + log.Debugf("credentials: network share connections are only supported on windows, skipping target %s", shareRoot) + + return nil +} + +func deleteShareConnection(_ string) error { + return nil +} + +func currentUserDomain() string { + return "" +} diff --git a/pkg/snclient/network_shares_windows.go b/pkg/snclient/network_shares_windows.go new file mode 100644 index 00000000..69e4bbda --- /dev/null +++ b/pkg/snclient/network_shares_windows.go @@ -0,0 +1,290 @@ +//go:build windows + +package snclient + +import ( + "errors" + "fmt" + "os" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcew + resourceTypeDisk = 0x1 +) + +var ( + winnetwkDll = windows.NewLazySystemDLL("Mpr.dll") + + // [in] lpLocalName Pointer to a constant null-terminated string that specifies the name of the local device to get the network name for. + // [out] lpRemoteName Pointer to a null-terminated string that receives the remote name used to make the connection. + // [in, out] lpnLength Pointer to a variable that specifies the size of the buffer pointed to by the lpRemoteName parameter, + // in characters. If the function fails because the buffer is not large enough, this parameter returns the required buffer size. + wNetGetConnectionW = winnetwkDll.NewProc("WNetGetConnectionW") + + // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetaddconnection2w + // A pointer to a NETRESOURCE structure that specifies details of the proposed connection, such as information about the network resource, the local device, and the network resource provider. + // Setting lpLocalName to null, the connection is established without mounting to a local device letter + // [in] LPNETRESOURCEW lpNetResource, + // A pointer to a constant null-terminated string that specifies a password to be used in making the network connection. + // If lpPassword is NULL, the function uses the current default password associated with the user specified by the lpUserName parameter. + // If lpPassword points to an empty string, the function does not use a password. + // [in] LPCWSTR lpPassword, + // A pointer to a constant null-terminated string that specifies a user name for making the connection. + // If lpUserName is NULL, the function uses the default user name. (The user context for the process provides the default user name.) + // [in] LPCWSTR lpUserName, + // A set of connection options. The possible values for the connection options are defined in the Winnetwk.h header file. The following values can currently be used. + // [in] DWORD dwFlags + wNetAddConnection2W = winnetwkDll.NewProc("WNetAddConnection2W") + + // https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/nf-winnetwk-wnetcancelconnection2w + // [in] LPCWSTR lpName, Pointer to a constant null-terminated string that specifies the name of either the redirected local device or the remote network resource to disconnect from. + // [in] DWORD dwFlags, connection type, irrelevant to our purposes. + // [in] BOOL fForce , Specifies whether the disconnection should occur if there are open files or jobs on the connection. + wNetCancelConnection2W = winnetwkDll.NewProc("WNetCancelConnection2W") +) + +// https://learn.microsoft.com/en-us/windows/win32/api/winnetwk/ns-winnetwk-netresourcew +// netResourceW is the NETRESOURCEW structure from winnetwk.h. +// do not reorder, the fields must match the Windows layout exactly. +// this is used in the wNetAddConnection2W call. +type netResourceW struct { + // dwScope indicates the scope of the enumeration. This can be one of the RESOURCE_CONNECTED, RESOURCE_GLOBALNET or RESOURCE_CONTEXT values. + dwScope uint32 + // dwType indicates the type of resource. This can be one of the + // RESOURCETYPE_DISK, RESOURCETYPE_PRINT or RESOURCETYPE_ANY values. + dwType uint32 + // dwDisplayType indicates how a provider wants a UI to display the resource. + dwDisplayType uint32 + // dwUsage is a bitmask describing resource enumeration flags. + dwUsage uint32 + // lpLocalName holds the name of a redirected local device when dwScope is RESOURCE_CONNECTED; otherwise it is undefined. + lpLocalName *uint16 + // lpRemoteName holds the remote network name of the resource. + lpRemoteName *uint16 + // lpComment is any provider-supplied comment about the resource. + lpComment *uint16 + // lpProvider is the name of the provider that owns the resource. + lpProvider *uint16 +} + +// addShareConnection establishes an SMB connection to the given share root using the supplied credentials. +// uses wNetAddConnection2W syscall +func addShareConnection(cred *Credential, shareRoot string) error { + if cred.Type != CredentialTypeWindowsShare { + return fmt.Errorf("unsupported credential type %q", cred.Type) + } + if cred.Username == "" { + return fmt.Errorf("missing username") + } + + shareRoot = strings.TrimSpace(shareRoot) + if shareRoot == "" { + return fmt.Errorf("empty share root") + } + + remoteUTF16, err := syscall.UTF16PtrFromString(shareRoot) + if err != nil { + return fmt.Errorf("share root to utf16: %s", err.Error()) + } + userUTF16, err := syscall.UTF16PtrFromString(cred.Username) + if err != nil { + return fmt.Errorf("username to utf16: %s", err.Error()) + } + var passwordUTF16 *uint16 + if cred.Password != "" { + passwordUTF16, err = syscall.UTF16PtrFromString(cred.Password) + if err != nil { + return fmt.Errorf("password to utf16: %s", err.Error()) + } + } + + resource := netResourceW{ + dwType: resourceTypeDisk, + lpRemoteName: remoteUTF16, + } + + ret, _, _ := wNetAddConnection2W.Call( + uintptr(unsafe.Pointer(&resource)), + uintptr(unsafe.Pointer(passwordUTF16)), + uintptr(unsafe.Pointer(userUTF16)), + 0, + ) + if ret != windows.NO_ERROR { + return wNetError(ret, fmt.Sprintf("WNetAddConnection2W failed for %s", shareRoot)) + } + + return nil +} + +// deleteShareConnection removes the SMB connection to the given share root again. +// uses wNetCancelConnection2W syscall +func deleteShareConnection(shareRoot string) error { + shareRoot = strings.TrimSpace(shareRoot) + if shareRoot == "" { + return fmt.Errorf("empty share root") + } + + shareRootUTF16, err := syscall.UTF16PtrFromString(shareRoot) + if err != nil { + return fmt.Errorf("share root to utf16: %s", err.Error()) + } + + ret, _, _ := wNetCancelConnection2W.Call( + uintptr(unsafe.Pointer(shareRootUTF16)), + 0, + 1, // fForce: close the connection even if files are still open + ) + if ret != windows.NO_ERROR { + return wNetError(ret, fmt.Sprintf("WNetCancelConnection2W failed for %s", shareRoot)) + } + + return nil +} + +// currentUserDomain returns the domain of the current user, e.g. CORP for CORP\svc. +// Local accounts report the computer name as their domain. +func currentUserDomain() string { + var size uint32 = 256 + for { + buffer := make([]uint16, size) + err := windows.GetUserNameEx(windows.NameSamCompatible, &buffer[0], &size) + if err == nil { + name := syscall.UTF16ToString(buffer) + if index := strings.IndexRune(name, '\\'); index > 0 { + return name[:index] + } + + return "" + } + if errors.Is(err, windows.ERROR_MORE_DATA) { + continue + } + log.Debugf("credentials: GetUserNameEx failed: %s, using USERDOMAIN env var", err.Error()) + + return os.Getenv("USERDOMAIN") + } +} + +func NetGetConnection(lpLocalName string) (lpRemoteName string, err error) { + if lpLocalName == "" { + return "", fmt.Errorf("lpLocalName cannot be empty") + } + + lpLocalNameW16 := windows.StringToUTF16(lpLocalName) + + var lpnLength uint32 = 32768 + lpRemoteNameW16 := make([]uint16, lpnLength) + returnValue, _, err := wNetGetConnectionW.Call( + uintptr(unsafe.Pointer(&lpLocalNameW16[0])), + uintptr(unsafe.Pointer(&lpRemoteNameW16[0])), + uintptr(unsafe.Pointer(&lpnLength)), + ) + + switch { + case returnValue == windows.NO_ERROR: + // this is what we want + case errors.Is(err, windows.ERROR_BAD_DEVICE): + return "", fmt.Errorf("the string pointed to by the lpLocalName parameter is invalid : %s", lpLocalName) + case errors.Is(err, windows.ERROR_NOT_CONNECTED): + return "", fmt.Errorf("the device specified by lpLocalName is not a redirected device. For more information, see the following Remarks section") + case errors.Is(err, windows.ERROR_MORE_DATA): + return "", fmt.Errorf("the buffer is too small. The lpnLength parameter points to a variable that contains the required buffer size. More entries are available with subsequent calls") + case errors.Is(err, windows.ERROR_CONNECTION_UNAVAIL): + return "", fmt.Errorf("the device is not currently connected, but it is a persistent connection. For more information, see the following Remarks section") + case errors.Is(err, windows.ERROR_NO_NETWORK): + return "", fmt.Errorf("the network is unavailable") + case errors.Is(err, windows.ERROR_EXTENDED_ERROR): + return "", fmt.Errorf("a network-specific error occurred. To obtain a description of the error, call the WNetGetLastError function."+ + "WNetGetLastError returned: %w", handleWNetError(returnValue, winnetwkDll)) + case errors.Is(err, windows.ERROR_NO_NET_OR_BAD_PATH): + return "", fmt.Errorf("none of the providers recognize the local name as having a connection. However, the network is not available for at least one provider to whom the connection may belong") + default: + return "", fmt.Errorf("mNetGetConnectionW returned an unrecognized error with value: %d", returnValue) + } + + lpRemoteName = windows.UTF16ToString(lpRemoteNameW16) + + return lpRemoteName, nil +} + +func handleWNetError(errorCode uintptr, winnetwkDll *windows.LazyDLL) (err error) { + wNetGetLastErrorAFunc := winnetwkDll.NewProc("WNetGetLastErrorA") + const lpErrorBufLength = uint32(1024) + lpErrorBuf := make([]byte, lpErrorBufLength) + const lpNameBufLength = uint32(256) + lpNameBuf := make([]byte, lpNameBufLength) + ret, _, _ := wNetGetLastErrorAFunc.Call( + errorCode, + uintptr(unsafe.Pointer(&lpErrorBuf)), + uintptr(lpErrorBufLength), + uintptr(unsafe.Pointer(&lpNameBuf)), + uintptr(lpNameBufLength), + ) + if ret != windows.NO_ERROR { + return fmt.Errorf("got en error while getting the extended network error") + } + if ret == uintptr(windows.ERROR_INVALID_ADDRESS) { + return fmt.Errorf("provided an invalid buffer while getting the extended network error") + } + + return nil +} + +// wNetError converts a WNet* return code into a descriptive error. +func wNetError(code uintptr, context string) error { + switch code { + case uintptr(windows.ERROR_ACCESS_DENIED): + return fmt.Errorf("%s: access denied", context) + case uintptr(windows.ERROR_BAD_NET_NAME): + return fmt.Errorf("%s: the share name is invalid or the share does not exist", context) + case uintptr(windows.ERROR_LOGON_FAILURE): + return fmt.Errorf("%s: logon failure, the username or password is incorrect", context) + case uintptr(windows.ERROR_INVALID_PASSWORD): + return fmt.Errorf("%s: invalid password", context) + case uintptr(windows.ERROR_BAD_USERNAME): + return fmt.Errorf("%s: invalid username", context) + case uintptr(windows.ERROR_ALREADY_ASSIGNED): + return fmt.Errorf("%s: the resource is already connected", context) + case uintptr(windows.ERROR_SESSION_CREDENTIAL_CONFLICT): + return fmt.Errorf("%s: %w", context, errSessionCredentialConflict) + case uintptr(windows.ERROR_NO_NET_OR_BAD_PATH): + return fmt.Errorf("%s: the network path was not found or is not available", context) + case uintptr(windows.ERROR_NOT_CONNECTED): + return fmt.Errorf("%s: the connection does not exist", context) + case uintptr(windows.ERROR_CONNECTION_UNAVAIL): + return fmt.Errorf("%s: the connection is not currently available", context) + case uintptr(windows.ERROR_OPEN_FILES): + return fmt.Errorf("%s: the connection could not be closed because files are open", context) + case uintptr(windows.ERROR_EXTENDED_ERROR): + return fmt.Errorf("%s: %s", context, wNetGetLastErrorText(code)) + default: + return fmt.Errorf("%s: unrecognized network error %d", context, code) + } +} + +// wNetGetLastErrorText returns the extended error description for a WNet* return code. +func wNetGetLastErrorText(code uintptr) string { + wNetGetLastErrorW := winnetwkDll.NewProc("WNetGetLastErrorW") + const errorBufLength = uint32(1024) + const nameBufLength = uint32(256) + errorBuf := make([]uint16, errorBufLength) + nameBuf := make([]uint16, nameBufLength) + ret, _, _ := wNetGetLastErrorW.Call( + code, + uintptr(unsafe.Pointer(&errorBuf[0])), + uintptr(errorBufLength), + uintptr(unsafe.Pointer(&nameBuf[0])), + uintptr(len(nameBuf)), + ) + if ret != windows.NO_ERROR { + return fmt.Sprintf("extended network error %d", code) + } + + return windows.UTF16ToString(errorBuf) +} diff --git a/pkg/snclient/snclient.go b/pkg/snclient/snclient.go index 12a75f86..96b49220 100644 --- a/pkg/snclient/snclient.go +++ b/pkg/snclient/snclient.go @@ -517,6 +517,9 @@ func (snc *Agent) Init(mode InitMode) (*AgentRunSet, error) { return initSet, err } + // load credentials configured with the on-start strategy + applyCredentialsOnStart(initSet.config) + initSet.mode = mode initSet.tasks = NewModuleSet("tasks")