636 lines
23 KiB
Swift
636 lines
23 KiB
Swift
|
|
import Foundation
|
|||
|
|
import Network
|
|||
|
|
import AppKit
|
|||
|
|
import Security
|
|||
|
|
import CryptoKit
|
|||
|
|
import CommonCrypto
|
|||
|
|
|
|||
|
|
@MainActor
|
|||
|
|
class VPNManager: ObservableObject {
|
|||
|
|
static let shared = VPNManager()
|
|||
|
|
|
|||
|
|
// Public API (same as before — PresenceEngine IMMUTABLE BLOCK compatible)
|
|||
|
|
@Published var isConnected = false
|
|||
|
|
@Published var isConnecting = false
|
|||
|
|
@Published var isConfigured = false
|
|||
|
|
@Published var vpnIP: String = ""
|
|||
|
|
@Published var pingMs: Int = 0
|
|||
|
|
@Published var serverLocation: String = "Amsterdam"
|
|||
|
|
|
|||
|
|
// WireGuard specific
|
|||
|
|
@Published var connectionError: String? = nil
|
|||
|
|
@Published var bytesIn: Int64 = 0
|
|||
|
|
@Published var bytesOut: Int64 = 0
|
|||
|
|
@Published var sessionStart: Date? = nil
|
|||
|
|
@Published var publicKey: String = ""
|
|||
|
|
@Published var needsSudo: Bool = false
|
|||
|
|
|
|||
|
|
var sessionDuration: Int {
|
|||
|
|
guard let start = sessionStart else { return 0 }
|
|||
|
|
return max(Int(Date().timeIntervalSince(start)), 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// WireGuard config
|
|||
|
|
private let serverAddress = "72.56.102.240"
|
|||
|
|
private let serverPort = 51820
|
|||
|
|
private let serverPublicKey = "/9zhnW4O4uOstQpR5mgGmCLiy+B+LL4uQmNzgupNzwc="
|
|||
|
|
private let subnet = "10.66.66"
|
|||
|
|
private let interfaceName = "utun9"
|
|||
|
|
|
|||
|
|
// Monitoring
|
|||
|
|
private var pathMonitor: NWPathMonitor?
|
|||
|
|
private var checkTimer: Timer?
|
|||
|
|
private var sessionTimer: Timer?
|
|||
|
|
private var wgProcess: Process?
|
|||
|
|
|
|||
|
|
// Paths
|
|||
|
|
private var configDir: URL {
|
|||
|
|
guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|||
|
|
fatalError("Application Support directory not available")
|
|||
|
|
}
|
|||
|
|
return appSupport.appendingPathComponent("Montana")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonisolated private static var homebrewPrefix: String {
|
|||
|
|
#if arch(arm64)
|
|||
|
|
return "/opt/homebrew"
|
|||
|
|
#else
|
|||
|
|
return "/usr/local"
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
private var configPath: String {
|
|||
|
|
configDir.appendingPathComponent("wg0.conf").path
|
|||
|
|
}
|
|||
|
|
private var privateKeyPath: String {
|
|||
|
|
configDir.appendingPathComponent("private.key.enc").path
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Montana Protocol keypair for encryption (post-quantum protection)
|
|||
|
|
private var montanaPrivateKey: Data? {
|
|||
|
|
// Load Montana Protocol private key from PresenceEngine
|
|||
|
|
// This is ML-DSA-65 (Dilithium) keypair - post-quantum secure
|
|||
|
|
guard var keyData = UserDefaults.standard.data(forKey: "montana_private_key") else { return nil }
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// SECURITY: Secure Memory Wiping
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// Zero out sensitive key material after use to prevent memory dumps
|
|||
|
|
defer {
|
|||
|
|
keyData.withUnsafeMutableBytes { ptr in
|
|||
|
|
guard let baseAddress = ptr.baseAddress else { return }
|
|||
|
|
memset_s(baseAddress, ptr.count, 0, ptr.count)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return keyData
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private init() {
|
|||
|
|
setupConfigDirectory()
|
|||
|
|
loadOrGenerateKeys()
|
|||
|
|
startPathMonitor()
|
|||
|
|
startStatusPolling()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
deinit {
|
|||
|
|
pathMonitor?.cancel()
|
|||
|
|
checkTimer?.invalidate()
|
|||
|
|
sessionTimer?.invalidate()
|
|||
|
|
wgProcess?.terminate()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Setup
|
|||
|
|
|
|||
|
|
private func setupConfigDirectory() {
|
|||
|
|
try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func loadOrGenerateKeys() {
|
|||
|
|
// Check if encrypted private key exists on disk
|
|||
|
|
if FileManager.default.fileExists(atPath: privateKeyPath),
|
|||
|
|
let privateKey = loadEncryptedPrivateKey(), !privateKey.isEmpty {
|
|||
|
|
// Derive public key from private key
|
|||
|
|
if let pubKey = derivePublicKey(from: privateKey) {
|
|||
|
|
self.publicKey = pubKey
|
|||
|
|
self.isConfigured = true
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Generate new keypair
|
|||
|
|
generateNewKeypair()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func generateNewKeypair() {
|
|||
|
|
let privateKey = Self.runProcess("\(Self.homebrewPrefix)/bin/wg", args: ["genkey"]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|||
|
|
guard !privateKey.isEmpty else {
|
|||
|
|
connectionError = "Montana VPN: WireGuard не установлен. Запустите: brew install wireguard-tools"
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Save private key encrypted with post-quantum protection
|
|||
|
|
guard saveEncryptedPrivateKey(privateKey) else {
|
|||
|
|
connectionError = "Ошибка сохранения зашифрованного ключа"
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Derive public key
|
|||
|
|
if let pubKey = derivePublicKey(from: privateKey) {
|
|||
|
|
self.publicKey = pubKey
|
|||
|
|
self.isConfigured = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func derivePublicKey(from privateKey: String) -> String? {
|
|||
|
|
let task = Process()
|
|||
|
|
task.executableURL = URL(fileURLWithPath: "\(Self.homebrewPrefix)/bin/wg")
|
|||
|
|
task.arguments = ["pubkey"]
|
|||
|
|
|
|||
|
|
let inputPipe = Pipe()
|
|||
|
|
let outputPipe = Pipe()
|
|||
|
|
task.standardInput = inputPipe
|
|||
|
|
task.standardOutput = outputPipe
|
|||
|
|
task.standardError = Pipe()
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
try task.run()
|
|||
|
|
inputPipe.fileHandleForWriting.write(privateKey.data(using: .utf8)!)
|
|||
|
|
try inputPipe.fileHandleForWriting.close()
|
|||
|
|
task.waitUntilExit()
|
|||
|
|
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
|||
|
|
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|||
|
|
} catch {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Post-Quantum Encryption (via Montana Protocol keypair)
|
|||
|
|
|
|||
|
|
/// Derive AES-256 encryption key from Montana Protocol private key
|
|||
|
|
private func deriveEncryptionKey() -> SymmetricKey? {
|
|||
|
|
guard let montanaKey = montanaPrivateKey else {
|
|||
|
|
// Fallback: use device-specific key if Montana key not available yet
|
|||
|
|
let fallbackSeed = "Montana-VPN-\(ProcessInfo.processInfo.hostName)".data(using: .utf8)!
|
|||
|
|
return SymmetricKey(data: SHA256.hash(data: fallbackSeed))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// KDF from Montana Protocol private key (ML-DSA-65)
|
|||
|
|
// This provides post-quantum security for VPN key encryption
|
|||
|
|
let salt = "Montana-VPN-v1".data(using: .utf8)!
|
|||
|
|
let kdfOutput = HKDF<SHA256>.deriveKey(
|
|||
|
|
inputKeyMaterial: SymmetricKey(data: montanaKey),
|
|||
|
|
salt: salt,
|
|||
|
|
outputByteCount: 32
|
|||
|
|
)
|
|||
|
|
return kdfOutput
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Save WireGuard private key encrypted with AES-256-GCM (post-quantum protected)
|
|||
|
|
private func saveEncryptedPrivateKey(_ key: String) -> Bool {
|
|||
|
|
guard let encryptionKey = deriveEncryptionKey(),
|
|||
|
|
let plaintext = key.data(using: .utf8) else { return false }
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
// Generate random nonce
|
|||
|
|
let nonce = try AES.GCM.Nonce()
|
|||
|
|
|
|||
|
|
// Encrypt with AES-256-GCM (AEAD)
|
|||
|
|
let sealedBox = try AES.GCM.seal(plaintext, using: encryptionKey, nonce: nonce)
|
|||
|
|
|
|||
|
|
// Combine nonce + ciphertext + tag
|
|||
|
|
guard let combined = sealedBox.combined else { return false }
|
|||
|
|
|
|||
|
|
// Write encrypted data to disk
|
|||
|
|
try combined.write(to: URL(fileURLWithPath: privateKeyPath), options: .atomic)
|
|||
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyPath)
|
|||
|
|
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
#if DEBUG
|
|||
|
|
print("Encryption failed: \(error)")
|
|||
|
|
#endif
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Load and decrypt WireGuard private key (post-quantum protected)
|
|||
|
|
private func loadEncryptedPrivateKey() -> String? {
|
|||
|
|
guard let encryptionKey = deriveEncryptionKey(),
|
|||
|
|
let encryptedData = try? Data(contentsOf: URL(fileURLWithPath: privateKeyPath)) else {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
// Decrypt AES-256-GCM
|
|||
|
|
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
|
|||
|
|
var decrypted = try AES.GCM.open(sealedBox, using: encryptionKey)
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// SECURITY: Secure Memory Wiping
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// Zero out decrypted key material after converting to string
|
|||
|
|
defer {
|
|||
|
|
decrypted.withUnsafeMutableBytes { ptr in
|
|||
|
|
guard let baseAddress = ptr.baseAddress else { return }
|
|||
|
|
memset_s(baseAddress, ptr.count, 0, ptr.count)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return String(data: decrypted, encoding: .utf8)
|
|||
|
|
} catch {
|
|||
|
|
#if DEBUG
|
|||
|
|
print("Decryption failed: \(error)")
|
|||
|
|
#endif
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Connect / Disconnect
|
|||
|
|
|
|||
|
|
func toggle() {
|
|||
|
|
if isConnected || isConnecting {
|
|||
|
|
disconnect()
|
|||
|
|
} else {
|
|||
|
|
connect()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func connect() {
|
|||
|
|
guard !isConnected, !isConnecting else { return }
|
|||
|
|
isConnecting = true
|
|||
|
|
connectionError = nil
|
|||
|
|
|
|||
|
|
// Load encrypted private key from disk
|
|||
|
|
guard let privateKey = loadEncryptedPrivateKey() else {
|
|||
|
|
isConnecting = false
|
|||
|
|
connectionError = "Montana VPN не настроен. Перезапустите приложение."
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Get next available IP from server
|
|||
|
|
Task {
|
|||
|
|
let clientIP = await registerPeerOnServer()
|
|||
|
|
guard !clientIP.isEmpty else {
|
|||
|
|
await MainActor.run {
|
|||
|
|
isConnecting = false
|
|||
|
|
connectionError = "Не удалось зарегистрировать peer на сервере"
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Generate config
|
|||
|
|
let config = generateWireGuardConfig(privateKey: privateKey, clientIP: clientIP)
|
|||
|
|
do {
|
|||
|
|
try config.write(toFile: configPath, atomically: true, encoding: .utf8)
|
|||
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: configPath)
|
|||
|
|
} catch {
|
|||
|
|
await MainActor.run {
|
|||
|
|
isConnecting = false
|
|||
|
|
connectionError = "Ошибка записи конфига: \(error.localizedDescription)"
|
|||
|
|
}
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Start WireGuard tunnel
|
|||
|
|
await startWireGuardTunnel()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func disconnect() {
|
|||
|
|
wgProcess?.terminate()
|
|||
|
|
wgProcess = nil
|
|||
|
|
|
|||
|
|
// Stop tunnel via wg-quick
|
|||
|
|
let path = configPath
|
|||
|
|
DispatchQueue.global().async {
|
|||
|
|
_ = Self.runProcess("\(Self.homebrewPrefix)/bin/wg-quick", args: ["down", path], sudo: true)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
isConnecting = false
|
|||
|
|
isConnected = false
|
|||
|
|
vpnIP = ""
|
|||
|
|
pingMs = 0
|
|||
|
|
bytesIn = 0
|
|||
|
|
bytesOut = 0
|
|||
|
|
sessionStart = nil
|
|||
|
|
stopSessionTimer()
|
|||
|
|
UserDefaults.standard.set(false, forKey: "vpnRelayActive")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Server Registration
|
|||
|
|
|
|||
|
|
private func registerPeerOnServer() async -> String {
|
|||
|
|
// API endpoint to register peer
|
|||
|
|
guard let url = URL(string: "https://efir.org/api/v1/vpn/register") else { return "" }
|
|||
|
|
|
|||
|
|
var request = URLRequest(url: url)
|
|||
|
|
request.httpMethod = "POST"
|
|||
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|||
|
|
|
|||
|
|
let body: [String: String] = ["public_key": publicKey]
|
|||
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
|||
|
|
|
|||
|
|
do {
|
|||
|
|
// macOS 12+ async API
|
|||
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|||
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|||
|
|
let clientIP = json["client_ip"] as? String {
|
|||
|
|
return clientIP
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
#if DEBUG
|
|||
|
|
print("Register peer failed: \(error)")
|
|||
|
|
#endif
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - WireGuard Tunnel
|
|||
|
|
|
|||
|
|
private func startWireGuardTunnel() async {
|
|||
|
|
// Try wg-quick up with sudo
|
|||
|
|
let path = configPath
|
|||
|
|
let iface = interfaceName
|
|||
|
|
let success = await withCheckedContinuation { continuation in
|
|||
|
|
DispatchQueue.global().async {
|
|||
|
|
let output = Self.runProcess("\(Self.homebrewPrefix)/bin/wg-quick", args: ["up", path], sudo: true)
|
|||
|
|
let success = output.contains("interface:") || output.contains(iface)
|
|||
|
|
continuation.resume(returning: success)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await MainActor.run {
|
|||
|
|
if success {
|
|||
|
|
isConnected = true
|
|||
|
|
isConnecting = false
|
|||
|
|
sessionStart = Date()
|
|||
|
|
startSessionTimer()
|
|||
|
|
checkVPNStatus()
|
|||
|
|
} else {
|
|||
|
|
isConnecting = false
|
|||
|
|
connectionError = "WireGuard не смог запустить туннель — проверьте пароль sudo"
|
|||
|
|
needsSudo = true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Config Generation
|
|||
|
|
|
|||
|
|
private func generateWireGuardConfig(privateKey: String, clientIP: String) -> String {
|
|||
|
|
return """
|
|||
|
|
[Interface]
|
|||
|
|
PrivateKey = \(privateKey)
|
|||
|
|
Address = \(clientIP)/24
|
|||
|
|
DNS = 1.1.1.1, 8.8.8.8
|
|||
|
|
|
|||
|
|
[Peer]
|
|||
|
|
PublicKey = \(serverPublicKey)
|
|||
|
|
Endpoint = \(serverAddress):\(serverPort)
|
|||
|
|
AllowedIPs = 0.0.0.0/0, ::/0
|
|||
|
|
PersistentKeepalive = 25
|
|||
|
|
"""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Status Monitoring
|
|||
|
|
|
|||
|
|
private func startPathMonitor() {
|
|||
|
|
pathMonitor?.cancel()
|
|||
|
|
pathMonitor = NWPathMonitor()
|
|||
|
|
pathMonitor?.pathUpdateHandler = { [weak self] path in
|
|||
|
|
Task { @MainActor in
|
|||
|
|
guard let self else { return }
|
|||
|
|
if path.status != .satisfied && self.isConnected {
|
|||
|
|
self.isConnected = false
|
|||
|
|
self.vpnIP = ""
|
|||
|
|
self.pingMs = 0
|
|||
|
|
self.bytesIn = 0
|
|||
|
|
self.bytesOut = 0
|
|||
|
|
self.sessionStart = nil
|
|||
|
|
self.sessionTimer?.invalidate()
|
|||
|
|
self.sessionTimer = nil
|
|||
|
|
}
|
|||
|
|
self.checkVPNStatus()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
pathMonitor?.start(queue: DispatchQueue(label: "network.montana.vpn.monitor"))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func startStatusPolling() {
|
|||
|
|
checkTimer?.invalidate()
|
|||
|
|
checkTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|||
|
|
Task { @MainActor in
|
|||
|
|
self?.checkVPNStatus()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func checkVPNStatus() {
|
|||
|
|
let iface = interfaceName
|
|||
|
|
let sub = subnet
|
|||
|
|
DispatchQueue.global().async { [weak self] in
|
|||
|
|
guard let self else { return }
|
|||
|
|
|
|||
|
|
// Check if interface exists
|
|||
|
|
let ifOutput = Self.runProcess("/sbin/ifconfig", args: [iface])
|
|||
|
|
let connected = ifOutput.contains(sub)
|
|||
|
|
|
|||
|
|
var detectedIP = ""
|
|||
|
|
var inBytes: Int64 = 0
|
|||
|
|
var outBytes: Int64 = 0
|
|||
|
|
|
|||
|
|
if connected {
|
|||
|
|
detectedIP = self.extractIP(from: ifOutput)
|
|||
|
|
|
|||
|
|
// Get WireGuard stats
|
|||
|
|
let wgOutput = Self.runProcess("\(Self.homebrewPrefix)/bin/wg", args: ["show", iface, "transfer"])
|
|||
|
|
if let (rx, tx) = self.parseWireGuardStats(wgOutput) {
|
|||
|
|
inBytes = rx
|
|||
|
|
outBytes = tx
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Measure ping
|
|||
|
|
self.measurePing()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Task { @MainActor in
|
|||
|
|
let wasConnected = self.isConnected
|
|||
|
|
self.isConnected = connected
|
|||
|
|
self.isConnecting = false
|
|||
|
|
|
|||
|
|
if connected {
|
|||
|
|
self.vpnIP = detectedIP
|
|||
|
|
self.bytesIn = inBytes
|
|||
|
|
self.bytesOut = outBytes
|
|||
|
|
self.connectionError = nil
|
|||
|
|
if !wasConnected {
|
|||
|
|
self.sessionStart = Date()
|
|||
|
|
self.startSessionTimer()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
self.vpnIP = ""
|
|||
|
|
self.pingMs = 0
|
|||
|
|
self.bytesIn = 0
|
|||
|
|
self.bytesOut = 0
|
|||
|
|
self.sessionStart = nil
|
|||
|
|
self.stopSessionTimer()
|
|||
|
|
}
|
|||
|
|
UserDefaults.standard.set(connected, forKey: "vpnRelayActive")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Helpers
|
|||
|
|
|
|||
|
|
nonisolated private func extractIP(from output: String) -> String {
|
|||
|
|
for line in output.components(separatedBy: "\n") {
|
|||
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|||
|
|
if trimmed.hasPrefix("inet ") && trimmed.contains(subnet) {
|
|||
|
|
let parts = trimmed.components(separatedBy: " ")
|
|||
|
|
if parts.count >= 2 { return parts[1] }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return subnet + ".x"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonisolated private func parseWireGuardStats(_ output: String) -> (Int64, Int64)? {
|
|||
|
|
let parts = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t")
|
|||
|
|
guard parts.count >= 2,
|
|||
|
|
let rx = Int64(parts[0]),
|
|||
|
|
let tx = Int64(parts[1]) else { return nil }
|
|||
|
|
return (rx, tx)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonisolated private func measurePing() {
|
|||
|
|
DispatchQueue.global().async { [weak self] in
|
|||
|
|
let output = Self.runProcess("/sbin/ping", args: ["-c", "1", "-t", "3", "72.56.102.240"])
|
|||
|
|
if let range = output.range(of: "time=") {
|
|||
|
|
let after = output[range.upperBound...]
|
|||
|
|
if let spaceIdx = after.firstIndex(of: " ") {
|
|||
|
|
let msStr = String(after[..<spaceIdx])
|
|||
|
|
let ms = Int(Double(msStr) ?? 0)
|
|||
|
|
Task { @MainActor in
|
|||
|
|
self?.pingMs = ms
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func startSessionTimer() {
|
|||
|
|
sessionTimer?.invalidate()
|
|||
|
|
sessionTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|||
|
|
Task { @MainActor in
|
|||
|
|
self?.objectWillChange.send()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private func stopSessionTimer() {
|
|||
|
|
sessionTimer?.invalidate()
|
|||
|
|
sessionTimer = nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Process Execution
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// SECURITY: Path Whitelist
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// Only allow execution of trusted system binaries and WireGuard tools
|
|||
|
|
nonisolated private static var allowedExecutablePaths: Set<String> {
|
|||
|
|
return [
|
|||
|
|
"/sbin/ifconfig",
|
|||
|
|
"/sbin/ping",
|
|||
|
|
"/usr/bin/osascript",
|
|||
|
|
"\(homebrewPrefix)/bin/wg",
|
|||
|
|
"\(homebrewPrefix)/bin/wg-quick"
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonisolated private static func escapeShellArg(_ arg: String) -> String {
|
|||
|
|
// Escape single quotes: ' -> '\''
|
|||
|
|
return "'\(arg.replacingOccurrences(of: "'", with: "'\\''"))'"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
nonisolated private static func runProcess(_ path: String, args: [String] = [], sudo: Bool = false) -> String {
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// SECURITY: Command Injection Prevention
|
|||
|
|
// ═══════════════════════════════════════════════════════════════════
|
|||
|
|
// 1. Validate executable path against whitelist
|
|||
|
|
guard allowedExecutablePaths.contains(path) else {
|
|||
|
|
#if DEBUG
|
|||
|
|
print("Security: Blocked execution of non-whitelisted path: \(path)")
|
|||
|
|
#endif
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Validate total argument length (prevent buffer overflow)
|
|||
|
|
let totalArgsLength = args.joined().count
|
|||
|
|
guard totalArgsLength < 1024 else {
|
|||
|
|
#if DEBUG
|
|||
|
|
print("Security: Blocked execution with excessive argument length: \(totalArgsLength)")
|
|||
|
|
#endif
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
if sudo {
|
|||
|
|
// Use osascript to request sudo via GUI (with proper escaping)
|
|||
|
|
let escapedPath = escapeShellArg(path)
|
|||
|
|
let escapedArgs = args.map { escapeShellArg($0) }.joined(separator: " ")
|
|||
|
|
let command = "\(escapedPath) \(escapedArgs)"
|
|||
|
|
let script = """
|
|||
|
|
do shell script \(escapeShellArg(command)) with administrator privileges
|
|||
|
|
"""
|
|||
|
|
let task = Process()
|
|||
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|||
|
|
task.arguments = ["-e", script]
|
|||
|
|
let pipe = Pipe()
|
|||
|
|
task.standardOutput = pipe
|
|||
|
|
task.standardError = pipe
|
|||
|
|
do {
|
|||
|
|
try task.run()
|
|||
|
|
task.waitUntilExit()
|
|||
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|||
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|||
|
|
} catch {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
let task = Process()
|
|||
|
|
task.executableURL = URL(fileURLWithPath: path)
|
|||
|
|
task.arguments = args
|
|||
|
|
let pipe = Pipe()
|
|||
|
|
task.standardOutput = pipe
|
|||
|
|
task.standardError = Pipe()
|
|||
|
|
do {
|
|||
|
|
try task.run()
|
|||
|
|
task.waitUntilExit()
|
|||
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|||
|
|
return String(data: data, encoding: .utf8) ?? ""
|
|||
|
|
} catch {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static func formatBytes(_ bytes: Int64) -> String {
|
|||
|
|
if bytes < 1024 { return "\(bytes) B" }
|
|||
|
|
let kb = Double(bytes) / 1024.0
|
|||
|
|
if kb < 1024 { return String(format: "%.1f KB", kb) }
|
|||
|
|
let mb = kb / 1024.0
|
|||
|
|
if mb < 1024 { return String(format: "%.1f MB", mb) }
|
|||
|
|
let gb = mb / 1024.0
|
|||
|
|
return String(format: "%.2f GB", gb)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// MARK: - Settings Helpers
|
|||
|
|
|
|||
|
|
func openVPNSettings() {
|
|||
|
|
// Open Montana VPN config folder
|
|||
|
|
NSWorkspace.shared.selectFile(configPath, inFileViewerRootedAtPath: configDir.path)
|
|||
|
|
}
|
|||
|
|
}
|