tv-anarchy/Sources/TVAnarchyCore/Mesh/MeshClientStore.swift

96 lines
4.2 KiB
Swift
Raw Permalink Normal View History

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/<device>/`
/// **shared with `wg-phone-add`** (same layout: `private.key`, `public.key`,
/// `address`, `<device>.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 }
}