Files
ovpn/README.md
T
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

289 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.