Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]
)
49 changes: 17 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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

Expand Down
10 changes: 8 additions & 2 deletions Resources/Locale.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<key>com.apple.security.application-groups</key>
<array>
<string>dev.offyotto.Locale.Helper</string>
<string>group.dev.offyotto.Locale</string>
</array>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>dns-proxy</string>
</array>
<key>com.apple.developer.system-extension.install</key>
<true/>
</dict>
</plist>
16 changes: 16 additions & 0 deletions Resources/LocaleDNSProxy.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.dev.offyotto.Locale</string>
</array>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>dns-proxy</string>
</array>
</dict>
</plist>
19 changes: 0 additions & 19 deletions Resources/dev.offyotto.Locale.Helper.plist

This file was deleted.

28 changes: 13 additions & 15 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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.
183 changes: 183 additions & 0 deletions Sources/LocaleApp/DNSProxyController.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Error>) 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<Void, Error>

init(continuation: CheckedContinuation<Void, Error>) {
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<Void, Error>) in
loadFromPreferences { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}

func saveToPreferences() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
saveToPreferences { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
}
Loading
Loading