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>
199 lines
8.0 KiB
Swift
199 lines
8.0 KiB
Swift
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)")
|
||
}
|
||
}
|