Initial release

This commit is contained in:
aculix committed 2026-09-28 18:28:32 +05:30
commit 4b67728cce
108 files changed
+13310

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Synchronization
public struct CLIRunner: Sendable {
public struct Output: Sendable {
public var status: Int32
public var text: String
public var lastLine: String {
text.split(whereSeparator: \.isNewline).last.map(String.init) ?? "exit status \(status)"
}
}
public enum Failure: Error, Equatable {
case notInstalled
case timedOut([String])
case launchFailed(String)
}
// Fixed paths: apps launched from the Services menu get a minimal PATH.
public static let searchPaths = ["/usr/local/bin/container", "/opt/homebrew/bin/container"]
public let searchPaths: [String]
public init(searchPaths: [String] = CLIRunner.searchPaths) {
self.searchPaths = searchPaths
}
/// Looked up on every call, so a runtime installed while the app runs is found.
public var executable: String? {
searchPaths.first { FileManager.default.isExecutableFile(atPath: $0) }
}
public func run(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(with: Result { try self.runBlocking(args, timeout: timeout, line: line) })
}
}
}
static let outputLimit = 1024 * 1024
private static let builds = Mutex(Set<Int32>())
/// Builds would outlive the app. Other commands are left to the quit path, which waits for them.
public static func stopBuilds() {
for pid in builds.withLock({ $0 }) { kill(pid, SIGTERM) }
}
public func runBlocking(_ args: [String], timeout: TimeInterval,
line: (@Sendable (String) -> Void)? = nil) throws -> Output {
guard let executable else { throw Failure.notInstalled }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
// Only the tail is kept: a noisy build can print for minutes.
let limit = Self.outputLimit
let collected = Mutex(Data())
let pending = Mutex(Data())
pipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
collected.withLock { buffer in
buffer.append(chunk)
if buffer.count > limit { buffer.removeFirst(buffer.count - limit) }
}
guard let line else { return }
let lines: [String] = pending.withLock { buffer in
buffer.append(chunk)
var out: [String] = []
while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
out.append(String(decoding: buffer[buffer.startIndex..<newline], as: UTF8.self))
buffer.removeSubrange(buffer.startIndex...newline)
}
if buffer.count > 16 * 1024 { // A line that never ends.
out.append(String(decoding: buffer.prefix(16 * 1024), as: UTF8.self))
buffer.removeAll()
}
return out
}
for text in lines where !text.trimmingCharacters(in: .whitespaces).isEmpty { line(text) }
}
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.launchFailed(error.localizedDescription)
}
let pid = process.processIdentifier
let isBuild = args.first == "build"
if isBuild { Self.builds.withLock { _ = $0.insert(pid) } }
defer { if isBuild { Self.builds.withLock { _ = $0.remove(pid) } } }
if finished.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
if finished.wait(timeout: .now() + 1) == .timedOut {
kill(process.processIdentifier, SIGKILL)
finished.wait()
}
pipe.fileHandleForReading.readabilityHandler = nil
throw Failure.timedOut(args)
}
pipe.fileHandleForReading.readabilityHandler = nil
let rest = pipe.fileHandleForReading.readDataToEndOfFile()
let data = collected.withLock { ($0 + rest).suffix(limit) }
return Output(status: process.terminationStatus, text: String(decoding: data, as: UTF8.self))
}
}
@@ -0,0 +1,311 @@
import Foundation
import Synchronization
public final class ContainerCLIBackend: VMBackend {
public static let image = "local/knotchy-vm:latest"
public static let staleAfterDays = 14
private let cli: CLIRunner
private let vmSources: URL?
private let runDirectory: URL
private let startsInFlight = Mutex(0)
private let route = Mutex(NetworkRoute.direct)
private let startedRoute = Mutex(NetworkRoute.direct)
public var lastStartedRoute: NetworkRoute { startedRoute.withLock { $0 } }
public func setNetworkRoute(_ newRoute: NetworkRoute) {
route.withLock { $0 = newRoute }
}
/// Test-only (knotchyctl --allow-local-proxy); the app never sets it.
private let localProxyAllowed = Mutex(false)
public func allowLocalProxy() {
localProxyAllowed.withLock { $0 = true }
}
public init(vmSources: URL?, cli: CLIRunner = CLIRunner()) {
self.vmSources = vmSources
self.cli = cli
runDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("knotchy", isDirectory: true)
}
// MARK: Preflight
public func preflight() async -> PreflightResult {
guard cli.executable != nil else { return .issue(.cliMissing) }
if case .outdated(let found) = await runtimeStatus() { return .issue(.cliOutdated(found: found)) }
guard let status = try? await cli.run(["system", "status"], timeout: 15), status.status == 0,
field("status", in: status.text) == "running"
else { return .issue(.serviceStopped) }
let appRoot = field("paths.appRoot", in: status.text)
?? NSHomeDirectory() + "/Library/Application Support/com.apple.container/"
let kernel = URL(fileURLWithPath: appRoot).appendingPathComponent("kernels/default.kernel-arm64")
guard FileManager.default.fileExists(atPath: kernel.path) else { return .issue(.kernelMissing) }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .issue(.imageMissing) }
let labels = Self.labels(in: inspect.text)
let found = labels["aculix.knotchy.protocol"].flatMap(Int.init) ?? 0
guard found == WireProtocol.version else {
return .issue(.imageOutdated(found: found, need: WireProtocol.version))
}
prepareRunDirectory()
return .ready(appRoot: appRoot, staleImage: Self.staleness(built: labels["aculix.knotchy.built"]))
}
/// Resolved on the Mac: a proxy route gives the VM no DNS, and a VPN's DNS needs the tunnel up.
static func resolveIPv4(_ host: String) throws -> String {
var address = host
if !WireGuardConfig.isIPv4(host) {
var hints = addrinfo()
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_STREAM
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0, let first = result, let sockaddr = first.pointee.ai_addr else {
throw VMBackendError.failed("Couldn't find the server “\(host)”. Check Settings → Network.")
}
defer { freeaddrinfo(result) }
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
var inAddr = sockaddr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
guard inet_ntop(AF_INET, &inAddr, &buffer, socklen_t(buffer.count)) != nil else {
throw VMBackendError.failed("Couldn't find the server “\(host)”.")
}
address = String(cString: buffer)
}
if address.hasPrefix("127.") || address == "0.0.0.0" {
throw VMBackendError.failed("The VM can't reach this Mac's localhost (“\(host)”). A proxy on this Mac must listen on the Mac's network address instead.")
}
return address
}
public func imageStatus() async -> ImageStatus {
guard cli.executable != nil else { return .noContainerTool }
guard let inspect = try? await cli.run(["image", "inspect", Self.image], timeout: 15), inspect.status == 0
else { return .missing }
let built = Self.labels(in: inspect.text)["aculix.knotchy.built"].flatMap { ISO8601DateFormatter().date(from: $0) }
return .built(built)
}
public enum ImageStatus: Equatable, Sendable {
case noContainerTool
case missing
case built(Date?)
}
public func runtimeStatus() async -> RuntimeStatus {
guard cli.executable != nil else { return .missing }
let output = try? await cli.run(["--version"], timeout: 10)
return ContainerRuntime.status(versionOutput: output?.text ?? "")
}
public func stopService() async throws {
let result = try await cli.run(["system", "stop"], timeout: 60)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't stop the container service: \(result.lastLine)") }
}
private func field(_ name: String, in text: String) -> String? {
for line in text.split(whereSeparator: \.isNewline) {
let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
if parts.count == 2, parts[0] == name {
return parts[1].trimmingCharacters(in: .whitespaces)
}
}
return nil
}
static func labels(in json: String) -> [String: String] {
var found: [String: String] = [:]
func walk(_ value: Any) {
if let dict = value as? [String: Any] {
for (key, child) in dict {
if key.hasPrefix("aculix.knotchy."), let text = child as? String { found[key] = text }
walk(child)
}
} else if let array = value as? [Any] {
array.forEach(walk)
}
}
if let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) { walk(object) }
return found
}
static func staleness(built: String?, now: Date = Date()) -> StaleImage? {
let formatter = ISO8601DateFormatter()
guard let built, let date = formatter.date(from: built) else { return StaleImage(builtDaysAgo: nil) }
let days = Int(now.timeIntervalSince(date) / 86_400)
return days > staleAfterDays ? StaleImage(builtDaysAgo: days) : nil
}
private func prepareRunDirectory() {
let fm = FileManager.default
let path = runDirectory.path
var isDirectory: ObjCBool = false
let attributes = try? fm.attributesOfItem(atPath: path)
let isSymlink = (attributes?[.type] as? FileAttributeType) == .typeSymbolicLink
let owner = (attributes?[.ownerAccountID] as? NSNumber)?.uint32Value
let mode = (attributes?[.posixPermissions] as? NSNumber)?.intValue
if isSymlink || !fm.fileExists(atPath: path, isDirectory: &isDirectory) || !isDirectory.boolValue
|| owner != getuid() || mode != 0o700 {
try? fm.removeItem(atPath: path)
try? fm.createDirectory(atPath: path, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
}
// Secrets left by a crash; a VM's init deletes its own within a second of booting.
let stale = Date().addingTimeInterval(-120)
for name in (try? fm.contentsOfDirectory(atPath: path)) ?? [] where name.hasSuffix(".secrets") || name.hasSuffix(".env") {
let item = runDirectory.appendingPathComponent(name)
if let modified = (try? fm.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date, modified < stale {
try? fm.removeItem(at: item)
}
}
}
// MARK: Start and destroy
public func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession {
startsInFlight.withLock { $0 += 1 }
defer { startsInFlight.withLock { $0 -= 1 } }
prepareRunDirectory()
let socket = runDirectory.appendingPathComponent("\(id).sock").path
unlink(socket)
let chosenRoute = route.withLock { $0 }
let launch = try chosenRoute.launch(resolve: Self.resolveIPv4, hostNetworks: blockCIDRs,
allowLocalProxy: localProxyAllowed.withLock { $0 })
var args = [
"run", "-d", "--rm", "--name", id,
"--cpus", "4", "--memory", "2g", "--shm-size", "512m",
"--dns", "1.1.1.1",
"--cap-add", "NET_ADMIN",
// WebKit's bwrap can't mount procfs over a masked /proc, and init writes /proc/sys.
"--masked-path", "NONE", "--read-only-path", "NONE",
"--publish-socket", "\(socket):/tmp/knotchy/knotchy.sock",
"-e", "KNOTCHY_URL=\(url.absoluteString)",
"-e", "KNOTCHY_SIZE=\(renderSize)",
"-e", "KNOTCHY_BLOCK_CIDRS=\(blockCIDRs.joined(separator: " "))",
]
for setting in launch.environment { args += ["-e", setting] }
if !launch.secrets.isEmpty {
// Init reads, deletes and unmounts this (needs SYS_ADMIN), then drops all capabilities.
let share = secretsDirectory(id)
try? FileManager.default.removeItem(at: share)
guard (try? FileManager.default.createDirectory(at: share, withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])) != nil,
FileManager.default.createFile(atPath: share.appendingPathComponent("route").path,
contents: Data((launch.secrets.joined(separator: "\n") + "\n").utf8),
attributes: [.posixPermissions: 0o600])
else {
try? FileManager.default.removeItem(at: share)
throw VMBackendError.failed("Couldn't pass the network settings to the VM.")
}
args += ["--cap-add", "SYS_ADMIN", "--mount", "type=bind,source=\(share.path),target=/run/knotchy-secrets"]
}
args.append(Self.image)
let output: CLIRunner.Output
do {
output = try await cli.run(args, timeout: 60)
} catch {
throw VMBackendError.failed("Starting the VM timed out.")
}
guard output.status == 0 else {
cleanUp(id)
throw VMBackendError.failed("Couldn't start the VM: \(output.lastLine)")
}
startedRoute.withLock { $0 = chosenRoute }
return VMSession(id: id, socketPath: socket)
}
public func destroy(id: String) async throws {
_ = try? await cli.run(["stop", "-t", "2", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func forceDestroy(id: String) async throws {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
if await waitUntilGone(id, for: 5) { return cleanUp(id) }
throw VMBackendError.destroyStuck(id: id)
}
public func sweepOrphans(keeping: Set<String>) async {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10) else { return }
for id in Self.ids(in: listed.text) where Self.isSessionID(id) && !keeping.contains(id) {
_ = try? await cli.run(["delete", "--force", id], timeout: 10)
cleanUp(id)
}
}
static func isSessionID(_ id: String) -> Bool {
id.count == 16 && id.hasPrefix("knotchy-") && id.dropFirst(8).allSatisfy(\.isHexDigit)
}
static func ids(in text: String) -> [String] {
text.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
private func isListed(_ id: String) async -> Bool? {
guard let listed = try? await cli.run(["ls", "-a", "-q"], timeout: 10), listed.status == 0 else { return nil }
return Self.ids(in: listed.text).contains(id)
}
private func waitUntilGone(_ id: String, for seconds: Double) async -> Bool {
let deadline = Date().addingTimeInterval(seconds)
repeat {
if await isListed(id) == false { return true }
try? await Task.sleep(for: .milliseconds(100))
} while Date() < deadline
return false
}
private func cleanUp(_ id: String) {
unlink(runDirectory.appendingPathComponent("\(id).sock").path)
try? FileManager.default.removeItem(at: secretsDirectory(id))
}
private func secretsDirectory(_ id: String) -> URL {
runDirectory.appendingPathComponent("\(id).secrets", isDirectory: true)
}
// MARK: Setup actions
public func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws {
switch issue {
case .serviceStopped, .kernelMissing:
let result = try await cli.run(["system", "start", "--enable-kernel-install"], timeout: 120, line: output)
guard result.status == 0 else { throw VMBackendError.failed("Couldn't start the service: \(result.lastLine)") }
case .imageMissing, .imageOutdated, .imageStale:
guard let vmSources else { throw VMBackendError.failed("The VM sources are missing from the app.") }
let now = Date()
let day = DateFormatter()
day.dateFormat = "yyyy-MM-dd"
let args = [
"build", "--cpus", "4", "--memory", "4g", "--dns", "1.1.1.1", "--pull",
"--build-arg", "APT_REFRESH=\(day.string(from: now))",
"--build-arg", "BUILD_DATE=\(ISO8601DateFormatter().string(from: now))",
"-f", vmSources.appendingPathComponent("Containerfile").path,
"-t", Self.image, vmSources.path,
]
let result = try await cli.run(args, timeout: 15 * 60, line: output)
guard result.status == 0 else { throw VMBackendError.failed("The image build failed: \(result.lastLine)") }
case .cliMissing, .cliOutdated, .lowDisk:
throw VMBackendError.failed("There's no automatic fix for this.")
}
}
// MARK: Quit path
public func destroyBlocking(ids: [String], deadline: Duration) {
let end = Date().addingTimeInterval(TimeInterval(deadline.components.seconds))
// Wait for in-flight starts so a VM created at the last moment is destroyed too.
while startsInFlight.withLock({ $0 }) > 0, Date() < end { usleep(50_000) }
for id in ids {
let left = end.timeIntervalSinceNow
guard left > 0.5 else { return }
_ = try? cli.runBlocking(["delete", "--force", id], timeout: min(10, left))
cleanUp(id)
}
}
}
@@ -0,0 +1,35 @@
import Darwin
import Foundation
public enum HostNetworks {
/// Blocked in the VM on top of the private ranges, for LANs and VPNs on public addresses.
public static func current() -> [String] {
var head: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&head) == 0, let first = head else { return [] }
defer { freeifaddrs(head) }
var networks = Set<String>()
for pointer in sequence(first: first, next: { $0.pointee.ifa_next }) {
let interface = pointer.pointee
guard let address = interface.ifa_addr, let mask = interface.ifa_netmask,
address.pointee.sa_family == UInt8(AF_INET),
interface.ifa_flags & UInt32(IFF_LOOPBACK) == 0 else { continue }
let ip = address.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let bits = mask.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let prefix = bits.nonzeroBitCount
guard prefix >= 8 else { continue } // Never let a bogus /0 block the whole internet.
let network = ip & bits
networks.insert("\(network >> 24).\((network >> 16) & 0xFF).\((network >> 8) & 0xFF).\(network & 0xFF)/\(prefix)")
}
return networks.sorted()
}
}
public struct SystemDiskSpace: DiskSpaceChecking {
public init() {}
@concurrent
public func bytesFree(atPath path: String) async -> Int64? {
let values = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
return values?.volumeAvailableCapacityForImportantUsage
}
}
@@ -0,0 +1,245 @@
import Foundation
/// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out.
public enum NetworkRoute: Equatable, Sendable {
case direct
case proxy(ProxySettings)
case wireGuard(WireGuardConfig)
/// Sessions fail to start rather than quietly browsing directly.
case unusable(reason: String)
}
public struct ProxySettings: Equatable, Sendable {
public enum Kind: String, CaseIterable, Sendable {
case http
case socks5
}
public var kind: Kind
public var host: String
public var port: Int
public var username: String
public var password: String
public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") {
self.kind = kind
self.host = host
self.port = port
self.username = username
self.password = password
}
public func url(address: String) -> String {
var userinfo = ""
if !username.isEmpty {
userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@"
}
return "\(kind.rawValue)://\(userinfo)\(address):\(port)"
}
/// Must match the VM firewall's list.
static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"]
/// A local proxy is refused: pages could reach the Mac and its network through it.
public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool {
guard let ip = ipv4Value(address) else { return true }
return (localRanges + hostNetworks).contains { cidr in
let parts = cidr.split(separator: "/")
guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits)
else { return false }
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
return ip & mask == network & mask
}
}
static func ipv4Value(_ text: String) -> UInt32? {
guard WireGuardConfig.isIPv4(text) else { return nil }
return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! }
}
static func encode(_ text: String) -> String {
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
}
}
/// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says.
public struct WireGuardConfig: Equatable, Sendable {
public var privateKey: String
public var addresses: [String]
/// Empty means 1.1.1.1 through the tunnel.
public var dns: [String]
public var mtu: Int?
public var peerPublicKey: String
public var presharedKey: String?
public var endpointHost: String
public var endpointPort: Int
public var persistentKeepalive: Int?
public var name: String?
public enum ParseError: Error, Equatable, Sendable {
case missing(String)
case invalid(String)
case multiplePeers
}
public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig {
var section = ""
var interface: [String: String] = [:]
var peers: [[String: String]] = []
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0]
.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
if line.hasPrefix("[") {
section = line.lowercased()
if section == "[peer]" { peers.append([:]) }
continue
}
guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) }
let key = line[..<equals].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces)
switch section {
case "[interface]":
interface[key] = interface[key].map { $0 + "," + value } ?? value
case "[peer]":
peers[peers.count - 1][key] = value
default:
throw .invalid(line)
}
}
guard peers.count <= 1 else { throw .multiplePeers }
guard let peer = peers.first else { throw .missing("[Peer]") }
guard let privateKey = interface["privatekey"] else { throw .missing("PrivateKey") }
guard isKey(privateKey) else { throw .invalid("PrivateKey") }
guard let publicKey = peer["publickey"] else { throw .missing("PublicKey") }
guard isKey(publicKey) else { throw .invalid("PublicKey") }
let presharedKey = peer["presharedkey"]
if let presharedKey, !isKey(presharedKey) { throw .invalid("PresharedKey") }
let addresses = list(interface["address"]).compactMap(ipv4CIDR)
guard !addresses.isEmpty else { throw .missing("an IPv4 Address") }
let dns = list(interface["dns"]).filter(isIPv4)
let mtu = try interface["mtu"].map { (text: String) throws(ParseError) -> Int in
guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") }
return mtu
}
let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in
if text.lowercased() == "off" { return nil }
guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") }
return seconds == 0 ? nil : seconds
}
guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") }
guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"),
let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port)
else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") }
let host = String(endpoint[..<colon])
guard !host.isEmpty, host.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "-" }) else {
throw .invalid("Endpoint")
}
return WireGuardConfig(privateKey: privateKey, addresses: addresses, dns: dns, mtu: mtu,
peerPublicKey: publicKey, presharedKey: presharedKey,
endpointHost: host, endpointPort: port, persistentKeepalive: keepalive)
}
public func setconf(endpointAddress address: String) -> String {
var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"]
if let presharedKey { lines.append("PresharedKey = \(presharedKey)") }
lines.append("Endpoint = \(address):\(endpointPort)")
lines.append("AllowedIPs = 0.0.0.0/0")
lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)")
return lines.joined(separator: "\n") + "\n"
}
// MARK: Validation
/// Curve25519: 32 bytes in base64.
static func isKey(_ text: String) -> Bool {
text.count == 44 && Data(base64Encoded: text)?.count == 32
}
static func list(_ text: String?) -> [String] {
(text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
static func isIPv4(_ text: String) -> Bool {
let parts = text.split(separator: ".", omittingEmptySubsequences: false)
return parts.count == 4 && parts.allSatisfy { part in
!part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber)
&& Int(part).map { $0 <= 255 } == true
}
}
static func ipv4CIDR(_ text: String) -> String? {
let parts = text.split(separator: "/", omittingEmptySubsequences: false)
guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil }
guard parts.count == 2 else { return text + "/32" }
guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil }
return text
}
}
extension NetworkRoute {
public var label: String? {
switch self {
case .direct, .unusable: nil
case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy"
case .wireGuard(let config): config.name ?? "VPN"
}
}
}
extension WireGuardConfig.ParseError {
/// Finishes "The WireGuard config …".
public var message: String {
switch self {
case .missing(let what): "has no \(what)"
case .invalid(let what): "has an invalid \(what)"
case .multiplePeers: "has more than one peer; Knotchy needs one server per config"
}
}
}
public struct RouteLaunch: Equatable, Sendable {
/// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops.
public var environment: [String]
/// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`.
public var secrets: [String]
}
extension NetworkRoute {
public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [],
allowLocalProxy: Bool = false) throws -> RouteLaunch {
switch self {
case .direct:
return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: [])
case .unusable(let reason):
throw VMBackendError.failed(reason)
case .proxy(let proxy):
let address = try resolve(proxy.host)
if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) {
let which = proxy.host == address ? address : "\(proxy.host) is \(address)"
throw VMBackendError.failed(
"Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.")
}
return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"],
secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"])
case .wireGuard(let config):
let address = try resolve(config.endpointHost)
return RouteLaunch(
environment: [
"KNOTCHY_ROUTE=wireguard",
"KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)",
"KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))",
"KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))",
"KNOTCHY_WG_MTU=\(config.mtu ?? 1420)",
],
secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"]
)
}
}
}
@@ -0,0 +1,65 @@
import Foundation
/// The controller serializes all calls except `destroyBlocking`, which the quit path may make
/// from any thread while an async call is still running.
public protocol VMBackend: Sendable {
func preflight() async -> PreflightResult
/// Must register as in flight before its first suspension so `destroyBlocking` waits for it.
func start(id: String, url: URL, renderSize: PixelSize, blockCIDRs: [String]) async throws -> VMSession
/// Returns only once the VM is confirmed gone; otherwise throws `.destroyStuck`.
func destroy(id: String) async throws
func forceDestroy(id: String) async throws
func sweepOrphans(keeping: Set<String>) async
func fix(_ issue: SetupIssue, output: @escaping @Sendable (String) -> Void) async throws
/// Not serialized by the controller: `container --version` doesn't use the service.
func runtimeStatus() async -> RuntimeStatus
func stopService() async throws
/// Blocking. Waits for in-flight starts first so a VM created at the last moment is destroyed too.
func destroyBlocking(ids: [String], deadline: Duration)
}
public struct VMSession: Equatable, Sendable {
public let id: String
public let socketPath: String
public init(id: String, socketPath: String) {
self.id = id
self.socketPath = socketPath
}
}
public enum SetupIssue: Equatable, Sendable {
case cliMissing
case cliOutdated(found: String)
case serviceStopped
case kernelMissing
case imageMissing
case imageOutdated(found: Int, need: Int)
case imageStale(builtDaysAgo: Int?)
case lowDisk(bytesFree: Int64)
}
public enum PreflightResult: Equatable, Sendable {
case ready(appRoot: String, staleImage: StaleImage?)
/// Never `.imageStale` (that rides on `.ready`) or `.lowDisk` (the controller checks disk).
case issue(SetupIssue)
}
public struct StaleImage: Equatable, Sendable {
public var builtDaysAgo: Int?
public init(builtDaysAgo: Int?) {
self.builtDaysAgo = builtDaysAgo
}
}
public enum VMBackendError: Error, Equatable, Sendable {
case destroyStuck(id: String)
/// The message is shown to the user.
case failed(String)
}
public protocol DiskSpaceChecking: Sendable {
/// Async: the query can block for tens of milliseconds and stall the panel's opening animation.
func bytesFree(atPath path: String) async -> Int64?
}