import Foundation /// One enrolled mesh client as stored on disk (a phone, tablet, or another Mac /// that joined via the QR flow). The keypair lives here so re-showing the QR /// never mints a new identity. public struct MeshClient: Identifiable, Sendable, Equatable { public let device: String public let privateKey: String public let publicKey: String public let address: String public var id: String { device } /// `pubkey…` shortened for list display. public var shortPublicKey: String { String(publicKey.prefix(12)) + "…" } /// The full wg-quick config text for this client (what the QR encodes). public func config(generatedAt: Date = Date()) -> String { MeshJoin.clientConfig(device: device, privateKey: privateKey, address: address, generatedAt: generatedAt) } } /// The on-disk client store at `~/.config/wg-mesh/clients//` — /// **shared with `wg-phone-add`** (same layout: `private.key`, `public.key`, /// `address`, `.conf`), so devices enrolled from the terminal show up /// in the app and vice versa. Private keys: dirs 0700, files 0600. The root is /// injectable for tests; everything else scans the filesystem (the directory IS /// the source of truth, same pattern as `VPNConfigStore`). public struct MeshClientStore: Sendable { private let root: URL private static let fm = FileManager.default public init(root: URL? = nil) { self.root = root ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".config/wg-mesh/clients", isDirectory: true) } private func dir(_ device: String) -> URL { root.appendingPathComponent(device, isDirectory: true) } /// Every stored client whose keypair parses, sorted by name. Clients with a /// key but no recorded address are skipped (half-enrolled; re-enroll fixes). public func list() -> [MeshClient] { guard let dirs = try? Self.fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else { return [] } return dirs .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } .compactMap { load(device: $0.lastPathComponent) } .sorted { $0.device < $1.device } } public func load(device: String) -> MeshClient? { let d = dir(device) guard let priv = read(d.appendingPathComponent("private.key")), let keypair = WireGuardKeypair(privateKeyBase64: priv), let address = read(d.appendingPathComponent("address")) else { return nil } return MeshClient(device: device, privateKey: keypair.privateKey, publicKey: keypair.publicKey, address: address) } /// The stored keypair for a device, when one exists (enrollment reuses it). public func keypair(device: String) -> WireGuardKeypair? { read(dir(device).appendingPathComponent("private.key")) .flatMap(WireGuardKeypair.init(privateKeyBase64:)) } /// Persist an enrolled client: keys + address + rendered `.conf`, with /// key-material permissions (0700 dir, 0600 files). public func save(_ client: MeshClient) throws { let d = dir(client.device) try Self.fm.createDirectory(at: d, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) try write(client.privateKey, to: d.appendingPathComponent("private.key")) try write(client.publicKey, to: d.appendingPathComponent("public.key")) try write(client.address, to: d.appendingPathComponent("address")) try write(client.config(), to: d.appendingPathComponent("\(client.device).conf")) } private func read(_ url: URL) -> String? { (try? String(contentsOf: url, encoding: .utf8))? .trimmingCharacters(in: .whitespacesAndNewlines) .nonEmpty } private func write(_ text: String, to url: URL) throws { try text.write(to: url, atomically: true, encoding: .utf8) try Self.fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } } private extension String { var nonEmpty: String? { isEmpty ? nil : self } }