aequitas/macos-menubar-wireguard

View on GitHub
WireGuardStatusbarHelper/Helper.swift

Summary

Maintainability
A
35 mins
Test Coverage
// Helper main logic

import Foundation

// amount of ms to debounce filesystem events to prevent sending update notifications to the App to often
let fseventDebounce = 100

class Helper: NSObject, HelperProtocol, SKQueueDelegate {
    private var app: AppXPC?

    private var queue: SKQueue?

    // Prefix path for etc/wireguard, bin/wg, bin/wireguard-go and bin/bash (bash 4),
    // can be overridden by the user via root defaults to allow custom location for Homebrew.
    private var brewPrefix: String
    // Path to wg-quick, can be overriden by the user via root defaults.
    // NOTICE: the root defaults override feature is a half implemented feature
    // the GUI App will not be aware of these settings and might falsely warn that WireGuard
    // is not installed. This warning can be ignored.
    // Example, to set defaults as root for wgquickBinPath run:
    // sudo defaults write WireGuardStatusbarHelper wgquickBinPath /opt/local/bin/wg-quick
    private var wgquickBinPath: String
    // Path use to determine if WireGuard Homebrew package is installed and query wg for tunnel names and configuration
    // To check wg binary is enough to also guarentee wg-quick and wireguard-go when installed with Homebrew.
    private var wireguardBinPath: String

    let defaults = UserDefaults.standard

    let wireguard: WireGuard

    // Read preferences set via root defaults.
    override init() {
        defaults.register(defaults: DefaultSettings.Helper)

        brewPrefix = defaults.string(forKey: "brewPrefix")!
        if brewPrefix != DefaultSettings.Helper["brewPrefix"] {
            NSLog("Overriding 'brewPrefix' with: \(brewPrefix)")
        }
        wireguardBinPath = "\(brewPrefix)/bin/wg"

        wgquickBinPath = "\(brewPrefix)/bin/wg-quick"
        if let wgquickBinPath = defaults.string(forKey: "wgquickBinPath"), wgquickBinPath != "" {
            NSLog("Overriding 'wgquickBinPath' with: \(wgquickBinPath)")
            self.wgquickBinPath = wgquickBinPath
        }

        wireguard = WireGuard(
            brewPrefix: brewPrefix,
            wireguardBinPath: wireguardBinPath,
            wgquickBinPath: wgquickBinPath,
            configPaths: configPaths,
            runPath: runPath
        )
    }

    // Starts the helper daemon
    func run() {
        // create XPC to App
        app = AppXPC(exportedObject: self, onConnect: abortShutdown, onClose: shutdown)

        // watch configuration and runstate directories for changes to notify App
        registerWireGuardStateWatch()

        // keep running (last XPC connection closing quits)
        // TODO: Helper needs to live for at least 10 seconds or launchd will get unhappy
        CFRunLoopRun()
    }

    func registerWireGuardStateWatch() {
        // register watchers to respond to changes in wireguard config/runtime state
        // will trigger: receivedNotification
        if queue == nil {
            queue = SKQueue(delegate: self)!
        }
        for directory in configPaths + [runPath] {
            // skip already watched paths
            if queue!.isPathWatched(directory) { continue }

            if FileManager.default.fileExists(atPath: directory) {
                NSLog("Watching \(directory) for changes")
                queue!.addPath(directory)
            } else {
                NSLog("Not watching '\(directory)' as it does not exist")
            }
        }
    }

    var debounceFilesystemEvents: DispatchWorkItem?

    // SKQueue: handle incoming file/directory change events
    func receivedNotification(_ notification: SKQueueNotification, path: String, queue _: SKQueue) {
        if configPaths.contains(path) {
            NSLog("Configuration files changed, reloading")
        }
        if path == runPath {
            NSLog("Tunnel state changed, reloading")
        }
        // TODO: only send events on actual changes (/var/run/tunnel.name, /etc/wireguard/tunnel.conf)
        // not for every change in either run or config directories

        // prevent sending notifications about changes to config/runtime state to fast after another
        if debounceFilesystemEvents == nil {
            debounceFilesystemEvents = DispatchWorkItem {
                self.debounceFilesystemEvents = nil
                self.appUpdateState()
            }
            let deadline = DispatchTime.now() + DispatchTimeInterval.milliseconds(fseventDebounce)
            DispatchQueue.main.asyncAfter(deadline: deadline,
                                          execute: debounceFilesystemEvents!)
        }
    }

    // Send a signal to the App that tunnel state/configuration might have changed
    func appUpdateState() {
        for connection in app!.connections {
            if let remoteObject = connection.remoteObjectProxy as? AppProtocol {
                remoteObject.updateState()
            } else {
                NSLog("Failed to notify App of configuration/state changes.")
            }
        }
    }

    // XPC: return raw data to be used by App to construct tunnel configuration/state
    func getTunnels(reply: @escaping (TunnelInfo) -> Void) {
        reply(Dictionary(uniqueKeysWithValues: wireguard.tunnelNames().map { tunnelName in
            (tunnelName, [wireguard.interfaceName(tunnelName), wireguard.tunnelConfig(tunnelName)])
        }))
    }

    // XPC: called by App to have Helper change the state of a tunnel to up or down
    func setTunnel(tunnelName: String, enable: Bool, reply:
        @escaping (_ success: Bool, _ errorMessage: String) -> Void) {
        let state = enable ? "up" : "down"

        if !WireGuard.validateTunnelName(tunnelName: tunnelName) {
            NSLog("Invalid tunnel name '\(tunnelName)'")
            reply(false, "Invalid tunnel name '\(tunnelName)'")
            return
        }

        NSLog("Set tunnel \(tunnelName) \(state)")
        let (success, errorMessage) = wireguard.wgQuick([state, tunnelName])
        reply(success, errorMessage)

        // Because /var/run/wireguard might not exist and can be created after upping the first tunnel
        // run the registration of watchdirectories again and force trigger a state update to the app.
        // This is 'cheaper' than registering a watcher for the parent directory /var/run/.
        registerWireGuardStateWatch()

        // Notify the app to have it pull in changes.
        appUpdateState()
    }

    // XPC: allow App to query version of helper to allow updating when a new version is available
    func getVersion(_ reply: (String) -> Void) {
        if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String {
            reply(version)
        } else {
            NSLog("Unable to get version information")
            reply("n/a")
        }
    }

    func wireguardInstalled(_ reply: (Bool) -> Void) {
        let wireguardInstalled = FileManager.default.fileExists(atPath: wireguardBinPath)
        let wgquickInstalled = FileManager.default.fileExists(atPath: wgquickBinPath)
        reply(wgquickInstalled && wireguardInstalled)
    }

    // Launchd throttles services that restart to soon (<10 seconds), provide a mechanism to prevent this.
    // set the time in the future when it is safe to shutdown the helper without launchd penalty
    let launchdMinimaltimeExpired = DispatchTime.now() + DispatchTimeInterval.seconds(10)
    var shutdownTask: DispatchWorkItem?

    func shutdown() {
        NSLog("Going to shut down")
        // Dispatch the shutdown of the runloop to at least 10 seconds after starting the application.
        // This will shutdown immidiately if the deadline already passed.
        shutdownTask = DispatchWorkItem {
            CFRunLoopStop(CFRunLoopGetCurrent())
            NSLog("Shutting down")
        }
        // Dispatch to main queue since that is the thread where the runloop is
        DispatchQueue.main.asyncAfter(deadline: launchdMinimaltimeExpired, execute: shutdownTask!)
    }

    // allow shutdown to be aborted (eg: when a new XPC connection comes in)
    func abortShutdown() {
        if let shutdownTask = self.shutdownTask {
            NSLog("Aborting shutdown")
            shutdownTask.cancel()
            self.shutdownTask = nil
        }
    }
}