Files
ovpn/Sources/CampusNASMonitor/Mounter.swift
T
romanvandClaude Opus 5 cd53b6a1a1 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>
2026-07-31 13:19:31 +06:00

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
}
}