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>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user