Files
romanvandClaude Opus 5 cd53b6a1a1 Add CampusNAS menu bar agent
Native macOS agent that keeps the office VPN and the NAS shares working
without the shell wrapper it replaces.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:19:31 +06:00

13 KiB
Raw Permalink Blame History

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

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