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>
289 lines
13 KiB
Markdown
289 lines
13 KiB
Markdown
# 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
|
||
|
||
```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.
|