Add CampusNAS menu bar agent
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>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user