Native macOS agent that keeps the office VPN and the NAS shares working without the shell wrapper it replaces. - VPN: full port of the `ovpn` script — host-route pinning, gateway discovery via networksetup, tunnel liveness by address plus pushed route, startup grace, restart of a wedged daemon. Root steps are batched into one system authorization prompt. - SMB: mounts via NetFS, discovers shares from the server, detects mounts event-driven through NSWorkspace, matches the host by resolved alias so externally created mounts are recognised. - Credentials never pass through the process: the VPN auth file is written by `security` straight to disk, and the password is stored via SecItemAdd rather than a command line visible in ps. - UI: status window, settings editor, ⌘+/⌘− zoom that re-lays out rather than scaling a bitmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
8.8 KiB
Swift
219 lines
8.8 KiB
Swift
import Foundation
|
|
import NetFS
|
|
|
|
/// SMB mounting via NetFS. Chosen over shelling out to `mount_smbfs` because
|
|
/// NetFS reads credentials from the login Keychain on its own, and — when UI is
|
|
/// allowed — presents the standard system auth sheet with the "remember
|
|
/// password" checkbox. That sheet is the only supported way to create the
|
|
/// Keychain entry, and this app must never handle the password itself.
|
|
enum Mounter {
|
|
|
|
// Option keys from NetFS.h.
|
|
private static let uiOptionKey = "UIOption" as CFString
|
|
private static let uiOptionNoUI = "NoUI" as CFString
|
|
private static let softMountKey = "SoftMount" as CFString
|
|
|
|
struct Result: Sendable {
|
|
let share: String
|
|
let status: Int32
|
|
/// Already-mounted counts as success. The share can be mounted between
|
|
/// the UI reading its state and the click landing — most often by Finder
|
|
/// — and showing a red error for "it is already how you wanted it" is
|
|
/// just wrong.
|
|
var ok: Bool { status == 0 || status == EEXIST || status == EBUSY }
|
|
/// True when the failure looks like missing or rejected credentials,
|
|
/// i.e. retrying with the auth sheet is worth offering.
|
|
var needsCredentials: Bool {
|
|
status == EAUTH || status == EACCES || status == EPERM
|
|
}
|
|
/// Short human explanation. ENOENT gets a specific one: a share the
|
|
/// account is not listed in (`valid users` on the NAS) comes back as
|
|
/// "no such file or directory", not as a permission error, which is
|
|
/// otherwise a very misleading thing to show.
|
|
var reason: String {
|
|
switch status {
|
|
case ENOENT: return "\(share): нет доступа или шара не существует"
|
|
case EAUTH, EACCES, EPERM: return "\(share): нужен пароль"
|
|
case ETIMEDOUT: return "\(share): таймаут"
|
|
default: return "\(share): ошибка \(status)"
|
|
}
|
|
}
|
|
}
|
|
|
|
static func url(host: String, user: String, share: String) -> URL? {
|
|
var components = URLComponents()
|
|
components.scheme = "smb"
|
|
components.host = host
|
|
components.user = user
|
|
components.path = "/" + share
|
|
return components.url
|
|
}
|
|
|
|
/// Blocking — call off the main thread.
|
|
///
|
|
/// `allowUI: false` is the silent path: it succeeds only if the Keychain
|
|
/// already holds the credentials, and fails fast otherwise. Automatic
|
|
/// mounts always use it, so reconnecting never throws an unexpected
|
|
/// password sheet at the user.
|
|
static func mount(
|
|
host: String, user: String, share: String, allowUI: Bool
|
|
) -> Result {
|
|
guard let url = url(host: host, user: user, share: share) else {
|
|
return Result(share: share, status: EINVAL)
|
|
}
|
|
|
|
let openOptions = NSMutableDictionary()
|
|
if !allowUI {
|
|
openOptions[uiOptionKey] = uiOptionNoUI
|
|
}
|
|
|
|
// Soft mount: a NAS that disappears mid-session must return an error
|
|
// rather than wedge every Finder window that touches the volume.
|
|
let mountOptions = NSMutableDictionary()
|
|
mountOptions[softMountKey] = true
|
|
|
|
var mountpoints: Unmanaged<CFArray>?
|
|
let status = NetFSMountURLSync(
|
|
url as CFURL, nil, user as CFString, nil,
|
|
openOptions, mountOptions, &mountpoints)
|
|
mountpoints?.release()
|
|
|
|
if status != 0 {
|
|
NSLog("campusnas-monitor: mount %@ failed, status %d", share, status)
|
|
}
|
|
return Result(share: share, status: status)
|
|
}
|
|
|
|
/// Asks the server which shares it exports, via `smbutil view`.
|
|
///
|
|
/// NetFS does have a share-enumeration API, but it is not declared in the
|
|
/// public headers — reaching it would mean dlsym against private symbols,
|
|
/// which breaks on an OS update. `smbutil` is a documented system tool and
|
|
/// uses the same Keychain credentials.
|
|
///
|
|
/// Filters to disk shares and drops administrative ones (`IPC$` and
|
|
/// friends), which are never mountable as volumes.
|
|
static func listShares(
|
|
host: String, user: String, timeout: TimeInterval
|
|
) async -> [String] {
|
|
let output = await Probe.commandOutput(
|
|
"/usr/bin/smbutil",
|
|
args: ["view", "-N", "//\(user)@\(host)"],
|
|
timeout: timeout)
|
|
|
|
guard let output else {
|
|
Log.write("share enumeration failed for \(host)")
|
|
return []
|
|
}
|
|
|
|
var shares: [String] = []
|
|
for line in output.split(separator: "\n", omittingEmptySubsequences: true) {
|
|
// Columns are separated by runs of spaces: "name Disk comment"
|
|
let columns = line
|
|
.components(separatedBy: " ")
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
.filter { !$0.isEmpty }
|
|
guard columns.count >= 2 else { continue }
|
|
|
|
let name = columns[0]
|
|
guard columns[1] == "Disk", !name.hasSuffix("$") else { continue }
|
|
shares.append(name)
|
|
}
|
|
return shares.sorted()
|
|
}
|
|
|
|
static func unmount(mountPoint: String) -> Bool {
|
|
let status = mountPoint.withCString { Darwin.unmount($0, 0) }
|
|
if status != 0 {
|
|
NSLog("campusnas-monitor: unmount %@ failed: %d", mountPoint, errno)
|
|
}
|
|
return status == 0
|
|
}
|
|
|
|
/// Every spelling that means "this server": the configured name plus each
|
|
/// address it resolves to.
|
|
///
|
|
/// Needed because a mount can be created by something other than this app —
|
|
/// Finder mounts from the server browser, an older mount made by IP — and
|
|
/// those record a different host string. A plain case-sensitive `contains`
|
|
/// missed them and reported live mounts as "not mounted".
|
|
private static var aliasCache: (host: String, aliases: Set<String>)?
|
|
|
|
static func aliases(for host: String) -> Set<String> {
|
|
if let cached = aliasCache, cached.host == host { return cached.aliases }
|
|
|
|
var result: Set<String> = [host.lowercased()]
|
|
|
|
var hints = addrinfo()
|
|
hints.ai_family = AF_UNSPEC
|
|
hints.ai_socktype = SOCK_STREAM
|
|
var info: UnsafeMutablePointer<addrinfo>?
|
|
|
|
if getaddrinfo(host, nil, &hints, &info) == 0 {
|
|
var node = info
|
|
while let current = node {
|
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
if getnameinfo(
|
|
current.pointee.ai_addr, current.pointee.ai_addrlen,
|
|
&buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 {
|
|
result.insert(String(cString: buffer).lowercased())
|
|
}
|
|
node = current.pointee.ai_next
|
|
}
|
|
freeaddrinfo(info)
|
|
}
|
|
|
|
aliasCache = (host, result)
|
|
return result
|
|
}
|
|
|
|
/// Splits `//user@host/share` into its host and share parts.
|
|
private static func parse(mountFrom: String) -> (host: String, share: String)? {
|
|
guard mountFrom.hasPrefix("//") else { return nil }
|
|
let body = mountFrom.dropFirst(2)
|
|
guard let slash = body.firstIndex(of: "/") else { return nil }
|
|
|
|
var host = String(body[body.startIndex..<slash])
|
|
if let at = host.lastIndex(of: "@") {
|
|
host = String(host[host.index(after: at)...])
|
|
}
|
|
|
|
let share = String(body[body.index(after: slash)...])
|
|
guard !host.isEmpty, !share.isEmpty else { return nil }
|
|
return (host, share)
|
|
}
|
|
|
|
/// Currently mounted SMB shares served by `host`, mapped share -> mount point.
|
|
/// Read from the kernel mount table rather than by listing /Volumes, so a
|
|
/// leftover empty directory is never mistaken for a live mount.
|
|
static func mountedShares(host: String) -> [String: String] {
|
|
var buffer: UnsafeMutablePointer<statfs>?
|
|
let count = getmntinfo(&buffer, MNT_NOWAIT)
|
|
guard count > 0, let buffer else { return [:] }
|
|
|
|
let known = aliases(for: host)
|
|
var result: [String: String] = [:]
|
|
|
|
for index in 0..<Int(count) {
|
|
var entry = buffer[index]
|
|
|
|
let fsType = withUnsafeBytes(of: &entry.f_fstypename) {
|
|
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
|
|
}
|
|
guard fsType == "smbfs" else { continue }
|
|
|
|
let from = withUnsafeBytes(of: &entry.f_mntfromname) {
|
|
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
|
|
}
|
|
guard let parsed = parse(mountFrom: from),
|
|
known.contains(parsed.host.lowercased()) else { continue }
|
|
|
|
let on = withUnsafeBytes(of: &entry.f_mntonname) {
|
|
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
|
|
}
|
|
result[parsed.share] = on
|
|
}
|
|
return result
|
|
}
|
|
}
|