Files
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

182 lines
7.7 KiB
Swift

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