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>
182 lines
7.7 KiB
Swift
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
|
|
}
|
|
}
|