commit cd53b6a1a1fff9e2390c79ba3d61c94ed3d0515b Author: Roman Varlakov Date: Fri Jul 31 13:19:31 2026 +0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51462b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# SwiftPM build artefacts +.build/ + +# Assembled .app and distribution archives — rebuildable, and megabytes of them +build/ +dist/ + +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..284bfa8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "CampusNASMonitor", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget( + name: "CampusNASMonitor", + path: "Sources/CampusNASMonitor", + swiftSettings: [.swiftLanguageMode(.v5)] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6224ec5 --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# CampusNAS Monitor + +Menu bar agent that watches the office OpenVPN tunnel and the office NAS, and +mounts the SMB shares itself. + +Replaces the autofs approach entirely: autofs is silent by design, so a share +that stops working just looks like an empty folder, and "empty because the +tunnel is down" is indistinguishable from "empty because the Keychain entry went +stale". This app mounts the shares directly and says which of those it is. + +## What it does + +Every `pollIntervalSeconds` (default 30): + +1. `ovpn office status` — exit code 0 means the tunnel is up. +2. TCP connect to `192.168.77.43:445` — does the NAS answer SMB? + +Three states, and the distinction is the point — they have different causes and +different fixes: + +| State | Menu bar icon | Meaning | +|---|---|---| +| `ok` | `externaldrive.connected.to.line.below` | NAS reachable | +| `nasDown` | `externaldrive.badge.exclamationmark` | tunnel up, NAS silent on 445 | +| `vpnDown` | `externaldrive.badge.xmark` | tunnel down | + +The menu bar item is the actual indicator: the icon changes with state, and a +short label (`VPN` / `NAS`) appears beside it when something is broken. Nothing +is drawn when healthy — a permanent label gets ignored within a day. + +Notifications are attempted on state transitions but **do not work under ad-hoc +signing** — see Known limits. The badge is what you will actually see. + +When the NAS becomes reachable, configured shares are mounted automatically — +silently, using credentials already in the login Keychain. A silent mount never +raises an auth sheet on its own; if credentials are missing it reports that and +lets you click. + +## VPN + +The `ovpn connect|disconnect|status|reroute` shell wrapper is ported +natively; the script is no longer invoked. Ported behaviour, including the parts +that exist for non-obvious reasons: + +- **Host-route pinning.** The server IP is pinned to the LAN gateway before the + tunnel comes up, otherwise the handshake tries to route through the tunnel it + is establishing. The stale route is deleted first, so it self-heals after a + subnet change — and disconnect always drops it, or it blackholes the server + later. +- **Gateway detection** walks `networksetup` services and takes the first with a + valid IPv4 `Router:`. Deliberately not the system default route: once the + tunnel is up, that points into it. The IPv4 check matters — the first service + here reports `(null)`. +- **Liveness is not the pid.** On an auth or TLS failure openvpn soft-restarts + forever, so the process stays up with no tunnel. Up means a `10.8.0.x` address + *and* a route to the pushed LAN. With no known LAN the check falls back to the + pid, because both profiles share `10.8.0.0/24` and the address alone would lie. +- **Startup grace.** A live pid with no tunnel is "still connecting" for 60 s, + and a wedged process after that — restarted as part of the same privileged + batch. +- `kill(pid, 0)` returning `EPERM` counts as alive: openvpn runs as root, this + app does not. + +### Credentials + +**The password never enters this process.** If `.auth` exists it is +used as-is. Otherwise the auth file is written by `security` redirecting +straight to a `umask 077` temp file — Keychain to disk, with the app knowing +only the path. The temp file is removed once the daemon has read it. + +The requirement check probes the Keychain entry *without* `-w`, so even the +existence test reads no secret. + +### Privileges + +Route changes and launching openvpn need root. They are batched into a single +`do shell script … with administrator privileges`, so macOS shows its own +authorization dialog **once per action** and collects the password itself. + +The alternative — an `SMJobBless` helper — needs a Developer ID signature, which +an ad-hoc build does not have. + +### Settings window + +**Настройки** (⌘, from the status menu, or the button in the main window) edits +what differs between machines: + +- the `.ovpn` profile — file picker, or the default `baseDir/profile.ovpn` +- path to the openvpn binary +- server IP, tunnel network, office LAN +- Keychain service and login +- the VPN password +- the SMB login + +The password is written with `SecItemAdd` / `SecItemUpdate`, never by shelling +out to `security add-generic-password -w `: command-line arguments are +readable in `ps` by every process on the machine. It goes straight to the +Keychain and is kept nowhere else — not in the config file, not in the log. The +account name is persisted before the password, so the secret cannot land under a +stale login where connect would not look for it. + +The SMB password is not handled here at all: macOS collects it in its own +mount dialog. + +### Requirements + +Checked at launch and listed in the window when unmet: the openvpn binary, the +`.ovpn` profile, and credentials (auth file or Keychain entry). A missing +dependency with an obvious fix gets an **Установить** button that opens Terminal +with the command — visible and user-initiated, never a silent install. + +## Share discovery + +The list of shares comes from the server, via `smbutil view`. A hand-written +list was the original design and it was wrong: the app showed four shares while +the NAS exported eleven, and nothing in the UI hinted that the list was partial. + +NetFS does have a share-enumeration API (`NetFSOpenSession` / +`NetFSEnumerateShares`), but it is not declared in the public headers — using it +would mean `dlsym` against private symbols, which breaks on an OS update. +`smbutil` is a documented system tool and shares the same Keychain credentials. + +Enumeration **requires credentials**: with no Keychain entry for the host it +exits 77 (`Authentication error`). So on a fresh host name the app falls back to +the manual `shares` list until the first interactive mount succeeds, at which +point it enumerates immediately. It is retried once per reconnect, not per poll. + +Displayed shares and auto-mounted shares are deliberately separate lists: +showing everything the server has is useful, silently mounting ten volumes is +not. + +## Mounting + +Uses `NetFS` (`NetFSMountURLSync`) rather than shelling out to `mount_smbfs`: +NetFS reads the login Keychain by itself 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 — **this app never handles +the password**. + +- automatic mounts → silent (`NoUI`), fail fast if credentials are missing +- clicking a share or "Подключить все" → auth sheet allowed + +Shares mount at `/Volumes/`, soft-mounted so a NAS that vanishes +mid-session returns errors instead of wedging every Finder window. + +Mount state is read from the kernel mount table (`getmntinfo`, filtered to +`smbfs` and this host), not by listing `/Volumes` — a leftover empty directory +is never mistaken for a live mount. + +Mounts appear and vanish without this app doing anything: opening a share in +Finder's server browser mounts it on the spot. Two consequences, both handled: + +- **Detection is event-driven.** `NSWorkspace.didMountNotification` / + `didUnmountNotification` refresh the state immediately. Polling alone left the + UI up to 30 s stale, which read as "the app shows fewer shares than are + actually connected". +- **Host matching goes by alias, not string.** The mount record carries whatever + host string the mounter used — `CAMPUSNAS`, `campusnas`, or the IP. The + configured host is resolved with `getaddrinfo` and compared + case-insensitively against that set, so an externally created mount is still + recognised as ours. + +Mounting an already-mounted share returns `EEXIST` (17) and is treated as +success — the share can be mounted between the UI reading its state and the +click landing, and a red error for "already how you wanted it" is wrong. + +## Build + +```bash +./build.sh +``` + +Produces `build/CampusNAS.app`, ad-hoc signed. The bundle is not optional: +`UNUserNotificationCenter` and `SMAppService` both refuse to operate on a bare +binary. + +Install: + +```bash +cp -R build/CampusNAS.app /Applications/ +``` + +## Config + +Written on first launch to `~/.config/campusnas-monitor/config.json`. Decoding is +tolerant — every missing key falls back to its default, so a hand-trimmed file +keeps working. Restart the app to apply. + +| Key | Default | Notes | +|---|---|---| +| `nasHost` | `192.168.77.43` | | +| `nasPort` | `445` | SMB | +| `smbUser` | current user | password lives in the Keychain, never here | +| `autoDiscoverShares` | `true` | ask the server instead of trusting a list | +| `shares` | `campus`, `develop`, `backup`, `downloads` | manual list, used only when discovery is off or has not succeeded yet | +| `excludeShares` | `[]` | never shown, discovered or not | +| `autoMountShares` | `campus`, `develop`, `backup`, `downloads` | mounted automatically on reconnect | +| `shareUsers` | `{}` | per-share account override, see below | +| `primaryShare` | `campus` | opened by "Открыть шару в Finder" | +| `autoMountOnReconnect` | `true` | silent mounts when the NAS comes back | +| `vpnStatusCommand` / `vpnStatusArgs` | `~/.local/bin/ovpn` `office status` | exit 0 = up | +| `vpnConnectCommand` | `~/.local/bin/ovpn office connect` | run in Terminal | +| `vpnDisconnectCommand` | `~/.local/bin/ovpn office disconnect` | run in Terminal | +| `pollIntervalSeconds` | `30` | | +| `probeTimeoutSeconds` | `4` | per probe | + +### shareUsers + +Some shares are restricted to a different account in the NAS's Samba config. On +this NAS, `git-storage` has: + +``` +[git-storage] + valid users=nobody,campus,nobody +``` + +so `romanv` cannot mount it. The server answers with a name-not-found error, +which macOS surfaces as `ENOENT` — "No such file or directory", not a permission +error. Misleading enough that the app spells it out in the failure message. + +To use such a share, mount it under the account that is allowed: + +```json +"shares": ["campus", "develop", "backup", "downloads", "git-storage"], +"shareUsers": { "git-storage": "campus" } +``` + +The first mount will ask for that account's password via the system auth sheet. + +## Window + +Opened from the menu (**Показать окно**, ⌘O) or by launching the app again +while it is running — Finder, Spotlight or `open` all bring it up. + +- current state, host, and a spinner while mounting +- **VPN**: Подключить / Отключить, each enabled only when it makes sense +- one row per share: mounted indicator, mount point, Открыть, and + Подключить/Отключить +- **Подключить все** / **Отключить все** / **Обновить** +- mount failures shown inline in red + +Both connect and disconnect run in Terminal on purpose: the wrapper needs sudo +for the host route in either direction, and a background agent has no way to +show that password prompt. + +The window and the menu bar item read the same `AppModel`, so they cannot +disagree about what is mounted. + +## Zoom + +⌘+ / ⌘− step through eight scales (0.8× … 2.0×), ⌘0 resets. The step is +persisted in `UserDefaults` under `zoomIndex`, and the window resizes to fit. + +Implemented as an explicit multiplier applied to every font size and padding. +Two obvious approaches were tried and rejected: + +- `dynamicTypeSize` — an iOS mechanism. AppKit ignores it for system fonts; + measured, the window came out identical at all seven steps. +- `scaleEffect` — stretches an already rasterised layer, so text blurs. + +An `.accessory` app has no menu bar, so the shortcuts cannot be menu items: +they are caught by a local `NSEvent` monitor while the window is key. The same +three actions are also in the status bar menu. + +## Menu + +- current state and a mounted-share count +- **Показать окно** (⌘O) +- **Проверить сейчас** (⌘R) +- **Запускать при входе** — `SMAppService.mainApp` +- **Открыть конфиг** + +## Known limits + +- **Notifications do not work.** `requestAuthorization` fails with + `UNErrorDomain code=1` ("Notifications are not allowed for this application"). + Verified to persist from `/Applications`, after deduplicating the + LaunchServices records and restarting `usernotificationsd`, and with + `LSUIElement` disabled. The app never appears in + `~/Library/Preferences/com.apple.ncprefs.plist`, so there is nothing to enable + in System Settings either. Cause is the ad-hoc signature; a Developer ID + signature would be required. The menu bar badge covers the same need without + any permission, so this is left as-is. The posting code stays in place and + starts working by itself if the app is ever signed properly. +- Diagnostics go to `~/Library/Logs/CampusNAS.log`, because `NSLog` from an + ad-hoc bundle does not reliably reach the unified log. +- Detects that the NAS stopped answering on 445, not why. +- Assumes the `ovpn` wrapper is the single source of truth for tunnel state. diff --git a/Sources/CampusNASMonitor/AppDelegate.swift b/Sources/CampusNASMonitor/AppDelegate.swift new file mode 100644 index 0000000..32fa883 --- /dev/null +++ b/Sources/CampusNASMonitor/AppDelegate.swift @@ -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? + private var settingsWindow: NSWindow? + private var keyMonitor: Any? + private var cancellables = Set() + + 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)") + } + } + } +} diff --git a/Sources/CampusNASMonitor/AppModel.swift b/Sources/CampusNASMonitor/AppModel.swift new file mode 100644 index 0000000..baf0580 --- /dev/null +++ b/Sources/CampusNASMonitor/AppModel.swift @@ -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) + } +} diff --git a/Sources/CampusNASMonitor/Config.swift b/Sources/CampusNASMonitor/Config.swift new file mode 100644 index 0000000..bf81478 --- /dev/null +++ b/Sources/CampusNASMonitor/Config.swift @@ -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() + } +} diff --git a/Sources/CampusNASMonitor/ContentView.swift b/Sources/CampusNASMonitor/ContentView.swift new file mode 100644 index 0000000..77a5d73 --- /dev/null +++ b/Sources/CampusNASMonitor/ContentView.swift @@ -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) + } +} diff --git a/Sources/CampusNASMonitor/KeychainStore.swift b/Sources/CampusNASMonitor/KeychainStore.swift new file mode 100644 index 0000000..797e801 --- /dev/null +++ b/Sources/CampusNASMonitor/KeychainStore.swift @@ -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 `: 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) + } +} diff --git a/Sources/CampusNASMonitor/LinkState.swift b/Sources/CampusNASMonitor/LinkState.swift new file mode 100644 index 0000000..913a594 --- /dev/null +++ b/Sources/CampusNASMonitor/LinkState.swift @@ -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 + } +} diff --git a/Sources/CampusNASMonitor/Log.swift b/Sources/CampusNASMonitor/Log.swift new file mode 100644 index 0000000..55450bb --- /dev/null +++ b/Sources/CampusNASMonitor/Log.swift @@ -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) + } + } + } +} diff --git a/Sources/CampusNASMonitor/Main.swift b/Sources/CampusNASMonitor/Main.swift new file mode 100644 index 0000000..1f49512 --- /dev/null +++ b/Sources/CampusNASMonitor/Main.swift @@ -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() + } +} diff --git a/Sources/CampusNASMonitor/MeasuredScrollView.swift b/Sources/CampusNASMonitor/MeasuredScrollView.swift new file mode 100644 index 0000000..69b9248 --- /dev/null +++ b/Sources/CampusNASMonitor/MeasuredScrollView.swift @@ -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: 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 } + } +} diff --git a/Sources/CampusNASMonitor/Mounter.swift b/Sources/CampusNASMonitor/Mounter.swift new file mode 100644 index 0000000..7cf0030 --- /dev/null +++ b/Sources/CampusNASMonitor/Mounter.swift @@ -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? + 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)? + + static func aliases(for host: String) -> Set { + if let cached = aliasCache, cached.host == host { return cached.aliases } + + var result: Set = [host.lowercased()] + + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + var info: UnsafeMutablePointer? + + 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.. 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? + 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.. 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) + } + } + } + +} diff --git a/Sources/CampusNASMonitor/ScaledButtonStyle.swift b/Sources/CampusNASMonitor/ScaledButtonStyle.swift new file mode 100644 index 0000000..89aa5af --- /dev/null +++ b/Sources/CampusNASMonitor/ScaledButtonStyle.swift @@ -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) + ) + } + } + } +} diff --git a/Sources/CampusNASMonitor/SettingsView.swift b/Sources/CampusNASMonitor/SettingsView.swift new file mode 100644 index 0000000..0a8e24e --- /dev/null +++ b/Sources/CampusNASMonitor/SettingsView.swift @@ -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) -> 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) + } +} diff --git a/Sources/CampusNASMonitor/VPNActions.swift b/Sources/CampusNASMonitor/VPNActions.swift new file mode 100644 index 0000000..3b172fc --- /dev/null +++ b/Sources/CampusNASMonitor/VPNActions.swift @@ -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)") + } +} diff --git a/Sources/CampusNASMonitor/VPNService.swift b/Sources/CampusNASMonitor/VPNService.swift new file mode 100644 index 0000000..b0c7206 --- /dev/null +++ b/Sources/CampusNASMonitor/VPNService.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Native port of the `ovpn 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? + guard getifaddrs(&head) == 0, let head else { return false } + defer { freeifaddrs(head) } + + var node: UnsafeMutablePointer? = 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 + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d19e2a7 --- /dev/null +++ b/build.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Builds CampusNAS.app — a menu bar agent, so it needs a real bundle: +# UNUserNotificationCenter and SMAppService both refuse to work on a bare binary. +set -euo pipefail + +cd "$(dirname "$0")" + +APP_NAME="CampusNAS" +BUNDLE_ID="com.campusagro.campusnas-monitor" +VERSION="1.0" +APP="build/${APP_NAME}.app" + +# UNIVERSAL=1 builds for both architectures — needed when the .app will run on +# a machine other than this one, which may be Intel. +if [ "${UNIVERSAL:-0}" = "1" ]; then + echo "==> swift build (arm64 + x86_64)" + swift build -c release --arch arm64 --arch x86_64 + BINARY=".build/apple/Products/Release/CampusNASMonitor" +else + echo "==> swift build" + swift build -c release + BINARY=".build/release/CampusNASMonitor" +fi + +echo "==> assembling ${APP}" +rm -rf "$APP" +mkdir -p "${APP}/Contents/MacOS" "${APP}/Contents/Resources" + +cp "$BINARY" "${APP}/Contents/MacOS/${APP_NAME}" + +cat > "${APP}/Contents/Info.plist" < + + + + CFBundleName + ${APP_NAME} + CFBundleDisplayName + ${APP_NAME} + CFBundleIdentifier + ${BUNDLE_ID} + CFBundleExecutable + ${APP_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + Internal tool + + +PLIST + +plutil -lint "${APP}/Contents/Info.plist" >/dev/null + +echo "==> ad-hoc signing" +codesign --force --sign - --timestamp=none "$APP" +codesign --verify --verbose=1 "$APP" + +echo +echo "Built: $(pwd)/${APP}" +echo "Run: open '$(pwd)/${APP}'" +echo "Install: cp -R '$(pwd)/${APP}' /Applications/" diff --git a/package.sh b/package.sh new file mode 100755 index 0000000..635543f --- /dev/null +++ b/package.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Builds a transferable bundle: the universal .app, the sources to rebuild it, +# and install instructions. +set -euo pipefail + +cd "$(dirname "$0")" + +NAME="CampusNAS" +STAMP="$(date +%Y%m%d)" +OUT="dist/${NAME}-${STAMP}" + +echo "==> universal build" +UNIVERSAL=1 ./build.sh >/dev/null + +ARCHS="$(lipo -archs "build/${NAME}.app/Contents/MacOS/${NAME}")" +echo " architectures: ${ARCHS}" + +rm -rf "$OUT" +mkdir -p "${OUT}/source" + +echo "==> collecting" +# ditto keeps the bundle structure and the ad-hoc signature intact; cp -R does +# not always preserve extended attributes on a signed bundle. +ditto "build/${NAME}.app" "${OUT}/${NAME}.app" + +cp -R Sources Package.swift build.sh package.sh README.md "${OUT}/source/" + +cat > "${OUT}/INSTALL.md" <<'DOC' +# CampusNAS — установка на другой Mac + +## Что внутри + +- `CampusNAS.app` — готовое приложение, universal (Apple Silicon + Intel) +- `source/` — исходники, чтобы пересобрать при необходимости +- `README.md` в `source/` — как всё устроено и почему + +## Установка + +```bash +cp -R CampusNAS.app /Applications/ +xattr -dr com.apple.quarantine /Applications/CampusNAS.app +open /Applications/CampusNAS.app +``` + +Вторая строка обязательна. Приложение подписано ad-hoc, без Developer ID, +поэтому файл, приехавший с другой машины, помечается карантином и Gatekeeper +его не пустит. Команда снимает эту метку. + +Иконка появится в меню-баре. Окно — повторный запуск приложения либо +«Показать окно» в меню. + +## Настройка + +Меню-бар → **Настройки…** (⌘,): + +- путь к профилю `.ovpn` и к бинарю `openvpn` +- IP VPN-сервера, сеть туннеля, офисная сеть +- запись Keychain, логин и пароль VPN +- логин SMB + +Адрес NAS и список шар правятся в `~/.config/campusnas-monitor/config.json`, +он создаётся при первом запуске. + +## Что нужно на целевой машине + +- **openvpn** — `brew install openvpn`. Приложение само проверит наличие и + покажет кнопку установки, если не найдёт. +- **профиль `.ovpn`** — скопировать отдельно, в комплект он не входит: там + сертификаты. +- **пароль VPN** — ввести в окне настроек, он ляжет в Keychain. +- **пароль SMB** — спросит сам macOS при первом монтировании. Поставьте галку + «Запомнить пароль в связке ключей», иначе автомонтирование работать не будет. + +Если NAS доступен только по имени, а DNS его не отдаёт — добавьте запись: + +```bash +printf '192.168.77.43\tCAMPUSNAS\n' | sudo tee -a /etc/hosts +``` + +## Пересборка + +```bash +cd source +UNIVERSAL=1 ./build.sh +``` + +Нужен Xcode с командными утилитами. Собирается за несколько секунд. + +## Чего приложение не делает + +Не хранит паролей. Пароль VPN пишется прямо в Keychain и читается оттуда +системной утилитой при подключении. Пароль SMB приложение не видит вообще — +им занимается macOS. +DOC + +echo "==> archiving" +cd dist +ditto -c -k --sequesterRsrc --keepParent "${NAME}-${STAMP}" "${NAME}-${STAMP}.zip" +cd .. + +SIZE="$(du -h "dist/${NAME}-${STAMP}.zip" | cut -f1)" +echo +echo "Готово: $(pwd)/dist/${NAME}-${STAMP}.zip (${SIZE})"