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

226 lines
9.9 KiB
Swift

import Foundation
/// Everything the old shell wrapper kept in its `profile_config()` case block.
struct VPNConfig: Codable {
/// Profile name — also the basename of the .ovpn/.pid/.log/.auth files.
var profile: String
var label: String
/// Public IP of the VPN server. Pinned to the LAN gateway during connect so
/// the handshake does not route into the tunnel it is building.
var serverHost: String
var openvpnPath: String
var baseDir: String
/// Explicit path to the .ovpn profile. Empty falls back to
/// `baseDir/profile.ovpn`, which is where the shell wrapper kept it.
var configPathOverride: String
/// Address family pulled inside the tunnel, e.g. "10.8.0".
var tunNetwork: String
/// Pushed office LAN, e.g. "192.168.77". Empty disables the route check —
/// both profiles share 10.8.0.0/24, so without a distinct LAN there is no
/// honest way to tell which tunnel is up.
var lanNetwork: String
var keychainService: String
var keychainAccount: String
/// How long openvpn may sit without a tunnel before connect treats it as
/// wedged rather than still handshaking. A normal connect takes ~2 s.
var startupGraceSeconds: Int
var configPath: String {
configPathOverride.isEmpty ? "\(baseDir)/\(profile).ovpn" : configPathOverride
}
var pidPath: String { "\(baseDir)/\(profile).pid" }
var logPath: String { "\(baseDir)/\(profile).log" }
var authPath: String { "\(baseDir)/\(profile).auth" }
/// Tolerant like `Config`'s: a file written by an older build is missing
/// keys, and losing every other setting over one absent field would be a
/// poor trade.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = VPNConfig.office
profile = try c.decodeIfPresent(String.self, forKey: .profile) ?? d.profile
label = try c.decodeIfPresent(String.self, forKey: .label) ?? d.label
serverHost = try c.decodeIfPresent(String.self, forKey: .serverHost) ?? d.serverHost
openvpnPath = try c.decodeIfPresent(String.self, forKey: .openvpnPath) ?? d.openvpnPath
baseDir = try c.decodeIfPresent(String.self, forKey: .baseDir) ?? d.baseDir
configPathOverride = try c.decodeIfPresent(
String.self, forKey: .configPathOverride) ?? d.configPathOverride
tunNetwork = try c.decodeIfPresent(String.self, forKey: .tunNetwork) ?? d.tunNetwork
lanNetwork = try c.decodeIfPresent(String.self, forKey: .lanNetwork) ?? d.lanNetwork
keychainService = try c.decodeIfPresent(
String.self, forKey: .keychainService) ?? d.keychainService
keychainAccount = try c.decodeIfPresent(
String.self, forKey: .keychainAccount) ?? d.keychainAccount
startupGraceSeconds = try c.decodeIfPresent(
Int.self, forKey: .startupGraceSeconds) ?? d.startupGraceSeconds
}
init(
profile: String, label: String, serverHost: String, openvpnPath: String,
baseDir: String, configPathOverride: String, tunNetwork: String,
lanNetwork: String, keychainService: String, keychainAccount: String,
startupGraceSeconds: Int
) {
self.profile = profile
self.label = label
self.serverHost = serverHost
self.openvpnPath = openvpnPath
self.baseDir = baseDir
self.configPathOverride = configPathOverride
self.tunNetwork = tunNetwork
self.lanNetwork = lanNetwork
self.keychainService = keychainService
self.keychainAccount = keychainAccount
self.startupGraceSeconds = startupGraceSeconds
}
static let office = VPNConfig(
profile: "office",
label: "Office",
serverHost: "92.255.183.64",
openvpnPath: "/opt/homebrew/sbin/openvpn",
baseDir: NSHomeDirectory() + "/.config/openvpn-cli",
configPathOverride: "",
tunNetwork: "10.8.0",
lanNetwork: "192.168.77",
keychainService: "ovpn-office",
keychainAccount: NSUserName(),
startupGraceSeconds: 60
)
}
/// User-editable settings, read from ~/.config/campusnas-monitor/config.json.
/// Decoding is deliberately tolerant: every field falls back to its default, so
/// an older or hand-trimmed file keeps working instead of silently reverting
/// everything to defaults.
struct Config: Codable {
var nasHost: String
var nasPort: UInt16
/// SMB account used to build the mount URL. The password is never stored
/// here — it lives in the login Keychain, put there by the system auth sheet.
var smbUser: String
/// Ask the server what it exports instead of trusting a hand-written list.
/// With this off, `shares` is used verbatim.
var autoDiscoverShares: Bool
/// Manual share list — used only when `autoDiscoverShares` is false.
var shares: [String]
/// Never shown, discovered or not.
var excludeShares: [String]
/// Mounted automatically when the NAS becomes reachable. Deliberately
/// separate from the displayed list: showing every share the server has is
/// useful, silently mounting ten volumes is not.
var autoMountShares: [String]
/// Per-share account override. Some shares are restricted to a different
/// account in the NAS's Samba config (`valid users`), and mounting those
/// with the default user fails as ENOENT rather than a permission error.
/// Example: {"git-storage": "campus"}.
var shareUsers: [String: String]
/// Share opened by "Открыть шару в Finder".
var primaryShare: String
/// Mount every share automatically when the NAS becomes reachable again.
/// Only ever attempted silently — never pops an auth sheet on its own.
var autoMountOnReconnect: Bool
/// VPN is driven natively now; the `ovpn` shell wrapper is no longer called.
var vpn: VPNConfig
var pollIntervalSeconds: Double
var probeTimeoutSeconds: Double
static let `default` = Config(
nasHost: "CAMPUSNAS",
nasPort: 445,
smbUser: NSUserName(),
autoDiscoverShares: true,
shares: ["campus", "develop", "backup", "downloads"],
excludeShares: [],
autoMountShares: ["campus", "develop", "backup", "downloads"],
shareUsers: [:],
primaryShare: "campus",
autoMountOnReconnect: true,
vpn: .office,
pollIntervalSeconds: 30,
probeTimeoutSeconds: 4
)
init(
nasHost: String, nasPort: UInt16, smbUser: String,
autoDiscoverShares: Bool, shares: [String], excludeShares: [String],
autoMountShares: [String],
shareUsers: [String: String], primaryShare: String,
autoMountOnReconnect: Bool, vpn: VPNConfig,
pollIntervalSeconds: Double, probeTimeoutSeconds: Double
) {
self.vpn = vpn
self.nasHost = nasHost
self.nasPort = nasPort
self.smbUser = smbUser
self.autoDiscoverShares = autoDiscoverShares
self.shares = shares
self.excludeShares = excludeShares
self.autoMountShares = autoMountShares
self.shareUsers = shareUsers
self.primaryShare = primaryShare
self.autoMountOnReconnect = autoMountOnReconnect
self.pollIntervalSeconds = pollIntervalSeconds
self.probeTimeoutSeconds = probeTimeoutSeconds
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = Config.default
nasHost = try c.decodeIfPresent(String.self, forKey: .nasHost) ?? d.nasHost
nasPort = try c.decodeIfPresent(UInt16.self, forKey: .nasPort) ?? d.nasPort
smbUser = try c.decodeIfPresent(String.self, forKey: .smbUser) ?? d.smbUser
autoDiscoverShares = try c.decodeIfPresent(
Bool.self, forKey: .autoDiscoverShares) ?? d.autoDiscoverShares
shares = try c.decodeIfPresent([String].self, forKey: .shares) ?? d.shares
excludeShares = try c.decodeIfPresent([String].self, forKey: .excludeShares)
?? d.excludeShares
autoMountShares = try c.decodeIfPresent([String].self, forKey: .autoMountShares)
?? d.autoMountShares
shareUsers = try c.decodeIfPresent([String: String].self, forKey: .shareUsers)
?? d.shareUsers
primaryShare = try c.decodeIfPresent(String.self, forKey: .primaryShare)
?? d.primaryShare
autoMountOnReconnect = try c.decodeIfPresent(
Bool.self, forKey: .autoMountOnReconnect) ?? d.autoMountOnReconnect
vpn = try c.decodeIfPresent(VPNConfig.self, forKey: .vpn) ?? d.vpn
pollIntervalSeconds = try c.decodeIfPresent(
Double.self, forKey: .pollIntervalSeconds) ?? d.pollIntervalSeconds
probeTimeoutSeconds = try c.decodeIfPresent(
Double.self, forKey: .probeTimeoutSeconds) ?? d.probeTimeoutSeconds
}
func user(for share: String) -> String {
shareUsers[share] ?? smbUser
}
static var configURL: URL {
URL(fileURLWithPath: NSHomeDirectory())
.appendingPathComponent(".config/campusnas-monitor/config.json")
}
static func load() -> Config {
guard let data = try? Data(contentsOf: configURL) else { return .default }
guard let parsed = try? JSONDecoder().decode(Config.self, from: data) else {
NSLog("campusnas-monitor: config.json is malformed, using defaults")
return .default
}
return parsed
}
/// Writes the current values out so the file is there to edit.
func write() {
let url = Config.configURL
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try? encoder.encode(self).write(to: url)
}
func writeIfAbsent() {
guard !FileManager.default.fileExists(atPath: Config.configURL.path) else { return }
write()
}
}