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,181 @@
|
||||
import Foundation
|
||||
|
||||
/// Native port of the `ovpn <profile> connect|disconnect|status|reroute` shell
|
||||
/// wrapper. Everything the script did is here; the script is no longer invoked.
|
||||
///
|
||||
/// Two rules shaped the design:
|
||||
///
|
||||
/// 1. **The password never enters this process.** The auth file is written by
|
||||
/// `security` redirecting straight to disk, so the secret goes Keychain →
|
||||
/// file without passing through app memory. The app only ever knows the path.
|
||||
/// 2. **Root work goes through one system prompt.** Route pinning and launching
|
||||
/// openvpn are batched into a single `do shell script … with administrator
|
||||
/// privileges`, so macOS shows its own auth dialog once and handles the
|
||||
/// password itself.
|
||||
enum VPNService {
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
struct Requirement: Identifiable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let satisfied: Bool
|
||||
/// Shell command that would fix it, if there is an obvious one.
|
||||
let fix: String?
|
||||
}
|
||||
|
||||
static func requirements(_ vpn: VPNConfig) async -> [Requirement] {
|
||||
var result: [Requirement] = []
|
||||
|
||||
let openvpnOK = FileManager.default.isExecutableFile(atPath: vpn.openvpnPath)
|
||||
result.append(Requirement(
|
||||
id: "openvpn",
|
||||
title: "OpenVPN",
|
||||
detail: openvpnOK ? vpn.openvpnPath : "не найден: \(vpn.openvpnPath)",
|
||||
satisfied: openvpnOK,
|
||||
fix: openvpnOK ? nil : "brew install openvpn"))
|
||||
|
||||
let configOK = FileManager.default.isReadableFile(atPath: vpn.configPath)
|
||||
result.append(Requirement(
|
||||
id: "config",
|
||||
title: "Профиль .ovpn",
|
||||
detail: configOK ? vpn.configPath : "не найден: \(vpn.configPath)",
|
||||
satisfied: configOK,
|
||||
fix: nil))
|
||||
|
||||
// Either source of credentials will do; the file wins if present.
|
||||
let authFileOK = FileManager.default.isReadableFile(atPath: vpn.authPath)
|
||||
let keychainOK = authFileOK ? true : await keychainEntryExists(vpn)
|
||||
result.append(Requirement(
|
||||
id: "auth",
|
||||
title: "Учётные данные",
|
||||
detail: authFileOK ? "файл \(vpn.authPath)"
|
||||
: (keychainOK ? "Keychain: \(vpn.keychainService)"
|
||||
: "нет ни файла, ни записи в Keychain"),
|
||||
satisfied: authFileOK || keychainOK,
|
||||
fix: keychainOK || authFileOK ? nil
|
||||
: "security add-generic-password -s \(vpn.keychainService) "
|
||||
+ "-a \(vpn.keychainAccount) -w"))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Existence check only — deliberately without `-w`, so no secret is read.
|
||||
private static func keychainEntryExists(_ vpn: VPNConfig) async -> Bool {
|
||||
await Probe.commandSucceeds(
|
||||
"/usr/bin/security",
|
||||
args: ["find-generic-password", "-s", vpn.keychainService,
|
||||
"-a", vpn.keychainAccount],
|
||||
timeout: 5)
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
static func pid(_ vpn: VPNConfig) -> pid_t? {
|
||||
guard let text = try? String(contentsOfFile: vpn.pidPath, encoding: .utf8),
|
||||
let value = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Signal 0 probes for existence without delivering anything. EPERM still
|
||||
/// means the process is there — openvpn runs as root, we do not.
|
||||
static func processAlive(_ vpn: VPNConfig) -> Bool {
|
||||
guard let pid = pid(vpn) else { return false }
|
||||
if kill(pid, 0) == 0 { return true }
|
||||
return errno == EPERM
|
||||
}
|
||||
|
||||
/// Seconds since openvpn started, from the pidfile mtime — it is written
|
||||
/// once at startup. macOS `ps` has no etimes field worth parsing.
|
||||
static func processAge(_ vpn: VPNConfig) -> Int? {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: vpn.pidPath),
|
||||
let modified = attrs[.modificationDate] as? Date
|
||||
else { return nil }
|
||||
return Int(Date().timeIntervalSince(modified))
|
||||
}
|
||||
|
||||
/// A live pid proves nothing: on an auth or TLS failure openvpn soft-restarts
|
||||
/// and retries forever, so the process stays up while the tunnel never comes
|
||||
/// back. Check the pulled address and the pushed route instead.
|
||||
static func tunnelUp(_ vpn: VPNConfig) async -> Bool {
|
||||
// Both profiles land on the same 10.8.0.0/24, so the tun address alone
|
||||
// cannot tell them apart. Without a known pushed LAN there is no honest
|
||||
// test, so fall back to the pid check rather than one that lies.
|
||||
guard !vpn.lanNetwork.isEmpty else { return processAlive(vpn) }
|
||||
guard hasTunnelAddress(vpn.tunNetwork) else { return false }
|
||||
return await hasLanRoute(vpn.lanNetwork)
|
||||
}
|
||||
|
||||
/// Reads interface addresses directly instead of scraping `ifconfig`.
|
||||
private static func hasTunnelAddress(_ network: String) -> Bool {
|
||||
var head: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&head) == 0, let head else { return false }
|
||||
defer { freeifaddrs(head) }
|
||||
|
||||
var node: UnsafeMutablePointer<ifaddrs>? = head
|
||||
while let current = node {
|
||||
defer { node = current.pointee.ifa_next }
|
||||
guard let addr = current.pointee.ifa_addr,
|
||||
addr.pointee.sa_family == sa_family_t(AF_INET) else { continue }
|
||||
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
guard getnameinfo(addr, socklen_t(addr.pointee.sa_len),
|
||||
&buffer, socklen_t(buffer.count),
|
||||
nil, 0, NI_NUMERICHOST) == 0 else { continue }
|
||||
|
||||
if String(cString: buffer).hasPrefix(network + ".") { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func hasLanRoute(_ network: String) async -> Bool {
|
||||
guard let output = await Probe.commandOutput(
|
||||
"/usr/sbin/netstat", args: ["-rn", "-f", "inet"], timeout: 5)
|
||||
else { return false }
|
||||
|
||||
return output.split(separator: "\n").contains { line in
|
||||
let first = line.split(separator: " ").first.map(String.init) ?? ""
|
||||
return first == network || first.hasPrefix(network + "/")
|
||||
}
|
||||
}
|
||||
|
||||
static func isUp(_ vpn: VPNConfig) async -> Bool {
|
||||
guard processAlive(vpn) else { return false }
|
||||
return await tunnelUp(vpn)
|
||||
}
|
||||
|
||||
// MARK: - Gateway
|
||||
|
||||
/// The LAN router, found the same way the script did: walk the configured
|
||||
/// network services and take the first with an IPv4 Router. Deliberately not
|
||||
/// the system default route — once the tunnel is up that points into it.
|
||||
static func findGateway() async -> String? {
|
||||
guard let services = await Probe.commandOutput(
|
||||
"/usr/sbin/networksetup", args: ["-listallnetworkservices"], timeout: 8)
|
||||
else { return nil }
|
||||
|
||||
for line in services.split(separator: "\n").dropFirst() {
|
||||
let service = line.trimmingCharacters(in: .whitespaces)
|
||||
// A leading asterisk marks a disabled service.
|
||||
guard !service.isEmpty, !service.hasPrefix("*") else { continue }
|
||||
|
||||
guard let info = await Probe.commandOutput(
|
||||
"/usr/sbin/networksetup", args: ["-getinfo", service], timeout: 8)
|
||||
else { continue }
|
||||
|
||||
for infoLine in info.split(separator: "\n") where infoLine.hasPrefix("Router: ") {
|
||||
let value = infoLine.dropFirst("Router: ".count)
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if isIPv4(value) { return value }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func isIPv4(_ text: String) -> Bool {
|
||||
var addr = in_addr()
|
||||
return inet_pton(AF_INET, text, &addr) == 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user