Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
6.3 KiB
Swift
133 lines
6.3 KiB
Swift
import Foundation
|
||
import CryptoKit
|
||
|
||
/// The wg1 device mesh, as the app knows it. This is the **device-to-device
|
||
/// fabric** (Plane 1 of the networking design — see
|
||
/// `.project/history/20260608_fleet-manager-mesh-design.md`), distinct from the
|
||
/// public-swarm OpenVPN exit managed by `VPNConfigStore`. Source of truth for
|
||
/// the topology is `net-tools/data/mesh-hosts.json`; these constants mirror its
|
||
/// `mesh` block and the enrollment contract of `session-tools/bin/wg-phone-add`
|
||
/// (same hub, same client store, same conf shape — the app and the script are
|
||
/// interchangeable front-ends for one enrollment).
|
||
public enum MeshDefaults {
|
||
/// The hub's wg endpoint (yuzu / quinn-vps, 1984 Hosting Iceland).
|
||
public static let hubEndpoint = "89.127.233.145:51820"
|
||
/// The hub's wg1 public key.
|
||
public static let hubPublicKey = "t6LT6Ff4AxcGCd9Zug7dxkT7tXhkMV+fd28UCY/h8Xw="
|
||
/// SSH endpoints for editing the hub config, tried in order: the
|
||
/// `~/.ssh/config` alias first (carries the 1984 identity file), bare
|
||
/// root@public-IP as fallback.
|
||
public static let hubSSHEndpoints = ["quinn-vps", "root@89.127.233.145"]
|
||
/// Path of the hub-side wg config the enrollment appends to.
|
||
public static let hubConfigPath = "/etc/wireguard/wg1.conf"
|
||
public static let meshSubnet = "10.9.0.0/24"
|
||
public static let lanSubnet = "10.0.0.0/24"
|
||
/// Mesh DNS (apricot's dnsmasq) — gives joined devices `.wg`/`.lan` names.
|
||
public static let meshDNS = "10.9.0.2"
|
||
}
|
||
|
||
/// A WireGuard Curve25519 keypair in wg's base64 wire format. Generated with
|
||
/// CryptoKit (X25519 per RFC 7748 — byte-compatible with `wg genkey`/`wg pubkey`,
|
||
/// verified by test vector), so enrollment needs no `wg` binary on this Mac.
|
||
public struct WireGuardKeypair: Sendable, Equatable {
|
||
public let privateKey: String
|
||
public let publicKey: String
|
||
|
||
/// Fresh random keypair.
|
||
public init() {
|
||
let priv = Curve25519.KeyAgreement.PrivateKey()
|
||
privateKey = priv.rawRepresentation.base64EncodedString()
|
||
publicKey = priv.publicKey.rawRepresentation.base64EncodedString()
|
||
}
|
||
|
||
/// Rebuild from a stored base64 private key (re-derives the public key).
|
||
/// Nil when the string isn't a valid 32-byte base64 scalar.
|
||
public init?(privateKeyBase64: String) {
|
||
let trimmed = privateKeyBase64.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard let raw = Data(base64Encoded: trimmed), raw.count == 32,
|
||
let priv = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: raw) else { return nil }
|
||
privateKey = trimmed
|
||
publicKey = priv.publicKey.rawRepresentation.base64EncodedString()
|
||
}
|
||
}
|
||
|
||
/// Pure pieces of the enrollment: device-name validation, free-IP selection
|
||
/// against the hub config, and rendering of both sides' config text. Mirrors
|
||
/// `wg-phone-add` byte-for-byte where it matters (the hub `[Peer]` block probe
|
||
/// is an exact-line `grep`), so the script and the app stay idempotent against
|
||
/// each other.
|
||
public enum MeshJoin {
|
||
/// Valid device name: non-empty, `[A-Za-z0-9_-]` only (used in filenames and
|
||
/// as the comment line in the hub conf). Same rule as `wg-phone-add`.
|
||
public static func isValidDeviceName(_ name: String) -> Bool {
|
||
!name.isEmpty && name.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "_" || $0 == "-") }
|
||
}
|
||
|
||
/// Lowest free `10.9.0.n` (n in 5...254) given the hub config text — `.1`–`.4`
|
||
/// are the fixed hosts, phones/tablets start at `.5`. Nil when the /24 is full.
|
||
public static func freeAddress(hubConfigText: String) -> String? {
|
||
var used = Set<Int>()
|
||
for match in hubConfigText.matches(of: #/10\.9\.0\.(\d+)/#) {
|
||
if let n = Int(match.1) { used.insert(n) }
|
||
}
|
||
return (5...254).first { !used.contains($0) }.map { "10.9.0.\($0)" }
|
||
}
|
||
|
||
/// The `[Peer]` block appended to the hub's wg1.conf for a new device.
|
||
public static func hubPeerBlock(device: String, publicKey: String, address: String) -> String {
|
||
"""
|
||
|
||
[Peer]
|
||
# \(device)
|
||
PublicKey = \(publicKey)
|
||
AllowedIPs = \(address)/32
|
||
PersistentKeepalive = 25
|
||
"""
|
||
}
|
||
|
||
/// The app-setup payload for the iOS app's join page — the SECOND QR a
|
||
/// phone scans (after the WireGuard app takes the mesh one). Carries this
|
||
/// Mac's bridge endpoints: primary = the LAN leg, fallback = the mesh leg,
|
||
/// matching BridgeSettings' host/fallbackHost model. The iOS side parses
|
||
/// this exact shape (tvanarchy://bridge?host=…&fallback=…&port=…); change
|
||
/// them together. Nil when this Mac has no address to advertise.
|
||
public static func phoneSetupURL(lanHost: String? = LocalNetwork.lanAddress(),
|
||
meshHost: String? = LocalNetwork.meshAddress(),
|
||
port: Int = 8787) -> URL? {
|
||
guard let primary = lanHost ?? meshHost else { return nil }
|
||
var comps = URLComponents()
|
||
comps.scheme = "tvanarchy"
|
||
comps.host = "bridge"
|
||
var items = [URLQueryItem(name: "host", value: primary),
|
||
URLQueryItem(name: "port", value: String(port))]
|
||
if let meshHost, meshHost != primary {
|
||
items.append(URLQueryItem(name: "fallback", value: meshHost))
|
||
}
|
||
comps.queryItems = items
|
||
return comps.url
|
||
}
|
||
|
||
/// The client-side wg-quick config the new device imports (QR or file).
|
||
/// Routes the mesh /24 and the home LAN /24 through the tunnel; DNS via the
|
||
/// mesh resolver so `.wg`/`.lan` names work on the device.
|
||
public static func clientConfig(device: String, privateKey: String, address: String,
|
||
generatedAt: Date = Date()) -> String {
|
||
let stamp = ISO8601DateFormatter().string(from: generatedAt)
|
||
return """
|
||
# WireGuard config for \(device)
|
||
# Generated by TVAnarchy at \(stamp)
|
||
# Hub: \(MeshDefaults.hubEndpoint)
|
||
|
||
[Interface]
|
||
PrivateKey = \(privateKey)
|
||
Address = \(address)/32
|
||
DNS = \(MeshDefaults.meshDNS)
|
||
|
||
[Peer]
|
||
PublicKey = \(MeshDefaults.hubPublicKey)
|
||
Endpoint = \(MeshDefaults.hubEndpoint)
|
||
AllowedIPs = \(MeshDefaults.meshSubnet), \(MeshDefaults.lanSubnet)
|
||
PersistentKeepalive = 25
|
||
"""
|
||
}
|
||
}
|