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,198 @@
|
||||
import Foundation
|
||||
|
||||
/// Runs a shell command as root through the system authorization dialog.
|
||||
///
|
||||
/// macOS renders the prompt and collects the password itself — nothing here
|
||||
/// ever sees it. Steps are batched into one invocation so a connect asks once
|
||||
/// rather than once per `route` call.
|
||||
enum Privileged {
|
||||
static func run(_ steps: [String]) async -> (ok: Bool, output: String) {
|
||||
// AppleScript string literals cannot span lines, so the batch is a
|
||||
// single `;`-joined line.
|
||||
let command = steps.joined(separator: " ; ")
|
||||
let escaped = command
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
let script = "do shell script \"\(escaped)\" with administrator privileges"
|
||||
|
||||
let result = await Probe.commandRun(
|
||||
"/usr/bin/osascript", args: ["-e", script], timeout: 90)
|
||||
|
||||
if !result.ok {
|
||||
Log.write("privileged step failed: \(result.error.trimmingCharacters(in: .whitespacesAndNewlines))")
|
||||
}
|
||||
return (result.ok, result.output)
|
||||
}
|
||||
|
||||
/// Single-quotes a path for /bin/sh.
|
||||
static func quote(_ value: String) -> String {
|
||||
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
extension VPNService {
|
||||
|
||||
enum ActionResult: Sendable {
|
||||
case ok(String)
|
||||
case failed(String)
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .ok(let text), .failed(let text): return text
|
||||
}
|
||||
}
|
||||
var isError: Bool {
|
||||
if case .failed = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credentials
|
||||
|
||||
/// Path to a file containing "account\npassword\n", and whether it is a
|
||||
/// throwaway that must be deleted afterwards.
|
||||
///
|
||||
/// The secret is written by `security` redirecting straight to the file. It
|
||||
/// never passes through this process, which is the whole point — the app
|
||||
/// must never hold the password in memory.
|
||||
private static func prepareAuthFile(
|
||||
_ vpn: VPNConfig
|
||||
) async -> (path: String, temporary: Bool)? {
|
||||
if FileManager.default.isReadableFile(atPath: vpn.authPath) {
|
||||
return (vpn.authPath, false)
|
||||
}
|
||||
|
||||
let temp = NSTemporaryDirectory()
|
||||
+ "ovpn-auth." + UUID().uuidString.prefix(8)
|
||||
|
||||
let script = [
|
||||
"umask 077",
|
||||
"printf '%s\\n' \(Privileged.quote(vpn.keychainAccount)) > \(Privileged.quote(temp))",
|
||||
"/usr/bin/security find-generic-password -s \(Privileged.quote(vpn.keychainService))"
|
||||
+ " -a \(Privileged.quote(vpn.keychainAccount)) -w >> \(Privileged.quote(temp))",
|
||||
].joined(separator: " && ")
|
||||
|
||||
let ok = await Probe.commandSucceeds(
|
||||
"/bin/sh", args: ["-c", script], timeout: 30)
|
||||
guard ok else {
|
||||
Log.write("could not build auth file from Keychain")
|
||||
try? FileManager.default.removeItem(atPath: temp)
|
||||
return nil
|
||||
}
|
||||
return (temp, true)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
static func connect(_ vpn: VPNConfig) async -> ActionResult {
|
||||
guard FileManager.default.isExecutableFile(atPath: vpn.openvpnPath) else {
|
||||
return .failed("OpenVPN не найден: \(vpn.openvpnPath)")
|
||||
}
|
||||
guard FileManager.default.isReadableFile(atPath: vpn.configPath) else {
|
||||
return .failed("Профиль не найден: \(vpn.configPath)")
|
||||
}
|
||||
|
||||
var steps: [String] = []
|
||||
|
||||
if processAlive(vpn) {
|
||||
if await tunnelUp(vpn) {
|
||||
// Re-pin: the subnet or gateway may have changed since it
|
||||
// started, which would leave the host route on a dead gateway.
|
||||
return await reroute(vpn, note: "VPN уже поднят")
|
||||
}
|
||||
let age = processAge(vpn) ?? 0
|
||||
if age < vpn.startupGraceSeconds {
|
||||
return .ok("VPN ещё подключается (\(age) с)")
|
||||
}
|
||||
// Alive but no tunnel past the grace period: openvpn is stuck
|
||||
// retrying. Restart it as part of the same privileged batch.
|
||||
if let pid = pid(vpn) {
|
||||
steps += [
|
||||
"kill \(pid) 2>/dev/null || true",
|
||||
"sleep 1",
|
||||
"kill -9 \(pid) 2>/dev/null || true",
|
||||
]
|
||||
}
|
||||
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
|
||||
}
|
||||
|
||||
guard let gateway = await findGateway() else {
|
||||
return .failed("Не найден шлюз локальной сети")
|
||||
}
|
||||
guard let auth = await prepareAuthFile(vpn) else {
|
||||
return .failed("Нет учётных данных: ни файла, ни записи в Keychain")
|
||||
}
|
||||
|
||||
// Pin the server IP to the LAN gateway so the handshake does not try to
|
||||
// go through the tunnel it is establishing. Delete first, so it also
|
||||
// self-heals after a subnet change.
|
||||
steps += [
|
||||
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
|
||||
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
|
||||
[
|
||||
Privileged.quote(vpn.openvpnPath),
|
||||
"--config", Privileged.quote(vpn.configPath),
|
||||
"--auth-user-pass", Privileged.quote(auth.path),
|
||||
"--auth-nocache",
|
||||
"--writepid", Privileged.quote(vpn.pidPath),
|
||||
"--daemon",
|
||||
"--log", Privileged.quote(vpn.logPath),
|
||||
].joined(separator: " "),
|
||||
]
|
||||
|
||||
let result = await Privileged.run(steps)
|
||||
|
||||
if auth.temporary {
|
||||
// openvpn reads the file at startup; give the daemon a moment
|
||||
// before removing it.
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
try? FileManager.default.removeItem(atPath: auth.path)
|
||||
}
|
||||
|
||||
guard result.ok else {
|
||||
return .failed("Не удалось запустить VPN — см. \(vpn.logPath)")
|
||||
}
|
||||
return .ok("VPN запущен, маршрут на \(vpn.serverHost) через \(gateway)")
|
||||
}
|
||||
|
||||
static func disconnect(_ vpn: VPNConfig) async -> ActionResult {
|
||||
var steps: [String] = []
|
||||
|
||||
if let pid = pid(vpn), processAlive(vpn) {
|
||||
steps += [
|
||||
"kill \(pid) 2>/dev/null || true",
|
||||
"sleep 1",
|
||||
"kill -9 \(pid) 2>/dev/null || true",
|
||||
]
|
||||
} else {
|
||||
steps.append(
|
||||
"pkill -f \(Privileged.quote("openvpn --config " + vpn.configPath))"
|
||||
+ " 2>/dev/null || true")
|
||||
}
|
||||
|
||||
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
|
||||
// Always drop the host route: left behind, it blackholes the server
|
||||
// after the next subnet change.
|
||||
steps.append(
|
||||
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost))"
|
||||
+ " >/dev/null 2>&1 || true")
|
||||
|
||||
let result = await Privileged.run(steps)
|
||||
guard result.ok else { return .failed("Не удалось отключить VPN") }
|
||||
return .ok("VPN отключён, маршрут на \(vpn.serverHost) снят")
|
||||
}
|
||||
|
||||
/// Re-pins the host route to the current gateway without touching a running
|
||||
/// tunnel — for use after a network change.
|
||||
static func reroute(_ vpn: VPNConfig, note: String = "Маршрут обновлён") async -> ActionResult {
|
||||
guard let gateway = await findGateway() else {
|
||||
return .failed("Не найден шлюз локальной сети")
|
||||
}
|
||||
let result = await Privileged.run([
|
||||
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
|
||||
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
|
||||
])
|
||||
guard result.ok else { return .failed("Не удалось обновить маршрут") }
|
||||
return .ok("\(note): \(vpn.serverHost) через \(gateway)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user