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)") } }