From 4f331c5382efb953d97ec7f2c3001436c51f99a2 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Fri, 28 Aug 2026 15:32:42 +0200 Subject: [PATCH 1/6] check_drivesize: ai assisted improve detection of hidden share these are generally improvements/fixes when share is not mounted to a drive letter, and instead left as an UNC path as is. If it has a drive letter, it is generally used instead. skip trying to get DeviceFlags and Media Type if path looks like an UNC Path. If paths are UNC paths, but mounted to a drive letter, the drive letter is used instead. set 'type' attrbibute to 'remote' if an UNC path is used, e.g for discovering a hidden share. Skip calling GetDriveType in these cases improve localized remote path calculation, trims the seperators more cleanly, works when remote path has multiple seperators at the end add 'connected' and 'hidden' arguments , windows only and are set if the network drive is connected, used in addPersistentNetworkDrives. Hidden is used if the given UNC path looks to be a hidden drive. This is used if the user specifies multiple paths, but only wants to filter to or out hidden shares. add tests for the cleanupPathString, isNetworkSharePath , isHiddenSharePath, shareRoot and matchNetworkShare helper functions general improvements add timeout to disk.Partitions by using disk.PartitionsWithContext add new argument: addPersistentNetworkDrives , this tries discovering the persistent network drives when drive=all or drive=all-shares is specified. This is opt-in so it will only work when its toggled on If a persistent drive is disconnected/unmounted, the default filter filters them out anyway. move some functions to take (l *CheckDrivesize) receivers -> helps to isolate these helper functions to checkDrivesize fix the example output of check_drivesize in windows. add BoolTo01String function in convert.go -> this might be unnecessary. --- docs/checks/commands/check_drivesize.md | 3 + pkg/convert/convert.go | 9 + pkg/snclient/check_drivesize.go | 34 ++-- pkg/snclient/check_drivesize_windows.go | 195 ++++++++++++++++--- pkg/snclient/check_drivesize_windows_test.go | 166 ++++++++++++++++ 5 files changed, 364 insertions(+), 43 deletions(-) diff --git a/docs/checks/commands/check_drivesize.md b/docs/checks/commands/check_drivesize.md index e2dea9fc..8349e9e2 100644 --- a/docs/checks/commands/check_drivesize.md +++ b/docs/checks/commands/check_drivesize.md @@ -70,6 +70,7 @@ Naemon Config | Argument | Description | | ------------------------- | ----------------------------------------------------------------------------------------- | +| add-persistent-network-drives | Include persistent network drives (net use /persistent), even if currently disconnected, in the all/all-shares listing. Disconnected drives are excluded by the default filter (mounted = 1) but are still listed when the filter is overridden. | | drive | The drives to check, e.g. C:\ or / | | exclude | List of drives to exclude from check | | folder | The folders to check (parent mountpoint) | @@ -125,4 +126,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/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..4f1a1988 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,16 @@ 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 } func NewCheckDrivesize() CheckHandler { @@ -97,9 +98,10 @@ func (l *CheckDrivesize) Build() *CheckData { "total": {value: &l.total, description: "Include the total of all matching drives"}, "magic": {value: &l.magic, description: "Magic number for use with scaling drive sizes. " + "Note there is also a more generic magic factor in the perf-config option."}, - "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"}, + "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"}, }, defaultFilter: l.getDefaultFilter(), defaultWarning: "used_pct > 80", @@ -152,6 +154,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."}, }, @@ -269,6 +273,10 @@ func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData if !l.hasCustomPath { for i, 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" } diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index 6d4f41c4..b2cef299 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -40,10 +40,30 @@ 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$' ` } @@ -114,7 +134,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 +147,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 +287,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 +314,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 +401,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 +516,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 +611,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 +670,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"] = l.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 +823,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 +865,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 +875,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:\' @@ -859,3 +944,53 @@ func isNetworkSharePath(path string) (isNetworkSharePath bool) { return true } + +// 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 + } + + return strings.HasSuffix(parts[3], "$") +} + +// returns the share root of a UNC path, e.g. \\server\share for \\server\share\folder\file +func (l *CheckDrivesize) shareRoot(path string) string { + parts := strings.Split(path, "\\") + if len(parts) < 4 { + return path + } + + return strings.Join(parts[:4], "\\") +} + +// 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 "", nil, false +} diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index 879d52c9..847aec1f 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -154,3 +154,169 @@ func TestNonexistingDrive(t *testing.T) { StopTestAgent(t, snc) } + +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 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`}, + } + cd := CheckDrivesize{} + for _, test := range tests { + assert.Equalf(t, test.want, cd.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 + _, _, 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) + assert.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) + } +} From aea8f449b5df8b8f000005e9065f468904ba2d59 Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Mon, 10 Aug 2026 16:29:07 +0200 Subject: [PATCH 2/6] check_drivesize: golangci-lint fixes --- pkg/snclient/check_drivesize.go | 17 ++++++++++------- pkg/snclient/check_drivesize_windows_test.go | 4 +++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/pkg/snclient/check_drivesize.go b/pkg/snclient/check_drivesize.go index 4f1a1988..de431e42 100644 --- a/pkg/snclient/check_drivesize.go +++ b/pkg/snclient/check_drivesize.go @@ -82,6 +82,7 @@ func NewCheckDrivesize() CheckHandler { } } +//nolint:funlen // there are lots of attributes in this check func (l *CheckDrivesize) Build() *CheckData { return &CheckData{ name: "check_drivesize", @@ -98,10 +99,12 @@ func (l *CheckDrivesize) Build() *CheckData { "total": {value: &l.total, description: "Include the total of all matching drives"}, "magic": {value: &l.magic, description: "Magic number for use with scaling drive sizes. " + "Note there is also a more generic magic factor in the perf-config option."}, - "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"}, + "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", + }, }, defaultFilter: l.getDefaultFilter(), defaultWarning: "used_pct > 80", @@ -169,7 +172,7 @@ func (l *CheckDrivesize) Build() *CheckData { } } -//nolint:funlen // no need to split the function, it is simple as is +//nolint:funlen,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 { @@ -271,14 +274,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_test.go b/pkg/snclient/check_drivesize_windows_test.go index 847aec1f..d082805a 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) { @@ -273,6 +274,7 @@ func TestMatchNetworkShare(t *testing.T) { 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") } @@ -295,7 +297,7 @@ func TestCleanupPathString(t *testing.T) { cd := CheckDrivesize{} for _, test := range tests { cleaned, isDrive, err := cd.cleanupPathString(test.path) - assert.NoErrorf(t, err, "cleanupPathString(%q)", 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) } From f162fd2ca3a363b3a7e2d6bb81877baefb843671 Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Thu, 13 Aug 2026 14:42:04 +0200 Subject: [PATCH 3/6] check_drivesize: ai assisted add credential support for network shares credentials: adds parsing of credentials from the config files. a credential is generic, currently it has the fields type, target, username, password and strategy. strategy is the load strategy used, if its loaded at the stard or loaded when its demanded. use const CredentialTypeWindowsShare = "windows-share" to add windows shares. currently only this type of credentials are used. add helper functions applyCredentialsOnStart , findOnDemandCredential , qualifyUsername, shareTargetFromUNCPath, normalizeCredentialTargetFromUNCPath and move isNetworkSharePath to credentials.go credential_windows.go adds functions relating to network share credentials using advapi32.dll addShareCredential uses a saved config credential. uses the CredWriteW, deleteShareCredential uses credDeleteW, hasShareCredential uses credReadW check_drivesize: use these functions and load on-demand credentials if a network share with credentials are used. additionally, add a share-user and share-password argument. these are used with the currently specified shares as an override --- docs/checks/commands/check_drivesize.md | 2 + docs/configuration/_index.md | 50 +++++ pkg/snclient/check_drivesize.go | 121 +++++++++++- pkg/snclient/check_drivesize_windows.go | 28 +-- pkg/snclient/check_drivesize_windows_test.go | 18 -- pkg/snclient/credentials.go | 186 +++++++++++++++++++ pkg/snclient/credentials_other.go | 24 +++ pkg/snclient/credentials_test.go | 150 +++++++++++++++ pkg/snclient/credentials_windows.go | 176 ++++++++++++++++++ pkg/snclient/snclient.go | 3 + 10 files changed, 719 insertions(+), 39 deletions(-) create mode 100644 pkg/snclient/credentials.go create mode 100644 pkg/snclient/credentials_other.go create mode 100644 pkg/snclient/credentials_test.go create mode 100644 pkg/snclient/credentials_windows.go diff --git a/docs/checks/commands/check_drivesize.md b/docs/checks/commands/check_drivesize.md index 8349e9e2..f5488dfd 100644 --- a/docs/checks/commands/check_drivesize.md +++ b/docs/checks/commands/check_drivesize.md @@ -78,6 +78,8 @@ Naemon Config | 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. The credential is added to the Windows Credential Manager on demand and removed again after the 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. If it contains no domain, the current users domain is added automatically. | | total | Include the total of all matching drives | ## Attributes 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/snclient/check_drivesize.go b/pkg/snclient/check_drivesize.go index de431e42..62fa4af6 100644 --- a/pkg/snclient/check_drivesize.go +++ b/pkg/snclient/check_drivesize.go @@ -71,6 +71,8 @@ type CheckDrivesize struct { hasCustomPath bool freespaceIgnoreReserved bool addPersistentNetworkDrives bool + shareUser string + sharePassword string } func NewCheckDrivesize() CheckHandler { @@ -105,6 +107,14 @@ func (l *CheckDrivesize) Build() *CheckData { "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 credential is added to the Windows Credential Manager 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", @@ -172,7 +182,7 @@ func (l *CheckDrivesize) Build() *CheckData { } } -//nolint:funlen,contextcheck,nolintlint // no need to split the function, it is simple as is , context is constructed when needed +//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 { @@ -241,6 +251,115 @@ func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData l.tidyThresholdDriveValues(check) + // overridden credentials from the share-user / share-password check arguments, + // they apply to all UNC shares given in this check + overrideCredentials := map[string]Credential{} + if l.shareUser != "" { + for _, k := range keys { + drive := requiredDisks[k] + if !isNetworkSharePath(drive["drive_or_id"]) { + continue + } + target := shareTargetFromUNCPath(drive["drive_or_id"]) + if target == "" { + continue + } + // user is always needed, but password can be empty for a valid login + overrideCredentials[target] = Credential{ + Type: CredentialTypeWindowsShare, + Target: target, + Username: qualifyUsername(l.shareUser, currentUserDomain()), + Password: l.sharePassword, + Strategy: CredentialStrategyOnDemand, + } + } + } + + // on-demand credentials from the [/settings/credentials] config section, + // they are only added for UNC shares that have no override above + onDemandCredentials := map[string]Credential{} + for _, k := range keys { + drive := requiredDisks[k] + if !isNetworkSharePath(drive["drive_or_id"]) { + continue + } + target := shareTargetFromUNCPath(drive["drive_or_id"]) + if target == "" { + continue + } + if _, ok := overrideCredentials[target]; ok { + continue + } + if _, ok := onDemandCredentials[target]; ok { + continue + } + if cred, ok := findOnDemandCredential(snc.config, target); ok { + onDemandCredentials[target] = cred + } + } + + // keep track of the credentials that were actually written in this run, + // so the cleanup only removes the ones snclient added + addedCredentials := map[string]bool{} + + // add the override credentials first + for target, cred := range overrideCredentials { + // leave credentials the user set up on their own untouched + if hasShareCredential(target) { + log.Debugf("credentials: credential for %s already exists, leaving it untouched", target) + + continue + } + if err := addShareCredential(&cred); err != nil { + log.Errorf("credentials: failed to add override credential for %s: %s", target, err.Error()) + + continue + } + log.Debugf("credentials: added override credential for %s", target) + addedCredentials[target] = true + } + + // then add the on-demand credentials when necessary + for target, cred := range onDemandCredentials { + // leave credentials the user set up on their own untouched + if hasShareCredential(target) { + log.Debugf("credentials: credential for %s already exists, leaving it untouched", target) + + continue + } + if err := addShareCredential(&cred); err != nil { + log.Errorf("credentials: failed to add on-demand credential for %s: %s", target, err.Error()) + + continue + } + log.Debugf("credentials: added on-demand credential for %s", target) + addedCredentials[target] = true + } + + addedCredentialsCount := 0 + for _, added := range addedCredentials { + if added { + addedCredentialsCount++ + } + } + + if addedCredentialsCount >= 1 { + // wait a bit for credentials to take effect + time.Sleep(1 * time.Second) + } + + // remove all added credentials again after the check finished + defer func() { + for target := range addedCredentials { + if err := deleteShareCredential(target); err != nil { + log.Errorf("credentials: failed to remove on-demand credential for %s: %s", target, err.Error()) + + continue + } + log.Debugf("credentials: removed on-demand credential for %s", target) + } + }() + for _, k := range keys { if ctxErr := ctx.Err(); ctxErr != nil { return nil, fmt.Errorf("disk scan canceled: %w", ctxErr) diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index b2cef299..bf96055f 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -64,11 +64,18 @@ Include persistent network drives (net use /persistent), even if they are curren 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. +The credential is added to the Windows Credential Manager 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. @@ -926,25 +933,6 @@ 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 { - return false - } - - if !strings.HasPrefix(path, "\\\\") && !strings.HasPrefix(path, "//") { - return false - } - - return true -} - // 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, "\\") diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index d082805a..ad329eb5 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -156,24 +156,6 @@ func TestNonexistingDrive(t *testing.T) { StopTestAgent(t, snc) } -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 TestIsHiddenSharePath(t *testing.T) { tests := []struct { path string diff --git a/pkg/snclient/credentials.go b/pkg/snclient/credentials.go new file mode 100644 index 00000000..7b01e79c --- /dev/null +++ b/pkg/snclient/credentials.go @@ -0,0 +1,186 @@ +package snclient + +import ( + "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" +) + +// 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 +} + +// 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 +} + +// 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/credentials_other.go b/pkg/snclient/credentials_other.go new file mode 100644 index 00000000..086219b7 --- /dev/null +++ b/pkg/snclient/credentials_other.go @@ -0,0 +1,24 @@ +//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 deleteShareCredential(_ string) error { + return nil +} + +func hasShareCredential(_ string) bool { + return false +} + +func currentUserDomain() string { + return "" +} 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..09642648 --- /dev/null +++ b/pkg/snclient/credentials_windows.go @@ -0,0 +1,176 @@ +//go:build windows + +package snclient + +import ( + "errors" + "fmt" + "os" + "strings" + "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") + +var ( + credWriteW = advapi32.NewProc("CredWriteW") + credReadW = advapi32.NewProc("CredReadW") + credDeleteW = advapi32.NewProc("CredDeleteW") + credFree = advapi32.NewProc("CredFree") +) + +// 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. +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 +} + +// deleteShareCredential removes the domain password credential for the given target +// from the Credential Manager. +func deleteShareCredential(target string) error { + target = normalizeCredentialTargetFromUNCPath(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()) + } + + ret, _, err := credDeleteW.Call(uintptr(unsafe.Pointer(targetUTF16)), uintptr(credTypeDomainPassword), 0) + if ret == 0 { + return fmt.Errorf("credDeleteW failed: %s", err.Error()) + } + + return nil +} + +// hasShareCredential returns true if a domain password credential for the given target already exists in the Credential Manager. +func hasShareCredential(target string) bool { + target = normalizeCredentialTargetFromUNCPath(target) + if target == "" { + return false + } + + targetUTF16, err := syscall.UTF16PtrFromString(target) + if err != nil { + log.Debugf("credentials: target to utf16: %s", err.Error()) + + return false + } + + var credential *credentialW + // the flag is always 0 + ret, _, _ := credReadW.Call( + uintptr(unsafe.Pointer(targetUTF16)), + uintptr(credTypeDomainPassword), + 0, + uintptr(unsafe.Pointer(&credential)), + ) + if ret == 0 { + return false + } + // the returned credential needs to be freed by this special CredFree function + _, _, err = credFree.Call(uintptr(unsafe.Pointer(credential))) + if err != nil { + log.Debugf("credentials: credFree failed: %s", err.Error()) + } + + return true +} + +// 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") + } +} 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") From a300df8e4e8d385f81cb80688e65fdb22ed2a00d Mon Sep 17 00:00:00 2001 From: Ahmet Oeztuerk Date: Tue, 18 Aug 2026 16:51:03 +0200 Subject: [PATCH 4/6] make docs --- docs/checks/commands/check_drivesize.md | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/checks/commands/check_drivesize.md b/docs/checks/commands/check_drivesize.md index f5488dfd..b1d5b217 100644 --- a/docs/checks/commands/check_drivesize.md +++ b/docs/checks/commands/check_drivesize.md @@ -68,19 +68,19 @@ Naemon Config ## Check Specific Arguments -| Argument | Description | -| ------------------------- | ----------------------------------------------------------------------------------------- | -| add-persistent-network-drives | Include persistent network drives (net use /persistent), even if currently disconnected, in the all/all-shares listing. Disconnected drives are excluded by the default filter (mounted = 1) but are still listed when the filter is overridden. | -| 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. The credential is added to the Windows Credential Manager on demand and removed again after the 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. If it contains no domain, the current users domain is added automatically. | -| 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 credential is added to the Windows Credential Manager on demand and removed again after the check. | +| total | Include the total of all matching drives | ## Attributes @@ -129,5 +129,5 @@ these can be used in filters and thresholds (along with the default attributes): | 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) | +| 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. | From d90de80c2a9ac5a2731e0534a5d2bf37c13d5e8d Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Tue, 18 Aug 2026 16:59:03 +0200 Subject: [PATCH 5/6] fix cleanupPathString test --- pkg/snclient/check_drivesize_windows_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index ad329eb5..229bfbc1 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -267,8 +267,8 @@ func TestCleanupPathString(t *testing.T) { 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}, From 429ce4d1b8f7967a213ffa859630fc6a48359a2d Mon Sep 17 00:00:00 2001 From: Ahmet Ozturk Date: Wed, 19 Aug 2026 15:15:55 +0200 Subject: [PATCH 6/6] check_drivesize: ai assisted, add wNetAddConnection2W and wNetCancelConnection2W wrappers and organize functions into credentials(_windows).go and network_shares(_windows).go files with these two functions, a share can be directly connected and is immediately active. it does not have to be mounted to a letter, if we call it with lpLocalName as nil this is useful since the check can immediately deliver results. the drawback however, since the check browses and SMB path, the SMB redirector caches the credential. even with an on-demand loading strategy where credentials are deleted immediately, the SMB redirector cache is not cleared. The clearing seems to take 10 seconds. once the credentials are in SMB redirector cache, browsing the share caches it again. seems like a credential can stay cached indefinitely --- docs/checks/commands/check_drivesize.md | 2 +- pkg/snclient/check_drivesize.go | 120 +++----- pkg/snclient/check_drivesize_windows.go | 14 +- pkg/snclient/check_drivesize_windows_test.go | 3 +- .../check_drivesize_windows_win32api.go | 74 ----- pkg/snclient/credentials.go | 57 +--- pkg/snclient/credentials_windows.go | 92 +----- pkg/snclient/network_shares.go | 69 +++++ ...tials_other.go => network_shares_other.go} | 8 +- pkg/snclient/network_shares_windows.go | 290 ++++++++++++++++++ 10 files changed, 418 insertions(+), 311 deletions(-) create mode 100644 pkg/snclient/network_shares.go rename pkg/snclient/{credentials_other.go => network_shares_other.go} (63%) create mode 100644 pkg/snclient/network_shares_windows.go diff --git a/docs/checks/commands/check_drivesize.md b/docs/checks/commands/check_drivesize.md index b1d5b217..5d00eceb 100644 --- a/docs/checks/commands/check_drivesize.md +++ b/docs/checks/commands/check_drivesize.md @@ -79,7 +79,7 @@ Naemon Config | 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 credential is added to the Windows Credential Manager on demand and removed again after the check. | +| 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 diff --git a/pkg/snclient/check_drivesize.go b/pkg/snclient/check_drivesize.go index 62fa4af6..dcbe5690 100644 --- a/pkg/snclient/check_drivesize.go +++ b/pkg/snclient/check_drivesize.go @@ -109,7 +109,7 @@ func (l *CheckDrivesize) Build() *CheckData { }, "share-user": { value: &l.shareUser, description: "Windows only: username used to authenticate to the network shares given in this check. " + - "The credential is added to the Windows Credential Manager on demand and removed again after the 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. " + @@ -251,112 +251,74 @@ func (l *CheckDrivesize) Check(ctx context.Context, snc *Agent, check *CheckData l.tidyThresholdDriveValues(check) - // overridden credentials from the share-user / share-password check arguments, - // they apply to all UNC shares given in this check - overrideCredentials := map[string]Credential{} - if l.shareUser != "" { - for _, k := range keys { - drive := requiredDisks[k] - if !isNetworkSharePath(drive["drive_or_id"]) { - continue - } - target := shareTargetFromUNCPath(drive["drive_or_id"]) - if target == "" { - continue - } - // user is always needed, but password can be empty for a valid login - overrideCredentials[target] = Credential{ - Type: CredentialTypeWindowsShare, - Target: target, - Username: qualifyUsername(l.shareUser, currentUserDomain()), - Password: l.sharePassword, - Strategy: CredentialStrategyOnDemand, - } - } - } - - // on-demand credentials from the [/settings/credentials] config section, - // they are only added for UNC shares that have no override above - onDemandCredentials := map[string]Credential{} + // 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 } - target := shareTargetFromUNCPath(drive["drive_or_id"]) - if target == "" { + root := shareRoot(drive["drive_or_id"]) + if root == "" { continue } - if _, ok := overrideCredentials[target]; ok { + if _, ok := shareCredentials[root]; ok { continue } - if _, ok := onDemandCredentials[target]; ok { - continue - } - if cred, ok := findOnDemandCredential(snc.config, target); ok { - onDemandCredentials[target] = cred - } - } - // keep track of the credentials that were actually written in this run, - // so the cleanup only removes the ones snclient added - addedCredentials := map[string]bool{} - - // add the override credentials first - for target, cred := range overrideCredentials { - // leave credentials the user set up on their own untouched - if hasShareCredential(target) { - log.Debugf("credentials: credential for %s already exists, leaving it untouched", target) + 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 err := addShareCredential(&cred); err != nil { - log.Errorf("credentials: failed to add override credential for %s: %s", target, err.Error()) - continue + if cred, ok := findOnDemandCredential(snc.config, shareTargetFromUNCPath(root)); ok { + shareCredentials[root] = cred } - log.Debugf("credentials: added override credential for %s", target) - addedCredentials[target] = true } - // then add the on-demand credentials when necessary - for target, cred := range onDemandCredentials { - // leave credentials the user set up on their own untouched - if hasShareCredential(target) { - log.Debugf("credentials: credential for %s already exists, leaving it untouched", target) + // keep track of the connections snclient established, so the cleanup only tears down the ones it added itself + addedConnections := map[string]bool{} - continue + 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 := addShareCredential(&cred); err != nil { - log.Errorf("credentials: failed to add on-demand credential for %s: %s", target, err.Error()) - continue - } - log.Debugf("credentials: added on-demand credential for %s", target) - addedCredentials[target] = true - } + 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()) - addedCredentialsCount := 0 - for _, added := range addedCredentials { - if added { - addedCredentialsCount++ + continue + } } + log.Debugf("credentials: established connection for %s", root) + addedConnections[root] = true } - if addedCredentialsCount >= 1 { - // wait a bit for credentials to take effect - time.Sleep(1 * time.Second) - } - - // remove all added credentials again after the check finished + // remove all newly added connections again after the check finished defer func() { - for target := range addedCredentials { - if err := deleteShareCredential(target); err != nil { - log.Errorf("credentials: failed to remove on-demand credential for %s: %s", target, err.Error()) + 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 on-demand credential for %s", target) + log.Debugf("credentials: removed connection for %s", root) } }() diff --git a/pkg/snclient/check_drivesize_windows.go b/pkg/snclient/check_drivesize_windows.go index bf96055f..f40c1e9d 100644 --- a/pkg/snclient/check_drivesize_windows.go +++ b/pkg/snclient/check_drivesize_windows.go @@ -66,7 +66,7 @@ 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. -The credential is added to the Windows Credential Manager for the duration of the check and removed afterwards. +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' @@ -708,7 +708,7 @@ func (l *CheckDrivesize) setCustomPath(path string, requiredDrives map[string]ma // 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"] = l.shareRoot(normalizedPath) + entry["remote_name"] = shareRoot(normalizedPath) entry["hidden"] = convert.BoolTo01String(l.isHiddenSharePath(normalizedPath)) requiredDrives[normalizedPath] = entry @@ -945,16 +945,6 @@ func (l *CheckDrivesize) isHiddenSharePath(path string) bool { return strings.HasSuffix(parts[3], "$") } -// returns the share root of a UNC path, e.g. \\server\share for \\server\share\folder\file -func (l *CheckDrivesize) shareRoot(path string) string { - parts := strings.Split(path, "\\") - if len(parts) < 4 { - return path - } - - return strings.Join(parts[:4], "\\") -} - // 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 { diff --git a/pkg/snclient/check_drivesize_windows_test.go b/pkg/snclient/check_drivesize_windows_test.go index 229bfbc1..2c63ffdc 100644 --- a/pkg/snclient/check_drivesize_windows_test.go +++ b/pkg/snclient/check_drivesize_windows_test.go @@ -188,9 +188,8 @@ func TestShareRoot(t *testing.T) { {`\\server`, `\\server`}, {`C:\folder`, `C:\folder`}, } - cd := CheckDrivesize{} for _, test := range tests { - assert.Equalf(t, test.want, cd.shareRoot(test.path), "shareRoot(%q)", test.path) + assert.Equalf(t, test.want, shareRoot(test.path), "shareRoot(%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 index 7b01e79c..22ae34c3 100644 --- a/pkg/snclient/credentials.go +++ b/pkg/snclient/credentials.go @@ -1,6 +1,7 @@ package snclient import ( + "errors" "sort" "strings" ) @@ -20,6 +21,10 @@ const ( 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 @@ -132,55 +137,3 @@ func findOnDemandCredential(config *Config, target string) (Credential, bool) { return Credential{}, false } - -// 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 -} - -// 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/credentials_windows.go b/pkg/snclient/credentials_windows.go index 09642648..7a6a30fb 100644 --- a/pkg/snclient/credentials_windows.go +++ b/pkg/snclient/credentials_windows.go @@ -3,10 +3,7 @@ package snclient import ( - "errors" "fmt" - "os" - "strings" "syscall" "unsafe" @@ -22,13 +19,10 @@ const ( credPersistSession = 1 ) -var advapi32 = windows.NewLazySystemDLL("advapi32.dll") - var ( - credWriteW = advapi32.NewProc("CredWriteW") - credReadW = advapi32.NewProc("CredReadW") - credDeleteW = advapi32.NewProc("CredDeleteW") - credFree = advapi32.NewProc("CredFree") + advapi32 = windows.NewLazySystemDLL("advapi32.dll") + + credWriteW = advapi32.NewProc("CredWriteW") ) // credentialW is the CREDENTIALW structure from wincred.h. @@ -51,6 +45,7 @@ type credentialW struct { // 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) @@ -95,82 +90,3 @@ func addShareCredential(cred *Credential) error { return nil } - -// deleteShareCredential removes the domain password credential for the given target -// from the Credential Manager. -func deleteShareCredential(target string) error { - target = normalizeCredentialTargetFromUNCPath(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()) - } - - ret, _, err := credDeleteW.Call(uintptr(unsafe.Pointer(targetUTF16)), uintptr(credTypeDomainPassword), 0) - if ret == 0 { - return fmt.Errorf("credDeleteW failed: %s", err.Error()) - } - - return nil -} - -// hasShareCredential returns true if a domain password credential for the given target already exists in the Credential Manager. -func hasShareCredential(target string) bool { - target = normalizeCredentialTargetFromUNCPath(target) - if target == "" { - return false - } - - targetUTF16, err := syscall.UTF16PtrFromString(target) - if err != nil { - log.Debugf("credentials: target to utf16: %s", err.Error()) - - return false - } - - var credential *credentialW - // the flag is always 0 - ret, _, _ := credReadW.Call( - uintptr(unsafe.Pointer(targetUTF16)), - uintptr(credTypeDomainPassword), - 0, - uintptr(unsafe.Pointer(&credential)), - ) - if ret == 0 { - return false - } - // the returned credential needs to be freed by this special CredFree function - _, _, err = credFree.Call(uintptr(unsafe.Pointer(credential))) - if err != nil { - log.Debugf("credentials: credFree failed: %s", err.Error()) - } - - return true -} - -// 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") - } -} 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/credentials_other.go b/pkg/snclient/network_shares_other.go similarity index 63% rename from pkg/snclient/credentials_other.go rename to pkg/snclient/network_shares_other.go index 086219b7..ddc12bca 100644 --- a/pkg/snclient/credentials_other.go +++ b/pkg/snclient/network_shares_other.go @@ -11,12 +11,14 @@ func addShareCredential(cred *Credential) error { return nil } -func deleteShareCredential(_ string) error { +func addShareConnection(_ *Credential, shareRoot string) error { + log.Debugf("credentials: network share connections are only supported on windows, skipping target %s", shareRoot) + return nil } -func hasShareCredential(_ string) bool { - return false +func deleteShareConnection(_ string) error { + return nil } func currentUserDomain() string { 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) +}