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:
2026-07-31 13:19:31 +06:00
co-authored by Claude Opus 5
commit cd53b6a1a1
20 changed files with 2897 additions and 0 deletions
+198
View File
@@ -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)")
}
}