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

199 lines
8.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)")
}
}