diff --git a/Network Share Mounter/helper/ActivityController.swift b/Network Share Mounter/helper/ActivityController.swift index 6410a3bc0de556d0615130a74ae6df84200ff93e..1d256efe45bb6f6ea8cf52d99b62ede8c6e1bfc0 100644 --- a/Network Share Mounter/helper/ActivityController.swift +++ b/Network Share Mounter/helper/ActivityController.swift @@ -10,129 +10,278 @@ import Foundation import AppKit import OSLog - +/// Monitors system events and responds with appropriate actions for network shares /// -/// class to handle system (NSWorkspace) notifications when system starts sleeping +/// The ActivityController registers for system events such as sleep, +/// logout, wake up, and login. Based on these events, network shares +/// are either mounted or unmounted as appropriate. class ActivityController { - var prefs = PreferenceManager() - var appDelegate: AppDelegate - + // MARK: - Properties + + /// Access to user preferences + private let prefs = PreferenceManager() + + /// Reference to AppDelegate for accessing important app components + private weak var appDelegate: AppDelegate? + + // MARK: - Initialization + + /// Initializes the controller and starts monitoring system events + /// + /// - Parameter appDelegate: Reference to the AppDelegate instance init(appDelegate: AppDelegate) { self.appDelegate = appDelegate startMonitoring() } - - /// initialize observers to get notifications + + // MARK: - Observer Management + + /// Registers the controller for all relevant system notifications + /// + /// This method registers observers for the following categories: + /// - System events (sleep, wake, shutdown) + /// - Session events (active, inactive) + /// - Timer events for regular actions + /// - Custom app events (mount, unmount) + /// - Network changes func startMonitoring() { - // create an observer for NSWorkspace notifications - // first stop possible exitisting observers + // Remove existing observers to avoid duplicate registrations NSWorkspace.shared.notificationCenter.removeObserver(self) DistributedNotificationCenter.default.removeObserver(self) - // trigger if macOS sleep will be started - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(unmountShares), name: NSWorkspace.willSleepNotification, object: nil) - // trigger if session becomes inactive - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(unmountShares), name: NSWorkspace.sessionDidResignActiveNotification, object: nil) - // trigger if user logs out or shuts down macOS - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(unmountShares), name: NSWorkspace.willPowerOffNotification, object: nil) - // trigger if Mac wakes up from sleep - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(wakeupFromSleep), name: NSWorkspace.didWakeNotification, object: nil) - // trigger if user session becomes active - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(mountShares), name: NSWorkspace.sessionDidBecomeActiveNotification, object: nil) - // time trigger to reauthenticate - NotificationCenter.default.addObserver(self, selector: #selector(processAutomaticSignIn), name: Defaults.nsmAuthTriggerNotification, object: nil) - // time trigger to mount shares/check for new profile - NotificationCenter.default.addObserver(self, selector: #selector(timeGoesBySoSlowly), name: Defaults.nsmTimeTriggerNotification, object: nil) - // trigger to mount shares - NotificationCenter.default.addObserver(self, selector: #selector(mountShares), name: Defaults.nsmMountTriggerNotification, object: nil) - // triogger to unmount shares - NotificationCenter.default.addObserver(self, selector: #selector(unmountShares), name: Defaults.nsmUnmountTriggerNotification, object: nil) - // trigger to manually mount shares - NotificationCenter.default.addObserver(self, selector: #selector(mountSharesWithUserTrigger), name: Defaults.nsmMountManuallyTriggerNotification, object: nil) - // trigger on network change to mount shares - NotificationCenter.default.addObserver(self, selector: #selector(mountSharesWithUserTrigger), name: Defaults.nsmNetworkChangeTriggerNotification, object: nil) - // triger reconstruct menu - NotificationCenter.default.addObserver(self, selector: #selector(reconstructMenuTrigger), name: Defaults.nsmReconstructMenuTriggerNotification, object: nil) - - // get notification for "CCAPICCacheChangedNotification" (as defined in kcm.h) changes - DistributedNotificationCenter.default.addObserver(self, selector: #selector(processAutomaticSignIn), name: "CCAPICCacheChangedNotification" as CFString as NSNotification.Name, object: nil) + Logger.activityController.debug("Starting monitoring of system events") + + // System event observers + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(unmountShares), + name: NSWorkspace.willSleepNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(unmountShares), + name: NSWorkspace.sessionDidResignActiveNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(unmountShares), + name: NSWorkspace.willPowerOffNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(wakeupFromSleep), + name: NSWorkspace.didWakeNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(mountShares), + name: NSWorkspace.sessionDidBecomeActiveNotification, + object: nil + ) + + // App-specific observers + NotificationCenter.default.addObserver( + self, + selector: #selector(processAutomaticSignIn), + name: Defaults.nsmAuthTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(timeGoesBySoSlowly), + name: Defaults.nsmTimeTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(mountShares), + name: Defaults.nsmMountTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(unmountShares), + name: Defaults.nsmUnmountTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(mountSharesWithUserTrigger), + name: Defaults.nsmMountManuallyTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(mountSharesWithUserTrigger), + name: Defaults.nsmNetworkChangeTriggerNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(reconstructMenuTrigger), + name: Defaults.nsmReconstructMenuTriggerNotification, + object: nil + ) + + // Kerberos cache changes + DistributedNotificationCenter.default.addObserver( + self, + selector: #selector(processAutomaticSignIn), + name: "CCAPICCacheChangedNotification" as CFString as NSNotification.Name, + object: nil + ) + + Logger.activityController.debug("All observers successfully registered") } - // call unmount shares on NSWorkspace notification + // MARK: - System Event Handlers + + /// Unmounts all network shares + /// + /// Called when: + /// - System enters sleep mode + /// - Session becomes inactive + /// - System shuts down + /// - User manually triggers unmount @objc func unmountShares() { - if let mounter = appDelegate.mounter { - Logger.activityController.debug(" ▶︎ unmountAllShares called by willSleepNotification.") - Task { - await mounter.unmountAllMountedShares() - } + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("Unmount failed: Mounter not available") + return + } + + let notificationName = (Thread.callStackSymbols.first ?? "Unknown") + .components(separatedBy: " ") + .last ?? "Unknown" + + Logger.activityController.debug("▶︎ unmountAllShares called by \(notificationName)") + + Task { + await mounter.unmountAllMountedShares() + Logger.activityController.debug("All shares successfully unmounted") } } - // functions called after wake up + /// Handles system wake up from sleep + /// + /// Mounts all configured shares and restarts Finder + /// to work around known macOS issues @objc func wakeupFromSleep() { - if let mounter = appDelegate.mounter { - Logger.activityController.debug(" ▶︎ mountGivenShares called by didWakeNotification.") - Task { - // await self.mounter.mountAllShares(userTriggered: true) - await mounter.mountGivenShares() - Logger.activityController.debug(" 🐛 Restart Finder to bypass a presumed bug in macOS.") - let finderController = FinderController() - await finderController.restartFinder() - } + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("Wake-up processing failed: Mounter not available") + return + } + + Logger.activityController.debug("▶︎ mountGivenShares called by didWakeNotification") + + Task { + await mounter.mountGivenShares() + Logger.activityController.debug("🐛 Restarting Finder to bypass a presumed bug in macOS") + + let finderController = FinderController() + await finderController.restartFinder() } } - // call mount shares on NSWorkspace notification + /// Mounts configured network shares + /// + /// Called when: + /// - Session becomes active + /// - App mount requests @objc func mountShares() { - if let mounter = appDelegate.mounter { - Logger.activityController.debug(" ▶︎ mountGivenShares called by didWakeNotification.") - Task { - // await self.mounter.mountAllShares(userTriggered: true) - await mounter.mountGivenShares() - } + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("Mount failed: Mounter not available") + return + } + + let notificationName = (Thread.callStackSymbols.first ?? "Unknown") + .components(separatedBy: " ") + .last ?? "Unknown" + + Logger.activityController.debug("▶︎ mountGivenShares called by \(notificationName)") + + Task { + await mounter.mountGivenShares() + Logger.activityController.debug("All shares successfully mounted") } } - // call automatic sign in on notification + // MARK: - Authentication Handlers + + /// Starts the automatic sign-in process for Kerberos + /// + /// Only executes when Kerberos authentication is configured. @objc func processAutomaticSignIn() { - // run authenticaction only if kerberos auth is enabled - // forcing unwrapping the optional is OK, since values are "registered" - // and set to empty string if not set - // check if a kerberos domain/realm is set and is not empty - if let krbRealm = self.prefs.string(for: .kerberosRealm), !krbRealm.isEmpty { - Task { - Logger.activityController.debug(" ▶︎ kerberos realm configured, processing AutomaticSignIn.") - await appDelegate.automaticSignIn.signInAllAccounts() - } + // Check if a Kerberos realm is configured + guard let krbRealm = self.prefs.string(for: .kerberosRealm), !krbRealm.isEmpty else { + Logger.activityController.debug("No Kerberos realm configured, skipping AutomaticSignIn") + return + } + + Task { + Logger.activityController.debug("▶︎ Kerberos realm configured, processing AutomaticSignIn") + await appDelegate?.automaticSignIn.signInAllAccounts() + Logger.activityController.info("Automatic sign-in completed successfully") } } - // call mount shares with manually parameter and, if configured, renew kerberos tickets + /// Mounts shares after user request + /// + /// First performs Kerberos authentication, then mounts the shares @objc func mountSharesWithUserTrigger() { - // renew tickets + // Renew Kerberos tickets processAutomaticSignIn() - // mount shares - if let mounter = appDelegate.mounter { - Logger.activityController.debug(" ▶︎ mountGivenShares with user-trigger called.") - Task { - await mounter.mountGivenShares(userTriggered: true) - } + + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("User request for mounting failed: Mounter not available") + return + } + + Logger.activityController.debug("▶︎ mountGivenShares with user-trigger called") + + Task { + await mounter.mountGivenShares(userTriggered: true) + Logger.activityController.info("Shares successfully mounted after user request") } } + /// Updates the app menu + /// + /// Rebuilds the app menu based on current status @objc func reconstructMenuTrigger() { - if let mounter = appDelegate.mounter { - Logger.activityController.debug(" ▶︎ reconstruct menu trigger called.") - Task { @MainActor in - await appDelegate.constructMenu(withMounter: mounter) - } + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("Menu update failed: Mounter not available") + return + } + + Logger.activityController.debug("▶︎ Menu reconstruction called") + + Task { @MainActor in + await appDelegate?.constructMenu(withMounter: mounter) + Logger.activityController.debug("Menu successfully updated") } } - /// perform some actions now and then, such as renew Kerberos tickets, - /// mount shares etc. + /// Performs periodic tasks /// + /// This method is called by a timer and checks: + /// - Changes in MDM profile + /// - Status of mounted shares + /// /// Time goes by so slowly /// Time goes by so slowly /// Time goes by so slowly for those who wait @@ -140,15 +289,36 @@ class ActivityController { /// Those who run seem to have all the fun /// I'm caught up, I don't know what to do @objc func timeGoesBySoSlowly() { - if let mounter = appDelegate.mounter { - Logger.activityController.debug("⏰ Time goes by so slowly: got timer notification.") - Logger.activityController.debug(" ▶︎ ...check for possible MDM profile changes.") - // call updateShareArray() to reflect possible changes in MDM profile? - Task { - await mounter.shareManager.updateShareArray() - Logger.activityController.debug(" ▶︎ ...finally call mountGivenShares.") - await mounter.mountGivenShares() - } + guard let mounter = appDelegate?.mounter else { + Logger.activityController.error("Timer processing failed: Mounter not available") + return + } + + Logger.activityController.debug("⏰ Time goes by so slowly: Timer notification received") + Logger.activityController.debug("▶︎ ...checking for possible MDM profile changes") + + // Check ShareArray for possible changes in MDM profile + Task { + await mounter.shareManager.updateShareArray() + Logger.activityController.debug("▶︎ ...calling mountGivenShares") + await mounter.mountGivenShares() + Logger.activityController.debug("Timer processing completed successfully") + } + } + + // MARK: - Helpers for utilizing the cliTaskAsync method + + /// Executes a CLI command asynchronously with error handling + /// + /// - Parameter command: The command to execute + /// - Returns: The command output if successful + /// - Throws: Any errors that occur during command execution + private func executeCommand(_ command: String) async throws -> String { + do { + return try await cliTaskAsync(command) + } catch { + Logger.activityController.error("Command execution failed: \(command), error: \(error.localizedDescription)") + throw error } } } diff --git a/Network Share Mounter/helper/AutomaticSignIn.swift b/Network Share Mounter/helper/AutomaticSignIn.swift index f3c0fd27e5d437964c011a32950206553ecdf749..0b393ef94947362a00b1680323f44cf75df2561a 100644 --- a/Network Share Mounter/helper/AutomaticSignIn.swift +++ b/Network Share Mounter/helper/AutomaticSignIn.swift @@ -11,139 +11,298 @@ import Foundation import OSLog import dogeADAuth +/// Mögliche Fehler bei der automatischen Anmeldung +public enum AutoSignInError: Error, LocalizedError { + case noSRVRecords(String) + case noActiveTickets + case keychainAccessFailed(Error) + case authenticationFailed(String) + case networkError(String) + + public var errorDescription: String? { + switch self { + case .noSRVRecords(let domain): + return "Keine SRV-Einträge gefunden für Domäne: \(domain)" + case .noActiveTickets: + return "Keine aktiven Kerberos-Tickets vorhanden" + case .keychainAccessFailed(let error): + return "Zugriff auf Keychain fehlgeschlagen: \(error.localizedDescription)" + case .authenticationFailed(let message): + return "Authentifizierung fehlgeschlagen: \(message)" + case .networkError(let message): + return "Netzwerkfehler: \(message)" + } + } +} + +/// Benutzer-Sitzungsobjekt mit Informationen zur Authentifizierung public struct Doge_SessionUserObject { + /// Benutzerprincipal (z.B. user@DOMAIN.COM) var userPrincipal: String + /// Active Directory Sitzung var session: dogeADSession + /// Gibt an, ob Passwort-Aging aktiviert ist var aging: Bool + /// Ablaufdatum des Passworts, falls vorhanden var expiration: Date? + /// Verbleibende Tage bis zum Ablauf des Passworts var daysToGo: Int? + /// Benutzerinformationen aus Active Directory var userInfo: ADUserRecord? } +/// Actor für die automatische Anmeldung an Active Directory +/// +/// Verwaltet automatische Anmeldungen für mehrere Konten actor AutomaticSignIn { + /// Gemeinsame Instanz (Singleton) static let shared = AutomaticSignIn() + /// Preference Manager für Einstellungen var prefs = PreferenceManager() + /// Accounts Manager für Benutzerkontenverwaltung let accountsManager = AccountsManager.shared + /// Private Initialisierung für Singleton-Pattern private init() {} + /// Meldet alle relevanten Konten automatisch an + /// + /// Basierend auf Einstellungen werden entweder alle Konten oder nur das Standard-Konto angemeldet. func signInAllAccounts() async { + Logger.automaticSignIn.info("Starte automatischen Anmeldeprozess") + let klist = KlistUtil() - _ = await klist.klist().map({ $0.principal }) + // Alle verfügbaren Kerberos-Principals abrufen + let principals = await klist.klist().map({ $0.principal }) let defaultPrinc = await klist.defaultPrincipal - // sign in only for defaultPrinc-Account if singleUserMode == true or only one account exists, walk through all accounts - // if singleUserMode == false and more than 1 account exists + Logger.automaticSignIn.debug("Gefundene Principals: \(principals.joined(separator: ", "))") + Logger.automaticSignIn.debug("Standard-Principal: \(defaultPrinc ?? "Keiner")") + + // Konten abrufen und Anmeldestrategie bestimmen: + // - Wenn Single-User-Modus aktiv ist, nur Standard-Konto anmelden + // - Ansonsten alle Konten anmelden let accounts = await accountsManager.accounts let accountsCount = accounts.count + for account in accounts { if !prefs.bool(for: .singleUserMode) || account.upn == defaultPrinc || accountsCount == 1 { + Logger.automaticSignIn.info("Automatische Anmeldung für Konto: \(account.upn)") let worker = AutomaticSignInWorker(account: account) await worker.checkUser() } } + + // Standard-Principal wiederherstellen if let defPrinc = defaultPrinc { - _ = try? await cliTask("kswitch -p \(defPrinc)") + do { + let output = try await cliTaskAsync("kswitch -p \(defPrinc)") + Logger.automaticSignIn.debug("kswitch Ausgabe: \(output)") + } catch { + Logger.automaticSignIn.error("Fehler beim Umschalten auf Standard-Principal: \(error.localizedDescription)") + } } } } +/// Worker-Actor für die Anmeldung eines einzelnen Kontos +/// +/// Implementiert die Delegate-Methoden für dogeADUserSessionDelegate actor AutomaticSignInWorker: dogeADUserSessionDelegate { + /// Preference Manager für Einstellungen var prefs = PreferenceManager() + + /// Das zu verwaltende Benutzerkonto var account: DogeAccount + + /// Active Directory Sitzung var session: dogeADSession + + /// DNS-Resolver für SRV-Einträge var resolver = SRVResolver() + + /// Die Domäne des Benutzerkontos let domain: String + /// Initialisiert einen neuen Worker mit einem Benutzerkonto + /// + /// - Parameter account: Das Benutzerkonto für die Anmeldung init(account: DogeAccount) { self.account = account domain = account.upn.userDomain() ?? "" self.session = dogeADSession(domain: domain, user: account.upn.user()) self.session.setupSessionFromPrefs(prefs: prefs) + + Logger.automaticSignIn.debug("Worker initialisiert für Benutzer: \(account.upn), Domäne: \(self.domain)") } + /// Überprüft den Benutzer und führt die Anmeldung durch + /// + /// Der Prozess umfasst: + /// 1. Auflösen der SRV-Einträge für LDAP-Server + /// 2. Überprüfung bestehender Kerberos-Tickets + /// 3. Abrufen von Benutzerinformationen oder Authentifizierung func checkUser() async { let klist = KlistUtil() let princs = await klist.klist().map({ $0.principal }) - await withCheckedContinuation { continuation in - resolver.resolve(query: "_ldap._tcp." + domain.lowercased()) { result in - Logger.automaticSignIn.info("SRV Response for: _ldap._tcp.\(self.domain, privacy: .public)") + // SRV-Einträge für LDAP auflösen + do { + let records = try await resolveSRVRecords() + + // Wenn SRV-Einträge gefunden wurden und das Konto ein gültiges Ticket hat + if !records.SRVRecords.isEmpty { + if princs.contains(where: { $0.lowercased() == self.account.upn }) { + Logger.automaticSignIn.info("Gültiges Ticket gefunden für: \(self.account.upn)") + await getUserInfo() + } else { + Logger.automaticSignIn.info("Kein gültiges Ticket gefunden, starte Authentifizierung") + await auth() + } + } else { + Logger.automaticSignIn.warning("Keine SRV-Einträge gefunden für Domäne: \(self.domain)") + throw AutoSignInError.noSRVRecords(domain) + } + } catch { + Logger.automaticSignIn.error("Fehler beim Auflösen der SRV-Einträge: \(error.localizedDescription)") + // Bei Fehlern trotzdem Authentifizierung versuchen + await auth() + } + } + + /// Löst SRV-Einträge für die LDAP-Dienste auf + /// + /// - Returns: Die gefundenen SRV-Einträge + /// - Throws: Fehler, wenn keine Einträge gefunden werden + private func resolveSRVRecords() async throws -> SRVResult { + return try await withCheckedThrowingContinuation { continuation in + let query = "_ldap._tcp." + domain.lowercased() + Logger.automaticSignIn.debug("Löse SRV-Einträge auf für: \(query)") + + resolver.resolve(query: query) { result in + Logger.automaticSignIn.info("SRV-Antwort für: \(query)") switch result { case .success(let records): - if !records.SRVRecords.isEmpty { - if princs.contains(where: { $0.lowercased() == self.account.upn }) { - Task { - await self.getUserInfo() - } - } - } else { - Logger.automaticSignIn.info("No SRV records found.") - } + continuation.resume(returning: records) case .failure(let error): - Logger.automaticSignIn.error("No DNS results for domain \(self.domain, privacy: .public), unable to automatically login. Error: \(error, privacy: .public)") + Logger.automaticSignIn.error("Keine DNS-Ergebnisse für Domäne \(self.domain), automatische Anmeldung nicht möglich. Fehler: \(error)") + continuation.resume(throwing: error) } - continuation.resume() } } - - await auth() } + /// Authentifiziert den Benutzer mit Keychain-Zugangsdaten + /// + /// Ruft das Passwort aus dem Keychain ab und startet den Authentifizierungsprozess func auth() async { let keyUtil = KeychainManager() + do { + // Passwort aus Keychain abrufen if let pass = try keyUtil.retrievePassword(forUsername: account.upn.lowercaseDomain(), andService: Defaults.keyChainService) { + Logger.automaticSignIn.debug("Passwort für \(self.account.upn) aus Keychain abgerufen") account.hasKeychainEntry = true session.userPass = pass session.delegate = self + + // Authentifizierung starten await session.authenticate() + } else { + Logger.automaticSignIn.warning("Kein Passwort im Keychain gefunden für: \(self.account.upn)") + account.hasKeychainEntry = false + NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["KrbAuthError": MounterError.authenticationError]) } } catch { + Logger.automaticSignIn.error("Fehler beim Zugriff auf Keychain: \(error.localizedDescription)") account.hasKeychainEntry = false NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["KrbAuthError": MounterError.authenticationError]) } } + /// Ruft Benutzerinformationen vom Active Directory ab + /// + /// Wechselt zum Benutzer-Principal und ruft Detailinformationen ab func getUserInfo() async { - _ = try? await cliTask("kswitch -p \(session.userPrincipal)") - session.delegate = self - await session.userInfo() + do { + // Zum Benutzer-Principal wechseln + let output = try await cliTaskAsync("kswitch -p \(session.userPrincipal)") + Logger.automaticSignIn.debug("kswitch Ausgabe: \(output)") + + // Benutzerdaten abrufen + session.delegate = self + await session.userInfo() + } catch { + Logger.automaticSignIn.error("Fehler beim Abrufen der Benutzerinformationen: \(error.localizedDescription)") + } } + // MARK: - dogeADUserSessionDelegate Methoden + + /// Wird aufgerufen, wenn die Authentifizierung erfolgreich war func dogeADAuthenticationSucceded() async { - Logger.automaticSignIn.info("Auth succeeded for user: \(self.account.upn, privacy: .public)") - _ = try? await cliTask("kswitch -p \(session.userPrincipal)") - NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["krbAuthenticated": MounterError.krbAuthSuccessful]) - await session.userInfo() + Logger.automaticSignIn.info("Authentifizierung erfolgreich für: \(self.account.upn)") + + do { + // Zum authentifizierten Benutzer wechseln + let output = try await cliTaskAsync("kswitch -p \(session.userPrincipal)") + Logger.automaticSignIn.debug("kswitch Ausgabe: \(output)") + + // Erfolg mitteilen + NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["krbAuthenticated": MounterError.krbAuthSuccessful]) + + // Benutzerinformationen abrufen + await session.userInfo() + } catch { + Logger.automaticSignIn.error("Fehler nach erfolgreicher Authentifizierung: \(error.localizedDescription)") + } } + /// Wird aufgerufen, wenn die Authentifizierung fehlgeschlagen ist + /// + /// - Parameters: + /// - error: Fehlertyp + /// - description: Fehlerbeschreibung func dogeADAuthenticationFailed(error: dogeADSessionError, description: String) { - Logger.automaticSignIn.info("Auth failed for user: \(self.account.upn, privacy: .public), Error: \(description, privacy: .public)") + Logger.automaticSignIn.info("Authentifizierung fehlgeschlagen für: \(self.account.upn), Fehler: \(description)") + switch error { case .AuthenticationFailure, .PasswordExpired: + // Bei Authentifizierungsfehlern oder abgelaufenen Passwörtern: + // - Benachrichtigung senden + // - Falsches Passwort aus Keychain entfernen NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["KrbAuthError": MounterError.krbAuthenticationError]) - Logger.automaticSignIn.info("Removing bad password from keychain") + Logger.automaticSignIn.info("Entferne ungültiges Passwort aus Keychain") + let keyUtil = KeychainManager() do { try keyUtil.removeCredential(forUsername: account.upn) - Logger.automaticSignIn.info("Successfully removed keychain item") + Logger.automaticSignIn.info("Keychain-Eintrag erfolgreich entfernt") } catch { - Logger.automaticSignIn.info("Failed to remove keychain item for username \(self.account.upn)") + Logger.automaticSignIn.error("Fehler beim Entfernen des Keychain-Eintrags für: \(self.account.upn), Fehler: \(error.localizedDescription)") } + case .OffDomain: - Logger.automaticSignIn.info("Outside Kerberos realm network") + // Wenn außerhalb der Kerberos-Domäne + Logger.automaticSignIn.info("Außerhalb des Kerberos-Realm-Netzwerks") NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["krbOffDomain": MounterError.offDomain]) + default: + Logger.automaticSignIn.warning("Unbehandelter Authentifizierungsfehler: \(error)") break } } + /// Wird aufgerufen, wenn Benutzerinformationen erfolgreich abgerufen wurden + /// + /// - Parameter user: Abgerufene Benutzerinformationen func dogeADUserInformation(user: ADUserRecord) { - Logger.automaticSignIn.debug("User info: \(user.userPrincipal)") + Logger.automaticSignIn.debug("Benutzerinformationen erhalten für: \(user.userPrincipal)") + + // Benutzerinformationen im PreferenceManager speichern prefs.setADUserInfo(user: user) } } diff --git a/Network Share Mounter/helper/Logger.swift b/Network Share Mounter/helper/Logger.swift index 53feeb397905c4a264b6ebb4bfe53713789cfd5a..f6d0b5270ff252008accf5e7c7d659ed0c1b8452 100644 --- a/Network Share Mounter/helper/Logger.swift +++ b/Network Share Mounter/helper/Logger.swift @@ -28,4 +28,5 @@ extension Logger { static let directoryOperations = Logger(subsystem: subsystem, category: "directoryOperations") static let authUI = Logger(subsystem: subsystem, category: "authUI") static let FAU = Logger(subsystem: subsystem, category: "FAU") + static let preferences = Logger(subsystem: subsystem, category: "preferences") } diff --git a/Network Share Mounter/helper/NSTaskWrapper.swift b/Network Share Mounter/helper/NSTaskWrapper.swift index 16e7dc5cc8cf4ef28941718b1e9abe14d5ab0d4f..93a318e86dc12c83c98a3ab643d47c10d2798fd9 100644 --- a/Network Share Mounter/helper/NSTaskWrapper.swift +++ b/Network Share Mounter/helper/NSTaskWrapper.swift @@ -12,181 +12,357 @@ import SystemConfiguration import IOKit import OSLog -@discardableResult public func cliTask( _ command: String, arguments: [String]? = nil) -> String { - - - var commandLaunchPath: String - var commandPieces: [String] - - if ( arguments == nil ) { - // turn the command into an array and get the first element as the launch path - commandPieces = command.components(separatedBy: " ") - // loop through the components and see if any end in \ - if command.contains("\\") { - - // we need to rebuild the string with the right components - var x = 0 - - for line in commandPieces { - if line.last == "\\" { - commandPieces[x] = commandPieces[x].replacingOccurrences(of: "\\", with: " ") + commandPieces.remove(at: x+1) - x -= 1 - } - x += 1 - } +/// Definiert mögliche Fehler bei der Ausführung von Shell-Befehlen +public enum ShellCommandError: Error, LocalizedError { + case commandNotFound(String) + case executionFailed(String, Int32) + case invalidCommand + case outputEncodingFailed + + public var errorDescription: String? { + switch self { + case .commandNotFound(let command): + return "Befehl nicht gefunden: \(command)" + case .executionFailed(let command, let code): + return "Befehlsausführung fehlgeschlagen: \(command), Exit-Code: \(code)" + case .invalidCommand: + return "Ungültiger Befehl" + case .outputEncodingFailed: + return "Ausgabetext konnte nicht kodiert werden" } - commandLaunchPath = commandPieces.remove(at: 0) - } else { - commandLaunchPath = command - commandPieces = arguments! - //myLogger.logit(.debug, message: commandLaunchPath + " " + arguments!.joinWithSeparator(" ")) - } - - // make sure the launch path is the full path -- think we're going down a rabbit hole here - - if !commandLaunchPath.contains("/") { - let realPath = which(commandLaunchPath) - commandLaunchPath = realPath } - - // set up the NSTask instance and an NSPipe for the result - - let myTask = Process() - let myPipe = Pipe() - let myErrorPipe = Pipe() - - // Setup and Launch! - - myTask.launchPath = commandLaunchPath - myTask.arguments = commandPieces - myTask.standardOutput = myPipe - // myTask.standardInput = myInputPipe - myTask.standardError = myErrorPipe - - myTask.launch() - myTask.waitUntilExit() - - let data = myPipe.fileHandleForReading.readDataToEndOfFile() - let error = myErrorPipe.fileHandleForReading.readDataToEndOfFile() - let outputError = NSString(data: error, encoding: String.Encoding.utf8.rawValue)! as String - let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String - - return output + outputError } -public func cliTaskNoTerm( _ command: String) -> String { - - // This is here because klist -v won't actually trigger the NSTask termination - - // turn the command into an array and get the first element as the launch path - - var commandPieces = command.components(separatedBy: " ") - - // loop through the components and see if any end in \ - - if command.contains("\\") { +/// Cache für Systemwerte, die sich nicht ändern +private struct SystemInfoCache { + static var serialNumber: String? + static var macAddress: String? +} - // we need to rebuild the string with the right components - var x = 0 +/// Führt einen Shell-Befehl aus und gibt die Ausgabe zurück +/// +/// - Parameters: +/// - command: Der auszuführende Befehl oder Pfad zur ausführbaren Datei +/// - arguments: Optionale Befehlsargumente, wenn nicht im command enthalten +/// - Returns: Die Ausgabe des Befehls (stdout + stderr) +@discardableResult +public func cliTask(_ command: String, arguments: [String]? = nil) -> String { + Logger.tasks.debug("Ausführung: \(command) \(arguments?.joined(separator: " ") ?? "")") + + // Sicher die Befehlskomponenten extrahieren + let (commandLaunchPath, commandPieces) = prepareCommand(command, arguments: arguments) + + // Shell-Befehl ausführen + let output = executeTask(launchPath: commandLaunchPath, arguments: commandPieces) + return output +} - for line in commandPieces { - if line.last == "\\" { - commandPieces[x] = commandPieces[x].replacingOccurrences(of: "\\", with: " ") + commandPieces.remove(at: x+1) - x -= 1 +/// Asynchrone Version von cliTask +/// +/// - Parameters: +/// - command: Der auszuführende Befehl oder Pfad zur ausführbaren Datei +/// - arguments: Optionale Befehlsargumente, wenn nicht im command enthalten +/// - Returns: Die Ausgabe des Befehls (stdout + stderr) +/// - Throws: ShellCommandError wenn die Ausführung fehlschlägt +@discardableResult +public func cliTaskAsync(_ command: String, arguments: [String]? = nil) async throws -> String { + Logger.tasks.debug("Asynchrone Ausführung: \(command) \(arguments?.joined(separator: " ") ?? "")") + + return try await withCheckedThrowingContinuation { continuation in + // Sicher die Befehlskomponenten extrahieren + do { + let (commandLaunchPath, commandPieces) = try validateAndPrepareCommand(command, arguments: arguments) + + // Task ausführen + let myTask = Process() + let myPipe = Pipe() + let myErrorPipe = Pipe() + + myTask.executableURL = URL(fileURLWithPath: commandLaunchPath) + myTask.arguments = commandPieces + myTask.standardOutput = myPipe + myTask.standardError = myErrorPipe + + // Befehl abschließen behandeln + myTask.terminationHandler = { process in + let data = myPipe.fileHandleForReading.readDataToEndOfFile() + let error = myErrorPipe.fileHandleForReading.readDataToEndOfFile() + + // Ausgabe kodieren + guard let output = String(data: data, encoding: .utf8), + let errorOutput = String(data: error, encoding: .utf8) else { + continuation.resume(throwing: ShellCommandError.outputEncodingFailed) + return + } + + // Fehlercode überprüfen + if process.terminationStatus != 0 { + Logger.tasks.error("Befehl fehlgeschlagen: \(command) mit Exit-Code \(process.terminationStatus)") + continuation.resume(throwing: ShellCommandError.executionFailed(command, process.terminationStatus)) + } else { + let result = output + errorOutput + continuation.resume(returning: result) + } } - x += 1 + + try myTask.run() + } catch { + // Fehler zurückgeben + continuation.resume(throwing: error) } } +} - var commandLaunchPath = commandPieces.remove(at: 0) - - // make sure the launch path is the full path -- think we're going down a rabbit hole here - - if !commandLaunchPath.contains("/") { - let realPath = which(commandLaunchPath) - commandLaunchPath = realPath - } - - // set up the NSTask instance and an NSPipe for the result - +/// Führt einen Shell-Befehl ohne zu warten auf Beendigung aus +/// +/// Speziell für Befehle, die nicht normal terminieren +/// +/// - Parameter command: Der auszuführende Befehl +/// - Returns: Die bisherige Ausgabe des Befehls +public func cliTaskNoTerm(_ command: String) -> String { + Logger.tasks.debug("Ausführung ohne Terminierung: \(command)") + + // Sicher die Befehlskomponenten extrahieren + let (commandLaunchPath, commandPieces) = prepareCommand(command) + + // Task ausführen ohne zu warten let myTask = Process() let myPipe = Pipe() let myInputPipe = Pipe() let myErrorPipe = Pipe() - - // Setup and Launch! - + myTask.launchPath = commandLaunchPath myTask.arguments = commandPieces myTask.standardOutput = myPipe myTask.standardInput = myInputPipe myTask.standardError = myErrorPipe - + myTask.launch() - - let data = myPipe.fileHandleForReading.readDataToEndOfFile() - let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String - + + guard let output = String(data: myPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) else { + Logger.tasks.error("Ausgabe konnte nicht kodiert werden") + return "" + } + return output } - -// this is a quick routine to get the console user - +/// Gibt den aktuellen Konsolenbenutzer zurück +/// +/// - Returns: Benutzername des aktuellen Konsolenbenutzers public func getConsoleUser() -> String { var uid: uid_t = 0 var gid: gid_t = 0 - var userName: String = "" - - // use SCDynamicStore to find out who the console user is - - let theResult = SCDynamicStoreCopyConsoleUser( nil, &uid, &gid) - userName = theResult! as String - return userName + + guard let theResult = SCDynamicStoreCopyConsoleUser(nil, &uid, &gid) else { + Logger.tasks.error("Konsolenbenutzer konnte nicht ermittelt werden") + return "" + } + + return theResult as String } +/// Gibt die Seriennummer des Geräts zurück +/// +/// - Returns: Die Seriennummer oder einen leeren String bei Fehler public func getSerial() -> String { + // Wert aus Cache zurückgeben, wenn verfügbar + if let cachedSerial = SystemInfoCache.serialNumber { + return cachedSerial + } + let platformExpert = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IOPlatformExpertDevice")) - let serialNumberAsCFString = IORegistryEntryCreateCFProperty(platformExpert, kIOPlatformSerialNumberKey as CFString, kCFAllocatorDefault, 0) - // swiftlint:disable force_cast - return serialNumberAsCFString?.takeUnretainedValue() as! String - // swiftlint:enable force_cast + + guard platformExpert > 0 else { + Logger.tasks.error("Platform Expert nicht gefunden") + return "" + } + + defer { + IOObjectRelease(platformExpert) + } + + guard let serialNumberAsCFString = IORegistryEntryCreateCFProperty(platformExpert, + kIOPlatformSerialNumberKey as CFString, + kCFAllocatorDefault, + 0) else { + Logger.tasks.error("Seriennummer konnte nicht ausgelesen werden") + return "" + } + + guard let serialNumber = serialNumberAsCFString.takeUnretainedValue() as? String else { + Logger.tasks.error("Seriennummer hat ein ungültiges Format") + return "" + } + + // Wert im Cache speichern + SystemInfoCache.serialNumber = serialNumber + return serialNumber } -// get hardware MAC addresss - +/// Gibt die MAC-Adresse des ersten Netzwerkinterfaces zurück +/// +/// - Returns: Die MAC-Adresse oder einen leeren String bei Fehler public func getMAC() -> String { - + // Wert aus Cache zurückgeben, wenn verfügbar + if let cachedMAC = SystemInfoCache.macAddress { + return cachedMAC + } + let myMACOutput = cliTask("/sbin/ifconfig -a").components(separatedBy: "\n") var myMac = "" - + for line in myMACOutput { if line.contains("ether") { - myMac = line.replacingOccurrences(of: "ether", with: "").trimmingCharacters(in: CharacterSet.whitespaces) + myMac = line.replacingOccurrences(of: "ether", with: "").trimmingCharacters(in: .whitespaces) break } } + + // Wert im Cache speichern, wenn eine MAC gefunden wurde + if !myMac.isEmpty { + SystemInfoCache.macAddress = myMac + } + return myMac } -// private function to get the path to the binary if the full path isn't given +// MARK: - Private Hilfsfunktionen +/// Bereitet einen Befehl für die Ausführung vor +/// +/// - Parameters: +/// - command: Der Befehl +/// - arguments: Optionale Argumente +/// - Returns: Tuple mit dem Pfad zum Befehl und den Argumenten +private func prepareCommand(_ command: String, arguments: [String]? = nil) -> (String, [String]) { + var commandLaunchPath: String + var commandPieces: [String] + + if arguments == nil { + // Befehl in Komponenten aufteilen + commandPieces = command.components(separatedBy: " ") + + // Escape-Sequenzen verarbeiten + if command.contains("\\") { + var x = 0 + + for line in commandPieces { + if line.last == "\\" { + commandPieces[x] = commandPieces[x].replacingOccurrences(of: "\\", with: " ") + commandPieces.remove(at: x+1) + x -= 1 + } + x += 1 + } + } + + commandLaunchPath = commandPieces.remove(at: 0) + } else { + commandLaunchPath = command + commandPieces = arguments! + } + + // Vollständigen Pfad für den Befehl ermitteln, wenn nötig + if !commandLaunchPath.contains("/") { + commandLaunchPath = which(commandLaunchPath) + } + + return (commandLaunchPath, commandPieces) +} + +/// Führt einen Shell-Befehl aus +/// +/// - Parameters: +/// - launchPath: Pfad zur ausführbaren Datei +/// - arguments: Befehlsargumente +/// - Returns: Die Ausgabe des Befehls +private func executeTask(launchPath: String, arguments: [String]) -> String { + let myTask = Process() + let myPipe = Pipe() + let myErrorPipe = Pipe() + + myTask.launchPath = launchPath + myTask.arguments = arguments + myTask.standardOutput = myPipe + myTask.standardError = myErrorPipe + + do { + myTask.launch() + myTask.waitUntilExit() + + let data = myPipe.fileHandleForReading.readDataToEndOfFile() + let error = myErrorPipe.fileHandleForReading.readDataToEndOfFile() + + guard let output = String(data: data, encoding: .utf8), + let errorOutput = String(data: error, encoding: .utf8) else { + Logger.tasks.error("Ausgabe konnte nicht kodiert werden") + return "" + } + + if myTask.terminationStatus != 0 { + Logger.tasks.error("Befehl fehlgeschlagen mit Exit-Code \(myTask.terminationStatus): \(launchPath) \(arguments.joined(separator: " "))") + } + + return output + errorOutput + } catch { + Logger.tasks.error("Fehler bei Befehlsausführung: \(error.localizedDescription)") + return "" + } +} + +/// Validiert und bereitet einen Befehl für die Ausführung vor +/// +/// - Parameters: +/// - command: Der Befehl +/// - arguments: Optionale Argumente +/// - Returns: Tuple mit dem Pfad zum Befehl und den Argumenten +/// - Throws: ShellCommandError wenn der Befehl ungültig ist +private func validateAndPrepareCommand(_ command: String, arguments: [String]? = nil) throws -> (String, [String]) { + if command.isEmpty { + throw ShellCommandError.invalidCommand + } + + let (launchPath, args) = prepareCommand(command, arguments: arguments) + + // Überprüfen, ob der Befehl existiert + if launchPath.isEmpty || !FileManager.default.fileExists(atPath: launchPath) { + throw ShellCommandError.commandNotFound(command) + } + + return (launchPath, args) +} + +/// Ermittelt den vollständigen Pfad eines Befehls +/// +/// - Parameter command: Der zu suchende Befehl +/// - Returns: Vollständiger Pfad zum Befehl oder leerer String bei Fehler private func which(_ command: String) -> String { let task = Process() task.launchPath = "/usr/bin/which" task.arguments = [command] - + let whichPipe = Pipe() task.standardOutput = whichPipe - task.launch() - - let data = whichPipe.fileHandleForReading.readDataToEndOfFile() - let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String - if output == "" { - Logger.tasks.error("Binary doesn't exist") + do { + task.launch() + task.waitUntilExit() + + let data = whichPipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + Logger.tasks.error("Which-Ausgabe konnte nicht kodiert werden") + return "" + } + + let result = output.components(separatedBy: "\n").first ?? "" + + if result.isEmpty { + Logger.tasks.error("Binary existiert nicht: \(command)") + } + + return result + } catch { + Logger.tasks.error("Fehler bei which-Befehl: \(error.localizedDescription)") + return "" } - - return output.components(separatedBy: "\n").first! - } diff --git a/Network Share Mounter/lookups/SRVRecord.swift b/Network Share Mounter/lookups/SRVRecord.swift index c5fc3117ffdf07a29938d5177d63769f9ee428e9..eb2c77689123479a512e21e4070cdd7bfc130ab8 100644 --- a/Network Share Mounter/lookups/SRVRecord.swift +++ b/Network Share Mounter/lookups/SRVRecord.swift @@ -15,29 +15,20 @@ public struct SRVResult { /// Sorts SRV records by weight and returns target hostnames /// - Returns: Array of target hostnames sorted by weight, nil if no records exist - /// - Note: Uses bubble sort algorithm for simplicity. Consider using Swift's sort() for better performance + /// - Note: Uses Swift's native sort for optimal performance func sortByWeight() -> [String]? { - guard !SRVRecords.isEmpty else { return nil} + guard !SRVRecords.isEmpty else { return nil } - var data_set = SRVRecords - var swap = true - while swap == true { - swap = false - for i in 0..<data_set.count - 1 { - if data_set[i].weight > data_set[i + 1].weight { - let temp = data_set [i + 1] - data_set [i + 1] = data_set[i] - data_set[i] = temp - swap = true - } - } - } - return data_set.map({ $0.target }) + return SRVRecords + .sorted { $0.weight < $1.weight } + .map { $0.target } } } // MARK: - CustomStringConvertible Implementation extension SRVResult: CustomStringConvertible { + /// Returns a string representation of the SRV result + /// - Returns: A formatted string containing query and record information public var description: String { var result = "Query for: \(query)" result += "\n\tRecord Count: \(SRVRecords.count)" @@ -54,7 +45,7 @@ public struct SRVRecord: Codable, Equatable { let priority: Int /// Relative weight for records with same priority let weight: Int - /// TCP/UDP port number of the service + /// TCP/UDP port number of the service (valid range: 1-65535) let port: Int /// Hostname of the target machine let target: String @@ -62,36 +53,45 @@ public struct SRVRecord: Codable, Equatable { /// Initializes an SRV record from raw DNS response data /// - Parameter data: Raw DNS response bytes /// - Returns: nil if data is invalid or too short - /// - Note: Current implementation has basic Unicode control character handling + /// - Note: Implements robust Unicode handling for hostnames init?(data: Data) { guard data.count > 8 else { return nil } - var workingTarget = "" - + // Parse priority, weight, and port priority = Int(data[0]) * 256 + Int(data[1]) weight = Int(data[2]) * 256 + Int(data[3]) port = Int(data[4]) * 256 + Int(data[5]) - // Skip byte 6 (Unicode control character) + // Validate port number + guard port > 0 && port <= 65535 else { return nil } - // Parse hostname from remaining bytes - // TODO: Consider using a more robust Unicode handling approach - for byte in data[7...(data.count - 1)] { - if let char = String(data: Data([byte]), encoding: .utf8) { - switch char { - case "\u{03}", "\u{04}", "\u{05}", "\0": - workingTarget += "." - default: - workingTarget += char - } + // Parse hostname with improved Unicode handling + var workingTarget = "" + var currentByte = 7 + + while currentByte < data.count { + let byte = data[currentByte] + + // Handle control characters and dots + if byte == 0x00 || (byte >= 0x03 && byte <= 0x05) { + workingTarget += "." + } else if let char = String(data: Data([byte]), encoding: .utf8) { + workingTarget += char } + currentByte += 1 } + + // Validate target hostname + guard !workingTarget.isEmpty else { return nil } + target = workingTarget } } // MARK: - CustomStringConvertible Implementation extension SRVRecord: CustomStringConvertible { + /// Returns a string representation of the SRV record + /// - Returns: A formatted string containing target, priority, weight, and port public var description: String { "\(target) \(priority) \(weight) \(port)" } diff --git a/Network Share Mounter/lookups/SRVResolver.swift b/Network Share Mounter/lookups/SRVResolver.swift index ab2c2b2ebb075205be29f399883d2235dab5e309..7f99aae650b4a04c268cee29dd52ddf0931a31c1 100644 --- a/Network Share Mounter/lookups/SRVResolver.swift +++ b/Network Share Mounter/lookups/SRVResolver.swift @@ -14,6 +14,10 @@ import Combine /// Enum representing possible errors during SRV resolution. public enum SRVResolverError: String, Error, Codable { case unableToComplete = "Unable to complete lookup" + case invalidQuery = "Invalid query string" + case socketError = "Failed to create socket" + case timeout = "DNS lookup timed out" + case serviceError = "DNS service error" } /// Type alias for the result of an SRV resolution. @@ -23,27 +27,43 @@ public typealias SRVResolverResult = Result<SRVResult, SRVResolverError> public typealias SRVResolverCompletion = (SRVResolverResult) -> Void /// Class responsible for resolving SRV records. +/// +/// This class handles DNS-SRV record resolution using the dnssd framework. +/// It provides asynchronous resolution with timeout handling and proper resource cleanup. class SRVResolver { + /// Dispatch queue for handling DNS operations private let queue = DispatchQueue(label: "SRVResolution") + + /// Source for reading from the DNS socket private var dispatchSourceRead: DispatchSourceRead? + + /// Timer for handling timeouts private var timeoutTimer: DispatchSourceTimer? + + /// Reference to the DNS service private var serviceRef: DNSServiceRef? + + /// Socket file descriptor private var socket: dnssd_sock_t = -1 + + /// The current DNS query string private var query: String? - /// Default timeout for DNS lookups. - private let timeout = TimeInterval(5) + /// Default timeout for DNS lookups in seconds + private let timeout: TimeInterval = 5 + /// Array to store resolved SRV records var results = [SRVRecord]() + + /// Completion handler for the resolution process var completion: SRVResolverCompletion? - /// Callback function for processing DNS results. - let queryCallback: DNSServiceQueryRecordReply = { (sdRef, flags, interfaceIndex, errorCode, fullname, rrtype, rrclass, rdlen, rdata, ttl, context) -> Void in - + /// Callback function for processing DNS results + private let queryCallback: DNSServiceQueryRecordReply = { (sdRef, flags, interfaceIndex, errorCode, fullname, rrtype, rrclass, rdlen, rdata, ttl, context) -> Void in guard let context = context else { return } let resolver: SRVResolver = SRVResolver.bridge(context) - + if let data = rdata?.assumingMemoryBound(to: UInt8.self), let record = SRVRecord(data: Data(bytes: data, count: Int(rdlen))) { resolver.results.append(record) @@ -54,55 +74,69 @@ class SRVResolver { } } - /// Bridges an Objective-C object to a Swift pointer. - static func bridge<T: AnyObject>(_ obj: T) -> UnsafeMutableRawPointer { + /// Bridges an Objective-C object to a Swift pointer + /// - Parameter obj: The object to bridge + /// - Returns: An UnsafeMutableRawPointer containing the bridged object + private static func bridge<T: AnyObject>(_ obj: T) -> UnsafeMutableRawPointer { return Unmanaged.passUnretained(obj).toOpaque() } - /// Bridges a Swift pointer back to an Objective-C object. - static func bridge<T: AnyObject>(_ ptr: UnsafeMutableRawPointer) -> T { + /// Bridges a Swift pointer back to an Objective-C object + /// - Parameter ptr: The pointer to bridge + /// - Returns: The bridged object + private static func bridge<T: AnyObject>(_ ptr: UnsafeMutableRawPointer) -> T { return Unmanaged<T>.fromOpaque(ptr).takeUnretainedValue() } - /// Handles a failed SRV resolution. - func fail() { + /// Handles a failed SRV resolution + private func fail() { stopQuery() completion?(.failure(.unableToComplete)) } - /// Handles a successful SRV resolution. - func success() { + /// Handles a successful SRV resolution + private func success() { stopQuery() let result = SRVResult(SRVRecords: results, query: query ?? "Unknown Query") completion?(.success(result)) } - /// Stops the DNS query and cleans up resources. + /// Stops the DNS query and cleans up resources private func stopQuery() { timeoutTimer?.cancel() + timeoutTimer = nil + dispatchSourceRead?.cancel() + dispatchSourceRead = nil + if let serviceRef = serviceRef { DNSServiceRefDeallocate(serviceRef) self.serviceRef = nil } + + if socket != -1 { + close(socket) + socket = -1 + } } - /// Initiates the SRV resolution process. + /// Initiates the SRV resolution process /// - Parameters: - /// - query: The DNS query string. - /// - completion: The completion handler to call when the resolution is complete. + /// - query: The DNS query string + /// - completion: The completion handler to call when the resolution is complete func resolve(query: String, completion: @escaping SRVResolverCompletion) { self.completion = completion self.query = query + guard let namec = query.cString(using: .utf8) else { - fail() + completion(.failure(.invalidQuery)) return } let result = DNSServiceQueryRecord( &serviceRef, kDNSServiceFlagsReturnIntermediates, - 0, // query on all interfaces. + 0, // query on all interfaces namec, UInt16(kDNSServiceType_SRV), UInt16(kDNSServiceClass_IN), @@ -113,46 +147,60 @@ class SRVResolver { switch result { case DNSServiceErrorType(kDNSServiceErr_NoError): guard let sdRef = serviceRef else { - fail() + completion(.failure(.serviceError)) return } socket = DNSServiceRefSockFD(sdRef) guard socket != -1 else { - fail() + completion(.failure(.socketError)) return } - dispatchSourceRead = DispatchSource.makeReadSource(fileDescriptor: socket, queue: queue) - - dispatchSourceRead?.setEventHandler(handler: { - let res = DNSServiceProcessResult(sdRef) - if res != kDNSServiceErr_NoError { - self.fail() - } - }) - - dispatchSourceRead?.setCancelHandler(handler: { - if let serviceRef = self.serviceRef { - DNSServiceRefDeallocate(serviceRef) - } - }) - - dispatchSourceRead?.resume() - - timeoutTimer = DispatchSource.makeTimerSource(flags: [], queue: queue) - - timeoutTimer?.setEventHandler(handler: { - self.fail() - }) - - let deadline = DispatchTime.now() + timeout - timeoutTimer?.schedule(deadline: deadline, repeating: .infinity, leeway: .never) - timeoutTimer?.resume() + setupDispatchSource(for: sdRef) + setupTimeoutTimer() default: - fail() + completion(.failure(.serviceError)) + } + } + + /// Sets up the dispatch source for reading from the socket + /// - Parameter sdRef: The DNS service reference + private func setupDispatchSource(for sdRef: DNSServiceRef) { + dispatchSourceRead = DispatchSource.makeReadSource(fileDescriptor: socket, queue: queue) + + dispatchSourceRead?.setEventHandler { [weak self] in + guard let self = self else { return } + let res = DNSServiceProcessResult(sdRef) + if res != kDNSServiceErr_NoError { + self.fail() + } } + + dispatchSourceRead?.setCancelHandler { [weak self] in + guard let self = self else { return } + if let serviceRef = self.serviceRef { + DNSServiceRefDeallocate(serviceRef) + } + } + + dispatchSourceRead?.resume() + } + + /// Sets up the timeout timer + private func setupTimeoutTimer() { + timeoutTimer = DispatchSource.makeTimerSource(flags: [], queue: queue) + + timeoutTimer?.setEventHandler { [weak self] in + guard let self = self else { return } + self.completion?(.failure(.timeout)) + self.stopQuery() + } + + let deadline = DispatchTime.now() + timeout + timeoutTimer?.schedule(deadline: deadline, repeating: .infinity, leeway: .never) + timeoutTimer?.resume() } } diff --git a/Network Share Mounter/preferences/FAU.swift b/Network Share Mounter/preferences/FAU.swift index edcb344baad96e149f04b43cbb8e9583ed095502..d945262661d54bdbc994ffb0a2a622270c7a9254 100644 --- a/Network Share Mounter/preferences/FAU.swift +++ b/Network Share Mounter/preferences/FAU.swift @@ -10,85 +10,182 @@ import Foundation import OSLog import dogeADAuth +/// Constants specific to FAU (Friedrich-Alexander-Universität) authentication struct FAU { + /// Service name for FAU IdM keychain entries static let keyChainServiceFAUIdM = "FAU IdM account" + + /// Comment for keychain entries static let keyChainComment = "FAU IdM credentials for Kerberos ticket management" - static let kerberosRealm = "fauad.fau.de" // this is intentiopnally set to lowercase + + /// Kerberos realm for FAU (intentionally lowercase) + static let kerberosRealm = "fauad.fau.de" + + /// Logo image name for authentication dialogs static let authenticationDialogImage = "FAUMac_Logo_512" } +/// Errors that can occur during migration processes +enum MigrationError: Error, LocalizedError { + case keychainAccessFailed + case migrationFailed + + var errorDescription: String? { + switch self { + case .keychainAccessFailed: + return "Failed to access keychain entries" + case .migrationFailed: + return "Migration of user account failed" + } + } +} + +/// Manages migration of user accounts from legacy format to new format class Migrator: dogeADUserSessionDelegate { + /// Current authentication session var session: dogeADSession? + + /// Access to user preferences var prefs = PreferenceManager() + + /// Shared accounts manager let accountsManager = AccountsManager.shared + /// Called when authentication succeeds + /// + /// Switches to the authenticated Kerberos principal and posts a notification func dogeADAuthenticationSucceded() async { + guard let principal = self.session?.userPrincipal else { + Logger.FAU.error("Authentication succeeded but userPrincipal is nil") + return + } + do { - _ = try await cliTask("kswitch -p \(String(describing: self.session?.userPrincipal))") + let result = try await cliTaskAsync("kswitch -p \(principal)") + Logger.FAU.debug("Successfully switched Kerberos principal: \(result)") } catch { Logger.FAU.error("kswitch -p failed: \(error.localizedDescription)") + // Continue despite error, as authentication still succeeded } - NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["krbAuthenticated": MounterError.krbAuthSuccessful]) + + NotificationCenter.default.post( + name: .nsmNotification, + object: nil, + userInfo: ["krbAuthenticated": MounterError.krbAuthSuccessful] + ) + await session?.userInfo() } + /// Called when authentication fails + /// + /// - Parameters: + /// - error: The error that occurred during authentication + /// - description: Description of the error func dogeADAuthenticationFailed(error: dogeADAuth.dogeADSessionError, description: String) { - Logger.FAU.debug("Auth failed after FAU user migration, \(description, privacy: .public)") + Logger.FAU.error("Authentication failed after FAU user migration: \(description)") } + /// Called when user information is retrieved + /// + /// - Parameter user: The retrieved user record func dogeADUserInformation(user: dogeADAuth.ADUserRecord) { - Logger.FAU.debug("User info: \(user.userPrincipal, privacy: .public)") + Logger.FAU.debug("User information received for: \(user.userPrincipal, privacy: .public)") } - /// get keychain entry, create new user and start kerberos authentication + /// Migrates legacy keychain entries to the new format + /// + /// This method retrieves existing keychain entries from Prefix Assistant, + /// creates a new user account, and initiates Kerberos authentication. func migrate() async { + Logger.FAU.debug("Starting migration of FAU user accounts") + + let keyUtil = KeychainManager() + do { - // get existing prefix assistant keychain entry - let keyUtil = KeychainManager() - let keyChainEntries = try keyUtil.retrieveAllEntries(forService: FAU.keyChainServiceFAUIdM, accessGroup: Defaults.keyChainAccessGroup) - // use the first found entry (should also be the only one) - if let firstAccount = keyChainEntries.first { - // call the keychain migration - if migrateKeychainEntry(forUsername: firstAccount.username, andPassword: firstAccount.password, toRealm: FAU.kerberosRealm) { - // create new DogeAccount (at FAU the migrated account will always stored in keychain) - let newAccount = DogeAccount(displayName: firstAccount.username, upn: firstAccount.username + "@" + FAU.kerberosRealm, hasKeychainEntry: true) - await accountsManager.addAccount(account: newAccount) - // start kerberos authentication - self.session = dogeADSession.init(domain: FAU.kerberosRealm, user: firstAccount.username + "@" + FAU.kerberosRealm) - self.session?.setupSessionFromPrefs(prefs: prefs) - self.session?.userPass = firstAccount.password - self.session?.delegate = self - await self.session?.authenticate() - Logger.FAU.debug("FAU user migrated, NSM account created and authenticated.") - } else { - Logger.FAU.debug("FAU user migration failed.") - } + // Get existing prefix assistant keychain entries + let keyChainEntries = try keyUtil.retrieveAllEntries( + forService: FAU.keyChainServiceFAUIdM, + accessGroup: Defaults.keyChainAccessGroup + ) + + guard let firstAccount = keyChainEntries.first else { + Logger.FAU.notice("No accounts found to migrate") + return + } + + // Migrate the keychain entry + let migrationSuccessful = await migrateKeychainEntry( + forUsername: firstAccount.username, + andPassword: firstAccount.password, + toRealm: FAU.kerberosRealm + ) + + if migrationSuccessful { + // Create new DogeAccount + let upn = firstAccount.username + "@" + FAU.kerberosRealm + let newAccount = DogeAccount( + displayName: firstAccount.username, + upn: upn, + hasKeychainEntry: true + ) + + await accountsManager.addAccount(account: newAccount) + + // Start Kerberos authentication + self.session = dogeADSession(domain: FAU.kerberosRealm, user: upn) + self.session?.setupSessionFromPrefs(prefs: prefs) + self.session?.userPass = firstAccount.password + self.session?.delegate = self + + await self.session?.authenticate() + Logger.FAU.info("FAU user migrated, NSM account created and authenticated") + } else { + Logger.FAU.error("FAU user migration failed") } } catch { - Logger.FAU.error("Keychain access failed, FAU user migration failed.") + Logger.FAU.error("Keychain access failed: \(error.localizedDescription)") } } - /// retrieve keychain entry for a given userName, append kerberos realm and save into - /// a new keychain entry - /// - Parameter forUsername: ``username`` login for share - /// - Parameter toRealm: ``realm`` kerberos realm appended to userName (defaults to FAU.kerberosRealm - func migrateKeychainEntry(forUsername: String, andPassword pass: String, toRealm realm: String = FAU.kerberosRealm) -> Bool { + /// Migrates a keychain entry by adding the Kerberos realm and saving to a new entry + /// + /// - Parameters: + /// - forUsername: Login username for share + /// - andPassword: Password for the account + /// - toRealm: Kerberos realm to append (defaults to FAU.kerberosRealm) + /// - Returns: Whether migration was successful + func migrateKeychainEntry( + forUsername: String, + andPassword pass: String, + toRealm realm: String = FAU.kerberosRealm + ) async -> Bool { let pwm = KeychainManager() var userName = forUsername.removeDomain() + + userName.appendDomain(domain: realm.lowercased()) + do { - userName.appendDomain(domain: realm.lowercased()) - try pwm.saveCredential(forUsername: userName, - andPassword: pass, - withService: Defaults.keyChainService, - accessGroup: Defaults.keyChainAccessGroup, - comment: "FAU IdM Kerberos Account for Network Share Mounter") - Logger.FAU.debug("Prefix Assistant keychain entry migration for user \(userName, privacy: .public) done") + try pwm.saveCredential( + forUsername: userName, + andPassword: pass, + withService: Defaults.keyChainService, + accessGroup: Defaults.keyChainAccessGroup, + comment: "FAU IdM Kerberos Account for Network Share Mounter" + ) + + Logger.FAU.debug("Prefix Assistant keychain entry migration for user \(userName, privacy: .public) completed") prefs.set(for: .keyChainPrefixManagerMigration, value: true) - NotificationCenter.default.post(name: .nsmNotification, object: nil, userInfo: ["ClearError": MounterError.noError]) + + NotificationCenter.default.post( + name: .nsmNotification, + object: nil, + userInfo: ["ClearError": MounterError.noError] + ) + return true } catch { - Logger.FAU.error("Could not save Prefix Assistant migrated keychain entry for user: \(userName, privacy: .public)") + Logger.FAU.error("Could not save migrated keychain entry: \(error.localizedDescription)") return false } } diff --git a/Network Share Mounter/preferences/PreferenceKeys.swift b/Network Share Mounter/preferences/PreferenceKeys.swift index 34ca053eb38f462ecd18f2874564c4112d2b7ebb..c7b05ce4b2b122a6787724fe87714afbbd0cf6c2 100644 --- a/Network Share Mounter/preferences/PreferenceKeys.swift +++ b/Network Share Mounter/preferences/PreferenceKeys.swift @@ -8,222 +8,261 @@ // import Foundation +import OSLog +/// Keys used for accessing user preferences in the application +/// +/// This enumeration defines all preference keys used throughout the application. +/// Each key corresponds to a specific setting that can be stored in and retrieved from `UserDefaults`. enum PreferenceKeys: String, CaseIterable { typealias RawValue = String + // MARK: - User Account Settings + + /// List of user accounts case accounts = "Accounts" -// case actionItemOnly = "ActionItemOnly" + + /// Active Directory domain case aDDomain = "ADDomain" -// case aDSite = "ADSite" + + /// Active Directory domain controller case aDDomainController = "ADDomainController" -// case allowEAPOL = "AllowEAPOL" + + /// Dictionary of all user information case allUserInformation = "AllUserInformation" -// case autoAddAccounts = "AutoAddAccounts" -// case autoConfigure = "AutoConfigure" -// case autoRenewCert = "AutoRenewCert" -// case changePasswordCommand = "ChangePasswordCommand" -// case changePasswordType = "ChangePasswordType" -// case changePasswordOptions = "ChangePasswordOptions" -// case caribouTime = "CaribouTime" -// case cleanCerts = "CleanCerts" -// case configureChrome = "ConfigureChrome" -// case configureChromeDomain = "ConfigureChromeDomain" + + /// Custom LDAP attributes to query case customLDAPAttributes = "CustomLDAPAttributes" + + /// Results of custom LDAP attribute queries case customLDAPAttributesResults = "CustomLDAPAttributesResults" -// case deadLDAPKillTickets = "DeadLDAPKillTickets" + + /// User's display name case displayName = "DisplayName" -// case dontMatchKerbPrefs = "DontMatchKerbPrefs" -// case dontShowWelcome = "DontShowWelcome" -// case dontShowWelcomeDefaultOn = "DontShowWelcomeDefaultOn" -// case exportableKey = "ExportableKey" -// case firstRunDone = "FirstRunDone" -// case getCertAutomatically = "GetCertificateAutomatically" -// case getHelpType = "GetHelpType" -// case getHelpOptions = "GetHelpOptions" -// case groups = "Groups" -// case hicFix = "HicFix" -// case hideAbout = "HideAbout" -// case hideAccounts = "HideAccounts" -// case hideExpiration = "HideExpiration" -// case hideExpirationMessage = "HideExpirationMessage" -// case hideCertificateNumber = "HideCertificateNumber" -// case hideHelp = "HideHelp" -// case hideGetSoftware = "HideGetSoftware" + + /// Whether to hide the last user in UI case hideLastUser = "HideLastUser" -// case hideLockScreen = "HideLockScreen" -// case hideRenew = "HideRenew" -// case hidePrefs = "HidePrefs" -// case hideSignIn = "HideSignIn" -// case hideTickets = "HideTickets" -// case hideQuit = "HideQuit" -// case hideSignOut = "HideSignOut" -// case homeAppendDomain = "HomeAppendDomain" -// case iconOff = "IconOff" -// case iconOffDark = "IconOffDark" -// case iconOn = "IconOn" -// case iconOnDark = "IconOnDark" + + /// Kerberos realm for authentication case kerberosRealm = "kerberosRealm" -// case keychainItems = "KeychainItems" -// case keychainItemsInternet = "KeychainItemsInternet" -// case keychainItemsCreateSerial = "KeychainItemsCreateSerial" -// case keychainItemsDebug = "KeychainItemsDebug" -// case keychainMinderWindowTitle = "KeychainMinderWindowTitle" -// case keychainMinderWindowMessage = "KeychainMinderWindowMessage" -// case keychainMinderShowReset = "KeychainMinderShowReset" -// case keychainPasswordMatch = "KeychainPasswordMatch" -// case lastCertificateExpiration = "LastCertificateExpiration" -// case lightsOutIKnowWhatImDoing = "LightsOutIKnowWhatImDoing" -// case loginComamnd = "LoginComamnd" -// case loginItem = "LoginItem" + + /// Whether LDAP queries should be anonymous case ldapAnonymous = "LDAPAnonymous" -// case lDAPSchema = "LDAPSchema" + + /// List of LDAP servers to query case lDAPServerList = "LDAPServerList" -// case lDAPServerListDeny = "LDAPServerListDeny" + + /// Whether to use SSL for LDAP connections case lDAPoverSSL = "LDAPOverSSL" -// case lDAPOnly = "LDAPOnly" -// case lDAPType = "LDAPType" -// case localPasswordSync = "LocalPasswordSync" -// case localPasswordSyncDontSyncLocalUsers = "LocalPasswordSyncDontSyncLocalUsers" -// case localPasswordSyncDontSyncNetworkUsers = "LocalPasswordSyncDontSyncNetworkUsers" -// case localPasswordSyncOnMatchOnly = "LocalPasswordSyncOnMatchOnly" -// case lockedKeychainCheck = "LockedKeychainCheck" + + /// Last logged in user case lastUser = "LastUser" -// case lastPasswordWarning = "LastPasswordWarning" -// case lastPasswordExpireDate = "LastPasswordExpireDate" -// case loginLogo = "LoginLogo" -// -// case messageLocalSync = "MessageLocalSync" -// case messageNotConnected = "MessageNotConnected" -// case messageUPCAlert = "MessageUPCAlert" -// case messagePasswordChangePolicy = "MessagePasswordChangePolicy" -// case mountSharesWithFinder = "MountSharesWithFinder" -// case passwordExpirationDays = "PasswordExpirationDays" -// case passwordExpireAlertTime = "PasswordExpireAlertTime" -// case passwordExpireCustomAlert = "PasswordExpireCustomAlert" -// case passwordExpireCustomWarnTime = "PasswordExpireCustomWarnTime" -// case passwordExpireCustomAlertTime = "PasswordExpireCustomAlertTime" -// case passwordPolicy = "PasswordPolicy" -// case persistExpiration = "PersistExpiration" -// case profileDone = "ProfileDone" -// case profileWait = "ProfileWait" -// case recursiveGroupLookup = "RecursiveGroupLookup" -// case renewTickets = "RenewTickets" -// case showHome = "ShowHome" -// case secondsToRenew = "SecondsToRenew" -// case shareReset = "ShareReset" // clean listing of shares between runs -// case signInCommand = "SignInCommand" -// case signInWindowAlert = "SignInWindowAlert" -// case signInWindowAlertTime = "SignInWindowAlertTime" -// case signInWindowOnLaunch = "SignInWindowOnLaunch" -// case signInWindowOnLaunchExclusions = "SignInWindowOnLaunchExclusions" -// case signedIn = "SignedIn" -// case signOutCommand = "SignOutCommand" + + /// Whether to use single user mode case singleUserMode = "SingleUserMode" -// case siteIgnore = "SiteIgnore" -// case siteForce = "SiteForce" -// case slowMount = "SlowMount" -// case slowMountDelay = "SlowMountDelay" -// case stateChangeAction = "StateChangeAction" -// case switchKerberosUser = "SwitchKerberosUser" -// case template = "Template" -// case titleSignIn = "TitleSignIn" -// case uPCAlert = "UPCAlert" -// case uPCAlertAction = "UPCAlertAction" + + /// User's Common Name (CN) case userCN = "UserCN" + + /// User's group memberships case userGroups = "UserGroups" + + /// User's Kerberos principal case userPrincipal = "UserPrincipal" + + /// User's home directory case userHome = "UserHome" + + /// User's password expiration date case userPasswordExpireDate = "UserPasswordExpireDate" -// case userCommandTask1 = "UserCommandTask1" -// case userCommandName1 = "UserCommandName1" -// case userCommandHotKey1 = "UserCommandHotKey1" + + /// Date when user's password was last set case userPasswordSetDate = "UserPasswordSetDate" + + /// Whether to use Keychain for password storage case useKeychain = "UseKeychain" -// case useKeychainPrompt = "UseKeychainPrompt" -// case userAging = "UserAging" -// case userAttributes = "UserAttributes" + + /// User's email address case userEmail = "UserEmail" + + /// User's first name case userFirstName = "UserFirstName" + + /// User's full name case userFullName = "UserFullName" + + /// User's last name case userLastName = "UserLastName" + + /// Last time user information was checked case userLastChecked = "UserLastChecked" + + /// User's short name (username) case userShortName = "UserShortName" -// case userSwitch = "UserSwitch" + + /// User's User Principal Name (UPN) case userUPN = "UserUPN" -// case verbose = "Verbose" -// case wifiNetworks = "WifiNetworks" -// case windowSignIn = "WindowSignIn" -// case x509CA = "X509CA" -// case x509Name = "X509Name" + // MARK: - Application Settings + + /// Whether to unmount shares on application exit case unmountOnExit = "unmountOnExit" + + /// URL for help documentation case helpURL = "helpURL" + + /// Whether user can change autostart setting case canChangeAutostart = "canChangeAutostart" + + /// Whether user can quit the application case canQuit = "canQuit" + + /// Whether application starts automatically at login case autostart = "autostart" + + /// Whether auto-updater is enabled case enableAutoUpdater = "enableAutoUpdater" + + /// Whether to automatically check for updates case autoUpdate = "autoUpdate" + + /// Directory for cleanup operations case cleanupLocationDirectory = "cleanupLocationDirectory" + + /// Unique identifier for the application instance case UUID = "UUID" + + /// Override for username if local and remote usernames differ + case usernameOverride = "usernameOverride" + + /// Whether to send diagnostic data + case sendDiagnostics = "sendDiagnostics" + + /// Whether to use new default location for mounts + case useNewDefaultLocation = "useNewDefaultLocation" + + // MARK: - Network Share Settings + + /// List of network shares case networkSharesKey = "networkShares" + + /// List of managed network shares (from MDM) case managedNetworkSharesKey = "managedNetworkShares" + + /// Authentication type for shares case authType = "authType" + + /// Network share path case networkShare = "networkShare" + + /// Mount point for shares case mountPoint = "mountPoint" + + /// Username for share authentication case username = "username" + + /// List of custom network shares case customSharesKey = "customNetworkShares" + + /// List of user-specific network shares case userNetworkShares = "userNetworkShares" + + /// Location for network shares case location = "location" + + // MARK: - UI Settings + + /// Image name for authentication dialog case authenticationDialogImage = "authenticationDialogImage" + + /// Service name for keychain entries case keyChainService = "keyChainService" + + /// Label for keychain entries case keyChainLabel = "keyChainLabel" + + /// Comment for keychain entries case keyChainComment = "keyChainComment" + + /// Whether keychain migration from Prefix Manager is done case keyChainPrefixManagerMigration = "keyChainPrefixManagerMigration" - case useNewDefaultLocation = "useNewDefaultLocation" - // used to manually override %USERNAME% if local and remoter user names differ - case usernameOverride = "usernameOverride" - // used to define if diagnostic data should be sent to the FAUmac team - case sendDiagnostics = "sendDiagnostics" + // MARK: - Menu Items - // control menu items + /// Whether to show Quit menu item case menuQuit = "menuQuit" + + /// Whether to show About menu item case menuAbout = "menuAbout" + + /// Whether to show Connect Shares menu item case menuConnectShares = "menuConnectShares" + + /// Whether to show Disconnect Shares menu item case menuDisconnectShares = "menuDisconnectShares" + + /// Whether to show Check Updates menu item case menuCheckUpdates = "menuCheckUpdates" + + /// Whether to show Show Shares Mount Directory menu item case menuShowSharesMountDir = "menuShowSharesMountDir" + + /// Whether to show Show Shares menu item case menuShowShares = "menuShowShares" + + /// Whether to show Settings menu item case menuSettings = "menuSettings" + // MARK: - Utility Functions + + /// Prints all preferences and their values to the console + /// + /// This function iterates through all preference keys and prints + /// their current values from UserDefaults, formatting them according + /// to their type. func printAllPrefs() { let defaults = UserDefaults.standard + + Logger.preferences.debug("Printing all preference values:") + for key in PreferenceKeys.allCases { - let pref = defaults.object(forKey: key.rawValue) as AnyObject + guard let value = defaults.object(forKey: key.rawValue) else { + Logger.preferences.debug("\(key.rawValue): Unset") + continue + } - switch String(describing: type(of: pref)) { - case "__NSCFBoolean" : - print("\t" + key.rawValue + ": " + String(describing: ( defaults.bool(forKey: key.rawValue)))) - case "__NSCFArray" : - print("\t" + key.rawValue + ": " + ( String(describing: (defaults.array(forKey: key.rawValue))))) - case "__NSTaggedDate": - if let object = pref as? Date { - print("\t" + key.rawValue + ": " + object.description(with: Locale.current)) - } else { - print("\t" + key.rawValue + ": Unknown") - } - case "__NSCFDictionary": - let description = String(describing: defaults.dictionary(forKey: key.rawValue)) - print("\t" + key.rawValue + ": " + description) - case "__NSCFData" : - print("\t" + key.rawValue + ": " + (defaults.data(forKey: key.rawValue)?.base64EncodedString() ?? "Unknown")) - default : - print("\t" + key.rawValue + ": " + ( defaults.object(forKey: key.rawValue) as? String ?? "Unset")) + // Format the value based on its type + let formattedValue: String + + switch value { + case let boolValue as Bool: + formattedValue = String(describing: boolValue) + + case let arrayValue as [Any]: + formattedValue = String(describing: arrayValue) + + case let dateValue as Date: + formattedValue = dateValue.description(with: Locale.current) + + case let dictValue as [String: Any]: + formattedValue = String(describing: dictValue) + + case let dataValue as Data: + formattedValue = dataValue.base64EncodedString() + + case let stringValue as String: + formattedValue = stringValue + + default: + formattedValue = String(describing: value) } + + // Log the key and its value if defaults.objectIsForced(forKey: key.rawValue) { - print("\t\tForced") + Logger.preferences.debug("\(key.rawValue): \(formattedValue) (Forced)") + } else { + Logger.preferences.debug("\(key.rawValue): \(formattedValue)") } } } diff --git a/Network Share Mounter/preferences/PreferenceManager.swift b/Network Share Mounter/preferences/PreferenceManager.swift index d1e565e69f788e7fe791ebab8a720695a469ed07..271c8331b2a0a8d8f8d464044be89cf9b57a384c 100644 --- a/Network Share Mounter/preferences/PreferenceManager.swift +++ b/Network Share Mounter/preferences/PreferenceManager.swift @@ -9,123 +9,208 @@ import Foundation import dogeADAuth +import OSLog -let kStateDomain = "de.fau.rrze.NetworkShareMounter.doge.state" -let kSharedDefaultsName = "de.fau.rrze.NetworkShareMounter" +/// Constants for UserDefaults domains +private enum UserDefaultsDomains { + static let stateDomain = "de.fau.rrze.NetworkShareMounter.doge.state" + static let sharedDefaultsName = "de.fau.rrze.NetworkShareMounter" +} +/// Extension to UserDefaults for dynamic property support extension UserDefaults { @objc dynamic var Accounts: Data? { return data(forKey: Defaults.Accounts) } } +/// Manages application preferences and user state using UserDefaults +/// +/// This class handles all preference-related operations including: +/// - Reading and writing user preferences +/// - Managing AD user information +/// - Handling state persistence +/// - Loading default values struct PreferenceManager { + /// Logger instance for preference operations + private static let logger = Logger.preferences + + /// Standard UserDefaults instance for general preferences + private let defaults = UserDefaults.standard - let defaults = UserDefaults.standard - let stateDefaults = UserDefaults.init(suiteName: kStateDomain) + /// State-specific UserDefaults instance for user state + private let stateDefaults: UserDefaults? + /// Initializes a new PreferenceManager + /// + /// This initializer sets up the UserDefaults instances and loads default values init() { + stateDefaults = UserDefaults(suiteName: UserDefaultsDomains.stateDomain) + if let defaultValues = readPropertyList() { defaults.register(defaults: defaultValues) + Self.logger.debug("Default values loaded successfully") + } else { + Self.logger.error("Failed to load default values") } } + // MARK: - Preference Access Methods + + /// Retrieves an array for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The array value if found, nil otherwise func array(for prefKey: PreferenceKeys) -> [Any]? { defaults.array(forKey: prefKey.rawValue) } + /// Retrieves a string for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The string value if found, nil otherwise func string(for prefKey: PreferenceKeys) -> String? { defaults.string(forKey: prefKey.rawValue) } + /// Retrieves an object for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The object value if found, nil otherwise func object(for prefKey: PreferenceKeys) -> Any? { defaults.object(forKey: prefKey.rawValue) } + /// Retrieves a dictionary for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The dictionary value if found, nil otherwise func dictionary(for prefKey: PreferenceKeys) -> [String:Any]? { defaults.dictionary(forKey: prefKey.rawValue) } + /// Retrieves a boolean for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The boolean value func bool(for prefKey: PreferenceKeys) -> Bool { defaults.bool(forKey: prefKey.rawValue) } - func set(for prefKey: PreferenceKeys, value: Any) { - defaults.set(value as AnyObject, forKey: prefKey.rawValue) + /// Sets a value for the specified preference key + /// - Parameters: + /// - prefKey: The preference key to set + /// - value: The value to set + func set<T>(for prefKey: PreferenceKeys, value: T) { + defaults.set(value, forKey: prefKey.rawValue) } + /// Retrieves an integer for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The integer value func int(for prefKey: PreferenceKeys) -> Int { defaults.integer(forKey: prefKey.rawValue) } + /// Retrieves a date for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The date value if found, nil otherwise func date(for prefKey: PreferenceKeys) -> Date? { defaults.object(forKey: prefKey.rawValue) as? Date } + /// Clears the value for the specified preference key + /// - Parameter prefKey: The preference key to clear func clear(for prefKey: PreferenceKeys) { defaults.set(nil, forKey: prefKey.rawValue) } + /// Retrieves data for the specified preference key + /// - Parameter prefKey: The preference key to look up + /// - Returns: The data value if found, nil otherwise func data(for prefKey: PreferenceKeys) -> Data? { defaults.data(forKey: prefKey.rawValue) } + // MARK: - AD User Information Management + + /// Sets AD user information in UserDefaults + /// - Parameter user: The AD user record to store func setADUserInfo(user: ADUserRecord) { + Self.logger.debug("Setting AD user info for user: \(user.userPrincipal)") + defaults.set(user.userPrincipal.lowercased(), forKey: PreferenceKeys.lastUser.rawValue) if let passwordAging = user.passwordAging, passwordAging { if let expireDate = user.computedExpireDate { self.set(for: .userPasswordExpireDate, value: expireDate) + Self.logger.debug("Password expiration date set: \(expireDate)") } } else { self.clear(for: .userPasswordExpireDate) + Self.logger.debug("Password expiration date cleared") } - guard let stateDefaults = stateDefaults else { return } + guard let stateDefaults = stateDefaults else { + Self.logger.error("Failed to access state defaults") + return + } + + // Store user information in state defaults + let userInfo: [PreferenceKeys: Any] = [ + .userCN: user.cn, + .userGroups: user.groups, + .userPasswordExpireDate: user.computedExpireDate as Any, + .userPasswordSetDate: user.passwordSet as Any, + .userHome: user.homeDirectory as Any, + .userPrincipal: user.userPrincipal, + .customLDAPAttributesResults: user.customAttributes as Any, + .userShortName: user.shortName, + .userUPN: user.upn, + .userEmail: user.email as Any, + .userFullName: user.fullName, + .userFirstName: user.firstName, + .userLastName: user.lastName, + .userLastChecked: Date() + ] - stateDefaults.set(user.cn, forKey: PreferenceKeys.userCN.rawValue) - stateDefaults.set(user.groups, forKey: PreferenceKeys.userGroups.rawValue) - stateDefaults.set(user.computedExpireDate, forKey: PreferenceKeys.userPasswordExpireDate.rawValue) - stateDefaults.set(user.passwordSet, forKey: PreferenceKeys.userPasswordSetDate.rawValue) - stateDefaults.set(user.homeDirectory, forKey: PreferenceKeys.userHome.rawValue) - stateDefaults.set(user.userPrincipal, forKey: PreferenceKeys.userPrincipal.rawValue) - stateDefaults.set(user.customAttributes, forKey: PreferenceKeys.customLDAPAttributesResults.rawValue) - stateDefaults.set(user.shortName, forKey: PreferenceKeys.userShortName.rawValue) - stateDefaults.set(user.upn, forKey: PreferenceKeys.userUPN.rawValue) - stateDefaults.set(user.email, forKey: PreferenceKeys.userEmail.rawValue) - stateDefaults.set(user.fullName, forKey: PreferenceKeys.userFullName.rawValue) - stateDefaults.set(user.firstName, forKey: PreferenceKeys.userFirstName.rawValue) - stateDefaults.set(user.lastName, forKey: PreferenceKeys.userLastName.rawValue) - stateDefaults.set(Date(), forKey: PreferenceKeys.userLastChecked.rawValue) + for (key, value) in userInfo { + stateDefaults.set(value, forKey: key.rawValue) + } - // Break down the complex expression into simpler steps + // Update all users dictionary var allUsers = stateDefaults.dictionary(forKey: PreferenceKeys.allUserInformation.rawValue) as? [String: [String: AnyObject]] ?? [:] - var userInfo = [String: AnyObject]() - userInfo["CN"] = user.cn as AnyObject - userInfo["groups"] = user.groups as AnyObject - userInfo["UserPasswordExpireDate"] = user.computedExpireDate?.description as AnyObject? ?? "" as AnyObject - userInfo["UserHome"] = user.homeDirectory as AnyObject? ?? "" as AnyObject - userInfo["UserPrincipal"] = user.userPrincipal as AnyObject - userInfo["CustomLDAPAttributesResults"] = user.customAttributes?.description as AnyObject? ?? "" as AnyObject - userInfo["UserShortName"] = user.shortName as AnyObject - userInfo["UserUPN"] = user.upn as AnyObject - userInfo["UserEmail"] = user.email as AnyObject? ?? "" as AnyObject - userInfo["UserFullName"] = user.fullName as AnyObject - userInfo["UserFirstName"] = user.firstName as AnyObject - userInfo["UserLastName"] = user.lastName as AnyObject - userInfo["UserLastChecked"] = Date() as AnyObject - - allUsers[user.userPrincipal] = userInfo + var userInfoDict = [String: AnyObject]() + for (key, value) in userInfo { + userInfoDict[key.rawValue] = value as AnyObject + } + + allUsers[user.userPrincipal] = userInfoDict stateDefaults.setValue(allUsers, forKey: PreferenceKeys.allUserInformation.rawValue) - } - + Self.logger.debug("Successfully updated user information for: \(user.userPrincipal)") + } + + // MARK: - Private Methods + + /// Reads the default values from the property list + /// - Returns: Dictionary of default values if successful, nil otherwise private func readPropertyList() -> [String: Any]? { - guard let plistPath = Bundle.main.path(forResource: "DefaultValues", ofType: "plist"), - let plistData = FileManager.default.contents(atPath: plistPath) else { + guard let plistPath = Bundle.main.path(forResource: "DefaultValues", ofType: "plist") else { + Self.logger.error("DefaultValues.plist not found") + return nil + } + + guard let plistData = FileManager.default.contents(atPath: plistPath) else { + Self.logger.error("Failed to read DefaultValues.plist") + return nil + } + + do { + let defaults = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + if defaults == nil { + Self.logger.error("Failed to parse DefaultValues.plist") + } + return defaults + } catch { + Self.logger.error("Error parsing DefaultValues.plist: \(error.localizedDescription)") return nil } - return try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] } } diff --git a/Network Share MounterTests/ManagerTests/KeychainManagerMockTests.swift b/Network Share MounterTests/ManagerTests/KeychainManagerMockTests.swift index a4d93ed8742aad728b9fe6aa08e28cfa6289d429..10ec4e0a4c0442aeb0e7a755561cb41ad34640da 100644 --- a/Network Share MounterTests/ManagerTests/KeychainManagerMockTests.swift +++ b/Network Share MounterTests/ManagerTests/KeychainManagerMockTests.swift @@ -21,7 +21,7 @@ final class KeychainManagerMockTests: XCTestCase { return ["mockKey": "mockValue"] } - override func makeQuery(username: String, service: String = Defaults.keyChainService, accessGroup: String? = nil, label: String? = nil, comment: String? = nil, iCloudSync: Bool? = nil) throws -> [String: Any] { + override func makeQuery(username: String, service: String = Defaults.keyChainService, accessGroup: String? = nil, label: String? = nil, comment: String? = nil) throws -> [String: Any] { makeQueryCalled = true if shouldThrowOnMakeQuery { @@ -72,7 +72,7 @@ final class KeychainManagerMockTests: XCTestCase { savedCredentials.removeValue(forKey: key) } - override func removeCredential(forUsername username: String, andService service: String = Defaults.keyChainService, accessGroup: String = Defaults.keyChainAccessGroup, iCloudSync: Bool? = nil) throws { + override func removeCredential(forUsername username: String, andService service: String = Defaults.keyChainService, accessGroup: String = Defaults.keyChainAccessGroup) throws { let key = "\(service)|\(username)" savedCredentials.removeValue(forKey: key) } @@ -148,4 +148,4 @@ final class KeychainManagerMockTests: XCTestCase { XCTAssertEqual(try mockKeychainManager.retrievePassword(forShare: testURL1, withUsername: testUsername), testPassword1) XCTAssertEqual(try mockKeychainManager.retrievePassword(forShare: testURL2, withUsername: testUsername), testPassword2) } -} \ No newline at end of file +} diff --git a/Network Share MounterTests/ManagerTests/KeychainManagerTests.swift b/Network Share MounterTests/ManagerTests/KeychainManagerTests.swift index 20f4943bf8e88d653af5d2a4269c6ba469f81294..4d9bdf156f41aa5d5ecd4433a5e34c2c1e55096e 100644 --- a/Network Share MounterTests/ManagerTests/KeychainManagerTests.swift +++ b/Network Share MounterTests/ManagerTests/KeychainManagerTests.swift @@ -141,4 +141,4 @@ class MockKeychainManager: KeychainManager { // Simulate the code path where password encoding fails throw KeychainError.unexpectedPasswordData } -} \ No newline at end of file +} diff --git a/Network Share MounterTests/ManagerTests/PreferenceManagerTests.swift b/Network Share MounterTests/ManagerTests/PreferenceManagerTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..b3a0e6fa94076c3a591762b85c581fc2b0d5ffd2 --- /dev/null +++ b/Network Share MounterTests/ManagerTests/PreferenceManagerTests.swift @@ -0,0 +1,139 @@ +import XCTest +@testable import Network_Share_Mounter + +/// Tests für den PreferenceManager +/// Fokus auf grundlegende Funktionalität zum Speichern und Abrufen von Einstellungen +final class PreferenceManagerTests: XCTestCase { + + // MARK: - Properties + + var sut: PreferenceManager! + let testSuiteName = "de.fau.rrze.NetworkShareMounter.tests" + var testDefaults: UserDefaults! + + // MARK: - Lifecycle + + override func setUp() { + super.setUp() + + // Test-UserDefaults erstellen und zurücksetzen + testDefaults = UserDefaults(suiteName: testSuiteName) + testDefaults.removePersistentDomain(forName: testSuiteName) + + // SUT (System Under Test) initialisieren + sut = PreferenceManager() + } + + override func tearDown() { + // Aufräumen + testDefaults.removePersistentDomain(forName: testSuiteName) + testDefaults = nil + sut = nil + + super.tearDown() + } + + // MARK: - Tests: Grundlegende Wertespeicherung und -abruf + + /// Test: String-Wert speichern und abrufen + func testStringValueStorage() { + // Given + let key = PreferenceKeys.lastUser + let value = "testUser" + + // When + sut.set(for: key, value: value) + let result = sut.string(for: key) + + // Then + XCTAssertEqual(result, value, "Der gespeicherte String sollte korrekt abgerufen werden") + } + + /// Test: Boolean-Wert speichern und abrufen + func testBoolValueStorage() { + // Given + let key = PreferenceKeys.canQuit + let value = true + + // When + sut.set(for: key, value: value) + let result = sut.bool(for: key) + + // Then + XCTAssertEqual(result, value, "Der gespeicherte Boolean sollte korrekt abgerufen werden") + } + + /// Test: Integer-Wert speichern und abrufen + func testIntValueStorage() { + // Given + let key = PreferenceKeys.singleUserMode + let value = 42 + + // When + sut.set(for: key, value: value) + let result = sut.int(for: key) + + // Then + XCTAssertEqual(result, value, "Der gespeicherte Integer sollte korrekt abgerufen werden") + } + + /// Test: Array speichern und abrufen + func testArrayStorage() { + // Given + let key = PreferenceKeys.lDAPServerList + let value = ["server1.example.com", "server2.example.com"] + + // When + sut.set(for: key, value: value) + let result = sut.array(for: key) as? [String] + + // Then + XCTAssertEqual(result, value, "Das gespeicherte Array sollte korrekt abgerufen werden") + } + + /// Test: Dictionary speichern und abrufen + func testDictionaryStorage() { + // Given + let key = PreferenceKeys.allUserInformation + let value: [String: Any] = ["name": "Test User", "role": "Admin"] + + // When + sut.set(for: key, value: value) + let result = sut.dictionary(for: key) + + // Then + XCTAssertEqual(result?["name"] as? String, "Test User") + XCTAssertEqual(result?["role"] as? String, "Admin") + } + + // MARK: - Tests: Werte löschen und Standard-Werte + + /// Test: Wert löschen + func testClearValue() { + // Given + let key = PreferenceKeys.lastUser + sut.set(for: key, value: "testUser") + + // When + sut.clear(for: key) + let result = sut.string(for: key) + + // Then + XCTAssertNil(result, "Nach dem Löschen sollte der Wert nil sein") + } + + /// Test: Standard-Werte aus Property List + func testDefaultValues() { + // Dieser Test setzt voraus, dass es eine Default-Property-List gibt + // und dass mindestens ein Wert darin enthalten ist + + // Ein Beispiel für einen Standard-Wert, der in der Property List definiert sein könnte + // Dies ist nur ein Beispiel und muss an die tatsächliche Default-Property-List angepasst werden + let key = PreferenceKeys.canQuit + let defaultValue = sut.bool(for: key) + + // Wir überprüfen nicht den genauen Wert, sondern nur dass ein Wert vorhanden ist + // Dies dient als Smoke-Test für die Default-Werte-Funktionalität + XCTAssertNotNil(defaultValue, "Es sollte ein Default-Wert für \(key) gesetzt sein") + } +} \ No newline at end of file diff --git a/networkShareMounter.xcodeproj/project.pbxproj b/networkShareMounter.xcodeproj/project.pbxproj index e174b3e96902ba626ded8aff43959db7afd1951d..d7cf5749628c2533da28fe7fdb46d83ebc12e549 100644 --- a/networkShareMounter.xcodeproj/project.pbxproj +++ b/networkShareMounter.xcodeproj/project.pbxproj @@ -647,7 +647,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = C8F68RFW4L; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -677,7 +677,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = C8F68RFW4L; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -711,7 +711,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = C8F68RFW4L; @@ -761,7 +761,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 200; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=macosx*]" = C8F68RFW4L;