Two small files: the app that asks the OS to install the extension, and the provider that judges every network flow. All Swift, no C.
main.swift and FilterDataProvider.swift — what runs in the app, what runs in the extension, and where a connection gets allowed or dropped. The files are in courses/macos-kernel-extensions/demo/netfilter/.A content filter is two cooperating programs. The container app is a thin launcher: its whole job is to get the extension installed and switched on. The provider is the extension: a long-lived, sandboxed process the OS keeps alive and hands every new connection.
FilterDataProvider.swiftThis subclass of NEFilterDataProvider is the heart. Three overrides: start, stop, and the per-flow verdict.
import NetworkExtension import os.log final class FilterDataProvider: NEFilterDataProvider { // Called once when the filter is switched on. Install the rules. override func startFilter(completionHandler: @escaping (Error?) -> Void) { // Empty rule list + defaultAction .filterData => EVERY new flow // is routed to handleNewFlow(_:). The simplest "see everything". let settings = NEFilterSettings(rules: [], defaultAction: .filterData) apply(settings) { error in completionHandler(error) } } override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { completionHandler() } // THE verdict. Called once per new connection. Return allow/drop/… override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { if let socket = flow as? NEFilterSocketFlow { let remote = socket.remoteEndpoint.map { String(describing: $0) } ?? "?" os_log("flow → %{public}@", remote) // shows up in `log stream` } return .allow() // change to .drop() to block } }
| Line | Why it matters |
|---|---|
defaultAction: .filterData | The trick for a demo: with an empty rule array, every flow falls through to the default, and .filterData means "ask the provider" → handleNewFlow fires for all traffic. A real product ships specific NEFilterRules for efficiency. |
handleNewFlow return value | A synchronous verdict per connection: .allow(), .drop(), .needRules(), or pause/peek-data variants. This is where a firewall makes its decision. |
os_log(...) | The extension is a background process with no console — os_log to a named subsystem is how you observe it (Lesson 5's log stream). |
You never call FilterDataProvider() | The framework instantiates it, by the name in NEProviderClasses. Your code only responds. |
Change return .allow() to return .drop() and every connection dies. To block selectively, inspect socket.remoteEndpoint (host/port) or socket.remoteHostname and return .drop() only for matches. That's a content blocker in ~3 lines.
Filter/main.swiftA System Extension is just an executable. Its main hands control to the framework, which then owns the provider's lifecycle:
import NetworkExtension autoreleasepool { NEProvider.startSystemExtensionMode() } dispatchMain()
That's the whole entry point. startSystemExtensionMode() tells NetworkExtension "I'm a sysext — read my Info.plist, instantiate my provider class, and start calling it." dispatchMain() parks the thread so the process stays alive.
App/main.swiftThe app does two things in order: (a) activate the extension, then (b) configure the filter so it actually starts. Both are async with delegate/closure callbacks.
demo/netfilter/Sources/App/main.swift (abridged)import SystemExtensions; import NetworkExtension let extBundleID = "com.example.netfilterdemo.filter" final class Controller: NSObject, OSSystemExtensionRequestDelegate { func activate() { let req = OSSystemExtensionRequest .activationRequest(forExtensionWithIdentifier: extBundleID, queue: .main) req.delegate = self OSSystemExtensionManager.shared.submitRequest(req) // (a) install/activate } // The OS calls these back: func requestNeedsUserApproval(_ r: OSSystemExtensionRequest) { print("⏳ approve it in System Settings ▸ Login Items & Extensions") } func request(_ r: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { enableContentFilter() // (b) now turn the filter on } func request(_ r: OSSystemExtensionRequest, didFailWithError error: Error) { print("❌ \(error)"); exit(1) } func enableContentFilter() { let mgr = NEFilterManager.shared() mgr.loadFromPreferences { _ in if mgr.providerConfiguration == nil { let cfg = NEFilterProviderConfiguration() cfg.filterSockets = true mgr.providerConfiguration = cfg; mgr.localizedDescription = "NetFilterDemo" } mgr.isEnabled = true mgr.saveToPreferences { _ in print("✅ filtering") } } } } Controller().activate(); dispatchMain()
There are two system prompts. (a) OSSystemExtensionRequest installs the extension (approve under Network Extensions). (b) NEFilterManager.saveToPreferences turns on content filtering (a separate "…would like to filter network content" prompt). Both are required before handleNewFlow ever runs.
| Step | Process | Call |
|---|---|---|
| 1 | app | submitRequest(activationRequest) → user approves |
| 2 | app | NEFilterManager … isEnabled = true; saveToPreferences → user approves |
| 3 | OS | launches the extension; calls NEProvider.startSystemExtensionMode()'s machinery → your startFilter |
| 4 | extension | every new connection → handleNewFlow → .allow() / .drop() |
handleNewFlow entirely, what happens to traffic?With defaultAction: .filterData the framework still routes flows to the provider, but the base class's default verdict applies — you'd lose your logging/decision point. The point of a data provider is to override handleNewFlow; without it there's no filter logic.
No. Only an app calling OSSystemExtensionRequest (with the system-extension.install entitlement) can ask the OS to activate an extension, and only the user can approve it. The extension can't self-install.
App/main.swift → the container app process. Filter/main.swift + FilterDataProvider.swift → the separate, sandboxed extension process the OS manages.