mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -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?
|
||||
}
|
||||
Reference in new issue
Block a user