Files
ovpn/Sources/CampusNASMonitor/Probe.swift
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

222 lines
7.8 KiB
Swift

import Foundation
import Network
/// One-shot guard so a continuation can never be resumed twice, which would trap.
private final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var fired = false
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if fired { return false }
fired = true
return true
}
}
enum Probe {
/// Runs a command and reports whether it exited 0. A process that outlives
/// `timeout` is killed and treated as failure — the VPN wrapper can block on
/// a dead network otherwise.
static func commandSucceeds(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> Bool {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
NSLog("campusnas-monitor: not executable: %@", launchPath)
return false
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
guard once.claim() else { return }
continuation.resume(returning: proc.terminationStatus == 0)
}
do {
try process.run()
} catch {
NSLog("campusnas-monitor: failed to run %@: %@", launchPath, "\(error)")
if once.claim() { continuation.resume(returning: false) }
return
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() { continuation.resume(returning: false) }
}
}
}
struct RunResult: Sendable {
let ok: Bool
let output: String
let error: String
}
/// Like `commandOutput`, but also reports stderr and never discards output
/// on a non-zero exit — needed to diagnose privileged steps.
static func commandRun(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> RunResult {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
return RunResult(ok: false, output: "", error: "not executable: \(launchPath)")
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
do {
try process.run()
} catch {
if once.claim() {
continuation.resume(returning: RunResult(
ok: false, output: "", error: "\(error)"))
}
return
}
DispatchQueue.global().async {
// Drain stderr on its own thread: both pipes filling at once
// would deadlock a sequential read.
var errorText = ""
let errorQueue = DispatchQueue(label: "campusnas.stderr")
let group = DispatchGroup()
group.enter()
errorQueue.async {
let data = errPipe.fileHandleForReading.readDataToEndOfFile()
errorText = String(data: data, encoding: .utf8) ?? ""
group.leave()
}
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.wait()
process.waitUntilExit()
guard once.claim() else { return }
continuation.resume(returning: RunResult(
ok: process.terminationStatus == 0,
output: String(data: outData, encoding: .utf8) ?? "",
error: errorText))
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() {
continuation.resume(returning: RunResult(
ok: false, output: "", error: "timeout after \(timeout)s"))
}
}
}
}
/// Runs a command and returns its stdout, or nil on failure/timeout.
///
/// stdout is drained on a background queue rather than after exit: a process
/// that fills the pipe buffer would otherwise block forever waiting for a
/// reader that never comes.
static func commandOutput(
_ launchPath: String, args: [String], timeout: TimeInterval
) async -> String? {
guard FileManager.default.isExecutableFile(atPath: launchPath) else {
Log.write("not executable: \(launchPath)")
return nil
}
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
do {
try process.run()
} catch {
Log.write("failed to run \(launchPath): \(error)")
if once.claim() { continuation.resume(returning: nil) }
return
}
DispatchQueue.global().async {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard once.claim() else { return }
let ok = process.terminationStatus == 0
continuation.resume(
returning: ok ? String(data: data, encoding: .utf8) : nil)
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
guard process.isRunning else { return }
process.terminate()
if once.claim() { continuation.resume(returning: nil) }
}
}
}
/// Opens a TCP connection and closes it immediately. Deliberately does not
/// touch the autofs path: an `ls` there would trigger a mount on every poll
/// and defeat the whole point of lazy mounting.
static func tcpReachable(
host: String, port: UInt16, timeout: TimeInterval
) async -> Bool {
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return false }
let connection = NWConnection(
host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
let once = ResumeOnce()
return await withCheckedContinuation { continuation in
@Sendable func finish(_ reachable: Bool) {
guard once.claim() else { return }
connection.cancel()
continuation.resume(returning: reachable)
}
connection.stateUpdateHandler = { state in
switch state {
case .ready:
finish(true)
case .failed, .cancelled:
finish(false)
case .waiting:
// Host unreachable / connection refused — no point waiting out
// the full timeout for a retry that will not happen.
finish(false)
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
finish(false)
}
}
}
}