From 79f9f23bc7132e4d8583ad2117e2baeb5d3fc274 Mon Sep 17 00:00:00 2001 From: offyotto Date: Sat, 6 Jun 2026 03:31:16 +0500 Subject: [PATCH] Implement DNS proxy network extension --- Package.swift | 20 +- README.md | 49 ++- Resources/Locale.entitlements | 10 +- Resources/LocaleDNSProxy.entitlements | 16 + Resources/dev.offyotto.Locale.Helper.plist | 19 -- SECURITY.md | 28 +- Sources/LocaleApp/DNSProxyController.swift | 183 +++++++++++ Sources/LocaleApp/HostsTabView.swift | 2 +- Sources/LocaleApp/LocaleHelperClient.swift | 116 ------- Sources/LocaleApp/LocaleStore.swift | 34 ++- Sources/LocaleApp/OnboardingView.swift | 8 +- Sources/LocaleApp/PreferencesView.swift | 12 +- Sources/LocaleApp/SystemApplyService.swift | 135 +-------- Sources/LocaleDNSProxy/DNSProxyProvider.swift | 283 ++++++++++++++++++ Sources/LocaleDNSProxy/main.swift | 8 + Sources/LocaleHelper/main.swift | 183 ----------- Sources/LocaleShared/DNSPacket.swift | 169 +++++++++++ .../LocaleShared/LocaleDNSConfiguration.swift | 94 ++++++ .../LocaleShared/LocaleHelperContract.swift | 13 - Tests/LocaleSharedTests/DNSPacketTests.swift | 61 ++++ docs/RELEASING.md | 36 ++- script/build_and_run.sh | 63 +++- script/package_notarized.sh | 6 +- website/index.html | 50 ++-- 24 files changed, 1027 insertions(+), 571 deletions(-) create mode 100644 Resources/LocaleDNSProxy.entitlements delete mode 100644 Resources/dev.offyotto.Locale.Helper.plist create mode 100644 Sources/LocaleApp/DNSProxyController.swift delete mode 100644 Sources/LocaleApp/LocaleHelperClient.swift create mode 100644 Sources/LocaleDNSProxy/DNSProxyProvider.swift create mode 100644 Sources/LocaleDNSProxy/main.swift delete mode 100644 Sources/LocaleHelper/main.swift create mode 100644 Sources/LocaleShared/DNSPacket.swift create mode 100644 Sources/LocaleShared/LocaleDNSConfiguration.swift delete mode 100644 Sources/LocaleShared/LocaleHelperContract.swift create mode 100644 Tests/LocaleSharedTests/DNSPacketTests.swift diff --git a/Package.swift b/Package.swift index 8be20b4..dacd16c 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let package = Package( ], products: [ .executable(name: "LocaleApp", targets: ["LocaleApp"]), - .executable(name: "LocaleHelper", targets: ["LocaleHelper"]) + .executable(name: "LocaleDNSProxy", targets: ["LocaleDNSProxy"]) ], targets: [ .target( @@ -24,16 +24,26 @@ let package = Package( .unsafeFlags(["-Osize"], .when(configuration: .release)) ], linkerSettings: [ - .linkedFramework("ServiceManagement") + .linkedFramework("NetworkExtension"), + .linkedFramework("SystemExtensions") ] ), .executableTarget( - name: "LocaleHelper", + name: "LocaleDNSProxy", dependencies: ["LocaleShared"], - path: "Sources/LocaleHelper", + path: "Sources/LocaleDNSProxy", swiftSettings: [ .unsafeFlags(["-Osize"], .when(configuration: .release)) + ], + linkerSettings: [ + .linkedFramework("NetworkExtension") ] + ), + .testTarget( + name: "LocaleSharedTests", + dependencies: ["LocaleShared"], + path: "Tests/LocaleSharedTests" ) - ] + ], + swiftLanguageModes: [.v5] ) diff --git a/README.md b/README.md index 3d71b92..77a5eed 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Locale -Locale is a macOS utility for switching between named `/etc/hosts` contexts. +Locale is a native macOS utility for switching named DNS contexts. It is built with SwiftUI and Swift Package Manager. Locale stores contexts -locally, writes only its own managed block in `/etc/hosts`, creates backups -before every apply, and flushes the local DNS cache after a successful write. +locally, applies the active host mappings through a sandboxed DNS Proxy Network +Extension, and leaves system host files untouched. ## Features -- Create named hosts contexts for local development, VPNs, labs, and temporary debugging. -- Add, disable, and remove host entries per context. -- Apply a context through Locale's bundled privileged helper. -- Revert to a clean `Home` context to remove Locale-managed hosts. +- Create named DNS contexts for local development, VPNs, labs, and temporary debugging. +- Add, disable, and remove host mappings per context. +- Apply a context through Locale's bundled `NEDNSProxyProvider` system extension. +- Revert to a clean `Home` context to clear Locale-managed DNS mappings. - Switch active contexts from the menu bar. - Import and export contexts as JSON. - Use an adaptive macOS app icon compiled from `Assets/AppIcon.icon`. @@ -21,39 +21,24 @@ before every apply, and flushes the local DNS cache after a successful write. The marketing site lives in `website/` and is deployed with GitHub Pages from `.github/workflows/pages.yml`. -## Safety Model +## Architecture -Locale never rewrites arbitrary parts of `/etc/hosts`. It removes and replaces -only this managed block: +Locale uses a sandboxed main app plus a bundled DNS Proxy Network Extension: -```text -# BEGIN LOCALE MANAGED HOSTS -... -# END LOCALE MANAGED HOSTS -``` - -Everything outside that block is preserved. Before each apply, Locale saves a -backup under: - -```text -~/Library/Application Support/Locale/HostsBackups -``` - -## Current Scope - -Locale currently applies hosts entries only. It does not change macOS network -service DNS settings yet. +- Main app writes the active context to the shared App Group defaults. +- `LocaleDNSProxy` reads the same App Group data. +- Matching DNS questions receive the configured IP address. +- Unmatched DNS traffic is forwarded to the system DNS servers. -Locale uses a sandboxed main app plus a bundled `SMAppService` launch daemon. -The helper is approved once by an admin, then receives only complete hosts-file -payloads over XPC. Production builds must be signed and notarized for the helper -to register successfully. +The app does not use `osascript`, a privileged helper, or direct `/etc/hosts` +writes. ## Requirements - macOS 14 or newer - Xcode command line tools -- Swift 6 toolchain +- Swift toolchain with SwiftPM +- Apple Developer capabilities for App Groups, System Extensions, and DNS Proxy Network Extension when signing for distribution ## Build And Run diff --git a/Resources/Locale.entitlements b/Resources/Locale.entitlements index 659f5a6..d23d438 100644 --- a/Resources/Locale.entitlements +++ b/Resources/Locale.entitlements @@ -6,9 +6,15 @@ com.apple.security.files.user-selected.read-write - com.apple.security.temporary-exception.mach-lookup.global-name + com.apple.security.application-groups - dev.offyotto.Locale.Helper + group.dev.offyotto.Locale + com.apple.developer.networking.networkextension + + dns-proxy + + com.apple.developer.system-extension.install + diff --git a/Resources/LocaleDNSProxy.entitlements b/Resources/LocaleDNSProxy.entitlements new file mode 100644 index 0000000..7ead2db --- /dev/null +++ b/Resources/LocaleDNSProxy.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.dev.offyotto.Locale + + com.apple.developer.networking.networkextension + + dns-proxy + + + diff --git a/Resources/dev.offyotto.Locale.Helper.plist b/Resources/dev.offyotto.Locale.Helper.plist deleted file mode 100644 index 34841cc..0000000 --- a/Resources/dev.offyotto.Locale.Helper.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - Label - dev.offyotto.Locale.Helper - BundleProgram - Contents/Library/LaunchServices/LocaleHelper - MachServices - - dev.offyotto.Locale.Helper - - - AssociatedBundleIdentifiers - - dev.offyotto.Locale - - - diff --git a/SECURITY.md b/SECURITY.md index 2f8eb9f..e13eb1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,11 @@ # Security Policy -Locale writes to `/etc/hosts` through a bundled `SMAppService` privileged helper. -The main app talks to the helper over XPC and the helper only accepts requests -from the Locale app signature. +Locale applies host mappings through a sandboxed DNS Proxy Network Extension. +The main app and extension communicate through the App Group +`group.dev.offyotto.Locale`. + +Locale does not edit `/etc/hosts`, install a privileged helper, or run commands +with elevated privileges. ## Reporting @@ -12,19 +15,14 @@ Please report security issues privately to the repository owner instead of openi Security-sensitive areas include: -- `/etc/hosts` parsing and writing -- privileged helper registration and XPC validation -- backup creation and restore behavior +- DNS query parsing and response generation +- DNS fallback forwarding for unmatched hostnames +- App Group configuration storage +- Network Extension activation and settings - hostname and IP validation -- notarized release packaging +- signed release packaging ## Expected Behavior -Locale should only replace content between: - -```text -# BEGIN LOCALE MANAGED HOSTS -# END LOCALE MANAGED HOSTS -``` - -Everything outside that block must be preserved. +When a context is active, Locale should answer only hostnames that match enabled +entries in that context. Everything else should forward to the system resolver. diff --git a/Sources/LocaleApp/DNSProxyController.swift b/Sources/LocaleApp/DNSProxyController.swift new file mode 100644 index 0000000..713e4cf --- /dev/null +++ b/Sources/LocaleApp/DNSProxyController.swift @@ -0,0 +1,183 @@ +import Foundation +import LocaleShared +import NetworkExtension +import SystemExtensions + +enum DNSProxyControllerError: LocalizedError { + case activationNeedsApproval + case activationRequiresReboot + case activationFailed(String) + case preferencesFailed(String) + + var errorDescription: String? { + switch self { + case .activationNeedsApproval: + return "Approve Locale DNS Proxy in System Settings, then apply the context again." + case .activationRequiresReboot: + return "Locale DNS Proxy will finish activating after a restart." + case .activationFailed(let message): + return "Could not activate Locale DNS Proxy: \(message)" + case .preferencesFailed(let message): + return "Could not update DNS proxy settings: \(message)" + } + } +} + +actor DNSProxyController { + static let shared = DNSProxyController() + + func apply(contextName: String, mappings: [LocaleDNSHostMapping]) async throws { + let configuration = LocaleActiveDNSConfiguration(contextName: contextName, mappings: mappings) + try LocaleDNSConfigurationStore.save(configuration) + + if mappings.isEmpty { + try await setProxyEnabled(false) + } else { + try await SystemExtensionActivator.activate() + try await setProxyEnabled(true) + } + } + + func clear() async throws { + try LocaleDNSConfigurationStore.clear() + try await setProxyEnabled(false) + } + + private func setProxyEnabled(_ isEnabled: Bool) async throws { + let manager = NEDNSProxyManager.shared() + + do { + try await manager.loadFromPreferences() + let providerProtocol = NEDNSProxyProviderProtocol() + providerProtocol.providerBundleIdentifier = LocaleDNSConstants.extensionBundleIdentifier + providerProtocol.serverAddress = "Locale DNS Proxy" + providerProtocol.providerConfiguration = [ + "appGroupIdentifier": LocaleDNSConstants.appGroupIdentifier, + "configurationKey": LocaleDNSConstants.activeConfigurationKey + ] + + manager.localizedDescription = "Locale" + manager.providerProtocol = providerProtocol + manager.isEnabled = isEnabled + try await manager.saveToPreferences() + } catch { + throw DNSProxyControllerError.preferencesFailed(error.localizedDescription) + } + } +} + +private enum SystemExtensionActivator { + static func activate() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let delegate = SystemExtensionRequestDelegate(continuation: continuation) + SystemExtensionRequestDelegateStore.shared.insert(delegate) + + let request = OSSystemExtensionRequest.activationRequest( + forExtensionWithIdentifier: LocaleDNSConstants.extensionBundleIdentifier, + queue: .main + ) + request.delegate = delegate + delegate.request = request + OSSystemExtensionManager.shared.submitRequest(request) + } + } +} + +private final class SystemExtensionRequestDelegateStore: @unchecked Sendable { + static let shared = SystemExtensionRequestDelegateStore() + private let lock = NSLock() + private var delegates: [ObjectIdentifier: SystemExtensionRequestDelegate] = [:] + + func insert(_ delegate: SystemExtensionRequestDelegate) { + lock.lock() + defer { lock.unlock() } + delegates[ObjectIdentifier(delegate)] = delegate + } + + func remove(_ delegate: SystemExtensionRequestDelegate) { + lock.lock() + defer { lock.unlock() } + delegates.removeValue(forKey: ObjectIdentifier(delegate)) + } +} + +private final class SystemExtensionRequestDelegate: NSObject, OSSystemExtensionRequestDelegate, @unchecked Sendable { + fileprivate weak var request: OSSystemExtensionRequest? + private let lock = NSLock() + private var didFinish = false + private let continuation: CheckedContinuation + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func request( + _ request: OSSystemExtensionRequest, + actionForReplacingExtension existing: OSSystemExtensionProperties, + withExtension ext: OSSystemExtensionProperties + ) -> OSSystemExtensionRequest.ReplacementAction { + .replace + } + + func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { + finish(throwing: DNSProxyControllerError.activationNeedsApproval) + } + + func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { + switch result { + case .completed: + finish() + case .willCompleteAfterReboot: + finish(throwing: DNSProxyControllerError.activationRequiresReboot) + @unknown default: + finish(throwing: DNSProxyControllerError.activationFailed("Unknown activation result: \(result.rawValue)")) + } + } + + func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { + finish(throwing: DNSProxyControllerError.activationFailed(error.localizedDescription)) + } + + private func finish(throwing error: Error? = nil) { + lock.lock() + guard !didFinish else { + lock.unlock() + return + } + didFinish = true + lock.unlock() + + SystemExtensionRequestDelegateStore.shared.remove(self) + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } +} + +private extension NEDNSProxyManager { + func loadFromPreferences() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + loadFromPreferences { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } + + func saveToPreferences() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + saveToPreferences { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } +} diff --git a/Sources/LocaleApp/HostsTabView.swift b/Sources/LocaleApp/HostsTabView.swift index 48226be..0a8b0b4 100644 --- a/Sources/LocaleApp/HostsTabView.swift +++ b/Sources/LocaleApp/HostsTabView.swift @@ -34,7 +34,7 @@ struct HostsTabView: View { Text("How it works:") .font(.system(size: 12, weight: .medium)) .foregroundStyle(.secondary) - Text("Locale writes these entries to /etc/hosts when this context is activated. Previous system entries are preserved and restored when you switch away.") + Text("Locale answers matching DNS requests from the active context and forwards everything else to your normal resolver.") .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(2) diff --git a/Sources/LocaleApp/LocaleHelperClient.swift b/Sources/LocaleApp/LocaleHelperClient.swift deleted file mode 100644 index 51d6e34..0000000 --- a/Sources/LocaleApp/LocaleHelperClient.swift +++ /dev/null @@ -1,116 +0,0 @@ -import Foundation -import LocaleShared -import ServiceManagement - -enum LocaleHelperError: LocalizedError { - case registrationFailed(String) - case requiresApproval - case notFound - case connectionFailed(String) - case rejected(String) - - var errorDescription: String? { - switch self { - case .registrationFailed(let message): - return "Could not register Locale Helper: \(message)" - case .requiresApproval: - return "Locale Helper needs approval in System Settings before it can update /etc/hosts." - case .notFound: - return "Locale Helper was not found inside the app bundle. Rebuild or reinstall Locale." - case .connectionFailed(let message): - return "Could not talk to Locale Helper: \(message)" - case .rejected(let message): - return message - } - } -} - -actor LocaleHelperClient { - static let shared = LocaleHelperClient() - - private let service = SMAppService.daemon(plistName: LocaleHelperConstants.helperPlistName) - - func applyHosts(_ content: String) async throws { - try registerIfNeeded() - - let connection = NSXPCConnection( - machServiceName: LocaleHelperConstants.helperLabel, - options: .privileged - ) - connection.remoteObjectInterface = NSXPCInterface(with: LocaleHelperXPCProtocol.self) - connection.resume() - defer { connection.invalidate() } - - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let replyBox = XPCReplyBox(continuation) - let proxy = connection.remoteObjectProxyWithErrorHandler { error in - replyBox.resume(throwing: LocaleHelperError.connectionFailed(error.localizedDescription)) - } as? LocaleHelperXPCProtocol - - guard let proxy else { - replyBox.resume(throwing: LocaleHelperError.connectionFailed("The helper did not expose the expected XPC protocol.")) - return - } - - proxy.applyHosts(content) { success, message in - if success { - replyBox.resume() - } else { - replyBox.resume(throwing: LocaleHelperError.rejected(message ?? "Locale Helper rejected the hosts update.")) - } - } - } - } - - private func registerIfNeeded() throws { - switch service.status { - case .enabled: - return - case .notRegistered: - do { - try service.register() - } catch { - throw LocaleHelperError.registrationFailed(error.localizedDescription) - } - - if service.status == .requiresApproval { - throw LocaleHelperError.requiresApproval - } - case .requiresApproval: - throw LocaleHelperError.requiresApproval - case .notFound: - throw LocaleHelperError.notFound - @unknown default: - throw LocaleHelperError.registrationFailed("Unknown helper status: \(service.status.rawValue)") - } - } -} - -private final class XPCReplyBox: @unchecked Sendable { - private let lock = NSLock() - private var didResume = false - private let continuation: CheckedContinuation - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func resume() { - guard claim() else { return } - continuation.resume() - } - - func resume(throwing error: Error) { - guard claim() else { return } - continuation.resume(throwing: error) - } - - private func claim() -> Bool { - lock.lock() - defer { lock.unlock() } - - guard !didResume else { return false } - didResume = true - return true - } -} diff --git a/Sources/LocaleApp/LocaleStore.swift b/Sources/LocaleApp/LocaleStore.swift index 2de794c..33a3250 100644 --- a/Sources/LocaleApp/LocaleStore.swift +++ b/Sources/LocaleApp/LocaleStore.swift @@ -101,7 +101,7 @@ final class LocaleStore: ObservableObject { } saveToDisk() isApplying = false - showToast(successMessage ?? "✓ \(contextName) activated · \(activeHostCount) hosts applied", type: .success) + showToast(successMessage ?? "✓ \(contextName) activated · \(activeHostCount) DNS mappings active", type: .success) case .failure(let error): isApplying = false @@ -111,7 +111,37 @@ final class LocaleStore: ObservableObject { } func deactivateAll() { - activateContext(homeContext, successMessage: "Reverted to Home context · Locale hosts removed") + guard !isApplying else { return } + isApplying = true + let homeID = homeContext.id + + Task { + let result: Result = await Task.detached(priority: .userInitiated) { + do { + try await SystemApplyService.clearDNSMappings() + return .success(()) + } catch { + return .failure(error) + } + }.value + + switch result { + case .success: + for idx in contexts.indices { + contexts[idx].isActive = (contexts[idx].id == homeID) + } + if let idx = contexts.firstIndex(where: { $0.id == homeID }) { + contexts[idx].lastActivated = Date() + } + saveToDisk() + isApplying = false + showToast("Reverted to Home context · Locale DNS mappings cleared", type: .success) + + case .failure(let error): + isApplying = false + showToast("Revert failed: \(error.localizedDescription)", type: .error) + } + } } func duplicateContext(_ context: NetworkContext) { diff --git a/Sources/LocaleApp/OnboardingView.swift b/Sources/LocaleApp/OnboardingView.swift index 1446982..52ba643 100644 --- a/Sources/LocaleApp/OnboardingView.swift +++ b/Sources/LocaleApp/OnboardingView.swift @@ -12,7 +12,7 @@ struct OnboardingView: View { iconColor: Color(red: 0.3, green: 0.55, blue: 0.95), title: "Welcome to Locale", subtitle: "macOS Network Context Manager", - description: "Locale lets you switch between named /etc/hosts configurations without editing system files by hand.", + description: "Locale lets you switch between named DNS contexts without editing system files by hand.", detail: nil ), OnboardingStep( @@ -21,15 +21,15 @@ struct OnboardingView: View { title: "What are Contexts?", subtitle: "One context per environment", description: "A context is a named set of hosts entries for an environment such as a client VPN, local API, lab network, or temporary debug setup.", - detail: "Create as many contexts as you need. Locale preserves everything outside its own managed block in /etc/hosts." + detail: "Create as many contexts as you need. Locale keeps your mappings local and applies only the active context." ), OnboardingStep( icon: "doc.text", iconColor: Color(red: 0.95, green: 0.55, blue: 0.2), title: "Hosts Entries", subtitle: "IP to hostname mappings", - description: "Each context holds a list of IP to hostname mappings. When you activate a context, enabled entries are written to Locale's managed block in /etc/hosts.", - detail: "Locale asks you to approve its helper once, creates a backup before writing, and flushes the local DNS cache after a successful apply." + description: "Each context holds a list of IP to hostname mappings. When you activate a context, Locale answers matching DNS requests with those IPs.", + detail: "Locale uses a sandboxed DNS proxy extension, so switching contexts does not require root access or host-file edits." ), OnboardingStep( icon: "menubar.rectangle", diff --git a/Sources/LocaleApp/PreferencesView.swift b/Sources/LocaleApp/PreferencesView.swift index 2ab4bdc..ae92fb7 100644 --- a/Sources/LocaleApp/PreferencesView.swift +++ b/Sources/LocaleApp/PreferencesView.swift @@ -31,17 +31,17 @@ struct PreferencesView: View { // Safety PrefsSection(title: "Safety") { PrefsInfoRow( - label: "Managed Hosts Block", + label: "DNS Proxy", icon: "lock.shield", - description: "Locale only replaces entries between its BEGIN/END markers in /etc/hosts." + description: "Locale applies mappings through a sandboxed DNS proxy extension." ) Divider().padding(.leading, 46).opacity(0.3) PrefsInfoRow( - label: "Automatic Backups", - icon: "clock.arrow.circlepath", - description: "Before every apply, Locale stores a backup in ~/Library/Application Support/Locale/HostsBackups." + label: "System Files", + icon: "doc.badge.gearshape", + description: "Locale does not edit host files or require root privileges to switch contexts." ) } @@ -180,7 +180,7 @@ struct PreferencesView: View { } Button("Cancel", role: .cancel) {} } message: { - Text("This removes every saved context and returns Locale to a clean Home context. Your system /etc/hosts file is not changed until you apply Home.") + Text("This removes every saved context and returns Locale to a clean Home context. DNS settings are not changed until you apply Home.") } } } diff --git a/Sources/LocaleApp/SystemApplyService.swift b/Sources/LocaleApp/SystemApplyService.swift index 1ed137d..1d82ae1 100644 --- a/Sources/LocaleApp/SystemApplyService.swift +++ b/Sources/LocaleApp/SystemApplyService.swift @@ -1,53 +1,43 @@ import Foundation +import LocaleShared import Network enum SystemApplyError: LocalizedError { case invalidHost(String) - case hostsReadFailed(String) - case privilegedWriteFailed(String) + case dnsProxyFailed(String) var errorDescription: String? { switch self { case .invalidHost(let message): return message - case .hostsReadFailed(let message): - return "Could not read /etc/hosts: \(message)" - case .privilegedWriteFailed(let message): - return "Could not write /etc/hosts: \(message)" + case .dnsProxyFailed(let message): + return "Could not update Locale DNS Proxy: \(message)" } } } enum SystemApplyService { - private static let hostsPath = "/etc/hosts" - private static let beginMarker = "# BEGIN LOCALE MANAGED HOSTS" - private static let endMarker = "# END LOCALE MANAGED HOSTS" - static func applyHosts(for context: NetworkContext) async throws { - let enabledHosts = try validatedHosts(from: context.hosts) - let currentHosts: String - + let mappings = try validatedMappings(from: context.hosts) do { - currentHosts = try String(contentsOfFile: hostsPath, encoding: .utf8) + try await DNSProxyController.shared.apply(contextName: context.name, mappings: mappings) } catch { - throw SystemApplyError.hostsReadFailed(error.localizedDescription) + throw SystemApplyError.dnsProxyFailed(error.localizedDescription) } + } - try writeBackup(of: currentHosts) - - let baseHosts = removingManagedBlock(from: currentHosts) - let nextHosts = composingHostsFile(from: baseHosts, context: context, enabledHosts: enabledHosts) + static func clearDNSMappings() async throws { do { - try await LocaleHelperClient.shared.applyHosts(nextHosts) + try await DNSProxyController.shared.clear() } catch { - throw SystemApplyError.privilegedWriteFailed(error.localizedDescription) + throw SystemApplyError.dnsProxyFailed(error.localizedDescription) } } - private static func validatedHosts(from hosts: [HostEntry]) throws -> [HostEntry] { + private static func validatedMappings(from hosts: [HostEntry]) throws -> [LocaleDNSHostMapping] { try hosts.filter(\.isEnabled).map { host in let ipAddress = host.ipAddress.trimmingCharacters(in: .whitespacesAndNewlines) - let hostname = host.hostname.trimmingCharacters(in: .whitespacesAndNewlines) + let hostname = LocaleDNSHostMapping.normalize(host.hostname) guard IPv4Address(ipAddress) != nil || IPv6Address(ipAddress) != nil else { throw SystemApplyError.invalidHost("Invalid IP address: \(host.ipAddress)") @@ -60,104 +50,7 @@ enum SystemApplyService { throw SystemApplyError.invalidHost("Invalid hostname: \(host.hostname)") } - var sanitized = host - sanitized.ipAddress = ipAddress - sanitized.hostname = hostname - sanitized.note = sanitized.note.replacingOccurrences(of: "\n", with: " ") - return sanitized - } - } - - private static func removingManagedBlock(from content: String) -> String { - var keptLines: [String] = [] - var pendingBlockLines: [String] = [] - var isInsideManagedBlock = false - - for line in content.components(separatedBy: .newlines) { - if line == beginMarker { - isInsideManagedBlock = true - pendingBlockLines = [line] - continue - } - - if isInsideManagedBlock { - pendingBlockLines.append(line) - if line == endMarker { - isInsideManagedBlock = false - pendingBlockLines = [] - } - } else { - keptLines.append(line) - } + return LocaleDNSHostMapping(hostname: hostname, ipAddress: ipAddress) } - - if isInsideManagedBlock { - keptLines.append(contentsOf: pendingBlockLines) - } - - return keptLines - .joined(separator: "\n") - .trimmingCharacters(in: .newlines) } - - private static func composingHostsFile( - from baseHosts: String, - context: NetworkContext, - enabledHosts: [HostEntry] - ) -> String { - var sections: [String] = [] - let trimmedBase = baseHosts.trimmingCharacters(in: .newlines) - if !trimmedBase.isEmpty { - sections.append(trimmedBase) - } - - if !enabledHosts.isEmpty { - var managedLines = [ - beginMarker, - "# Context: \(sanitizedComment(context.name))", - "# Applied: \(ISO8601DateFormatter().string(from: Date()))" - ] - - managedLines.append(contentsOf: enabledHosts.map { host in - let note = sanitizedComment(host.note) - if note.isEmpty { - return "\(host.ipAddress)\t\(host.hostname)" - } - return "\(host.ipAddress)\t\(host.hostname)\t# \(note)" - }) - managedLines.append(endMarker) - sections.append(managedLines.joined(separator: "\n")) - } - - return sections.joined(separator: "\n\n") + "\n" - } - - private static func sanitizedComment(_ value: String) -> String { - value - .replacingOccurrences(of: "\n", with: " ") - .replacingOccurrences(of: "\r", with: " ") - .replacingOccurrences(of: "#", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - private static func writeBackup(of currentHosts: String) throws { - let fileManager = FileManager.default - let supportDirectory = try fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let backupDirectory = supportDirectory - .appendingPathComponent("Locale", isDirectory: true) - .appendingPathComponent("HostsBackups", isDirectory: true) - try fileManager.createDirectory(at: backupDirectory, withIntermediateDirectories: true) - - let stamp = ISO8601DateFormatter() - .string(from: Date()) - .replacingOccurrences(of: ":", with: "-") - let backupURL = backupDirectory.appendingPathComponent("hosts-\(stamp).bak") - try currentHosts.write(to: backupURL, atomically: true, encoding: .utf8) - } - } diff --git a/Sources/LocaleDNSProxy/DNSProxyProvider.swift b/Sources/LocaleDNSProxy/DNSProxyProvider.swift new file mode 100644 index 0000000..997b882 --- /dev/null +++ b/Sources/LocaleDNSProxy/DNSProxyProvider.swift @@ -0,0 +1,283 @@ +import Foundation +import LocaleShared +import NetworkExtension +import Darwin + +final class DNSProxyProvider: NEDNSProxyProvider, @unchecked Sendable { + private let flowRegistry = FlowRegistry() + private let reloadQueue = DispatchQueue(label: "dev.offyotto.LocaleDNSProxy.reload") + private var activeLookup: [String: String] = [:] + private var changeObserver: NSObjectProtocol? + + override func startProxy(options: [String: Any]? = nil, completionHandler: @escaping (Error?) -> Void) { + reloadMappings() + changeObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name(LocaleDNSConstants.configurationChangedNotification), + object: LocaleDNSConstants.appBundleIdentifier, + queue: nil + ) { [weak self] _ in + self?.reloadMappings() + } + completionHandler(nil) + } + + override func stopProxy(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + if let changeObserver { + DistributedNotificationCenter.default().removeObserver(changeObserver) + self.changeObserver = nil + } + flowRegistry.closeAll() + completionHandler() + } + + override func sleep(completionHandler: @escaping () -> Void) { + completionHandler() + } + + override func wake() { + reloadMappings() + } + + override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool { + guard let udpFlow = flow as? NEAppProxyUDPFlow else { + flow.closeReadWithError(nil) + flow.closeWriteWithError(nil) + return false + } + + let handler = DNSUDPFlowHandler( + flow: udpFlow, + provider: self, + onClose: { [weak self] handler in + self?.flowRegistry.remove(handler) + } + ) + flowRegistry.insert(handler) + handler.start() + return true + } + + fileprivate func lookup() -> [String: String] { + reloadQueue.sync { activeLookup } + } + + fileprivate func upstreamServers() -> [String] { + let configuredServers = systemDNSSettings? + .flatMap(\.servers) + .filter { !$0.isEmpty } ?? [] + + if configuredServers.isEmpty { + return ["1.1.1.1", "8.8.8.8"] + } + return configuredServers + } + + private func reloadMappings() { + reloadQueue.async { + do { + self.activeLookup = try LocaleDNSConfigurationStore.load().lookup + } catch { + self.activeLookup = [:] + } + } + } +} + +private final class FlowRegistry: @unchecked Sendable { + private let lock = NSLock() + private var handlers: [ObjectIdentifier: DNSUDPFlowHandler] = [:] + + func insert(_ handler: DNSUDPFlowHandler) { + lock.lock() + defer { lock.unlock() } + handlers[ObjectIdentifier(handler)] = handler + } + + func remove(_ handler: DNSUDPFlowHandler) { + lock.lock() + defer { lock.unlock() } + handlers.removeValue(forKey: ObjectIdentifier(handler)) + } + + func closeAll() { + lock.lock() + let currentHandlers = Array(handlers.values) + handlers.removeAll() + lock.unlock() + + currentHandlers.forEach { $0.close() } + } +} + +private final class DNSUDPFlowHandler: @unchecked Sendable { + private let flow: NEAppProxyUDPFlow + private weak var provider: DNSProxyProvider? + private let onClose: (DNSUDPFlowHandler) -> Void + private let queue = DispatchQueue(label: "dev.offyotto.LocaleDNSProxy.udp-flow") + private let lock = NSLock() + private var isClosed = false + + init( + flow: NEAppProxyUDPFlow, + provider: DNSProxyProvider, + onClose: @escaping (DNSUDPFlowHandler) -> Void + ) { + self.flow = flow + self.provider = provider + self.onClose = onClose + } + + func start() { + flow.open(withLocalEndpoint: nil) { [weak self] (error: Error?) in + guard let self else { return } + if error == nil { + self.readNextBatch() + } else { + self.close() + } + } + } + + func close() { + lock.lock() + guard !isClosed else { + lock.unlock() + return + } + isClosed = true + lock.unlock() + + flow.closeReadWithError(nil) + flow.closeWriteWithError(nil) + onClose(self) + } + + private func readNextBatch() { + flow.readDatagrams { [weak self] (datagrams: [Data]?, endpoints: [NWEndpoint]?, error: Error?) in + guard let self else { return } + guard error == nil, + let datagrams, + let endpoints, + datagrams.count == endpoints.count, + !datagrams.isEmpty else { + self.close() + return + } + + for index in datagrams.indices { + self.handle(datagram: datagrams[index], endpoint: endpoints[index]) + } + self.readNextBatch() + } + } + + private func handle(datagram: Data, endpoint: NWEndpoint) { + guard let provider else { + close() + return + } + + if let packet = DNSPacket(datagram), + let mappedIP = provider.lookup()[packet.question.hostname], + let response = packet.response(for: mappedIP) { + write(response, to: endpoint) + return + } + + let servers = provider.upstreamServers() + queue.async { [weak self] in + guard let self, + let response = DNSForwarder.forward(datagram, to: servers) else { + return + } + self.write(response, to: endpoint) + } + } + + private func write(_ response: Data, to endpoint: NWEndpoint) { + flow.writeDatagrams([response], sentBy: [endpoint]) { [weak self] (error: Error?) in + if error != nil { + self?.close() + } + } + } +} + +private enum DNSForwarder { + static func forward( + _ datagram: Data, + to servers: [String] + ) -> Data? { + for server in servers { + if let response = query(datagram, server: server) { + return response + } + } + return nil + } + + private static func query(_ datagram: Data, server: String) -> Data? { + if let response = queryIPv4(datagram, server: server) { + return response + } + return queryIPv6(datagram, server: server) + } + + private static func queryIPv4(_ datagram: Data, server: String) -> Data? { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(53).bigEndian + guard server.withCString({ inet_pton(AF_INET, $0, &address.sin_addr) }) == 1 else { + return nil + } + + return withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + query(datagram, socketFamily: AF_INET, sockaddr: $0, length: socklen_t(MemoryLayout.size)) + } + } + } + + private static func queryIPv6(_ datagram: Data, server: String) -> Data? { + var address = sockaddr_in6() + address.sin6_len = UInt8(MemoryLayout.size) + address.sin6_family = sa_family_t(AF_INET6) + address.sin6_port = in_port_t(53).bigEndian + guard server.withCString({ inet_pton(AF_INET6, $0, &address.sin6_addr) }) == 1 else { + return nil + } + + return withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + query(datagram, socketFamily: AF_INET6, sockaddr: $0, length: socklen_t(MemoryLayout.size)) + } + } + } + + private static func query( + _ datagram: Data, + socketFamily: Int32, + sockaddr: UnsafePointer, + length: socklen_t + ) -> Data? { + let descriptor = socket(socketFamily, SOCK_DGRAM, IPPROTO_UDP) + guard descriptor >= 0 else { return nil } + defer { close(descriptor) } + + var timeout = timeval(tv_sec: 2, tv_usec: 0) + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + let sent = datagram.withUnsafeBytes { buffer -> ssize_t in + guard let baseAddress = buffer.baseAddress else { return -1 } + return sendto(descriptor, baseAddress, buffer.count, 0, sockaddr, length) + } + guard sent == datagram.count else { return nil } + + var buffer = [UInt8](repeating: 0, count: 4096) + let count = recv(descriptor, &buffer, buffer.count, 0) + guard count > 0 else { return nil } + return Data(buffer.prefix(count)) + } +} diff --git a/Sources/LocaleDNSProxy/main.swift b/Sources/LocaleDNSProxy/main.swift new file mode 100644 index 0000000..410c838 --- /dev/null +++ b/Sources/LocaleDNSProxy/main.swift @@ -0,0 +1,8 @@ +import Foundation +import NetworkExtension + +autoreleasepool { + NEProvider.startSystemExtensionMode() +} + +dispatchMain() diff --git a/Sources/LocaleHelper/main.swift b/Sources/LocaleHelper/main.swift deleted file mode 100644 index 29bafb1..0000000 --- a/Sources/LocaleHelper/main.swift +++ /dev/null @@ -1,183 +0,0 @@ -import Foundation -import LocaleShared -import Security - -private enum HelperError: LocalizedError { - case invalidPayload - case openFailed(String) - case writeFailed(String) - case metadataFailed(String) - case replaceFailed(String) - - var errorDescription: String? { - switch self { - case .invalidPayload: - return "Locale refused to write an empty or invalid hosts file." - case .openFailed(let message): - return "Could not create the temporary hosts file: \(message)" - case .writeFailed(let message): - return "Could not write the temporary hosts file: \(message)" - case .metadataFailed(let message): - return "Could not set hosts file ownership or permissions: \(message)" - case .replaceFailed(let message): - return "Could not replace /etc/hosts: \(message)" - } - } -} - -private final class LocaleHelperService: NSObject, LocaleHelperXPCProtocol { - func applyHosts(_ content: String, withReply reply: @escaping (Bool, String?) -> Void) { - do { - try HostsWriter.apply(content) - reply(true, nil) - } catch { - reply(false, error.localizedDescription) - } - } -} - -private final class HelperDelegate: NSObject, NSXPCListenerDelegate { - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { - guard ClientVerifier.isTrusted(connection) else { - return false - } - - connection.exportedInterface = NSXPCInterface(with: LocaleHelperXPCProtocol.self) - connection.exportedObject = LocaleHelperService() - connection.resume() - return true - } -} - -private enum ClientVerifier { - static func isTrusted(_ connection: NSXPCConnection) -> Bool { - let requirementText = """ - identifier "\(LocaleHelperConstants.appBundleIdentifier)" and certificate leaf[subject.OU] = "\(LocaleHelperConstants.releaseTeamIdentifier)" - """ - - if check(connection: connection, requirementText: requirementText) { - return true - } - - // Keeps ad-hoc local builds usable. Developer ID/App Store builds pass the stricter Team ID requirement above. - return check( - connection: connection, - requirementText: #"identifier "\#(LocaleHelperConstants.appBundleIdentifier)""# - ) - } - - private static func check(connection: NSXPCConnection, requirementText: String) -> Bool { - var code: SecCode? - let attributes = [kSecGuestAttributePid as String: connection.processIdentifier] as CFDictionary - guard SecCodeCopyGuestWithAttributes(nil, attributes, SecCSFlags(), &code) == errSecSuccess, - let code else { - return false - } - - var requirement: SecRequirement? - guard SecRequirementCreateWithString(requirementText as CFString, SecCSFlags(), &requirement) == errSecSuccess, - let requirement else { - return false - } - - return SecCodeCheckValidity(code, SecCSFlags(), requirement) == errSecSuccess - } -} - -private enum HostsWriter { - private static let hostsURL = URL(fileURLWithPath: "/private/etc/hosts") - private static let maxPayloadBytes = 1_048_576 - - static func apply(_ content: String) throws { - guard !content.isEmpty, - content.utf8.count <= maxPayloadBytes, - !content.contains("\0") else { - throw HelperError.invalidPayload - } - - let directory = hostsURL.deletingLastPathComponent() - let tempURL = directory.appendingPathComponent(".hosts.locale.\(UUID().uuidString)") - try write(content, to: tempURL) - - do { - try replaceHosts(with: tempURL) - flushDNSCache() - } catch { - try? FileManager.default.removeItem(at: tempURL) - throw error - } - } - - private static func write(_ content: String, to url: URL) throws { - let descriptor = open(url.path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) - guard descriptor >= 0 else { - throw HelperError.openFailed(String(cString: strerror(errno))) - } - var isClosed = false - defer { - if !isClosed { - _ = close(descriptor) - } - } - - let data = Array(content.utf8) - try data.withUnsafeBytes { buffer in - guard let baseAddress = buffer.baseAddress else { return } - - var written = 0 - while written < buffer.count { - let result = Darwin.write( - descriptor, - baseAddress.advanced(by: written), - buffer.count - written - ) - - if result < 0 { - throw HelperError.writeFailed(String(cString: strerror(errno))) - } - written += result - } - } - - if fsync(descriptor) != 0 { - throw HelperError.writeFailed(String(cString: strerror(errno))) - } - - if close(descriptor) != 0 { - throw HelperError.writeFailed(String(cString: strerror(errno))) - } - isClosed = true - } - - private static func replaceHosts(with tempURL: URL) throws { - guard chown(tempURL.path, 0, 0) == 0, - chmod(tempURL.path, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) == 0 else { - throw HelperError.metadataFailed(String(cString: strerror(errno))) - } - - guard rename(tempURL.path, hostsURL.path) == 0 else { - throw HelperError.replaceFailed(String(cString: strerror(errno))) - } - } - - private static func flushDNSCache() { - run("/usr/bin/dscacheutil", arguments: ["-flushcache"]) - run("/usr/bin/killall", arguments: ["-HUP", "mDNSResponder"]) - } - - private static func run(_ executable: String, arguments: [String]) { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - process.standardOutput = nil - process.standardError = nil - try? process.run() - process.waitUntilExit() - } -} - -private let delegate = HelperDelegate() -private let listener = NSXPCListener(machServiceName: LocaleHelperConstants.helperLabel) -listener.delegate = delegate -listener.resume() -RunLoop.main.run() diff --git a/Sources/LocaleShared/DNSPacket.swift b/Sources/LocaleShared/DNSPacket.swift new file mode 100644 index 0000000..cab7c40 --- /dev/null +++ b/Sources/LocaleShared/DNSPacket.swift @@ -0,0 +1,169 @@ +import Foundation +import Darwin + +public enum DNSRecordType: UInt16 { + case a = 1 + case aaaa = 28 + case any = 255 +} + +public struct DNSQuestion: Sendable { + public let hostname: String + public let type: UInt16 + public let qclass: UInt16 + public let questionBytes: [UInt8] +} + +public struct DNSPacket: Sendable { + private let bytes: [UInt8] + public let identifier: UInt16 + public let flags: UInt16 + public let question: DNSQuestion + + public init?(_ data: Data) { + let bytes = Array(data) + guard bytes.count >= 12 else { return nil } + guard DNSPacket.readUInt16(bytes, at: 4) == 1 else { return nil } + + var cursor = 12 + guard let hostname = DNSPacket.readName(bytes, cursor: &cursor), + cursor + 4 <= bytes.count else { + return nil + } + + let type = DNSPacket.readUInt16(bytes, at: cursor) + let qclass = DNSPacket.readUInt16(bytes, at: cursor + 2) + cursor += 4 + + self.bytes = bytes + self.identifier = DNSPacket.readUInt16(bytes, at: 0) + self.flags = DNSPacket.readUInt16(bytes, at: 2) + self.question = DNSQuestion( + hostname: LocaleDNSHostMapping.normalize(hostname), + type: type, + qclass: qclass, + questionBytes: Array(bytes[12.. Data? { + guard question.qclass == 1 else { return nil } + + let recordType: DNSRecordType + let rdata: [UInt8] + if let ipv4 = DNSPacket.ipv4Bytes(ipAddress), + question.type == DNSRecordType.a.rawValue || question.type == DNSRecordType.any.rawValue { + recordType = .a + rdata = ipv4 + } else if let ipv6 = DNSPacket.ipv6Bytes(ipAddress), + question.type == DNSRecordType.aaaa.rawValue || question.type == DNSRecordType.any.rawValue { + recordType = .aaaa + rdata = ipv6 + } else { + return noAnswerResponse() + } + + var response = responseHeader(answerCount: 1) + response.append(contentsOf: question.questionBytes) + response.append(0xC0) + response.append(0x0C) + DNSPacket.appendUInt16(recordType.rawValue, to: &response) + DNSPacket.appendUInt16(1, to: &response) + DNSPacket.appendUInt32(ttl, to: &response) + DNSPacket.appendUInt16(UInt16(rdata.count), to: &response) + response.append(contentsOf: rdata) + return Data(response) + } + + public func noAnswerResponse() -> Data { + var response = responseHeader(answerCount: 0) + response.append(contentsOf: question.questionBytes) + return Data(response) + } + + private func responseHeader(answerCount: UInt16) -> [UInt8] { + var response: [UInt8] = [] + DNSPacket.appendUInt16(identifier, to: &response) + let recursionDesired = flags & 0x0100 + DNSPacket.appendUInt16(0x8000 | 0x0080 | recursionDesired, to: &response) + DNSPacket.appendUInt16(1, to: &response) + DNSPacket.appendUInt16(answerCount, to: &response) + DNSPacket.appendUInt16(0, to: &response) + DNSPacket.appendUInt16(0, to: &response) + return response + } + + private static func readName(_ bytes: [UInt8], cursor: inout Int) -> String? { + var labels: [String] = [] + var jumped = false + var localCursor = cursor + var seenPointers = Set() + + while localCursor < bytes.count { + let length = Int(bytes[localCursor]) + + if length == 0 { + localCursor += 1 + if !jumped { + cursor = localCursor + } + return labels.joined(separator: ".") + } + + if length & 0xC0 == 0xC0 { + guard localCursor + 1 < bytes.count else { return nil } + let pointer = ((length & 0x3F) << 8) | Int(bytes[localCursor + 1]) + guard pointer < bytes.count, !seenPointers.contains(pointer) else { return nil } + seenPointers.insert(pointer) + if !jumped { + cursor = localCursor + 2 + } + localCursor = pointer + jumped = true + continue + } + + guard length & 0xC0 == 0, + localCursor + 1 + length <= bytes.count else { + return nil + } + + let labelBytes = bytes[(localCursor + 1)..<(localCursor + 1 + length)] + guard let label = String(bytes: labelBytes, encoding: .utf8) else { return nil } + labels.append(label) + localCursor += 1 + length + } + + return nil + } + + private static func readUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + (UInt16(bytes[offset]) << 8) | UInt16(bytes[offset + 1]) + } + + private static func appendUInt16(_ value: UInt16, to bytes: inout [UInt8]) { + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + } + + private static func appendUInt32(_ value: UInt32, to bytes: inout [UInt8]) { + bytes.append(UInt8((value >> 24) & 0xFF)) + bytes.append(UInt8((value >> 16) & 0xFF)) + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + } + + private static func ipv4Bytes(_ value: String) -> [UInt8]? { + var address = in_addr() + let result = value.withCString { inet_pton(AF_INET, $0, &address) } + guard result == 1 else { return nil } + return withUnsafeBytes(of: address) { Array($0) } + } + + private static func ipv6Bytes(_ value: String) -> [UInt8]? { + var address = in6_addr() + let result = value.withCString { inet_pton(AF_INET6, $0, &address) } + guard result == 1 else { return nil } + return withUnsafeBytes(of: address) { Array($0) } + } +} diff --git a/Sources/LocaleShared/LocaleDNSConfiguration.swift b/Sources/LocaleShared/LocaleDNSConfiguration.swift new file mode 100644 index 0000000..f8cbd1e --- /dev/null +++ b/Sources/LocaleShared/LocaleDNSConfiguration.swift @@ -0,0 +1,94 @@ +import Foundation + +public enum LocaleDNSConstants { + public static let appBundleIdentifier = "dev.offyotto.Locale" + public static let extensionBundleIdentifier = "dev.offyotto.Locale.LocaleDNSProxy" + public static let appGroupIdentifier = "group.dev.offyotto.Locale" + public static let activeConfigurationKey = "locale.activeDNSConfiguration" + public static let configurationChangedNotification = "dev.offyotto.Locale.activeDNSConfigurationChanged" +} + +public struct LocaleDNSHostMapping: Codable, Equatable, Sendable { + public var hostname: String + public var ipAddress: String + + public init(hostname: String, ipAddress: String) { + self.hostname = LocaleDNSHostMapping.normalize(hostname) + self.ipAddress = ipAddress.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func normalize(_ hostname: String) -> String { + hostname + .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + } +} + +public struct LocaleActiveDNSConfiguration: Codable, Equatable, Sendable { + public var contextName: String + public var mappings: [LocaleDNSHostMapping] + public var updatedAt: Date + + public init(contextName: String, mappings: [LocaleDNSHostMapping], updatedAt: Date = Date()) { + self.contextName = contextName + self.mappings = mappings + self.updatedAt = updatedAt + } + + public var lookup: [String: String] { + Dictionary(uniqueKeysWithValues: mappings.map { ($0.hostname, $0.ipAddress) }) + } +} + +public enum LocaleDNSConfigurationStore { + public static func load() throws -> LocaleActiveDNSConfiguration { + guard let defaults = UserDefaults(suiteName: LocaleDNSConstants.appGroupIdentifier) else { + throw StoreError.unavailable + } + + guard let data = defaults.data(forKey: LocaleDNSConstants.activeConfigurationKey) else { + return LocaleActiveDNSConfiguration(contextName: "Home", mappings: []) + } + + return try JSONDecoder().decode(LocaleActiveDNSConfiguration.self, from: data) + } + + public static func save(_ configuration: LocaleActiveDNSConfiguration) throws { + guard let defaults = UserDefaults(suiteName: LocaleDNSConstants.appGroupIdentifier) else { + throw StoreError.unavailable + } + + let data = try JSONEncoder().encode(configuration) + defaults.set(data, forKey: LocaleDNSConstants.activeConfigurationKey) + defaults.synchronize() + notifyChange() + } + + public static func clear() throws { + guard let defaults = UserDefaults(suiteName: LocaleDNSConstants.appGroupIdentifier) else { + throw StoreError.unavailable + } + + defaults.removeObject(forKey: LocaleDNSConstants.activeConfigurationKey) + defaults.synchronize() + notifyChange() + } + + private static func notifyChange() { + DistributedNotificationCenter.default().postNotificationName( + Notification.Name(LocaleDNSConstants.configurationChangedNotification), + object: LocaleDNSConstants.appBundleIdentifier, + userInfo: nil, + deliverImmediately: true + ) + } + + public enum StoreError: LocalizedError { + case unavailable + + public var errorDescription: String? { + "Locale could not open its shared app group storage." + } + } +} diff --git a/Sources/LocaleShared/LocaleHelperContract.swift b/Sources/LocaleShared/LocaleHelperContract.swift deleted file mode 100644 index 8e78dce..0000000 --- a/Sources/LocaleShared/LocaleHelperContract.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation - -public enum LocaleHelperConstants { - public static let appBundleIdentifier = "dev.offyotto.Locale" - public static let helperLabel = "dev.offyotto.Locale.Helper" - public static let helperPlistName = "\(helperLabel).plist" - public static let releaseTeamIdentifier = "6VDP675K4L" -} - -@objc(LocaleHelperXPCProtocol) -public protocol LocaleHelperXPCProtocol { - func applyHosts(_ content: String, withReply reply: @escaping (Bool, String?) -> Void) -} diff --git a/Tests/LocaleSharedTests/DNSPacketTests.swift b/Tests/LocaleSharedTests/DNSPacketTests.swift new file mode 100644 index 0000000..9ffef9c --- /dev/null +++ b/Tests/LocaleSharedTests/DNSPacketTests.swift @@ -0,0 +1,61 @@ +import Foundation +import XCTest +@testable import LocaleShared + +final class DNSPacketTests: XCTestCase { + func testARecordResponseForMappedHost() { + let query = makeQuery(identifier: 0x1234, hostname: "API.Company.Internal.", type: DNSRecordType.a.rawValue) + let packet = DNSPacket(query) + + XCTAssertEqual(packet?.identifier, 0x1234) + XCTAssertEqual(packet?.question.hostname, "api.company.internal") + + let response = packet?.response(for: "10.1.2.3") + let bytes = [UInt8](response ?? Data()) + + XCTAssertEqual(readUInt16(bytes, at: 0), 0x1234) + XCTAssertEqual(readUInt16(bytes, at: 4), 1) + XCTAssertEqual(readUInt16(bytes, at: 6), 1) + XCTAssertEqual(Array(bytes.suffix(4)), [10, 1, 2, 3]) + } + + func testWrongAddressFamilyReturnsNoAnswerResponse() { + let query = makeQuery(identifier: 0x5678, hostname: "api.company.internal", type: DNSRecordType.aaaa.rawValue) + let response = DNSPacket(query)?.response(for: "10.1.2.3") + let bytes = [UInt8](response ?? Data()) + + XCTAssertEqual(readUInt16(bytes, at: 0), 0x5678) + XCTAssertEqual(readUInt16(bytes, at: 4), 1) + XCTAssertEqual(readUInt16(bytes, at: 6), 0) + } + + private func makeQuery(identifier: UInt16, hostname: String, type: UInt16) -> Data { + var bytes: [UInt8] = [] + appendUInt16(identifier, to: &bytes) + appendUInt16(0x0100, to: &bytes) + appendUInt16(1, to: &bytes) + appendUInt16(0, to: &bytes) + appendUInt16(0, to: &bytes) + appendUInt16(0, to: &bytes) + + for label in hostname.trimmingCharacters(in: CharacterSet(charactersIn: ".")).split(separator: ".") { + let labelBytes = Array(label.utf8) + bytes.append(UInt8(labelBytes.count)) + bytes.append(contentsOf: labelBytes) + } + bytes.append(0) + appendUInt16(type, to: &bytes) + appendUInt16(1, to: &bytes) + return Data(bytes) + } + + private func appendUInt16(_ value: UInt16, to bytes: inout [UInt8]) { + bytes.append(UInt8((value >> 8) & 0xFF)) + bytes.append(UInt8(value & 0xFF)) + } + + private func readUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { + guard offset + 1 < bytes.count else { return 0 } + return (UInt16(bytes[offset]) << 8) | UInt16(bytes[offset + 1]) + } +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index db3f740..c008ee1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,14 +1,17 @@ # Releasing Locale -Locale uses a sandboxed main app plus a bundled `SMAppService` launch daemon to -update `/etc/hosts`. Launch daemon builds must be signed and notarized before the -helper can register successfully. +Locale ships a sandboxed macOS app with a bundled DNS Proxy Network Extension. +Distribution builds must be signed with profiles that include: + +- App Groups: `group.dev.offyotto.Locale` +- System Extension install +- Network Extension: DNS Proxy ## Prerequisites -- A `Developer ID Application` certificate in Keychain Access. -- A saved notary profile named `LocaleNotary`. -- The app bundle should live in `/Applications` when testing helper registration. +- A `Developer ID Application` certificate in Keychain Access for direct distribution, or Mac App Store signing assets for App Store builds. +- A saved notary profile named `LocaleNotary` for direct distribution. +- Network Extension and System Extension capabilities enabled for the app identifiers in the Apple Developer portal. Create the notary profile with: @@ -33,12 +36,19 @@ dist/Locale-notarized.zip It also validates the stapled app with `spctl` and `xcrun stapler validate`. -## Privileged Helper Notes +## Network Extension Notes + +`LocaleDNSProxy` is staged as a system extension at: + +```text +Contents/Library/SystemExtensions/dev.offyotto.Locale.LocaleDNSProxy.systemextension +``` -The main app registers `Contents/Library/LaunchDaemons/dev.offyotto.Locale.Helper.plist`. -That plist points at `Contents/Library/LaunchServices/LocaleHelper` using -`BundleProgram`, so relocating the app after registration requires unregistering -and registering the helper again. +The extension starts with `NEProvider.startSystemExtensionMode()` and exposes a +`NEDNSProxyProvider` class. The main app activates it with +`OSSystemExtensionManager`, then configures `NEDNSProxyManager` to point at the +extension bundle identifier. -The helper only exposes one XPC method: apply a complete hosts-file payload. It -validates the calling app signature before replacing `/private/etc/hosts`. +First launch may require the user to approve the system extension and DNS proxy +configuration in System Settings. Context switching after approval does not use +root privileges or edit system host files. diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 7264f0e..1dc3b35 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -3,11 +3,13 @@ set -euo pipefail MODE="${1:-run}" PRODUCT_NAME="LocaleApp" -HELPER_PRODUCT_NAME="LocaleHelper" +DNS_PROXY_PRODUCT_NAME="LocaleDNSProxy" APP_NAME="Locale" BUNDLE_ID="dev.offyotto.Locale" +DNS_PROXY_BUNDLE_ID="dev.offyotto.Locale.LocaleDNSProxy" DISPLAY_NAME="Locale" MIN_SYSTEM_VERSION="14.0" +TEAM_ID="${TEAM_ID:-6VDP675K4L}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" @@ -16,18 +18,21 @@ APP_CONTENTS="$APP_BUNDLE/Contents" APP_MACOS="$APP_CONTENTS/MacOS" APP_RESOURCES="$APP_CONTENTS/Resources" APP_LIBRARY="$APP_CONTENTS/Library" -APP_LAUNCH_DAEMONS="$APP_LIBRARY/LaunchDaemons" -APP_LAUNCH_SERVICES="$APP_LIBRARY/LaunchServices" +APP_SYSTEM_EXTENSIONS="$APP_LIBRARY/SystemExtensions" APP_BINARY="$APP_MACOS/$PRODUCT_NAME" -HELPER_BINARY="$APP_LAUNCH_SERVICES/$HELPER_PRODUCT_NAME" +DNS_PROXY_BUNDLE="$APP_SYSTEM_EXTENSIONS/$DNS_PROXY_BUNDLE_ID.systemextension" +DNS_PROXY_CONTENTS="$DNS_PROXY_BUNDLE/Contents" +DNS_PROXY_MACOS="$DNS_PROXY_CONTENTS/MacOS" +DNS_PROXY_BINARY="$DNS_PROXY_MACOS/$DNS_PROXY_PRODUCT_NAME" +DNS_PROXY_INFO_PLIST="$DNS_PROXY_CONTENTS/Info.plist" INFO_PLIST="$APP_CONTENTS/Info.plist" ICON_SOURCE="$ROOT_DIR/Assets/AppIcon.icon" ICON_BUILD_DIR="$ROOT_DIR/Resources/IconBuild" APP_ICON="$ROOT_DIR/Resources/AppIcon.icns" ASSETS_CAR="$ROOT_DIR/Resources/Assets.car" PARTIAL_INFO_PLIST="$ROOT_DIR/Resources/IconPartialInfo.plist" -HELPER_PLIST="$ROOT_DIR/Resources/dev.offyotto.Locale.Helper.plist" APP_ENTITLEMENTS="$ROOT_DIR/Resources/Locale.entitlements" +DNS_PROXY_ENTITLEMENTS="$ROOT_DIR/Resources/LocaleDNSProxy.entitlements" build_app_icon() { if [[ ! -d "$ICON_SOURCE" ]]; then @@ -57,16 +62,15 @@ pkill -x "$PRODUCT_NAME" >/dev/null 2>&1 || true swift build -c release BUILD_DIR="$(swift build -c release --show-bin-path)" BUILD_BINARY="$BUILD_DIR/$PRODUCT_NAME" -BUILD_HELPER_BINARY="$BUILD_DIR/$HELPER_PRODUCT_NAME" +BUILD_DNS_PROXY_BINARY="$BUILD_DIR/$DNS_PROXY_PRODUCT_NAME" build_app_icon rm -rf "$APP_BUNDLE" -mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$APP_LAUNCH_DAEMONS" "$APP_LAUNCH_SERVICES" +mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$DNS_PROXY_MACOS" cp "$BUILD_BINARY" "$APP_BINARY" chmod +x "$APP_BINARY" -cp "$BUILD_HELPER_BINARY" "$HELPER_BINARY" -chmod +x "$HELPER_BINARY" -cp "$HELPER_PLIST" "$APP_LAUNCH_DAEMONS/$(basename "$HELPER_PLIST")" +cp "$BUILD_DNS_PROXY_BINARY" "$DNS_PROXY_BINARY" +chmod +x "$DNS_PROXY_BINARY" cp "$APP_ICON" "$APP_RESOURCES/AppIcon.icns" cp "$ASSETS_CAR" "$APP_RESOURCES/Assets.car" @@ -103,7 +107,44 @@ cat >"$INFO_PLIST" < PLIST -codesign --force --sign - --identifier "dev.offyotto.Locale.Helper" "$HELPER_BINARY" >/dev/null +cat >"$DNS_PROXY_INFO_PLIST" < + + + + CFBundleExecutable + $DNS_PROXY_PRODUCT_NAME + CFBundleIdentifier + $DNS_PROXY_BUNDLE_ID + CFBundleName + $DNS_PROXY_PRODUCT_NAME + CFBundleDisplayName + Locale DNS Proxy + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 20260603.2 + CFBundlePackageType + SYSX + LSMinimumSystemVersion + $MIN_SYSTEM_VERSION + NetworkExtension + + NEProviderClasses + + com.apple.networkextension.dns-proxy + LocaleDNSProxy.DNSProxyProvider + + NEMachServiceName + $TEAM_ID.$DNS_PROXY_BUNDLE_ID + + NSSystemExtensionUsageDescription + Locale uses a DNS proxy system extension to switch local hostname mappings without editing system files. + + +PLIST + +codesign --force --sign - --identifier "$DNS_PROXY_BUNDLE_ID" --entitlements "$DNS_PROXY_ENTITLEMENTS" "$DNS_PROXY_BUNDLE" >/dev/null codesign --force --sign - --entitlements "$APP_ENTITLEMENTS" "$APP_BUNDLE" >/dev/null open_app() { diff --git a/script/package_notarized.sh b/script/package_notarized.sh index 08fa00d..b5852a7 100755 --- a/script/package_notarized.sh +++ b/script/package_notarized.sh @@ -8,8 +8,10 @@ NOTARY_PROFILE="${NOTARY_PROFILE:-LocaleNotary}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" APP_BUNDLE="$DIST_DIR/$APP_NAME.app" -HELPER_BINARY="$APP_BUNDLE/Contents/Library/LaunchServices/LocaleHelper" +DNS_PROXY_BUNDLE_ID="dev.offyotto.Locale.LocaleDNSProxy" +DNS_PROXY_BUNDLE="$APP_BUNDLE/Contents/Library/SystemExtensions/$DNS_PROXY_BUNDLE_ID.systemextension" APP_ENTITLEMENTS="$ROOT_DIR/Resources/Locale.entitlements" +DNS_PROXY_ENTITLEMENTS="$ROOT_DIR/Resources/LocaleDNSProxy.entitlements" SIGNED_ZIP="$DIST_DIR/$APP_NAME-signed.zip" NOTARIZED_ZIP="$DIST_DIR/$APP_NAME-notarized.zip" @@ -39,7 +41,7 @@ fi cd "$ROOT_DIR" "$ROOT_DIR/script/build_and_run.sh" --build-only -codesign --force --options runtime --timestamp --identifier "dev.offyotto.Locale.Helper" --sign "$IDENTITY" "$HELPER_BINARY" +codesign --force --options runtime --timestamp --identifier "$DNS_PROXY_BUNDLE_ID" --entitlements "$DNS_PROXY_ENTITLEMENTS" --sign "$IDENTITY" "$DNS_PROXY_BUNDLE" codesign --force --options runtime --timestamp --entitlements "$APP_ENTITLEMENTS" --sign "$IDENTITY" "$APP_BUNDLE" codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE" diff --git a/website/index.html b/website/index.html index 5c25a9b..6ff6b3b 100644 --- a/website/index.html +++ b/website/index.html @@ -4,10 +4,10 @@ Locale — macOS Network Context Manager - + - + @@ -815,7 +815,7 @@

Switch network contexts
without touching a terminal

- Named sets of /etc/hosts entries. One click to activate. Your system entries stay safe. + Named DNS mappings. One click to activate. System files stay untouched.

@@ -960,8 +958,8 @@

Everything in the right place

-
System entries preserved
-
Your original /etc/hosts is backed up. Revert restores it exactly as it was.
+
System files untouched
+
Locale applies contexts at DNS resolution time through a sandboxed Network Extension.
@@ -990,23 +988,23 @@

Everything in the right place

-
Managed block
-
Only Locale's section changes
+
DNS proxy
+
Only matching hostnames resolve locally
- Backup + App Group
-
Saved before every apply
+
Main app and extension share active mappings
-
Flush cache
-
Applies take effect quickly
+
Forwarding
+
Unmatched queries use normal DNS
Revert
-
Return to clean Home
+
Clear Locale mappings
@@ -1075,7 +1073,7 @@

Download Locale.

macOS 14 Sonoma or later
Apple Silicon or Intel
- Helper approval for /etc/hosts writes + System extension approval on first launch