# 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.