Add CampusNAS menu bar agent

Native macOS agent that keeps the office VPN and the NAS shares working
without the shell wrapper it replaces.

- VPN: full port of the `ovpn` script — host-route pinning, gateway
  discovery via networksetup, tunnel liveness by address plus pushed
  route, startup grace, restart of a wedged daemon. Root steps are
  batched into one system authorization prompt.
- SMB: mounts via NetFS, discovers shares from the server, detects
  mounts event-driven through NSWorkspace, matches the host by resolved
  alias so externally created mounts are recognised.
- Credentials never pass through the process: the VPN auth file is
  written by `security` straight to disk, and the password is stored via
  SecItemAdd rather than a command line visible in ps.
- UI: status window, settings editor, ⌘+/⌘− zoom that re-lays out rather
  than scaling a bitmap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:19:31 +06:00
co-authored by Claude Opus 5
commit cd53b6a1a1
20 changed files with 2897 additions and 0 deletions
+290
View File
@@ -0,0 +1,290 @@
import AppKit
import Combine
import ServiceManagement
import SwiftUI
import UserNotifications
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private var statusItem: NSStatusItem!
private var model: AppModel!
private var window: NSWindow?
private var hosting: NSHostingController<ContentView>?
private var settingsWindow: NSWindow?
private var keyMonitor: Any?
private var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
let config = Config.load()
config.writeIfAbsent()
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.menu = NSMenu()
statusItem.isVisible = true
statusItem.behavior = []
// Survives across launches so macOS restores the same slot instead of
// treating every rebuild as a brand new item.
statusItem.autosaveName = "CampusNASStatusItem"
requestNotificationAuthorization()
model = AppModel(config: config)
model.onNotableChange = { [weak self] state in
self?.postNotification(state.notificationBody)
}
// The menu bar item mirrors the model, same as the window does.
model.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.render() }
.store(in: &cancellables)
installKeyMonitor()
model.start()
render()
if let button = statusItem.button {
Log.write("status item: visible=\(statusItem.isVisible) "
+ "width=\(button.frame.width) hasImage=\(button.image != nil) "
+ "screens=\(NSScreen.screens.count)")
} else {
Log.write("status item has no button — menu bar is full")
}
}
/// Launching the app again while it is already running (Finder, Spotlight,
/// `open`) brings the window up instead of doing nothing, which is what a
/// second launch is normally asking for.
func applicationShouldHandleReopen(
_ sender: NSApplication, hasVisibleWindows: Bool
) -> Bool {
showWindow()
return true
}
// MARK: - Status item
private func render() {
let state = model.state
if let button = statusItem.button {
let image = NSImage(
systemSymbolName: state.symbolName, accessibilityDescription: state.title)
image?.isTemplate = true
button.image = image
// Without this fallback an unresolved SF Symbol leaves the item with
// no image and no title, i.e. zero width — present but invisible and
// unclickable, which is indistinguishable from the app not running.
if image == nil {
Log.write("SF Symbol missing: \(state.symbolName)")
}
let label = image == nil ? (state.badge.isEmpty ? "NAS" : state.badge)
: state.badge
button.imagePosition = label.isEmpty ? .imageOnly : .imageLeading
button.title = label.isEmpty ? "" : " " + label
button.toolTip = state.title
}
let menu = NSMenu()
let status = NSMenuItem(title: state.title, action: nil, keyEquivalent: "")
status.isEnabled = false
menu.addItem(status)
let mountedCount = model.mounted.count
let summary = NSMenuItem(
title: "Смонтировано шар: \(mountedCount) из \(model.shares.count)",
action: nil, keyEquivalent: "")
summary.isEnabled = false
menu.addItem(summary)
menu.addItem(.separator())
menu.addItem(action("Показать окно", #selector(showWindow), key: "o"))
menu.addItem(action("Проверить сейчас", #selector(checkNow), key: "r"))
menu.addItem(.separator())
menu.addItem(action("Крупнее (⌘+)", #selector(zoomIn)))
menu.addItem(action("Мельче (⌘−)", #selector(zoomOut)))
menu.addItem(action("Обычный размер (⌘0)", #selector(zoomReset)))
menu.addItem(.separator())
let launchItem = NSMenuItem(
title: "Запускать при входе", action: #selector(toggleLoginItem), keyEquivalent: "")
launchItem.state = (SMAppService.mainApp.status == .enabled) ? .on : .off
launchItem.target = self
menu.addItem(launchItem)
menu.addItem(action("Настройки…", #selector(showSettings), key: ","))
menu.addItem(action("Открыть конфиг", #selector(openConfig)))
menu.addItem(.separator())
menu.addItem(NSMenuItem(
title: "Выйти", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem.menu = menu
}
private func action(_ title: String, _ selector: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: selector, keyEquivalent: key)
item.target = self
return item
}
// MARK: - Window
@objc private func showWindow() {
if window == nil {
let hosting = NSHostingController(
rootView: ContentView(model: model) { [weak self] in self?.showSettings() })
self.hosting = hosting
let win = NSWindow(contentViewController: hosting)
win.title = "CampusNAS"
win.styleMask = [.titled, .closable, .miniaturizable, .resizable]
// The delegate is an accessory app, so the window is closed and
// reopened rather than destroyed — keep the instance alive.
win.isReleasedWhenClosed = false
win.delegate = self
win.center()
window = win
}
// An .accessory app is not activated by default; without this the
// window opens behind whatever is in front.
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
model.checkNow()
}
/// Rebuilt on each open so the editor starts from the current config rather
/// than whatever was on screen last time.
@objc private func showSettings() {
settingsWindow?.close()
let hosting = NSHostingController(
rootView: SettingsView(model: model) { [weak self] in
self?.settingsWindow?.close()
})
let win = NSWindow(contentViewController: hosting)
win.title = "Настройки CampusNAS"
win.styleMask = [.titled, .closable, .resizable]
win.isReleasedWhenClosed = false
win.center()
settingsWindow = win
NSApp.activate(ignoringOtherApps: true)
win.makeKeyAndOrderFront(nil)
}
// MARK: - Zoom
/// An .accessory app has no menu bar, so ⌘+ / ⌘− / ⌘0 cannot come from menu
/// items — they are caught here while our window is key.
private func installKeyMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
event.modifierFlags.contains(.command),
self.window?.isKeyWindow == true
else { return event }
switch event.charactersIgnoringModifiers {
case "+", "=":
self.model.zoomIn()
self.fitWindowToContent()
return nil
case "-":
self.model.zoomOut()
self.fitWindowToContent()
return nil
case "0":
self.model.zoomReset()
self.fitWindowToContent()
return nil
default:
return event
}
}
}
/// Resizes after SwiftUI has re-laid-out at the new type size — reading
/// fittingSize in the same turn returns the previous layout.
private func fitWindowToContent() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let window = self?.window, let hosting = self?.hosting else { return }
let size = hosting.view.fittingSize
guard size.width > 0, size.height > 0 else { return }
window.setContentSize(size)
}
}
@objc private func zoomIn() { model.zoomIn(); fitWindowToContent() }
@objc private func zoomOut() { model.zoomOut(); fitWindowToContent() }
@objc private func zoomReset() { model.zoomReset(); fitWindowToContent() }
// MARK: - Actions
@objc private func checkNow() {
model.checkNow()
}
@objc private func openConfig() {
model.openConfig()
}
@objc private func toggleLoginItem() {
do {
if SMAppService.mainApp.status == .enabled {
try SMAppService.mainApp.unregister()
} else {
try SMAppService.mainApp.register()
}
} catch {
Log.write("login item toggle failed: \(error)")
}
render()
}
// MARK: - Notifications
private func requestNotificationAuthorization() {
guard let bundleID = Bundle.main.bundleIdentifier else {
Log.write("no bundle identifier, notifications disabled")
return
}
Log.write("bundle \(bundleID) at \(Bundle.main.bundlePath)")
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error as NSError? {
Log.write("notification auth error: domain=\(error.domain) "
+ "code=\(error.code) \(error.localizedDescription)")
} else if !granted {
Log.write("notifications denied")
} else {
Log.write("notifications authorized")
}
}
}
/// Posts unconditionally rather than gating on the authorization result:
/// gating meant a missed permission prompt silenced the app forever. Under
/// ad-hoc signing this never actually delivers — the menu bar badge is the
/// real indicator — but the code costs nothing and starts working if the
/// app is ever signed with a Developer ID.
private func postNotification(_ body: String) {
guard Bundle.main.bundleIdentifier != nil, !body.isEmpty else { return }
let content = UNMutableNotificationContent()
content.title = "campusnas"
content.body = body
content.sound = .default
let request = UNNotificationRequest(
identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { error in
if let error = error as NSError? {
Log.write("notification post failed: code=\(error.code)")
}
}
}
}
+369
View File
@@ -0,0 +1,369 @@
import AppKit
import Combine
import Foundation
import SwiftUI
/// Single source of truth for both the menu bar item and the window, so the two
/// can never disagree about what is mounted or whether the tunnel is up.
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var state: LinkState = .unknown
/// share name -> mount point
@Published private(set) var mounted: [String: String] = [:]
@Published private(set) var busy = false
@Published private(set) var lastError: String?
/// Shares reported by the server itself. Empty until the first successful
/// enumeration.
@Published private(set) var discovered: [String] = []
/// Missing prerequisites — openvpn binary, profile, credentials.
@Published private(set) var requirements: [VPNService.Requirement] = []
/// Result of the last successful VPN action, for the window.
@Published private(set) var vpnMessage: String?
/// Zoom steps for ⌘+ / ⌘−, as an explicit multiplier for every font size and
/// padding.
///
/// Not `dynamicTypeSize`: that is an iOS mechanism and AppKit ignores it for
/// system fonts — measured, the window came out identical at every step.
/// Not `scaleEffect` either: it stretches an already rasterised layer and
/// blurs the text. Multiplying the metrics re-lays-out for real and stays
/// sharp.
static let zoomSteps: [CGFloat] = [0.8, 0.9, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]
/// 1.15×, one step above system size — the default the app is actually
/// comfortable at. ⌘0 returns here, not to 1.0×.
private static let defaultZoomIndex = 3
private static let zoomKey = "zoomIndex"
@Published var zoomIndex: Int = {
let stored = UserDefaults.standard.object(forKey: AppModel.zoomKey) as? Int
return stored ?? AppModel.defaultZoomIndex
}() {
didSet { UserDefaults.standard.set(zoomIndex, forKey: Self.zoomKey) }
}
var zoomScale: CGFloat { Self.zoomSteps[min(max(zoomIndex, 0), Self.zoomSteps.count - 1)] }
func zoomIn() { zoomIndex = min(zoomIndex + 1, Self.zoomSteps.count - 1) }
func zoomOut() { zoomIndex = max(zoomIndex - 1, 0) }
func zoomReset() { zoomIndex = Self.defaultZoomIndex }
private(set) var config: Config
private var timer: Timer?
private var checking = false
/// Tracked separately from `state` so "just became reachable" can be told
/// apart from "still reachable" when deciding to auto-mount.
private var previousState: LinkState = .unknown
/// Enumeration needs credentials, so it fails until the user has
/// authenticated once. Without this flag an empty result would make every
/// poll spawn another smbutil — a failing probe every 30 s forever.
private var discoveryTried = false
/// Fires on a transition worth announcing. The first probe only counts if it
/// found a problem: being told at login that everything is fine is noise.
var onNotableChange: ((LinkState) -> Void)?
init(config: Config) {
self.config = config
}
/// What the UI lists. Discovery is the default so the app cannot silently
/// disagree with the server about which shares exist — the hand-written
/// list was the reason it showed four of eleven.
var shares: [String] {
let base = config.autoDiscoverShares && !discovered.isEmpty
? discovered
: config.shares
return base.filter { !config.excludeShares.contains($0) }
}
var host: String { config.nasHost }
func start() {
observeVolumeChanges()
refreshRequirements()
timer?.invalidate()
timer = Timer.scheduledTimer(
withTimeInterval: config.pollIntervalSeconds, repeats: true
) { [weak self] _ in
Task { @MainActor in self?.checkNow() }
}
checkNow()
}
/// Mounts happen behind this app's back — opening a share in Finder's server
/// browser mounts it on the spot. macOS announces those, so react to the
/// event instead of waiting out the poll interval.
private func observeVolumeChanges() {
let center = NSWorkspace.shared.notificationCenter
for name in [NSWorkspace.didMountNotification, NSWorkspace.didUnmountNotification] {
center.addObserver(
forName: name, object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.refreshMountedNow() }
}
}
}
/// Cheap: reads the kernel mount table, touches no network.
func refreshMountedNow() {
let live = Mounter.mountedShares(host: config.nasHost)
guard live != mounted else { return }
mounted = live
Log.write("mount table changed: \(live.keys.sorted().joined(separator: ", "))")
}
// MARK: - Probing
func checkNow() {
guard !checking else { return }
checking = true
Task { @MainActor in
let next = await probe()
self.mounted = Mounter.mountedShares(host: config.nasHost)
let wasUnknown = self.previousState == .unknown
let changed = next != self.previousState
let notable = changed && !(wasUnknown && next == .ok)
let becameReachable = next == .ok && self.previousState != .ok
if changed { Log.write("state: \(self.previousState.rawValue) -> \(next.rawValue)") }
self.previousState = next
self.state = next
self.checking = false
if notable { self.onNotableChange?(next) }
// Enumerate once per reconnect, not every poll — spawning smbutil
// on a 30 s timer to re-learn a list that rarely changes is waste.
if next == .ok && (becameReachable || !self.discoveryTried) {
await self.refreshShares()
}
if becameReachable && config.autoMountOnReconnect {
self.mount(config.autoMountShares, allowUI: false)
}
}
}
/// Explicit refresh always re-enumerates, unlike the polling path.
func refreshAll() {
Task { @MainActor in
await refreshShares()
checkNow()
}
}
func refreshShares() async {
guard config.autoDiscoverShares else { return }
discoveryTried = true
let found = await Mounter.listShares(
host: config.nasHost,
user: config.smbUser,
timeout: max(config.probeTimeoutSeconds, 8))
guard !found.isEmpty else { return }
discovered = found
Log.write("discovered \(found.count) shares: \(found.joined(separator: ", "))")
}
private func probe() async -> LinkState {
let vpnUp = await VPNService.isUp(config.vpn)
guard vpnUp else { return .vpnDown }
let nasUp = await Probe.tcpReachable(
host: config.nasHost,
port: config.nasPort,
timeout: config.probeTimeoutSeconds)
return nasUp ? .ok : .nasDown
}
// MARK: - Mounting
func isMounted(_ share: String) -> Bool { mounted[share] != nil }
func mountPoint(_ share: String) -> String? { mounted[share] }
/// `allowUI: false` is the silent path — it succeeds only if the Keychain
/// already holds the credentials. Automatic mounts always use it, so
/// reconnecting never throws an unexpected password sheet at the user.
func mount(_ shares: [String], allowUI: Bool) {
// Re-read the mount table instead of trusting the polled snapshot: it
// can be up to a poll interval stale, and Finder mounts shares behind
// our back.
let live = Mounter.mountedShares(host: config.nasHost)
mounted = live
let pending = shares.filter { live[$0] == nil }
guard !pending.isEmpty, !busy else { return }
busy = true
lastError = nil
let host = config.nasHost
let users = pending.map { config.user(for: $0) }
Task.detached(priority: .userInitiated) {
var results: [Mounter.Result] = []
for (share, user) in zip(pending, users) {
results.append(
Mounter.mount(host: host, user: user, share: share, allowUI: allowUI))
}
let collected = results
await MainActor.run { [weak self] in
self?.finish(collected)
}
}
}
private func finish(_ results: [Mounter.Result]) {
busy = false
let failed = results.filter { !$0.ok }
lastError = failed.isEmpty ? nil : failed.map(\.reason).joined(separator: "; ")
if let lastError { Log.write("mount failures: \(lastError)") }
// A successful interactive mount is what puts the credentials in the
// Keychain, so this is the moment enumeration can start working.
if results.contains(where: { $0.ok }) && discovered.isEmpty {
Task { @MainActor in await refreshShares() }
}
checkNow()
}
func mountMissing(allowUI: Bool) {
mount(shares, allowUI: allowUI)
}
func unmount(_ shares: [String]) {
let points = shares.compactMap { mounted[$0] }
guard !points.isEmpty, !busy else { return }
busy = true
Task.detached(priority: .userInitiated) {
for point in points {
_ = Mounter.unmount(mountPoint: point)
}
await MainActor.run { [weak self] in
self?.busy = false
self?.checkNow()
}
}
}
func toggle(_ share: String) {
if isMounted(share) {
unmount([share])
} else {
// Explicit click: allow the system auth sheet, since this is where
// the Keychain entry gets created.
mount([share], allowUI: true)
}
}
func openInFinder(_ share: String) {
if let point = mounted[share] {
NSWorkspace.shared.open(URL(fileURLWithPath: point))
} else {
mount([share], allowUI: true)
}
}
// MARK: - VPN
func connectVPN() { runVPN { await VPNService.connect(self.config.vpn) } }
func disconnectVPN() { runVPN { await VPNService.disconnect(self.config.vpn) } }
func rerouteVPN() { runVPN { await VPNService.reroute(self.config.vpn) } }
/// Serialised through `busy` so two privileged batches cannot race each
/// other into a half-connected state.
private func runVPN(_ action: @escaping @Sendable () async -> VPNService.ActionResult) {
guard !busy else { return }
busy = true
lastError = nil
Task { @MainActor in
let result = await action()
self.busy = false
self.lastError = result.isError ? result.message : nil
self.vpnMessage = result.isError ? nil : result.message
Log.write("vpn: \(result.message)")
self.checkNow()
}
}
/// Opens Terminal with a suggested fix (e.g. `brew install openvpn`) rather
/// than running it silently: an install command should be visible to the
/// person who asked for it, and its own output is worth seeing.
func runFixInTerminal(_ command: String) {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = """
tell application "Terminal"
activate
do script "\(escaped)"
end tell
"""
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
do {
try process.run()
} catch {
Log.write("failed to launch Terminal: \(error)")
}
}
func openVPNLog() {
NSWorkspace.shared.open(URL(fileURLWithPath: config.vpn.logPath))
}
// MARK: - Settings
/// Persists the edited config and re-reads everything that depends on it.
func updateVPN(_ vpn: VPNConfig) {
config.vpn = vpn
config.write()
Log.write("vpn config updated: profile=\(vpn.configPath) "
+ "server=\(vpn.serverHost) keychain=\(vpn.keychainService)")
refreshRequirements()
checkNow()
}
func updateSMBUser(_ user: String) {
let trimmed = user.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty, trimmed != config.smbUser else { return }
config.smbUser = trimmed
config.write()
checkNow()
}
/// Stores the VPN password. The value is handed straight to the Keychain and
/// kept nowhere else — not in the config file, not in the log.
func saveVPNPassword(_ password: String) -> String? {
guard !password.isEmpty else { return "Пароль пустой" }
let error = KeychainStore.save(
service: config.vpn.keychainService,
account: config.vpn.keychainAccount,
password: password)
Log.write(error == nil
? "keychain: entry \(config.vpn.keychainService) saved"
: "keychain: save failed — \(error!)")
refreshRequirements()
return error
}
func vpnPasswordStored() -> Bool {
KeychainStore.exists(
service: config.vpn.keychainService,
account: config.vpn.keychainAccount)
}
func refreshRequirements() {
Task { @MainActor in
requirements = await VPNService.requirements(config.vpn)
}
}
func openConfig() {
config.writeIfAbsent()
NSWorkspace.shared.open(Config.configURL)
}
}
+225
View File
@@ -0,0 +1,225 @@
import Foundation
/// Everything the old shell wrapper kept in its `profile_config()` case block.
struct VPNConfig: Codable {
/// Profile name — also the basename of the .ovpn/.pid/.log/.auth files.
var profile: String
var label: String
/// Public IP of the VPN server. Pinned to the LAN gateway during connect so
/// the handshake does not route into the tunnel it is building.
var serverHost: String
var openvpnPath: String
var baseDir: String
/// Explicit path to the .ovpn profile. Empty falls back to
/// `baseDir/profile.ovpn`, which is where the shell wrapper kept it.
var configPathOverride: String
/// Address family pulled inside the tunnel, e.g. "10.8.0".
var tunNetwork: String
/// Pushed office LAN, e.g. "192.168.77". Empty disables the route check —
/// both profiles share 10.8.0.0/24, so without a distinct LAN there is no
/// honest way to tell which tunnel is up.
var lanNetwork: String
var keychainService: String
var keychainAccount: String
/// How long openvpn may sit without a tunnel before connect treats it as
/// wedged rather than still handshaking. A normal connect takes ~2 s.
var startupGraceSeconds: Int
var configPath: String {
configPathOverride.isEmpty ? "\(baseDir)/\(profile).ovpn" : configPathOverride
}
var pidPath: String { "\(baseDir)/\(profile).pid" }
var logPath: String { "\(baseDir)/\(profile).log" }
var authPath: String { "\(baseDir)/\(profile).auth" }
/// Tolerant like `Config`'s: a file written by an older build is missing
/// keys, and losing every other setting over one absent field would be a
/// poor trade.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = VPNConfig.office
profile = try c.decodeIfPresent(String.self, forKey: .profile) ?? d.profile
label = try c.decodeIfPresent(String.self, forKey: .label) ?? d.label
serverHost = try c.decodeIfPresent(String.self, forKey: .serverHost) ?? d.serverHost
openvpnPath = try c.decodeIfPresent(String.self, forKey: .openvpnPath) ?? d.openvpnPath
baseDir = try c.decodeIfPresent(String.self, forKey: .baseDir) ?? d.baseDir
configPathOverride = try c.decodeIfPresent(
String.self, forKey: .configPathOverride) ?? d.configPathOverride
tunNetwork = try c.decodeIfPresent(String.self, forKey: .tunNetwork) ?? d.tunNetwork
lanNetwork = try c.decodeIfPresent(String.self, forKey: .lanNetwork) ?? d.lanNetwork
keychainService = try c.decodeIfPresent(
String.self, forKey: .keychainService) ?? d.keychainService
keychainAccount = try c.decodeIfPresent(
String.self, forKey: .keychainAccount) ?? d.keychainAccount
startupGraceSeconds = try c.decodeIfPresent(
Int.self, forKey: .startupGraceSeconds) ?? d.startupGraceSeconds
}
init(
profile: String, label: String, serverHost: String, openvpnPath: String,
baseDir: String, configPathOverride: String, tunNetwork: String,
lanNetwork: String, keychainService: String, keychainAccount: String,
startupGraceSeconds: Int
) {
self.profile = profile
self.label = label
self.serverHost = serverHost
self.openvpnPath = openvpnPath
self.baseDir = baseDir
self.configPathOverride = configPathOverride
self.tunNetwork = tunNetwork
self.lanNetwork = lanNetwork
self.keychainService = keychainService
self.keychainAccount = keychainAccount
self.startupGraceSeconds = startupGraceSeconds
}
static let office = VPNConfig(
profile: "office",
label: "Office",
serverHost: "92.255.183.64",
openvpnPath: "/opt/homebrew/sbin/openvpn",
baseDir: NSHomeDirectory() + "/.config/openvpn-cli",
configPathOverride: "",
tunNetwork: "10.8.0",
lanNetwork: "192.168.77",
keychainService: "ovpn-office",
keychainAccount: NSUserName(),
startupGraceSeconds: 60
)
}
/// User-editable settings, read from ~/.config/campusnas-monitor/config.json.
/// Decoding is deliberately tolerant: every field falls back to its default, so
/// an older or hand-trimmed file keeps working instead of silently reverting
/// everything to defaults.
struct Config: Codable {
var nasHost: String
var nasPort: UInt16
/// SMB account used to build the mount URL. The password is never stored
/// here — it lives in the login Keychain, put there by the system auth sheet.
var smbUser: String
/// Ask the server what it exports instead of trusting a hand-written list.
/// With this off, `shares` is used verbatim.
var autoDiscoverShares: Bool
/// Manual share list — used only when `autoDiscoverShares` is false.
var shares: [String]
/// Never shown, discovered or not.
var excludeShares: [String]
/// Mounted automatically when the NAS becomes reachable. Deliberately
/// separate from the displayed list: showing every share the server has is
/// useful, silently mounting ten volumes is not.
var autoMountShares: [String]
/// Per-share account override. Some shares are restricted to a different
/// account in the NAS's Samba config (`valid users`), and mounting those
/// with the default user fails as ENOENT rather than a permission error.
/// Example: {"git-storage": "campus"}.
var shareUsers: [String: String]
/// Share opened by "Открыть шару в Finder".
var primaryShare: String
/// Mount every share automatically when the NAS becomes reachable again.
/// Only ever attempted silently — never pops an auth sheet on its own.
var autoMountOnReconnect: Bool
/// VPN is driven natively now; the `ovpn` shell wrapper is no longer called.
var vpn: VPNConfig
var pollIntervalSeconds: Double
var probeTimeoutSeconds: Double
static let `default` = Config(
nasHost: "CAMPUSNAS",
nasPort: 445,
smbUser: NSUserName(),
autoDiscoverShares: true,
shares: ["campus", "develop", "backup", "downloads"],
excludeShares: [],
autoMountShares: ["campus", "develop", "backup", "downloads"],
shareUsers: [:],
primaryShare: "campus",
autoMountOnReconnect: true,
vpn: .office,
pollIntervalSeconds: 30,
probeTimeoutSeconds: 4
)
init(
nasHost: String, nasPort: UInt16, smbUser: String,
autoDiscoverShares: Bool, shares: [String], excludeShares: [String],
autoMountShares: [String],
shareUsers: [String: String], primaryShare: String,
autoMountOnReconnect: Bool, vpn: VPNConfig,
pollIntervalSeconds: Double, probeTimeoutSeconds: Double
) {
self.vpn = vpn
self.nasHost = nasHost
self.nasPort = nasPort
self.smbUser = smbUser
self.autoDiscoverShares = autoDiscoverShares
self.shares = shares
self.excludeShares = excludeShares
self.autoMountShares = autoMountShares
self.shareUsers = shareUsers
self.primaryShare = primaryShare
self.autoMountOnReconnect = autoMountOnReconnect
self.pollIntervalSeconds = pollIntervalSeconds
self.probeTimeoutSeconds = probeTimeoutSeconds
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = Config.default
nasHost = try c.decodeIfPresent(String.self, forKey: .nasHost) ?? d.nasHost
nasPort = try c.decodeIfPresent(UInt16.self, forKey: .nasPort) ?? d.nasPort
smbUser = try c.decodeIfPresent(String.self, forKey: .smbUser) ?? d.smbUser
autoDiscoverShares = try c.decodeIfPresent(
Bool.self, forKey: .autoDiscoverShares) ?? d.autoDiscoverShares
shares = try c.decodeIfPresent([String].self, forKey: .shares) ?? d.shares
excludeShares = try c.decodeIfPresent([String].self, forKey: .excludeShares)
?? d.excludeShares
autoMountShares = try c.decodeIfPresent([String].self, forKey: .autoMountShares)
?? d.autoMountShares
shareUsers = try c.decodeIfPresent([String: String].self, forKey: .shareUsers)
?? d.shareUsers
primaryShare = try c.decodeIfPresent(String.self, forKey: .primaryShare)
?? d.primaryShare
autoMountOnReconnect = try c.decodeIfPresent(
Bool.self, forKey: .autoMountOnReconnect) ?? d.autoMountOnReconnect
vpn = try c.decodeIfPresent(VPNConfig.self, forKey: .vpn) ?? d.vpn
pollIntervalSeconds = try c.decodeIfPresent(
Double.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds
probeTimeoutSeconds = try c.decodeIfPresent(
Double.self, forKey: .probeTimeoutSeconds) ?? d.probeTimeoutSeconds
}
func user(for share: String) -> String {
shareUsers[share] ?? smbUser
}
static var configURL: URL {
URL(fileURLWithPath: NSHomeDirectory())
.appendingPathComponent(".config/campusnas-monitor/config.json")
}
static func load() -> Config {
guard let data = try? Data(contentsOf: configURL) else { return .default }
guard let parsed = try? JSONDecoder().decode(Config.self, from: data) else {
NSLog("campusnas-monitor: config.json is malformed, using defaults")
return .default
}
return parsed
}
/// Writes the current values out so the file is there to edit.
func write() {
let url = Config.configURL
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try? encoder.encode(self).write(to: url)
}
func writeIfAbsent() {
guard !FileManager.default.fileExists(atPath: Config.configURL.path) else { return }
write()
}
}
+237
View File
@@ -0,0 +1,237 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: AppModel
/// Injected rather than owned: window management belongs to the delegate.
var onOpenSettings: () -> Void = {}
/// Zoom multiplier. Every size below is expressed in terms of it, which is
/// what makes ⌘+ / ⌘− an actual re-layout instead of a stretched bitmap.
private var s: CGFloat { model.zoomScale }
private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size * s, weight: weight)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
if !missingRequirements.isEmpty {
requirementsSection
Divider()
}
vpnSection
Divider()
sharesSection
Divider()
footer
}
// Width is a floor, not a fixture: larger zoom steps need more room,
// and the window is resizable.
.frame(minWidth: 430 * s)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 10 * s) {
Image(systemName: model.state.symbolName)
.font(.system(size: 22 * s))
.foregroundStyle(tint)
.frame(width: 28 * s)
VStack(alignment: .leading, spacing: 2 * s) {
Text(model.state.title)
.font(font(13, .semibold))
Text(model.host)
.font(font(11))
.foregroundStyle(.secondary)
}
Spacer()
if model.busy {
ProgressView().controlSize(.small)
}
}
.padding(12 * s)
}
private var tint: Color {
switch model.state {
case .ok: return .green
case .nasDown: return .orange
case .vpnDown: return .red
case .unknown: return .secondary
}
}
// MARK: - Requirements
/// Only unmet requirements are listed. A permanent green checklist of things
/// that are fine is noise; a missing dependency is not.
private var missingRequirements: [VPNService.Requirement] {
model.requirements.filter { !$0.satisfied }
}
private var requirementsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Не хватает для запуска VPN")
.font(font(12, .medium))
.foregroundStyle(.orange)
ForEach(missingRequirements) { requirement in
HStack(alignment: .firstTextBaseline, spacing: 8 * s) {
VStack(alignment: .leading, spacing: 1 * s) {
Text(requirement.title).font(font(12))
Text(requirement.detail)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if let fix = requirement.fix {
Button("Установить") { model.runFixInTerminal(fix) }
.buttonStyle(ScaledButtonStyle(scale: s))
}
}
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - VPN
private var vpnSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
HStack(spacing: 8 * s) {
Text("VPN")
.font(font(12, .medium))
.frame(width: 40 * s, alignment: .leading)
Button("Подключить") { model.connectVPN() }
.disabled(model.busy || model.state.isTunnelUp)
Button("Отключить") { model.disconnectVPN() }
.disabled(model.busy || !model.state.isTunnelUp)
Button("Маршрут") { model.rerouteVPN() }
.disabled(model.busy)
Button("Лог") { model.openVPNLog() }
Spacer()
}
.buttonStyle(ScaledButtonStyle(scale: s))
if let message = model.vpnMessage {
Text(message)
.font(font(10))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 10 * s)
}
// MARK: - Shares
private var sharesSection: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Шары")
.font(font(12, .medium))
Spacer()
Text("смонтировано \(model.mounted.count) из \(model.shares.count)")
.font(font(11))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12 * s)
.padding(.top, 10 * s)
.padding(.bottom, 6 * s)
// The list is whatever the server reports, so it can be long —
// scroll rather than growing the window past the screen.
MeasuredScrollView(minHeight: 48 * s, maxHeight: 420 * s) {
VStack(alignment: .leading, spacing: 0) {
ForEach(model.shares, id: \.self) { share in
shareRow(share)
if share != model.shares.last {
Divider().padding(.leading, 12 * s)
}
}
}
}
}
.padding(.bottom, 8 * s)
}
private func shareRow(_ share: String) -> some View {
let isMounted = model.isMounted(share)
return HStack(spacing: 8 * s) {
Circle()
.fill(isMounted ? Color.green : Color.secondary.opacity(0.35))
.frame(width: 8 * s, height: 8 * s)
VStack(alignment: .leading, spacing: 1 * s) {
Text(share)
.font(font(13))
Text(model.mountPoint(share) ?? "не подключена")
.font(font(10))
.foregroundStyle(.secondary)
}
Spacer()
if isMounted {
Button("Открыть") { model.openInFinder(share) }
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
Button(isMounted ? "Отключить" : "Подключить") {
model.toggle(share)
}
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(model.busy || (!isMounted && model.state != .ok))
}
.padding(.horizontal, 12 * s)
.padding(.vertical, 6 * s)
}
// MARK: - Footer
private var footer: some View {
VStack(alignment: .leading, spacing: 8 * s) {
if let error = model.lastError {
Text(error)
.font(font(11))
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8 * s) {
Button("Подключить все") { model.mountMissing(allowUI: true) }
.disabled(model.busy || model.state != .ok)
.fixedSize()
Button("Отключить все") { model.unmount(model.shares) }
.disabled(model.busy || model.mounted.isEmpty)
.fixedSize()
Spacer()
Button("Настройки") { onOpenSettings() }
.fixedSize()
Button("Обновить") { model.refreshAll() }
.fixedSize()
}
.buttonStyle(ScaledButtonStyle(scale: s))
}
.padding(12 * s)
}
}
@@ -0,0 +1,63 @@
import Foundation
import Security
/// Writes the VPN password into the login Keychain.
///
/// Uses the Security framework directly rather than shelling out to
/// `security add-generic-password -w <password>`: command-line arguments are
/// visible in `ps` to every process on the machine, so a password must never
/// travel that way.
///
/// This is the one place the app touches the secret at all, and only because
/// the user typed it here on purpose. It goes straight into the Keychain and is
/// never stored, logged or echoed anywhere else — reading it back is left to
/// `security` at connect time.
enum KeychainStore {
static func exists(service: String, account: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: false,
]
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
/// Adds the item, or replaces the password if it is already there.
static func save(service: String, account: String, password: String) -> String? {
guard let data = password.data(using: .utf8) else {
return "Не удалось закодировать пароль"
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status: OSStatus
if exists(service: service, account: account) {
status = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
} else {
var attributes = query
attributes[kSecValueData as String] = data
status = SecItemAdd(attributes as CFDictionary, nil)
}
guard status != errSecSuccess else { return nil }
let message = SecCopyErrorMessageString(status, nil) as String?
return message ?? "Ошибка Keychain \(status)"
}
static func delete(service: String, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
enum LinkState: String {
case unknown
case ok
case nasDown
case vpnDown
var title: String {
switch self {
case .unknown: return "Проверяю…"
case .ok: return "NAS доступен"
case .nasDown: return "VPN жив, NAS не отвечает"
case .vpnDown: return "Офисный VPN не поднят"
}
}
var notificationBody: String {
switch self {
case .unknown: return ""
case .ok: return "Офисный NAS снова доступен"
case .nasDown: return "Туннель поднят, но NAS не отвечает на 445"
case .vpnDown: return "Офисный VPN отвалился"
}
}
/// Shown next to the menu bar icon. Empty when healthy — a permanent label
/// would be ignored within a day, which is the whole reason it is only
/// drawn when something is actually wrong.
var badge: String {
switch self {
case .unknown, .ok: return ""
case .nasDown: return "NAS"
case .vpnDown: return "VPN"
}
}
var symbolName: String {
switch self {
case .unknown: return "externaldrive.badge.questionmark"
case .ok: return "externaldrive.connected.to.line.below"
case .nasDown: return "externaldrive.badge.exclamationmark"
case .vpnDown: return "externaldrive.badge.xmark"
}
}
var isTunnelUp: Bool {
self == .ok || self == .nasDown
}
}
+33
View File
@@ -0,0 +1,33 @@
import Foundation
/// Appends to ~/Library/Logs/CampusNAS.log.
///
/// NSLog output from an ad-hoc signed bundle does not reliably reach the
/// unified log, which made real errors invisible while debugging notification
/// delivery. A plain file is boring and always readable.
enum Log {
static let fileURL = URL(fileURLWithPath: NSHomeDirectory())
.appendingPathComponent("Library/Logs/CampusNAS.log")
private static let queue = DispatchQueue(label: "campusnas.log")
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()
static func write(_ message: String) {
NSLog("campusnas-monitor: %@", message)
let line = "\(formatter.string(from: Date())) \(message)\n"
queue.async {
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: fileURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: fileURL)
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import AppKit
@main
enum Main {
@MainActor
static func main() {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
// Menu bar only — no Dock icon, no main window.
app.setActivationPolicy(.accessory)
app.run()
}
}
@@ -0,0 +1,38 @@
import SwiftUI
/// A ScrollView that is as tall as its content, up to a cap.
///
/// Exists because a plain `ScrollView` inside a `VStack` has a tiny ideal
/// height: given only `.frame(maxHeight:)` it collapses to nearly nothing. That
/// bug shipped twice — once emptying the share list, once emptying the whole
/// settings window — so the fix lives in one place now.
/// File scope, not nested: a generic type cannot hold the static storage a
/// PreferenceKey needs.
private struct HeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct MeasuredScrollView<Content: View>: View {
var minHeight: CGFloat = 0
var maxHeight: CGFloat
@ViewBuilder var content: Content
@State private var measured: CGFloat = 0
var body: some View {
ScrollView {
content
.background(
GeometryReader { geometry in
Color.clear.preference(
key: HeightKey.self, value: geometry.size.height)
}
)
}
.frame(height: min(max(measured, minHeight), maxHeight))
.onPreferenceChange(HeightKey.self) { measured = $0 }
}
}
+218
View File
@@ -0,0 +1,218 @@
import Foundation
import NetFS
/// SMB mounting via NetFS. Chosen over shelling out to `mount_smbfs` because
/// NetFS reads credentials from the login Keychain on its own, and — when UI is
/// allowed — presents the standard system auth sheet with the "remember
/// password" checkbox. That sheet is the only supported way to create the
/// Keychain entry, and this app must never handle the password itself.
enum Mounter {
// Option keys from NetFS.h.
private static let uiOptionKey = "UIOption" as CFString
private static let uiOptionNoUI = "NoUI" as CFString
private static let softMountKey = "SoftMount" as CFString
struct Result: Sendable {
let share: String
let status: Int32
/// Already-mounted counts as success. The share can be mounted between
/// the UI reading its state and the click landing — most often by Finder
/// — and showing a red error for "it is already how you wanted it" is
/// just wrong.
var ok: Bool { status == 0 || status == EEXIST || status == EBUSY }
/// True when the failure looks like missing or rejected credentials,
/// i.e. retrying with the auth sheet is worth offering.
var needsCredentials: Bool {
status == EAUTH || status == EACCES || status == EPERM
}
/// Short human explanation. ENOENT gets a specific one: a share the
/// account is not listed in (`valid users` on the NAS) comes back as
/// "no such file or directory", not as a permission error, which is
/// otherwise a very misleading thing to show.
var reason: String {
switch status {
case ENOENT: return "\(share): нет доступа или шара не существует"
case EAUTH, EACCES, EPERM: return "\(share): нужен пароль"
case ETIMEDOUT: return "\(share): таймаут"
default: return "\(share): ошибка \(status)"
}
}
}
static func url(host: String, user: String, share: String) -> URL? {
var components = URLComponents()
components.scheme = "smb"
components.host = host
components.user = user
components.path = "/" + share
return components.url
}
/// Blocking — call off the main thread.
///
/// `allowUI: false` is the silent path: it succeeds only if the Keychain
/// already holds the credentials, and fails fast otherwise. Automatic
/// mounts always use it, so reconnecting never throws an unexpected
/// password sheet at the user.
static func mount(
host: String, user: String, share: String, allowUI: Bool
) -> Result {
guard let url = url(host: host, user: user, share: share) else {
return Result(share: share, status: EINVAL)
}
let openOptions = NSMutableDictionary()
if !allowUI {
openOptions[uiOptionKey] = uiOptionNoUI
}
// Soft mount: a NAS that disappears mid-session must return an error
// rather than wedge every Finder window that touches the volume.
let mountOptions = NSMutableDictionary()
mountOptions[softMountKey] = true
var mountpoints: Unmanaged<CFArray>?
let status = NetFSMountURLSync(
url as CFURL, nil, user as CFString, nil,
openOptions, mountOptions, &mountpoints)
mountpoints?.release()
if status != 0 {
NSLog("campusnas-monitor: mount %@ failed, status %d", share, status)
}
return Result(share: share, status: status)
}
/// Asks the server which shares it exports, via `smbutil view`.
///
/// NetFS does have a share-enumeration API, but it is not declared in the
/// public headers — reaching it would mean dlsym against private symbols,
/// which breaks on an OS update. `smbutil` is a documented system tool and
/// uses the same Keychain credentials.
///
/// Filters to disk shares and drops administrative ones (`IPC$` and
/// friends), which are never mountable as volumes.
static func listShares(
host: String, user: String, timeout: TimeInterval
) async -> [String] {
let output = await Probe.commandOutput(
"/usr/bin/smbutil",
args: ["view", "-N", "//\(user)@\(host)"],
timeout: timeout)
guard let output else {
Log.write("share enumeration failed for \(host)")
return []
}
var shares: [String] = []
for line in output.split(separator: "\n", omittingEmptySubsequences: true) {
// Columns are separated by runs of spaces: "name Disk comment"
let columns = line
.components(separatedBy: " ")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard columns.count >= 2 else { continue }
let name = columns[0]
guard columns[1] == "Disk", !name.hasSuffix("$") else { continue }
shares.append(name)
}
return shares.sorted()
}
static func unmount(mountPoint: String) -> Bool {
let status = mountPoint.withCString { Darwin.unmount($0, 0) }
if status != 0 {
NSLog("campusnas-monitor: unmount %@ failed: %d", mountPoint, errno)
}
return status == 0
}
/// Every spelling that means "this server": the configured name plus each
/// address it resolves to.
///
/// Needed because a mount can be created by something other than this app —
/// Finder mounts from the server browser, an older mount made by IP — and
/// those record a different host string. A plain case-sensitive `contains`
/// missed them and reported live mounts as "not mounted".
private static var aliasCache: (host: String, aliases: Set<String>)?
static func aliases(for host: String) -> Set<String> {
if let cached = aliasCache, cached.host == host { return cached.aliases }
var result: Set<String> = [host.lowercased()]
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
var info: UnsafeMutablePointer<addrinfo>?
if getaddrinfo(host, nil, &hints, &info) == 0 {
var node = info
while let current = node {
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
if getnameinfo(
current.pointee.ai_addr, current.pointee.ai_addrlen,
&buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 {
result.insert(String(cString: buffer).lowercased())
}
node = current.pointee.ai_next
}
freeaddrinfo(info)
}
aliasCache = (host, result)
return result
}
/// Splits `//user@host/share` into its host and share parts.
private static func parse(mountFrom: String) -> (host: String, share: String)? {
guard mountFrom.hasPrefix("//") else { return nil }
let body = mountFrom.dropFirst(2)
guard let slash = body.firstIndex(of: "/") else { return nil }
var host = String(body[body.startIndex..<slash])
if let at = host.lastIndex(of: "@") {
host = String(host[host.index(after: at)...])
}
let share = String(body[body.index(after: slash)...])
guard !host.isEmpty, !share.isEmpty else { return nil }
return (host, share)
}
/// Currently mounted SMB shares served by `host`, mapped share -> mount point.
/// Read from the kernel mount table rather than by listing /Volumes, so a
/// leftover empty directory is never mistaken for a live mount.
static func mountedShares(host: String) -> [String: String] {
var buffer: UnsafeMutablePointer<statfs>?
let count = getmntinfo(&buffer, MNT_NOWAIT)
guard count > 0, let buffer else { return [:] }
let known = aliases(for: host)
var result: [String: String] = [:]
for index in 0..<Int(count) {
var entry = buffer[index]
let fsType = withUnsafeBytes(of: &entry.f_fstypename) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
guard fsType == "smbfs" else { continue }
let from = withUnsafeBytes(of: &entry.f_mntfromname) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
guard let parsed = parse(mountFrom: from),
known.contains(parsed.host.lowercased()) else { continue }
let on = withUnsafeBytes(of: &entry.f_mntonname) {
String(cString: $0.baseAddress!.assumingMemoryBound(to: CChar.self))
}
result[parsed.share] = on
}
return result
}
}
+221
View File
@@ -0,0 +1,221 @@
import Foundation
import Network
/// One-shot guard so a continuation can never be resumed twice, which would trap.
private final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var fired = false
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if fired { return false }
fired = true
return true
}
}
enum Probe {
/// Runs a command and reports whether it exited 0. A process that outlives
/// `timeout` is killed and treated as failure — the VPN wrapper can block on
/// a dead network otherwise.
static func commandSucceeds(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> Bool {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
NSLog("campusnas-monitor: not executable: %@", launchPath)
return false
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
guard once.claim() else { return }
continuation.resume(returning: proc.terminationStatus == 0)
}
do {
try process.run()
} catch {
NSLog("campusnas-monitor: failed to run %@: %@", launchPath, "\(error)")
if once.claim() { continuation.resume(returning: false) }
return
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() { continuation.resume(returning: false) }
}
}
}
struct RunResult: Sendable {
let ok: Bool
let output: String
let error: String
}
/// Like `commandOutput`, but also reports stderr and never discards output
/// on a non-zero exit — needed to diagnose privileged steps.
static func commandRun(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> RunResult {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
return RunResult(ok: false, output: "", error: "not executable: \(launchPath)")
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
do {
try process.run()
} catch {
if once.claim() {
continuation.resume(returning: RunResult(
ok: false, output: "", error: "\(error)"))
}
return
}
DispatchQueue.global().async {
// Drain stderr on its own thread: both pipes filling at once
// would deadlock a sequential read.
var errorText = ""
let errorQueue = DispatchQueue(label: "campusnas.stderr")
let group = DispatchGroup()
group.enter()
errorQueue.async {
let data = errPipe.fileHandleForReading.readDataToEndOfFile()
errorText = String(data: data, encoding: .utf8) ?? ""
group.leave()
}
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.wait()
process.waitUntilExit()
guard once.claim() else { return }
continuation.resume(returning: RunResult(
ok: process.terminationStatus == 0,
output: String(data: outData, encoding: .utf8) ?? "",
error: errorText))
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() {
continuation.resume(returning: RunResult(
ok: false, output: "", error: "timeout after \(timeout)s"))
}
}
}
}
/// Runs a command and returns its stdout, or nil on failure/timeout.
///
/// stdout is drained on a background queue rather than after exit: a process
/// that fills the pipe buffer would otherwise block forever waiting for a
/// reader that never comes.
static func commandOutput(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> String? {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
Log.write("not executable: \(launchPath)")
return nil
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
do {
try process.run()
} catch {
Log.write("failed to run \(launchPath): \(error)")
if once.claim() { continuation.resume(returning: nil) }
return
}
DispatchQueue.global().async {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard once.claim() else { return }
let ok = process.terminationStatus == 0
continuation.resume(
returning: ok ? String(data: data, encoding: .utf8) : nil)
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() { continuation.resume(returning: nil) }
}
}
}
/// Opens a TCP connection and closes it immediately. Deliberately does not
/// touch the autofs path: an `ls` there would trigger a mount on every poll
/// and defeat the whole point of lazy mounting.
static func tcpReachable(
host: String, port: UInt16, timeout: TimeInterval
) async -> Bool {
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return false }
let connection = NWConnection(
host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
@Sendable func finish(_ reachable: Bool) {
guard once.claim() else { return }
connection.cancel()
continuation.resume(returning: reachable)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
case .waiting:
// Host unreachable / connection refused — no point waiting out
// the full timeout for a retry that will not happen.
finish(false)
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
finish(false)
}
}
}
}
@@ -0,0 +1,53 @@
import SwiftUI
/// Button whose chrome scales, not just its label.
///
/// The system `.bordered` style sizes its padding, height and corner radius
/// from `controlSize`, which has four fixed steps — setting a larger font on it
/// grows the text inside a button that stays the same size. This style derives
/// every dimension from the zoom multiplier instead.
struct ScaledButtonStyle: ButtonStyle {
let scale: CGFloat
/// Text-only variant, for secondary actions like "Открыть".
var borderless = false
func makeBody(configuration: Configuration) -> some View {
Label(configuration: configuration, scale: scale, borderless: borderless)
}
/// Not named `Body`: that collides with `ButtonStyle`'s own associated type.
private struct Label: View {
let configuration: ButtonStyleConfiguration
let scale: CGFloat
let borderless: Bool
/// A custom style has to dim itself — SwiftUI only does that for the
/// built-in ones.
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(.system(size: 12 * scale))
.lineLimit(1)
.padding(.horizontal, borderless ? 2 * scale : 10 * scale)
.padding(.vertical, borderless ? 1 * scale : 5 * scale)
.background(background)
.foregroundStyle(borderless ? Color.accentColor : Color.primary)
.opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4)
.contentShape(Rectangle())
}
@ViewBuilder
private var background: some View {
if borderless {
Color.clear
} else {
RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)
.fill(Color.primary.opacity(configuration.isPressed ? 0.16 : 0.08))
.overlay(
RoundedRectangle(cornerRadius: 6 * scale, style: .continuous)
.strokeBorder(Color.primary.opacity(0.12), lineWidth: 1)
)
}
}
}
}
+225
View File
@@ -0,0 +1,225 @@
import AppKit
import SwiftUI
/// Editor for the things that differ between machines: where the .ovpn profile
/// lives, which server it dials, and the credentials it uses.
struct SettingsView: View {
@ObservedObject var model: AppModel
var onClose: () -> Void
@State private var draft: VPNConfig
@State private var smbUser: String
@State private var password = ""
@State private var passwordStored: Bool
@State private var message: String?
@State private var messageIsError = false
private var s: CGFloat { model.zoomScale }
init(model: AppModel, onClose: @escaping () -> Void) {
self.model = model
self.onClose = onClose
_draft = State(initialValue: model.config.vpn)
_smbUser = State(initialValue: model.config.smbUser)
_passwordStored = State(initialValue: model.vpnPasswordStored())
}
private func font(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font {
.system(size: size * s, weight: weight)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Настройки")
.font(font(14, .semibold))
.padding(12 * s)
Divider()
MeasuredScrollView(maxHeight: 520 * s) {
VStack(alignment: .leading, spacing: 14 * s) {
vpnFileSection
Divider()
serverSection
Divider()
credentialsSection
Divider()
smbSection
}
.padding(12 * s)
}
Divider()
footer
}
.frame(width: 470 * s)
}
// MARK: - Profile file
private var vpnFileSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Профиль OpenVPN").font(font(12, .medium))
HStack(spacing: 8 * s) {
Text(draft.configPath)
.font(font(10))
.foregroundStyle(
FileManager.default.isReadableFile(atPath: draft.configPath)
? .secondary : Color.red)
.lineLimit(2)
.truncationMode(.middle)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("Выбрать…") { chooseConfig() }
.buttonStyle(ScaledButtonStyle(scale: s))
}
if !draft.configPathOverride.isEmpty {
Button("Вернуть путь по умолчанию") {
draft.configPathOverride = ""
}
.buttonStyle(ScaledButtonStyle(scale: s, borderless: true))
}
labelled("Путь к openvpn", text: $draft.openvpnPath)
}
}
private func chooseConfig() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = []
panel.allowsOtherFileTypes = true
panel.message = "Выберите файл .ovpn"
panel.directoryURL = URL(fileURLWithPath: draft.baseDir)
guard panel.runModal() == .OK, let url = panel.url else { return }
draft.configPathOverride = url.path
}
// MARK: - Server
private var serverSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Сервер и сети").font(font(12, .medium))
labelled("IP сервера", text: $draft.serverHost)
Text("Пинуется на шлюз локальной сети, чтобы handshake не пошёл "
+ "в поднимаемый туннель.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
labelled("Сеть туннеля", text: $draft.tunNetwork)
labelled("Офисная сеть", text: $draft.lanNetwork)
Text("Пустая офисная сеть отключает проверку маршрута — "
+ "тогда «поднят» определяется только по процессу.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Credentials
private var credentialsSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётные данные VPN").font(font(12, .medium))
labelled("Запись Keychain", text: $draft.keychainService)
labelled("Логин", text: $draft.keychainAccount)
HStack(spacing: 8 * s) {
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
.font(font(11))
Button("Сохранить") { savePassword() }
.buttonStyle(ScaledButtonStyle(scale: s))
.disabled(password.isEmpty)
}
Text(passwordStored
? "Пароль в Keychain есть. Ввод заменит его."
: "Пароля в Keychain нет — подключение не заработает.")
.font(font(9))
.foregroundStyle(passwordStored ? .secondary : Color.orange)
Text("Пароль уходит прямо в связку ключей и нигде больше не "
+ "сохраняется. При подключении его читает системная утилита, "
+ "не это приложение.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private func savePassword() {
// Persist the account name first: saving under a stale login would put
// the secret where connect will not look for it.
model.updateVPN(draft)
if let error = model.saveVPNPassword(password) {
message = error
messageIsError = true
} else {
message = "Пароль сохранён в Keychain"
messageIsError = false
password = ""
passwordStored = true
}
}
// MARK: - SMB
private var smbSection: some View {
VStack(alignment: .leading, spacing: 6 * s) {
Text("Учётная запись SMB").font(font(12, .medium))
labelled("Логин", text: $smbUser)
Text("Пароль для шар хранит сам macOS — его спрашивает системный "
+ "диалог при первом монтировании.")
.font(font(9))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Chrome
private func labelled(_ title: String, text: Binding<String>) -> some View {
HStack(spacing: 8 * s) {
Text(title)
.font(font(11))
.frame(width: 120 * s, alignment: .leading)
TextField("", text: text)
.textFieldStyle(.roundedBorder)
.font(font(11))
}
}
private var footer: some View {
HStack(spacing: 8 * s) {
if let message {
Text(message)
.font(font(10))
.foregroundStyle(messageIsError ? Color.red : .secondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
Button("Закрыть") { onClose() }
Button("Сохранить") {
model.updateVPN(draft)
model.updateSMBUser(smbUser)
onClose()
}
}
.buttonStyle(ScaledButtonStyle(scale: s))
.padding(12 * s)
}
}
+198
View File
@@ -0,0 +1,198 @@
import Foundation
/// Runs a shell command as root through the system authorization dialog.
///
/// macOS renders the prompt and collects the password itself — nothing here
/// ever sees it. Steps are batched into one invocation so a connect asks once
/// rather than once per `route` call.
enum Privileged {
static func run(_ steps: [String]) async -> (ok: Bool, output: String) {
// AppleScript string literals cannot span lines, so the batch is a
// single `;`-joined line.
let command = steps.joined(separator: " ; ")
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "do shell script \"\(escaped)\" with administrator privileges"
let result = await Probe.commandRun(
"/usr/bin/osascript", args: ["-e", script], timeout: 90)
if !result.ok {
Log.write("privileged step failed: \(result.error.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return (result.ok, result.output)
}
/// Single-quotes a path for /bin/sh.
static func quote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
extension VPNService {
enum ActionResult: Sendable {
case ok(String)
case failed(String)
var message: String {
switch self {
case .ok(let text), .failed(let text): return text
}
}
var isError: Bool {
if case .failed = self { return true }
return false
}
}
// MARK: - Credentials
/// Path to a file containing "account\npassword\n", and whether it is a
/// throwaway that must be deleted afterwards.
///
/// The secret is written by `security` redirecting straight to the file. It
/// never passes through this process, which is the whole point — the app
/// must never hold the password in memory.
private static func prepareAuthFile(
_ vpn: VPNConfig
) async -> (path: String, temporary: Bool)? {
if FileManager.default.isReadableFile(atPath: vpn.authPath) {
return (vpn.authPath, false)
}
let temp = NSTemporaryDirectory()
+ "ovpn-auth." + UUID().uuidString.prefix(8)
let script = [
"umask 077",
"printf '%s\\n' \(Privileged.quote(vpn.keychainAccount)) > \(Privileged.quote(temp))",
"/usr/bin/security find-generic-password -s \(Privileged.quote(vpn.keychainService))"
+ " -a \(Privileged.quote(vpn.keychainAccount)) -w >> \(Privileged.quote(temp))",
].joined(separator: " && ")
let ok = await Probe.commandSucceeds(
"/bin/sh", args: ["-c", script], timeout: 30)
guard ok else {
Log.write("could not build auth file from Keychain")
try? FileManager.default.removeItem(atPath: temp)
return nil
}
return (temp, true)
}
// MARK: - Actions
static func connect(_ vpn: VPNConfig) async -> ActionResult {
guard FileManager.default.isExecutableFile(atPath: vpn.openvpnPath) else {
return .failed("OpenVPN не найден: \(vpn.openvpnPath)")
}
guard FileManager.default.isReadableFile(atPath: vpn.configPath) else {
return .failed("Профиль не найден: \(vpn.configPath)")
}
var steps: [String] = []
if processAlive(vpn) {
if await tunnelUp(vpn) {
// Re-pin: the subnet or gateway may have changed since it
// started, which would leave the host route on a dead gateway.
return await reroute(vpn, note: "VPN уже поднят")
}
let age = processAge(vpn) ?? 0
if age < vpn.startupGraceSeconds {
return .ok("VPN ещё подключается (\(age) с)")
}
// Alive but no tunnel past the grace period: openvpn is stuck
// retrying. Restart it as part of the same privileged batch.
if let pid = pid(vpn) {
steps += [
"kill \(pid) 2>/dev/null || true",
"sleep 1",
"kill -9 \(pid) 2>/dev/null || true",
]
}
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
}
guard let gateway = await findGateway() else {
return .failed("Не найден шлюз локальной сети")
}
guard let auth = await prepareAuthFile(vpn) else {
return .failed("Нет учётных данных: ни файла, ни записи в Keychain")
}
// Pin the server IP to the LAN gateway so the handshake does not try to
// go through the tunnel it is establishing. Delete first, so it also
// self-heals after a subnet change.
steps += [
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
[
Privileged.quote(vpn.openvpnPath),
"--config", Privileged.quote(vpn.configPath),
"--auth-user-pass", Privileged.quote(auth.path),
"--auth-nocache",
"--writepid", Privileged.quote(vpn.pidPath),
"--daemon",
"--log", Privileged.quote(vpn.logPath),
].joined(separator: " "),
]
let result = await Privileged.run(steps)
if auth.temporary {
// openvpn reads the file at startup; give the daemon a moment
// before removing it.
try? await Task.sleep(nanoseconds: 3_000_000_000)
try? FileManager.default.removeItem(atPath: auth.path)
}
guard result.ok else {
return .failed("Не удалось запустить VPN — см. \(vpn.logPath)")
}
return .ok("VPN запущен, маршрут на \(vpn.serverHost) через \(gateway)")
}
static func disconnect(_ vpn: VPNConfig) async -> ActionResult {
var steps: [String] = []
if let pid = pid(vpn), processAlive(vpn) {
steps += [
"kill \(pid) 2>/dev/null || true",
"sleep 1",
"kill -9 \(pid) 2>/dev/null || true",
]
} else {
steps.append(
"pkill -f \(Privileged.quote("openvpn --config " + vpn.configPath))"
+ " 2>/dev/null || true")
}
steps.append("rm -f \(Privileged.quote(vpn.pidPath))")
// Always drop the host route: left behind, it blackholes the server
// after the next subnet change.
steps.append(
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost))"
+ " >/dev/null 2>&1 || true")
let result = await Privileged.run(steps)
guard result.ok else { return .failed("Не удалось отключить VPN") }
return .ok("VPN отключён, маршрут на \(vpn.serverHost) снят")
}
/// Re-pins the host route to the current gateway without touching a running
/// tunnel — for use after a network change.
static func reroute(_ vpn: VPNConfig, note: String = "Маршрут обновлён") async -> ActionResult {
guard let gateway = await findGateway() else {
return .failed("Не найден шлюз локальной сети")
}
let result = await Privileged.run([
"/sbin/route -n delete -host \(Privileged.quote(vpn.serverHost)) >/dev/null 2>&1 || true",
"/sbin/route -n add -host \(Privileged.quote(vpn.serverHost)) \(Privileged.quote(gateway))",
])
guard result.ok else { return .failed("Не удалось обновить маршрут") }
return .ok("\(note): \(vpn.serverHost) через \(gateway)")
}
}
+181
View File
@@ -0,0 +1,181 @@
import Foundation
/// Native port of the `ovpn <profile> connect|disconnect|status|reroute` shell
/// wrapper. Everything the script did is here; the script is no longer invoked.
///
/// Two rules shaped the design:
///
/// 1. **The password never enters this process.** The auth file is written by
/// `security` redirecting straight to disk, so the secret goes Keychain →
/// file without passing through app memory. The app only ever knows the path.
/// 2. **Root work goes through one system prompt.** Route pinning and launching
/// openvpn are batched into a single `do shell script … with administrator
/// privileges`, so macOS shows its own auth dialog once and handles the
/// password itself.
enum VPNService {
// MARK: - Dependencies
struct Requirement: Identifiable, Sendable {
let id: String
let title: String
let detail: String
let satisfied: Bool
/// Shell command that would fix it, if there is an obvious one.
let fix: String?
}
static func requirements(_ vpn: VPNConfig) async -> [Requirement] {
var result: [Requirement] = []
let openvpnOK = FileManager.default.isExecutableFile(atPath: vpn.openvpnPath)
result.append(Requirement(
id: "openvpn",
title: "OpenVPN",
detail: openvpnOK ? vpn.openvpnPath : "не найден: \(vpn.openvpnPath)",
satisfied: openvpnOK,
fix: openvpnOK ? nil : "brew install openvpn"))
let configOK = FileManager.default.isReadableFile(atPath: vpn.configPath)
result.append(Requirement(
id: "config",
title: "Профиль .ovpn",
detail: configOK ? vpn.configPath : "не найден: \(vpn.configPath)",
satisfied: configOK,
fix: nil))
// Either source of credentials will do; the file wins if present.
let authFileOK = FileManager.default.isReadableFile(atPath: vpn.authPath)
let keychainOK = authFileOK ? true : await keychainEntryExists(vpn)
result.append(Requirement(
id: "auth",
title: "Учётные данные",
detail: authFileOK ? "файл \(vpn.authPath)"
: (keychainOK ? "Keychain: \(vpn.keychainService)"
: "нет ни файла, ни записи в Keychain"),
satisfied: authFileOK || keychainOK,
fix: keychainOK || authFileOK ? nil
: "security add-generic-password -s \(vpn.keychainService) "
+ "-a \(vpn.keychainAccount) -w"))
return result
}
/// Existence check only — deliberately without `-w`, so no secret is read.
private static func keychainEntryExists(_ vpn: VPNConfig) async -> Bool {
await Probe.commandSucceeds(
"/usr/bin/security",
args: ["find-generic-password", "-s", vpn.keychainService,
"-a", vpn.keychainAccount],
timeout: 5)
}
// MARK: - State
static func pid(_ vpn: VPNConfig) -> pid_t? {
guard let text = try? String(contentsOfFile: vpn.pidPath, encoding: .utf8),
let value = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil }
return value
}
/// Signal 0 probes for existence without delivering anything. EPERM still
/// means the process is there — openvpn runs as root, we do not.
static func processAlive(_ vpn: VPNConfig) -> Bool {
guard let pid = pid(vpn) else { return false }
if kill(pid, 0) == 0 { return true }
return errno == EPERM
}
/// Seconds since openvpn started, from the pidfile mtime — it is written
/// once at startup. macOS `ps` has no etimes field worth parsing.
static func processAge(_ vpn: VPNConfig) -> Int? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: vpn.pidPath),
let modified = attrs[.modificationDate] as? Date
else { return nil }
return Int(Date().timeIntervalSince(modified))
}
/// A live pid proves nothing: on an auth or TLS failure openvpn soft-restarts
/// and retries forever, so the process stays up while the tunnel never comes
/// back. Check the pulled address and the pushed route instead.
static func tunnelUp(_ vpn: VPNConfig) async -> Bool {
// Both profiles land on the same 10.8.0.0/24, so the tun address alone
// cannot tell them apart. Without a known pushed LAN there is no honest
// test, so fall back to the pid check rather than one that lies.
guard !vpn.lanNetwork.isEmpty else { return processAlive(vpn) }
guard hasTunnelAddress(vpn.tunNetwork) else { return false }
return await hasLanRoute(vpn.lanNetwork)
}
/// Reads interface addresses directly instead of scraping `ifconfig`.
private static func hasTunnelAddress(_ network: String) -> Bool {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let head else { return false }
defer { freeifaddrs(head) }
var node: UnsafeMutablePointer<ifaddrs>? = head
while let current = node {
defer { node = current.pointee.ifa_next }
guard let addr = current.pointee.ifa_addr,
addr.pointee.sa_family == sa_family_t(AF_INET) else { continue }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(addr, socklen_t(addr.pointee.sa_len),
&buffer, socklen_t(buffer.count),
nil, 0, NI_NUMERICHOST) == 0 else { continue }
if String(cString: buffer).hasPrefix(network + ".") { return true }
}
return false
}
private static func hasLanRoute(_ network: String) async -> Bool {
guard let output = await Probe.commandOutput(
"/usr/sbin/netstat", args: ["-rn", "-f", "inet"], timeout: 5)
else { return false }
return output.split(separator: "\n").contains { line in
let first = line.split(separator: " ").first.map(String.init) ?? ""
return first == network || first.hasPrefix(network + "/")
}
}
static func isUp(_ vpn: VPNConfig) async -> Bool {
guard processAlive(vpn) else { return false }
return await tunnelUp(vpn)
}
// MARK: - Gateway
/// The LAN router, found the same way the script did: walk the configured
/// network services and take the first with an IPv4 Router. Deliberately not
/// the system default route — once the tunnel is up that points into it.
static func findGateway() async -> String? {
guard let services = await Probe.commandOutput(
"/usr/sbin/networksetup", args: ["-listallnetworkservices"], timeout: 8)
else { return nil }
for line in services.split(separator: "\n").dropFirst() {
let service = line.trimmingCharacters(in: .whitespaces)
// A leading asterisk marks a disabled service.
guard !service.isEmpty, !service.hasPrefix("*") else { continue }
guard let info = await Probe.commandOutput(
"/usr/sbin/networksetup", args: ["-getinfo", service], timeout: 8)
else { continue }
for infoLine in info.split(separator: "\n") where infoLine.hasPrefix("Router: ") {
let value = infoLine.dropFirst("Router: ".count)
.trimmingCharacters(in: .whitespaces)
if isIPv4(value) { return value }
}
}
return nil
}
private static func isIPv4(_ text: String) -> Bool {
var addr = in_addr()
return inet_pton(AF_INET, text, &addr) == 1
}
}