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.