Native macOS agent that keeps the office VPN and the NAS shares working without the shell wrapper it replaces. - VPN: full port of the `ovpn` script — host-route pinning, gateway discovery via networksetup, tunnel liveness by address plus pushed route, startup grace, restart of a wedged daemon. Root steps are batched into one system authorization prompt. - SMB: mounts via NetFS, discovers shares from the server, detects mounts event-driven through NSWorkspace, matches the host by resolved alias so externally created mounts are recognised. - Credentials never pass through the process: the VPN auth file is written by `security` straight to disk, and the password is stored via SecItemAdd rather than a command line visible in ps. - UI: status window, settings editor, ⌘+/⌘− zoom that re-lays out rather than scaling a bitmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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):
ovpn office status— exit code 0 means the tunnel is up.- 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 <profile> 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
networksetupservices and takes the first with a valid IPv4Router:. 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.xaddress and a route to the pushed LAN. With no known LAN the check falls back to the pid, because both profiles share10.8.0.0/24and 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)returningEPERMcounts as alive: openvpn runs as root, this app does not.
Credentials
The password never enters this process. If <profile>.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
.ovpnprofile — file picker, or the defaultbaseDir/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 <password>: 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/<share>, 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/didUnmountNotificationrefresh 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 withgetaddrinfoand 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
./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:
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:
"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.
requestAuthorizationfails withUNErrorDomain code=1("Notifications are not allowed for this application"). Verified to persist from/Applications, after deduplicating the LaunchServices records and restartingusernotificationsd, and withLSUIElementdisabled. 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, becauseNSLogfrom an ad-hoc bundle does not reliably reach the unified log. - Detects that the NAS stopped answering on 445, not why.
- Assumes the
ovpnwrapper is the single source of truth for tunnel state.