diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d6ded6f4..7ac41df6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,6 +33,12 @@ jobs: uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - name: Install Protocol Buffers compiler run: brew install protobuf + - name: Install Go toolchain + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version: '1.26.5' + - name: Network helper + run: scripts/test.sh gvproxy - name: Swift packages run: scripts/test.sh swift - name: App tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d245da1..07d6ddaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.3.2 - 2026-07-17 + +### Added + +- Replaced fixed app editions with a smaller Docker Core app and signed, removable Kubernetes, + Linux Machines, Linux Desktop runtime, Debian, Ubuntu, and Kali components stored on the selected + Dory data drive. +- Added a pre-download component selector that safely carries the chosen optional payloads into + Dory for signed-size review and explicit installation confirmation. +- Retired the separate public lite app so direct downloads present one Docker Core app and optional + signed components instead of overlapping app editions. +- Added exact and leftmost-wildcard custom domain mappings in Settings > Network and the CLI. Dory + now routes nginx-style `/etc/hosts` domains through its built-in HTTP and trusted HTTPS proxies. + +### Changed + +- Reused Docker Core's signed engine kernel and rootfs for the macOS 14 fallback instead of storing + duplicate compatibility copies, removing about 112 MB from the installed Core app. +- Quitting Dory now stops its background engine by default. People who want an always-running + engine can explicitly enable **Keep engine running after quit**. + +### Fixed + +- Fixed built-in safe home sharing so names such as `library` are hidden only at the shared home + root, while nested project directories such as Composer package paths remain visible. +- Fixed the Network authorization button so the guided admin operation succeeds before optional + background-service registration and Login Items approval. +- Fixed local HTTPS authorization failing after the admin prompt. Dory now adds its CA to the + current user's login keychain through an interactive macOS trust prompt, while the privileged + helper is limited to resolver and PF changes. +- Fixed custom local domains returning 502 after ports 80 and 443 were authorized. +- Fixed local HTTPS identity refreshes accumulating certificates and private keys in the user's + login keychain. +- Fixed stale container details remaining open after the selected container disappeared or no + longer matched the current view, and added toolbar controls for hiding navigation and details. +- Fixed reopening Dory from the Dock or menu bar when the app was running without a visible window. +- Fixed `linux/amd64` builds on IPv4-only host networks by withholding unreachable IPv6 DNS + answers while preserving native IPv6 when the Mac has a routable IPv6 path. + ## 0.3.1 - 2026-07-16 ### Added diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a4fc0528..347a2bf6 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,6 +1,6 @@ # Dory compatibility -This document describes the Dory 0.3.1 public product surface. Dory is under active development, so +This document describes the Dory 0.3.2 public product surface. Dory is under active development, so please report workflows that behave differently from a standard Docker engine. ## Platform @@ -12,10 +12,10 @@ please report workflows that behave differently from a standard Docker engine. | Intel Mac | Not included in current releases; planned after dedicated hardware validation | | Windows or Linux host | Not supported by the macOS app | -The standard Apple Silicon build omits the large graphical guest images. The all-inclusive Desktop -build adds the managed Debian, Ubuntu, and Kali images. Both builds include containers, Kubernetes, -and headless Linux servers; an existing graphical machine remains manageable if the lean build is -installed later. +Dory ships one Apple Silicon Docker Core app. Kubernetes, Linux Machines, the shared Linux Desktop +runtime, and the managed Debian, Ubuntu, and Kali images are signed optional components. Their +payloads live on the selected Dory data drive and can be removed independently without deleting +workload data. ## Docker workflow @@ -54,7 +54,7 @@ operations, but it is not a substitute for a normal backup strategy. | Capability | Status | |---|---| | k3s provisioning | Supported in the shared engine | -| `kubectl` | Bundled | +| `kubectl` | Installed by the optional Kubernetes component | | Resource browser | Pods, deployments, services, config maps, secrets, and ingresses | | Workload actions | Logs, exec, scale, restart, rollout, and apply | | Multiple Kubernetes versions | Selectable from supported Dory presets | diff --git a/Config/Dory-Info.plist b/Config/Dory-Info.plist index 3b703b2b..d3316fb1 100644 --- a/Config/Dory-Info.plist +++ b/Config/Dory-Info.plist @@ -6,10 +6,25 @@ https://augani.github.io/dory/appcast.xml SUPublicEDKey AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4= + DoryComponentCatalogURL + https://augani.github.io/dory/components/arm64/catalog.json SUEnableInstallerLauncherService LSMultipleInstancesProhibited + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + dev.dory.component-selection + CFBundleURLSchemes + + dory + + + UTExportedTypeDeclarations diff --git a/Dory.xcodeproj/project.pbxproj b/Dory.xcodeproj/project.pbxproj index 1457cb32..de73e2fb 100644 --- a/Dory.xcodeproj/project.pbxproj +++ b/Dory.xcodeproj/project.pbxproj @@ -414,7 +414,7 @@ CODE_SIGN_ENTITLEMENTS = Dory/Dory.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; ENABLE_APP_SANDBOX = NO; ENABLE_DEBUG_DYLIB = NO; @@ -431,7 +431,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -452,7 +452,7 @@ CODE_SIGN_ENTITLEMENTS = Dory/Dory.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -468,7 +468,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.Dory; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -486,11 +486,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -507,11 +507,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -527,10 +527,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -546,10 +546,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 42; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 864H636QW4; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.3.1; + MARKETING_VERSION = 0.3.2; PRODUCT_BUNDLE_IDENTIFIER = com.pythonxi.DoryUITests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/Dory/App/AppDelegate.swift b/Dory/App/AppDelegate.swift index 3a97f0e7..c619011e 100644 --- a/Dory/App/AppDelegate.swift +++ b/Dory/App/AppDelegate.swift @@ -4,6 +4,7 @@ import SwiftUI final class DoryAppDelegate: NSObject, NSApplicationDelegate { fileprivate static let mainWindowIdentifier = NSUserInterfaceItemIdentifier("dory.main-window") + private static let openMainWindowNotification = Notification.Name("dev.dory.open-main-window") private static let instanceLock = NSLock() private static var instanceLockFD: Int32 = -1 private static let statusItemController = DoryStatusItemController() @@ -31,6 +32,11 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { guard !isNetworkHelperMaintenance() else { return } guard !isTestHost, let bundleIdentifier = Bundle.main.bundleIdentifier else { return } guard acquireInstanceLock() else { + DistributedNotificationCenter.default().postNotificationName( + openMainWindowNotification, + object: nil, + deliverImmediately: true + ) runningInstance(bundleIdentifier: bundleIdentifier)?.activate(options: [.activateAllWindows]) exit(EXIT_SUCCESS) } @@ -126,6 +132,12 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { } return } + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(openMainWindowFromNotification(_:)), + name: Self.openMainWindowNotification, + object: nil + ) NSApp.setActivationPolicy(.accessory) Self.refreshMenuBarVisibility() Task { @MainActor in @@ -138,11 +150,31 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { false } + @MainActor func applicationShouldHandleReopen( + _ sender: NSApplication, + hasVisibleWindows flag: Bool + ) -> Bool { + Self.openMainWindow() + return false + } + func applicationDidBecomeActive(_ notification: Notification) { Task { @MainActor in Self.closeDuplicateMainWindows() } } + func application(_ application: NSApplication, open urls: [URL]) { + Task { @MainActor in + guard urls.contains(where: Self.statusItemController.handleComponentSelectionURL) else { return } + Self.openMainWindow() + } + } + func applicationWillTerminate(_ notification: Notification) { + DistributedNotificationCenter.default().removeObserver( + self, + name: Self.openMainWindowNotification, + object: nil + ) if !Self.isTestHost, !AppStore.resolvedKeepDorydRunningAfterQuit(defaults: .standard) { DorydLaunchAgent.stopAndRemoveCurrentSynchronously() @@ -157,6 +189,10 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { } } + @objc private func openMainWindowFromNotification(_ notification: Notification) { + Task { @MainActor in Self.openMainWindow() } + } + @MainActor static func markMainWindow(_ window: NSWindow) { window.identifier = mainWindowIdentifier window.title = "Dory" @@ -220,8 +256,10 @@ private final class DoryStatusItemController: NSObject, NSPopoverDelegate, NSWin func openMainWindow() { guard let store else { return } if let existing = NSApp.windows.first(where: { $0.identifier == DoryAppDelegate.mainWindowIdentifier }) { - existing.makeKeyAndOrderFront(nil) DoryActivation.setForeground(true) + existing.deminiaturize(nil) + existing.makeKeyAndOrderFront(nil) + existing.orderFrontRegardless() return } @@ -252,8 +290,14 @@ private final class DoryStatusItemController: NSObject, NSPopoverDelegate, NSWin } DoryAppDelegate.markMainWindow(window) - window.makeKeyAndOrderFront(nil) DoryActivation.setForeground(true) + window.deminiaturize(nil) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + + func handleComponentSelectionURL(_ url: URL) -> Bool { + store?.handleComponentSelectionURL(url) == true } private func installStatusItemIfNeeded() { diff --git a/Dory/App/AppInfo.swift b/Dory/App/AppInfo.swift index c1fb9344..c60449e7 100644 --- a/Dory/App/AppInfo.swift +++ b/Dory/App/AppInfo.swift @@ -1,14 +1,47 @@ +import DoryOperations import Foundation /// The app's own version and build, read from the bundle (driven by the project's /// `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`) so the displayed version is always correct. -enum AppInfo { +nonisolated enum AppInfo { static let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" static let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0" - /// Build scripts set this explicitly. Defaulting to true keeps direct Xcode and test builds useful. - static let includesDesktopLinux = desktopLinuxIncluded( - from: Bundle.main.object(forInfoDictionaryKey: "DoryIncludesDesktopLinux") - ) + static var componentCatalogURL: URL { + if let override = ProcessInfo.processInfo.environment["DORY_COMPONENT_CATALOG_URL"], + let url = URL(string: override), + url.scheme == "https" || url.isFileURL { + return url + } + if let value = Bundle.main.object(forInfoDictionaryKey: "DoryComponentCatalogURL") as? String, + let url = URL(string: value), + url.scheme == "https" { + return url + } + return DoryComponentDefaults.catalogURL + } + /// Legacy 0.3.x builds used one Desktop boolean. New builds declare every payload in + /// DoryBundledComponents; optional installed components are resolved from the selected data drive. + static var includesDesktopLinux: Bool { + componentAvailable(.linuxDesktop) + && [.desktopDebian, .desktopUbuntu, .desktopKali].contains(where: componentAvailable) + } + + static func componentAvailable(_ id: DoryComponentID) -> Bool { + if bundledComponents.contains(id) { return true } + guard id.isRemovable, let store = try? DoryComponentStore.selected() else { return false } + return store.isInstalledAndValid(id) + } + + static var bundledComponents: Set { + if let raw = Bundle.main.object(forInfoDictionaryKey: "DoryBundledComponents") as? [String] { + return Set(raw.compactMap(DoryComponentID.init(rawValue:))).union([.dockerCore]) + } + var legacy: Set = [.dockerCore, .kubernetes, .linuxMachines] + if desktopLinuxIncluded(from: Bundle.main.object(forInfoDictionaryKey: "DoryIncludesDesktopLinux")) { + legacy.formUnion([.linuxDesktop, .desktopDebian, .desktopUbuntu, .desktopKali]) + } + return legacy + } static func desktopLinuxIncluded(from bundleValue: Any?) -> Bool { if let value = bundleValue as? Bool { diff --git a/Dory/App/DoryCommands.swift b/Dory/App/DoryCommands.swift index 88dd2906..e125b679 100644 --- a/Dory/App/DoryCommands.swift +++ b/Dory/App/DoryCommands.swift @@ -1,4 +1,5 @@ import AppKit +import DoryOperations import SwiftUI struct DoryCommands: Commands { @@ -17,31 +18,39 @@ struct DoryCommands: Commands { store.activeSheet = .newContainer } .keyboardShortcut("n", modifiers: .command) - if AppInfo.includesDesktopLinux { - Button("New Desktop") { - store.section = .desktops - store.activeSheet = .newDesktop - } - .keyboardShortcut("d", modifiers: [.command, .option]) + Button("New Desktop") { + openMain(.desktops) + store.presentPrimary(for: .desktops) } + .keyboardShortcut("d", modifiers: [.command, .option]) Button("New Server") { - store.section = .machines - store.activeSheet = .newMachine + openMain(.machines) + store.presentPrimary(for: .machines) } .keyboardShortcut("n", modifiers: [.command, .option]) } CommandGroup(after: .toolbar) { + Button(store.isSidebarVisible ? "Hide Sidebar" : "Show Sidebar") { + store.isSidebarVisible.toggle() + } + .keyboardShortcut("s", modifiers: [.command, .control]) + if store.section == .containers, store.selectedContainer != nil { + Button(store.isContainerInspectorVisible ? "Hide Container Details" : "Show Container Details") { + store.isContainerInspectorVisible.toggle() + } + .keyboardShortcut("i", modifiers: [.command, .option]) + } + Divider() Button("Containers") { store.section = .containers }.keyboardShortcut("1", modifiers: .command) Button("Images") { store.section = .images }.keyboardShortcut("2", modifiers: .command) Button("Volumes") { store.section = .volumes }.keyboardShortcut("3", modifiers: .command) Button("Networks") { store.section = .networks }.keyboardShortcut("4", modifiers: .command) Button("Compose") { store.section = .compose }.keyboardShortcut("5", modifiers: .command) Button("Kubernetes") { store.section = .kubernetes }.keyboardShortcut("6", modifiers: .command) - if AppInfo.includesDesktopLinux || store.machines.contains(where: { $0.displayMode == .desktop }) { - Button("Desktops") { store.section = .desktops }.keyboardShortcut("7", modifiers: .command) - } + Button("Desktops") { store.section = .desktops }.keyboardShortcut("7", modifiers: .command) Button("Servers") { store.section = .machines }.keyboardShortcut("8", modifiers: .command) - Button("Health") { store.section = .health }.keyboardShortcut("9", modifiers: .command) + Button("Components") { store.section = .components }.keyboardShortcut("9", modifiers: .command) + Button("Health") { store.section = .health } Button("Settings") { store.section = .settings }.keyboardShortcut(",", modifiers: .command) Button("Filter") { if store.section != .settings { store.filterFocusToken += 1 } } .keyboardShortcut("f", modifiers: .command) @@ -125,15 +134,13 @@ struct DoryCommands: Commands { } Menu("Linux Machines") { - if AppInfo.includesDesktopLinux { - Button("New Desktop") { - openMain(.desktops) - store.activeSheet = .newDesktop - } + Button("New Desktop") { + openMain(.desktops) + store.presentPrimary(for: .desktops) } Button("New Server") { openMain(.machines) - store.activeSheet = .newMachine + store.presentPrimary(for: .machines) } Divider() if store.machines.isEmpty { @@ -184,12 +191,21 @@ struct DoryCommands: Commands { } } else { Button("Enable Kubernetes") { - Task { await store.enableKubernetes() } + if AppInfo.componentAvailable(.kubernetes) { + Task { await store.enableKubernetes() } + } else { + openMain(.components) + store.actionError = "Install Kubernetes in Components before enabling the cluster." + } } .disabled(store.runtimeKind != .sharedVM || store.kubernetesBusy) } } } + CommandMenu("Components") { + Button("Open Components") { openMain(.components) } + Button("Manage Components in Settings") { openSettings(.components) } + } } private var runningServices: [Container] { @@ -230,9 +246,7 @@ struct DoryCommands: Commands { } private func openContainer(_ container: Container, scope: ContainerScope) { - store.setContainerScope(scope) - store.section = .containers - store.selectedContainerID = container.id + store.revealContainer(container, scope: scope) store.windowOpenRequested = true openWindow(id: Self.openDoryWindowID) } diff --git a/Dory/ContentView.swift b/Dory/ContentView.swift index e2add31e..67e2ad1b 100644 --- a/Dory/ContentView.swift +++ b/Dory/ContentView.swift @@ -6,7 +6,10 @@ struct RootView: View { var body: some View { HStack(spacing: 0) { - SidebarView() + if store.isSidebarVisible { + SidebarView() + .transition(.move(edge: .leading).combined(with: .opacity)) + } MainColumnView() } .frame(minWidth: 1000, minHeight: 660) @@ -66,6 +69,7 @@ struct RootView: View { .animation(.spring(duration: 0.3), value: store.settingsNotice) .animation(.spring(duration: 0.3), value: store.dockerHostConflict) .animation(.spring(duration: 0.3), value: store.dockerHostCleaned) + .animation(.easeInOut(duration: 0.18), value: store.isSidebarVisible) .task { store.startBackendIfNeeded() } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await store.refreshIfIdle() } diff --git a/Dory/Features/Components/ComponentsView.swift b/Dory/Features/Components/ComponentsView.swift new file mode 100644 index 00000000..29f45483 --- /dev/null +++ b/Dory/Features/Components/ComponentsView.swift @@ -0,0 +1,572 @@ +import DoryOperations +import SwiftUI + +struct ComponentsView: View { + @Environment(AppStore.self) private var appStore + @Environment(\.palette) private var p + let embedded: Bool + + @State private var catalog: DoryComponentCatalog? + @State private var catalogData = Data() + @State private var statuses: [DoryComponentStatus] = [] + @State private var progress: [DoryComponentID: DoryComponentProgress] = [:] + @State private var busy: Set = [] + @State private var pendingRemoval: DoryComponentID? + @State private var errorMessage: String? + @State private var usingCachedCatalog = false + @State private var installingSelection = false + + init(embedded: Bool = false) { + self.embedded = embedded + } + + var body: some View { + Group { + if embedded { + content + } else { + ScrollView { content.padding(.horizontal, 24).padding(.vertical, 20) } + } + } + .task { await refresh(preferRemote: true) } + .confirmationDialog( + "Remove \(pendingRemoval.map(displayName) ?? "component")?", + isPresented: Binding( + get: { pendingRemoval != nil }, + set: { if !$0 { pendingRemoval = nil } } + ), + titleVisibility: .visible + ) { + if let pendingRemoval { + Button("Remove \(displayName(pendingRemoval))", role: .destructive) { + self.pendingRemoval = nil + Task { await remove(pendingRemoval) } + } + } + Button("Cancel", role: .cancel) { pendingRemoval = nil } + } message: { + Text("Only the component payload is removed. Your containers, Kubernetes state, machines, disks, snapshots, and exports stay on the selected Dory data drive.") + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: 18) { + header + if !appStore.requestedComponentIDs.isEmpty { + requestedSelectionPanel + } + if let errorMessage { + errorPanel(errorMessage) + } + if statuses.isEmpty, errorMessage == nil { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("Loading signed component catalog…") + .font(.system(size: 12.5)).foregroundStyle(p.text2) + } + .padding(.vertical, 20) + } else { + componentGrid + } + dataSafetyPanel + } + .frame(maxWidth: 820, alignment: .leading) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 8) { + Text("Components") + .font(.system(size: embedded ? 18 : 22, weight: .bold)) + .foregroundStyle(p.text) + if usingCachedCatalog { + Text("Offline catalog") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(p.amber) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(p.amber.opacity(0.12), in: Capsule()) + } + } + Text("Start with Docker Core. Add only the Kubernetes, Linux machine, and desktop payloads you use. Each component updates and removes independently.") + .font(.system(size: 12.5)).foregroundStyle(p.text2).lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + if let catalog { + Text("Catalog \(catalog.releaseVersion) · Apple silicon · sizes shown before download") + .font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } + + private var componentGrid: some View { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: embedded ? 300 : 340, maximum: 410), spacing: 12)], + alignment: .leading, + spacing: 12 + ) { + ForEach(statuses) { status in + componentCard(status) + } + } + } + + private var requestedSelectionPanel: some View { + let releases = requestedSelectionReleases + let names = releases.isEmpty + ? appStore.requestedComponentIDs.map(displayName) + : releases.map(\.displayName) + let downloadBytes = releases.reduce(UInt64(0)) { $0 + $1.downloadBytes } + let installedBytes = releases.reduce(UInt64(0)) { $0 + $1.installedBytes } + + return VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 11) { + Image(systemName: "checklist.checked") + .font(.system(size: 17, weight: .semibold)).foregroundStyle(p.accent) + .frame(width: 36, height: 36) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 9)) + VStack(alignment: .leading, spacing: 4) { + Text("Your website selection") + .font(.system(size: 14, weight: .bold)).foregroundStyle(p.text) + Text(names.joined(separator: " + ")) + .font(.system(size: 11.5, weight: .medium)).foregroundStyle(p.text2) + .fixedSize(horizontal: false, vertical: true) + Text("Review the signed sizes, then install. Dory will not download anything until you confirm.") + .font(.system(size: 11)).foregroundStyle(p.text3) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + } + + HStack(spacing: 16) { + if !releases.isEmpty { + sizeFact("Download", downloadBytes) + sizeFact("Installed", installedBytes) + } + Spacer(minLength: 8) + Button("Clear") { appStore.requestedComponentIDs = [] } + .buttonStyle(.borderless) + .font(.system(size: 11.5, weight: .semibold)) + .disabled(installingSelection) + .accessibilityIdentifier("clear-selected-components") + Button { + Task { await installRequestedSelection() } + } label: { + HStack(spacing: 7) { + if installingSelection { ProgressView().controlSize(.small) } + Text(installingSelection ? "Installing…" : "Install selected components") + } + .font(.system(size: 11.5, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 13).padding(.vertical, 8) + .background(p.accent, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .disabled(catalog == nil || installingSelection || !busy.isEmpty) + .opacity(catalog == nil || installingSelection || !busy.isEmpty ? 0.55 : 1) + .accessibilityIdentifier("install-selected-components") + } + } + .padding(15) + .background(p.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(p.accent.opacity(0.35))) + .accessibilityIdentifier("requested-component-selection") + } + + private var requestedSelectionReleases: [DoryComponentRelease] { + guard let catalog else { return [] } + var seen: Set = [] + var releases: [DoryComponentRelease] = [] + for id in appStore.requestedComponentIDs { + guard let ordered = try? installationOrder(id, catalog: catalog) else { continue } + for release in ordered where seen.insert(release.id).inserted { + releases.append(release) + } + } + return releases + } + + private func componentCard(_ status: DoryComponentStatus) -> some View { + VStack(alignment: .leading, spacing: 13) { + HStack(alignment: .top, spacing: 11) { + Image(systemName: icon(status.id)) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(status.id == .dockerCore ? p.green : p.accent) + .frame(width: 38, height: 38) + .background((status.id == .dockerCore ? p.green : p.accent).opacity(0.12), in: RoundedRectangle(cornerRadius: 10)) + VStack(alignment: .leading, spacing: 3) { + Text(status.displayName) + .font(.system(size: 14, weight: .bold)).foregroundStyle(p.text) + Text(status.summary) + .font(.system(size: 11.5)).foregroundStyle(p.text2) + .lineLimit(3).fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 4) + statePill(status.state) + } + + HStack(spacing: 14) { + sizeFact("Download", status.downloadBytes) + sizeFact("Installed", status.installedBytes) + if !status.dependencies.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("REQUIRES").font(.system(size: 9, weight: .bold)).tracking(0.5).foregroundStyle(p.text3) + Text(status.dependencies.map(displayName).joined(separator: ", ")) + .font(.system(size: 10.5, weight: .medium)).foregroundStyle(p.text2).lineLimit(1) + } + } + } + + if let currentProgress = progress[status.id], busy.contains(status.id) { + VStack(alignment: .leading, spacing: 5) { + ProgressView( + value: Double(currentProgress.completedBytes), + total: Double(max(1, currentProgress.totalBytes)) + ) + .tint(p.accent) + Text("\(currentProgress.phase.rawValue.capitalized) · \(formatted(currentProgress.completedBytes)) of \(formatted(currentProgress.totalBytes))") + .font(.system(size: 10.5)).foregroundStyle(p.text3) + } + } + + actions(status) + } + .padding(15) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(status.state == .invalid ? p.red.opacity(0.55) : p.border)) + .accessibilityIdentifier("component-\(status.id.rawValue)") + } + + @ViewBuilder private func actions(_ status: DoryComponentStatus) -> some View { + HStack(spacing: 8) { + switch status.state { + case .bundled: + Label("Included in Dory", systemImage: "checkmark.circle.fill") + .font(.system(size: 11.5, weight: .semibold)).foregroundStyle(p.green) + case .notInstalled: + actionButton("Install", primary: true, disabled: isBusy(status.id)) { + Task { await install(status.id) } + } + case .updateAvailable: + actionButton("Update", primary: true, disabled: isBusy(status.id)) { + Task { await install(status.id) } + } + removeButton(status.id) + case .invalid: + actionButton("Repair", primary: true, disabled: isBusy(status.id)) { + Task { await install(status.id) } + } + removeButton(status.id) + case .installed: + actionButton("Verify", primary: false, disabled: isBusy(status.id)) { + Task { await verify(status.id) } + } + removeButton(status.id) + } + Spacer(minLength: 0) + Text(status.installedVersion.map { "v\($0)" } ?? "v\(status.availableVersion)") + .font(.system(size: 10.5, weight: .medium)).foregroundStyle(p.text3) + } + } + + private func actionButton( + _ label: String, + primary: Bool, + disabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Text(label).font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(primary ? Color.white : p.text) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(primary ? p.accent : p.bgInput, in: RoundedRectangle(cornerRadius: 7)) + .overlay { + if !primary { RoundedRectangle(cornerRadius: 7).strokeBorder(p.border) } + } + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.55 : 1) + } + + private func removeButton(_ id: DoryComponentID) -> some View { + actionButton("Remove", primary: false, disabled: isBusy(id)) { + pendingRemoval = id + } + } + + private func statePill(_ state: DoryComponentState) -> some View { + let color: Color = switch state { + case .bundled, .installed: p.green + case .updateAvailable: p.accent + case .invalid: p.red + case .notInstalled: p.text3 + } + let label: String = switch state { + case .bundled: "Core" + case .installed: "Installed" + case .updateAvailable: "Update" + case .invalid: "Repair" + case .notInstalled: "Optional" + } + return Text(label) + .font(.system(size: 9.5, weight: .bold)).foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(color.opacity(0.11), in: Capsule()) + } + + private func sizeFact(_ label: String, _ bytes: UInt64) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()).font(.system(size: 9, weight: .bold)).tracking(0.5).foregroundStyle(p.text3) + Text(formatted(bytes)).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(p.text) + } + } + + private var dataSafetyPanel: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "externaldrive.badge.checkmark") + .font(.system(size: 15, weight: .semibold)).foregroundStyle(p.green) + VStack(alignment: .leading, spacing: 3) { + Text("Removing a component never removes your work") + .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + Text("Only the installed payload is reclaimed. Containers, images, volumes, Kubernetes state, machine disks, snapshots, and backups stay on your selected Dory data drive.") + .font(.system(size: 11.5)).foregroundStyle(p.text2).lineSpacing(3) + } + } + .padding(13) + .background(p.green.opacity(0.07), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(p.green.opacity(0.25))) + } + + private func errorPanel(_ message: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(p.red) + VStack(alignment: .leading, spacing: 4) { + Text("Components are unavailable").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + Text(message).font(.system(size: 11.5)).foregroundStyle(p.text2).textSelection(.enabled) + } + Spacer() + Button("Retry") { Task { await refresh(preferRemote: true) } } + .buttonStyle(.borderless).font(.system(size: 11.5, weight: .semibold)) + } + .padding(13) + .background(p.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(p.red.opacity(0.28))) + } + + @MainActor private func refresh(preferRemote: Bool) async { + do { + let store = try DoryComponentStore.selected() + try store.prepare() + var loadedCatalog: DoryComponentCatalog + var loadedData: Data + var cached = true + if preferRemote { + do { + let client = DoryComponentCatalogClient( + catalogURL: AppInfo.componentCatalogURL, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: AppInfo.version + ) + let fetched = try await client.fetch() + loadedCatalog = try store.cacheCatalog( + data: fetched.data, + signature: fetched.signature, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: AppInfo.version + ) + loadedData = fetched.data + cached = false + } catch { + guard let local = try store.cachedCatalog( + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: AppInfo.version + ) else { throw error } + loadedCatalog = local.catalog + loadedData = local.data + } + } else if let local = try store.cachedCatalog( + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: AppInfo.version + ) { + loadedCatalog = local.catalog + loadedData = local.data + } else { + throw DoryComponentError.invalidCatalog("no verified component catalog is available") + } + catalog = loadedCatalog + catalogData = loadedData + statuses = store.list( + catalog: loadedCatalog, + catalogDigest: DoryComponentCatalogVerifier.digest(loadedData) + ) + usingCachedCatalog = cached + errorMessage = nil + } catch { + errorMessage = String(describing: error) + } + } + + @MainActor @discardableResult + private func install(_ id: DoryComponentID, showSuccess: Bool = true) async -> Bool { + guard let catalog, !catalogData.isEmpty else { return false } + var operationIDs: Set = [id] + busy.insert(id) + errorMessage = nil + defer { + for operationID in operationIDs { + busy.remove(operationID) + progress[operationID] = nil + } + } + do { + let store = try DoryComponentStore.selected() + let installer = DoryComponentInstaller(store: store) + let digest = DoryComponentCatalogVerifier.digest(catalogData) + for release in try installationOrder(id, catalog: catalog) { + if let current = try store.installedComponent(release.id), + current.version == release.version, + current.catalogDigest == digest, + (try? store.verify(release.id)) != nil { + continue + } + operationIDs.insert(release.id) + busy.insert(release.id) + _ = try await installer.install(release, catalogData: catalogData) { update in + Task { @MainActor in self.progress[release.id] = update } + } + busy.remove(release.id) + } + statuses = store.list(catalog: catalog, catalogDigest: digest) + HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) + if showSuccess { + appStore.showSettingsSuccess("\(displayName(id)) is installed and verified.") + } + return true + } catch { + errorMessage = String(describing: error) + return false + } + } + + @MainActor private func installRequestedSelection() async { + let requested = appStore.requestedComponentIDs + guard !requested.isEmpty else { return } + installingSelection = true + defer { installingSelection = false } + for id in requested { + guard await install(id, showSuccess: false) else { return } + } + appStore.requestedComponentIDs = [] + appStore.showSettingsSuccess("Your selected components are installed and verified.") + } + + @MainActor private func verify(_ id: DoryComponentID) async { + busy.insert(id) + defer { busy.remove(id) } + do { + let store = try DoryComponentStore.selected() + _ = try store.verify(id) + if let catalog { + statuses = store.list( + catalog: catalog, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + ) + } + appStore.showSettingsSuccess("\(displayName(id)) passed verification.") + } catch { + errorMessage = String(describing: error) + } + } + + @MainActor private func remove(_ id: DoryComponentID) async { + guard let catalog else { return } + busy.insert(id) + defer { busy.remove(id) } + do { + let store = try DoryComponentStore.selected() + try store.remove(id, catalog: catalog) + statuses = store.list( + catalog: catalog, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + ) + HostDockerCLI.reconcileOptionalTools(enabled: appStore.routeDockerCLI) + appStore.showSettingsSuccess("Removed \(displayName(id)). Your workload data was preserved.") + } catch { + errorMessage = String(describing: error) + } + } + + private func installationOrder( + _ id: DoryComponentID, + catalog: DoryComponentCatalog + ) throws -> [DoryComponentRelease] { + var visited: Set = [] + var ordered: [DoryComponentRelease] = [] + func append(_ current: DoryComponentID) throws { + guard current != .dockerCore, !visited.contains(current) else { return } + guard let release = catalog.component(current) else { + throw DoryComponentError.unknownComponent(current.rawValue) + } + for dependency in release.dependencies { try append(dependency) } + visited.insert(current) + ordered.append(release) + } + try append(id) + return ordered + } + + private func isBusy(_ id: DoryComponentID) -> Bool { installingSelection || busy.contains(id) } + + private func formatted(_ bytes: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(clamping: bytes), countStyle: .file) + } + + private func displayName(_ id: DoryComponentID) -> String { + catalog?.component(id)?.displayName ?? id.rawValue + } + + private func icon(_ id: DoryComponentID) -> String { + switch id { + case .dockerCore: "shippingbox.fill" + case .kubernetes: "square.3.layers.3d" + case .linuxMachines: "server.rack" + case .linuxDesktop: "display" + case .desktopDebian, .desktopUbuntu, .desktopKali: "desktopcomputer" + } + } +} + +struct MissingComponentView: View { + @Environment(AppStore.self) private var store + @Environment(\.palette) private var p + let component: DoryComponentID + let title: String + let message: String + + var body: some View { + VStack(spacing: 15) { + Image(systemName: "square.stack.3d.up.badge.a") + .font(.system(size: 34, weight: .semibold)).foregroundStyle(p.accent) + .frame(width: 72, height: 72) + .background(p.accentSoft, in: RoundedRectangle(cornerRadius: 19)) + Text(title).font(.system(size: 21, weight: .bold)).foregroundStyle(p.text) + Text(message) + .font(.system(size: 13)).foregroundStyle(p.text2).multilineTextAlignment(.center) + .lineSpacing(4).frame(maxWidth: 460) + Button { + store.section = .components + } label: { + Text("Choose components") + .font(.system(size: 12.5, weight: .semibold)).foregroundStyle(.white) + .padding(.horizontal, 17).padding(.vertical, 9) + .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("install-\(component.rawValue)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(30) + } +} diff --git a/Dory/Features/Compose/ComposeProjectsView.swift b/Dory/Features/Compose/ComposeProjectsView.swift index 0ee6cf13..c91dfb0a 100644 --- a/Dory/Features/Compose/ComposeProjectsView.swift +++ b/Dory/Features/Compose/ComposeProjectsView.swift @@ -119,9 +119,7 @@ private struct ProjectCard: View { .overlay(alignment: .top) { Rectangle().fill(p.border).frame(height: 1) } .contentShape(Rectangle()) .onTapGesture { - store.selectedContainerID = service.id - store.setContainerScope(.compose) - store.section = .containers + store.revealContainer(service, scope: .compose) } } } diff --git a/Dory/Features/Containers/ContainersView.swift b/Dory/Features/Containers/ContainersView.swift index debd0292..a4482c4c 100644 --- a/Dory/Features/Containers/ContainersView.swift +++ b/Dory/Features/Containers/ContainersView.swift @@ -11,12 +11,12 @@ struct ContainersView: View { GeometryReader { geo in let maxDetail = max(320, geo.size.width - 360 - resizeHandleWidth) let detailWidth = min(max(store.containerDetailWidth, 320), maxDetail) - let hasDetail = store.selectedContainer != nil + let hasDetail = store.isContainerInspectorVisible && store.selectedContainer != nil let listWidth = hasDetail ? geo.size.width - detailWidth - resizeHandleWidth : geo.size.width let compact = listWidth < 480 HStack(alignment: .top, spacing: 0) { listColumn(compact: compact) - if let selected = store.selectedContainer { + if store.isContainerInspectorVisible, let selected = store.selectedContainer { resizeHandle(currentWidth: detailWidth, maxDetail: maxDetail) ContainerDetailView(container: selected) .frame(width: detailWidth) @@ -63,7 +63,9 @@ struct ContainersView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .overlay(alignment: .trailing) { - if store.selectedContainer == nil { Rectangle().fill(p.border).frame(width: 1) } + if !store.isContainerInspectorVisible || store.selectedContainer == nil { + Rectangle().fill(p.border).frame(width: 1) + } } } @@ -272,7 +274,10 @@ private struct ContainerRow: View { } .overlay(alignment: .bottom) { Rectangle().fill(p.border).frame(height: 1) } .contentShape(Rectangle()) - .onTapGesture { store.selectedContainerID = container.id } + .onTapGesture { + store.selectedContainerID = container.id + store.isContainerInspectorVisible = true + } .onHover { hover = $0 } .accessibilityIdentifier("container-\(container.id)") .confirmationDialog("Delete \(container.name)?", isPresented: $confirmingDelete, titleVisibility: .visible) { diff --git a/Dory/Features/Main/MainColumnView.swift b/Dory/Features/Main/MainColumnView.swift index 5d6918a8..e92b40f3 100644 --- a/Dory/Features/Main/MainColumnView.swift +++ b/Dory/Features/Main/MainColumnView.swift @@ -1,3 +1,4 @@ +import DoryOperations import SwiftUI struct MainColumnView: View { @@ -19,12 +20,34 @@ struct MainColumnView: View { private var toolbar: some View { @Bindable var store = store return HStack(spacing: 12) { + IconButton( + systemImage: "sidebar.leading", + label: store.isSidebarVisible ? "Hide sidebar" : "Show sidebar", + size: 30 + ) { + store.isSidebarVisible.toggle() + } + .accessibilityIdentifier("toggle-sidebar") + .help(store.isSidebarVisible ? "Hide sidebar" : "Show sidebar") + + if store.section == .containers, store.selectedContainer != nil { + IconButton( + systemImage: "sidebar.trailing", + label: store.isContainerInspectorVisible ? "Hide container details" : "Show container details", + size: 30 + ) { + store.isContainerInspectorVisible.toggle() + } + .accessibilityIdentifier("toggle-container-details") + .help(store.isContainerInspectorVisible ? "Hide container details" : "Show container details") + } + VStack(alignment: .leading, spacing: 1) { Text(store.section.title).font(.system(size: 15, weight: .bold)).foregroundStyle(p.text) Text(store.subtitle(for: store.section)).font(.system(size: 11.5)).foregroundStyle(p.text3) } Spacer(minLength: 0) - if store.section != .settings { + if store.section != .settings && store.section != .components { filterBox(text: $store.filter) if store.section == .images { secondaryButton("Sign In") { store.activeSheet = .registryLogin } @@ -34,7 +57,7 @@ struct MainColumnView: View { secondaryButton("Import") { store.importMachineFile() } } if let label = store.section.primaryActionLabel, - store.section != .desktops || AppInfo.includesDesktopLinux { + primaryActionAvailable(for: store.section) { primaryButton(label) } } @@ -92,11 +115,48 @@ struct MainColumnView: View { case .volumes: VolumesView() case .networks: NetworksView() case .compose: ComposeProjectsView() - case .kubernetes: KubernetesView() - case .desktops: MachinesView(displayMode: .desktop) - case .machines: MachinesView(displayMode: .headless) + case .kubernetes: + if AppInfo.componentAvailable(.kubernetes) { + KubernetesView() + } else { + MissingComponentView( + component: .kubernetes, + title: "Add Kubernetes when you need it", + message: "Docker Core stays small. Install the signed Kubernetes component to add kubectl and Dory's local cluster workflow." + ) + } + case .desktops: + if AppInfo.includesDesktopLinux || store.machines.contains(where: { $0.displayMode == .desktop }) { + MachinesView(displayMode: .desktop) + } else { + MissingComponentView( + component: .linuxDesktop, + title: "Choose a Linux desktop", + message: "Install the Desktop runtime, then choose Debian, Ubuntu, or Kali independently. You only download the distributions you want." + ) + } + case .machines: + if AppInfo.componentAvailable(.linuxMachines) + || store.machines.contains(where: { $0.displayMode == .headless }) { + MachinesView(displayMode: .headless) + } else { + MissingComponentView( + component: .linuxMachines, + title: "Add headless Linux machines", + message: "Install Linux Machines for VPS-style servers, terminals, snapshots, and persistent disks without adding graphical desktop images." + ) + } + case .components: ComponentsView() case .health: HealthView() case .settings: SettingsView() } } + + private func primaryActionAvailable(for section: AppSection) -> Bool { + switch section { + case .desktops: AppInfo.includesDesktopLinux + case .machines: AppInfo.componentAvailable(.linuxMachines) + default: true + } + } } diff --git a/Dory/Features/MenuBar/MenuBarContentView.swift b/Dory/Features/MenuBar/MenuBarContentView.swift index e7308c78..b97a476e 100644 --- a/Dory/Features/MenuBar/MenuBarContentView.swift +++ b/Dory/Features/MenuBar/MenuBarContentView.swift @@ -1,3 +1,4 @@ +import DoryOperations import SwiftUI struct MenuBarActions { @@ -45,9 +46,8 @@ struct MenuBarContentView: View { } private func openContainer(_ container: Container, scope: ContainerScope = .all) { - store.selectedContainerID = container.id - store.setContainerScope(scope) - openSection(.containers) + store.revealContainer(container, scope: scope) + showMainWindow() } private func openTerminal(_ container: Container) { @@ -400,7 +400,13 @@ struct MenuBarContentView: View { } .buttonStyle(.plain).menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() } else { - rowIcon("play.fill", "Enable Kubernetes") { Task { await store.enableKubernetes() } } + rowIcon("play.fill", AppInfo.componentAvailable(.kubernetes) ? "Enable Kubernetes" : "Get Kubernetes") { + if AppInfo.componentAvailable(.kubernetes) { + Task { await store.enableKubernetes() } + } else { + openSection(.components) + } + } Menu { Button("Open Kubernetes") { openSection(.kubernetes) } } label: { diff --git a/Dory/Features/Onboarding/OnboardingView.swift b/Dory/Features/Onboarding/OnboardingView.swift index 61baddde..592c8f02 100644 --- a/Dory/Features/Onboarding/OnboardingView.swift +++ b/Dory/Features/Onboarding/OnboardingView.swift @@ -60,13 +60,13 @@ struct OnboardingView: View { .font(.system(size: 21, weight: .heavy)).foregroundStyle(p.text) .multilineTextAlignment(.center).lineSpacing(2).fixedSize(horizontal: false, vertical: true) .padding(.bottom, 8) - Text("A self-contained Mac-native engine for Docker, Compose, and Kubernetes. One shared Linux VM — and your tools just work.") + Text("A self-contained Mac-native engine for Docker and Compose, with Kubernetes available when you need it. One shared Linux VM, and your tools just work.") .font(.system(size: 13)).foregroundStyle(p.text2).multilineTextAlignment(.center).lineSpacing(3) .padding(.bottom, 22) VStack(spacing: 12) { feature(.shield, p.green, p.greenWeak, "One shared Linux engine", "All containers share one managed VM.") - feature(.eye, p.accentText, p.accentWeak, "Bundled docker, Compose & kubectl", "Dory ships the tools and points them at its engine.") + feature(.eye, p.accentText, p.accentWeak, "Docker & Compose included", "Add the signed Kubernetes component only when you need kubectl and k3s.") feature(.networks, p.amber, p.amberWeak, "Automatic *.dory.local domains", "Every container on a real HTTPS URL.") } .padding(.bottom, 24) @@ -88,7 +88,7 @@ struct OnboardingView: View { ProgressView().controlSize(.large).padding(.bottom, 22) - Text("Dory.app already includes the engine, kernel, networking, Docker, Compose, and kubectl. First launch extracts and starts them.") + Text("Docker Core includes the engine, kernel, networking, Docker, Compose, and Buildx. First launch extracts and starts them.") .font(.system(size: 11.5)).foregroundStyle(p.text3).multilineTextAlignment(.center) .padding(.bottom, 18) @@ -153,7 +153,7 @@ struct OnboardingView: View { VStack(spacing: 0) { Text("You're all set").font(.system(size: 20, weight: .heavy)).foregroundStyle(p.text) .padding(.bottom, 6) - Text("Dory's bundled tools and Docker context point at the engine — nothing else to install.") + Text("Dory's Core tools and Docker context point at the engine. Optional features remain one click away in Components.") .font(.system(size: 13)).foregroundStyle(p.text2).multilineTextAlignment(.center) .padding(.bottom, 20) diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift index fda8ef29..7ffd3139 100644 --- a/Dory/Features/Settings/SettingsView.swift +++ b/Dory/Features/Settings/SettingsView.swift @@ -10,6 +10,8 @@ struct SettingsView: View { @State private var httpPortDraft = "" @State private var httpsPortDraft = "" @State private var bridgeSubnetDraft = "" + @State private var customDomainDraft = "" + @State private var customDomainPortDraft = "80" @State private var customSocketDraft = "" @State private var machineEnvAllowListDraft = "" @State private var engineCPUCountDraft = 1 @@ -66,6 +68,7 @@ struct SettingsView: View { @ViewBuilder private var content: some View { switch store.settingsTab { case .general: general + case .components: ComponentsView(embedded: true) case .resources: resources case .machines: machines case .engine: engine @@ -274,7 +277,7 @@ struct SettingsView: View { comparisonRow("One shared VM engine", .yes, .yes, .no(nil), divider: true) comparisonRow("Hypervisor-backed virtualization", .yes, .yes, .no(nil), divider: true) comparisonRow("*.local domains + HTTPS", .yes, .yes, .no(nil), divider: true) - comparisonRow("Drop-in docker & kubectl", .yes, .yes, .yes, divider: true) + comparisonRow("Drop-in Docker + optional kubectl", .yes, .yes, .yes, divider: true) comparisonRow("Kubernetes built-in", .yes, .yes, .yes, divider: true) comparisonRow("Common x86 / amd64 images", .yes, .yes, .yes, divider: false) } @@ -494,8 +497,8 @@ struct SettingsView: View { VStack(spacing: 0) { toggleRow("Launch Dory at login", "Start the engine automatically when you log in.", isOn: Binding(get: { store.launchAtLogin }, set: { store.setLaunchAtLogin($0) }), divider: true) toggleRow("Show menu bar icon", store.isAgentMode ? "Always on — Dory runs in the menu bar in background mode." : "Quick access to containers from the menu bar.", isOn: Binding(get: { store.showMenuBarIcon }, set: { store.setShowMenuBarIcon($0) }), divider: true, disabled: store.isAgentMode) - toggleRow("Keep doryd running after quit", "Leave the daemon, Docker socket, local domains, and machines available after the Dory app exits. Turn off to stop doryd when you quit Dory.", isOn: Binding(get: { store.keepDorydRunningAfterQuit }, set: { store.setKeepDorydRunningAfterQuit($0) }), divider: true) - toggleRow("Terminal docker command", "doryd keeps `docker`, `docker compose`, `kubectl`, and Dory support commands ready in `~/.dory/bin` and points them at Dory's engine. No Docker Desktop or admin setup required; turn off to remove.", isOn: Binding(get: { store.routeDockerCLI }, set: { store.setRouteDockerCLI($0) }), divider: false) + toggleRow("Keep engine running after quit", "Optional: leave the Docker socket, local domains, and machines available after the Dory app exits. By default, Quit Dory stops the engine and returns its memory to macOS.", isOn: Binding(get: { store.keepDorydRunningAfterQuit }, set: { store.setKeepDorydRunningAfterQuit($0) }), divider: true) + toggleRow("Terminal docker command", "doryd keeps `docker`, `docker compose`, and Dory support commands ready in `~/.dory/bin`, plus `kubectl` when the Kubernetes component is installed. No Docker Desktop or admin setup required; turn off to remove.", isOn: Binding(get: { store.routeDockerCLI }, set: { store.setRouteDockerCLI($0) }), divider: false) } .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) @@ -1481,6 +1484,10 @@ struct SettingsView: View { .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) .padding(.bottom, 22) + groupLabel("CUSTOM DOMAINS") + customDomainsEditor + .padding(.bottom, 22) + groupLabel("SYSTEM ACCESS") VStack(alignment: .leading, spacing: 10) { HStack(spacing: 12) { @@ -1614,6 +1621,103 @@ struct SettingsView: View { } } + private var customDomainsEditor: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Route an exact hostname or leftmost wildcard to a container's published HTTP port. The hostname must already resolve to 127.0.0.1 through /etc/hosts or local DNS. Wildcards require local DNS or an /etc/hosts entry for each name. Dory serves both HTTP and trusted HTTPS after networking is authorized.") + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .lineSpacing(3) + + HStack(spacing: 8) { + TextField("admin.myproject.local", text: $customDomainDraft) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .accessibilityIdentifier("custom-domain-hostname") + TextField("80", text: $customDomainPortDraft) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(width: 84) + .accessibilityIdentifier("custom-domain-port") + Button(action: addCustomDomain) { + Text("Add") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 13) + .padding(.vertical, 7) + .background(p.accent, in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .disabled(store.customDomainRoutesBusy || !store.dorydRuntimeActive) + .accessibilityIdentifier("custom-domain-add") + } + + if store.customDomainRoutes.isEmpty { + Text("No custom domains configured.") + .font(.system(size: 12)) + .foregroundStyle(p.text3) + .padding(.vertical, 3) + } else { + VStack(spacing: 0) { + ForEach(Array(store.customDomainRoutes.enumerated()), id: \.element.hostname) { index, route in + HStack(spacing: 10) { + Image(systemName: route.hostname.hasPrefix("*.") ? "asterisk" : "globe") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.accent) + .frame(width: 18) + Text(route.hostname) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(p.text) + Spacer(minLength: 0) + Text("\(store.customDomainActiveHostnames.contains(route.hostname) ? "Active" : "Waiting") · published :\(route.port)") + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(store.customDomainActiveHostnames.contains(route.hostname) ? p.green : p.text3) + Button { + Task { await store.removeCustomDomainRoute(route) } + } label: { + Image(systemName: "trash") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(p.red) + .frame(width: 26, height: 24) + } + .buttonStyle(.plain) + .disabled(store.customDomainRoutesBusy) + .accessibilityLabel("Remove \(route.hostname)") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + if index < store.customDomainRoutes.count - 1 { + Rectangle().fill(p.border).frame(height: 1).padding(.leading, 38) + } + } + } + .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding(15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + .task(id: store.dorydRuntimeActive) { + if store.dorydRuntimeActive { await store.loadCustomDomainRoutes() } + } + } + + private func addCustomDomain() { + let rawPort = customDomainPortDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = UInt16(rawPort), port > 0 else { + store.showSettingsFailure("Published port must be between 1 and 65535.") + return + } + let hostname = customDomainDraft + Task { + await store.addCustomDomainRoute(hostname: hostname, publishedPort: port) + if let normalized = AppStore.normalizedCustomDomainPattern(hostname), + store.customDomainRoutes.contains(where: { $0.hostname == normalized && $0.port == port }) { + customDomainDraft = "" + } + } + } + private var domainSuffixField: some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index 8fb10f06..04edc9ef 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -1,4 +1,5 @@ import Darwin +import DoryOperations import SwiftUI struct NewMachineSheet: View { @@ -32,6 +33,11 @@ struct NewMachineSheet: View { _displayMode = State(initialValue: displayMode) _stage = State(initialValue: displayMode == .desktop ? .form : .useCase) _name = State(initialValue: NewMachineSheet.defaultName()) + if let installedDistro = DesktopMachineDistro.allCases.first(where: { + AppInfo.componentAvailable($0.componentID) + }) { + _desktopDistro = State(initialValue: installedDistro) + } } private var engineReady: Bool { store.dorydRuntimeActive } @@ -228,15 +234,19 @@ struct NewMachineSheet: View { VStack(alignment: .leading, spacing: 9) { sectionLabel("DESKTOP DISTRIBUTION") LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 9), count: 3), spacing: 9) { - ForEach(DesktopMachineDistro.allCases) { distro in + ForEach(installedDesktopDistros) { distro in desktopDistroButton(distro) } } - Text("Each choice is a real arm64 guest image with its own packages and official repositories. All use Dory's optimized kernel and Xfce display stack.") + Text("Only installed distributions are shown. Add or remove Debian, Ubuntu, and Kali independently in Components.") .font(.system(size: 11)).foregroundStyle(p.text3) } } + private var installedDesktopDistros: [DesktopMachineDistro] { + DesktopMachineDistro.allCases.filter { AppInfo.componentAvailable($0.componentID) } + } + private func desktopDistroButton(_ distro: DesktopMachineDistro) -> some View { let selected = desktopDistro == distro return Button { desktopDistro = distro } label: { diff --git a/Dory/Features/Sidebar/SidebarView.swift b/Dory/Features/Sidebar/SidebarView.swift index d84450fe..a076f0f3 100644 --- a/Dory/Features/Sidebar/SidebarView.swift +++ b/Dory/Features/Sidebar/SidebarView.swift @@ -1,3 +1,4 @@ +import DoryOperations import SwiftUI struct SidebarView: View { @@ -56,13 +57,27 @@ struct SidebarView: View { row(.networks, .networks, "Networks") row(.compose, .gridView, "Compose") sectionLabel("ORCHESTRATION").padding(.top, 6) - row(.kubernetes, .kubernetes, "Kubernetes") + row( + .kubernetes, + .kubernetes, + "Kubernetes", + trailing: AppInfo.componentAvailable(.kubernetes) ? nil : "Get" + ) sectionLabel("LINUX").padding(.top, 6) - if AppInfo.includesDesktopLinux || desktopCount > 0 { - row(.desktops, .machines, "Desktops", trailing: "\(desktopCount)") - } - row(.machines, .machines, "Servers", trailing: "\(serverCount)") + row( + .desktops, + .machines, + "Desktops", + trailing: !AppInfo.includesDesktopLinux && desktopCount == 0 ? "Get" : "\(desktopCount)" + ) + row( + .machines, + .machines, + "Servers", + trailing: !AppInfo.componentAvailable(.linuxMachines) && serverCount == 0 ? "Get" : "\(serverCount)" + ) sectionLabel("SYSTEM").padding(.top, 6) + row(.components, .gridView, "Components") row(.health, .health, "Health") } .padding(.horizontal, 10) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index aecaf422..1f9f4e7d 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -40,13 +40,20 @@ final class AppStore { var section: AppSection = .containers { didSet { if oldValue != section { filter = "" } } } + var requestedComponentIDs: [DoryComponentID] = [] var selectedContainerID: String? = nil + var isSidebarVisible = true + var isContainerInspectorVisible = true var detailTab: DetailTab = .overview var settingsTab: SettingsTab = .general var menuOpen = false var onboarding = false var isConnecting = false - var filter = "" + var filter = "" { + didSet { + if section == .containers { reconcileContainerSelection() } + } + } var filterFocusToken = 0 var imagesSort: TableSort? var volumesSort: TableSort? @@ -60,7 +67,7 @@ final class AppStore { var launchAtLogin = false var showMenuBarIcon = true var routeDockerCLI = true - var keepDorydRunningAfterQuit = true + var keepDorydRunningAfterQuit = false var machineEnvAllowList: [String] = MachineEnvImport.defaultNames var openLoginsOnMac = true var externalTerminalPreference = ExternalTerminalPreference(terminal: .terminal, customApplicationPath: nil) @@ -71,10 +78,15 @@ final class AppStore { var containerScope: ContainerScope = ContainerScope(rawValue: UserDefaults.standard.string(forKey: "dory.containerScope") ?? "") ?? .all { - didSet { UserDefaults.standard.set(containerScope.rawValue, forKey: Self.containerScopeKey) } + didSet { + UserDefaults.standard.set(containerScope.rawValue, forKey: Self.containerScopeKey) + reconcileContainerSelection() + } } - var containers: [Container] = [] + var containers: [Container] = [] { + didSet { reconcileContainerSelection() } + } var images: [DockerImage] = [] var volumes: [Volume] = [] var networks: [DoryNetwork] = [] @@ -92,7 +104,10 @@ final class AppStore { var containerFilter: ContainerFilter = ContainerFilter(rawValue: UserDefaults.standard.string(forKey: "containerFilter") ?? "") ?? .running { - didSet { UserDefaults.standard.set(containerFilter.rawValue, forKey: "containerFilter") } + didSet { + UserDefaults.standard.set(containerFilter.rawValue, forKey: "containerFilter") + reconcileContainerSelection() + } } var healthSnapshot: HealthSnapshot? @@ -161,6 +176,7 @@ final class AppStore { @ObservationIgnored private let dorydLaunchAgentEnsurer: @Sendable (DorydLaunchAgent.Configuration) async -> Bool @ObservationIgnored private let dorydLaunchAgentBootout: @Sendable () async -> Bool @ObservationIgnored private let authorizedNetworkingRemover: @Sendable () async throws -> Void + @ObservationIgnored private let localCATrustManager: any LocalCATrustManaging @ObservationIgnored private let environment: [String: String] @ObservationIgnored private let machineEnvResolver: @Sendable ([String]) async -> [String: String] @ObservationIgnored private let desktopMachineAssetPreparer: @Sendable ( @@ -183,6 +199,7 @@ final class AppStore { dorydLaunchAgentEnsurer: (@Sendable (DorydLaunchAgent.Configuration) async -> Bool)? = nil, dorydLaunchAgentBootout: (@Sendable () async -> Bool)? = nil, authorizedNetworkingRemover: (@Sendable () async throws -> Void)? = nil, + localCATrustManager: any LocalCATrustManaging = LocalCATrustManager(), environment: [String: String] = ProcessInfo.processInfo.environment, composeCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), buildCommandRunner: any ToolCommandRunning = BoundedToolProcessRunner(), @@ -224,6 +241,7 @@ final class AppStore { self.authorizedNetworkingRemover = authorizedNetworkingRemover ?? { try await Self.removeAuthorizedNetworkingIfPresent() } + self.localCATrustManager = localCATrustManager let networkHelperMaintenance = DoryAppDelegate.isNetworkHelperMaintenance() let realLaunch = !networkHelperMaintenance && env["DORY_SECTION"] == nil && env["DORY_APPEARANCE"] == nil @@ -339,7 +357,7 @@ final class AppStore { static let kubernetesVersionKey = "dory.kubernetesVersion" static func resolvedKeepDorydRunningAfterQuit(defaults: UserDefaults) -> Bool { - (defaults.object(forKey: keepDorydRunningAfterQuitKey) as? Bool) ?? true + (defaults.object(forKey: keepDorydRunningAfterQuitKey) as? Bool) ?? false } var externalTerminalDisplayName: String { @@ -457,7 +475,10 @@ final class AppStore { domainSuffix: domainSuffix, dnsPort: dnsPort, httpProxyPort: httpProxyPort, - httpsProxyPort: httpsProxyPort + httpsProxyPort: httpsProxyPort, + customDomains: customDomainRoutes.map { + ManagedCustomDomainRoute(hostname: $0.hostname, publishedPort: $0.port) + } ), autoIdle: ManagedAutoIdleSettings( mode: runtimeMode, @@ -674,11 +695,11 @@ final class AppStore { settingsNotice = nil } - private func showSettingsSuccess(_ message: String) { + func showSettingsSuccess(_ message: String) { settingsNotice = SettingsNotice(kind: .success, message: message) } - private func showSettingsFailure(_ message: String) { + func showSettingsFailure(_ message: String) { settingsNotice = SettingsNotice(kind: .failure, message: message) } @@ -737,6 +758,15 @@ final class AppStore { @ObservationIgnored private(set) var backendStartRequested = false @ObservationIgnored var windowOpenRequested = false + @discardableResult + func handleComponentSelectionURL(_ url: URL) -> Bool { + guard let ids = DoryComponentSelectionURL.parse(url) else { return false } + requestedComponentIDs = ids + section = .components + windowOpenRequested = true + return true + } + func startBackendIfNeeded() { guard !backendStartRequested else { return } backendStartRequested = true @@ -880,6 +910,7 @@ final class AppStore { runtime = DisconnectedRuntime() return false } + await loadCustomDomainRoutes() return true } catch { runtimeOwnedByDoryd = false @@ -1942,6 +1973,9 @@ final class AppStore { var defaultBridgeSubnet = DoryIPv4BridgeNetwork.defaultCIDR var networkingAuthorizationInFlight = false var networkingAuthorizationMessage: String? + var customDomainRoutes: [DorydDomainRoute] = [] + var customDomainActiveHostnames: Set = [] + var customDomainRoutesBusy = false @ObservationIgnored private var tlsProxy: DoryTLSProxy? private func startLocalNetworking() { @@ -2037,13 +2071,27 @@ final class AppStore { do { try await authorizedNetworkingRemover() authorizationRemoved = true + var trustRemovalNotice: String? + do { + _ = try localCATrustManager.remove( + certificateAt: URL(fileURLWithPath: NSHomeDirectory()) + .appendingPathComponent(".dory/ca/ca.crt").path + ) + } catch { + trustRemovalNotice = error.localizedDescription + } guard await refreshDorydLaunchAgentForNetworkingSettings() else { throw NetworkingAuthorizationUIError.cleanupFailed( "doryd could not restart with local domains disabled." ) } - networkingAuthorizationMessage = "Local domains and their system routing are disabled." - showSettingsSuccess("Local domains disabled.") + if let trustRemovalNotice { + networkingAuthorizationMessage = "Local domains and their system routing are disabled, but Dory could not remove its CA from your login keychain: \(trustRemovalNotice)" + showSettingsFailure("Local domains are disabled, but local CA cleanup needs attention.") + } else { + networkingAuthorizationMessage = "Local domains and their system routing are disabled." + showSettingsSuccess("Local domains disabled.") + } } catch { domainsEnabled = true UserDefaults.standard.set(true, forKey: Self.domainsEnabledKey) @@ -2141,6 +2189,93 @@ final class AppStore { return suffix } + nonisolated static func normalizedCustomDomainPattern(_ raw: String?) -> String? { + guard let raw else { return nil } + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + while value.hasSuffix(".") { value.removeLast() } + let hostname: String + if value.hasPrefix("*.") { + hostname = String(value.dropFirst(2)) + } else { + guard !value.contains("*") else { return nil } + hostname = value + } + guard hostname.count <= 253 else { return nil } + let labels = hostname.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return nil } + for label in labels { + guard !label.isEmpty, label.count <= 63, + label.first != "-", label.last != "-" else { + return nil + } + for scalar in label.unicodeScalars { + let value = scalar.value + guard (48...57).contains(value) || (97...122).contains(value) || value == 45 else { + return nil + } + } + } + return value + } + + func loadCustomDomainRoutes() async { + guard dorydRuntimeActive else { + customDomainRoutes = [] + customDomainActiveHostnames = [] + return + } + do { + let status = try await dorydClient.networkStatus() + customDomainRoutes = status.customRoutes.sorted { $0.hostname < $1.hostname } + let configured = Set(customDomainRoutes.map(\.hostname)) + customDomainActiveHostnames = Set(status.routes.map(\.hostname)).intersection(configured) + } catch { + showSettingsFailure("Custom domains could not be loaded: \(error.localizedDescription)") + } + } + + func addCustomDomainRoute(hostname rawHostname: String, publishedPort: UInt16) async { + guard let hostname = Self.normalizedCustomDomainPattern(rawHostname) else { + showSettingsFailure("Use a DNS hostname such as admin.myproject.local or *.myproject.local.") + return + } + let comparable = hostname.hasPrefix("*.") ? String(hostname.dropFirst(2)) : hostname + guard comparable != domainSuffix, !comparable.hasSuffix(".\(domainSuffix)") else { + showSettingsFailure("That hostname is already covered by *.\(domainSuffix).") + return + } + var routes = customDomainRoutes.filter { $0.hostname != hostname } + routes.append(DorydDomainRoute(hostname: hostname, address: "127.0.0.1", port: publishedPort)) + await replaceCustomDomainRoutes(routes, success: "Custom domain \(hostname) is configured for published port \(publishedPort).") + } + + func removeCustomDomainRoute(_ route: DorydDomainRoute) async { + await replaceCustomDomainRoutes( + customDomainRoutes.filter { $0.hostname != route.hostname }, + success: "Custom domain \(route.hostname) removed." + ) + } + + private func replaceCustomDomainRoutes(_ routes: [DorydDomainRoute], success: String) async { + guard dorydRuntimeActive else { + showSettingsFailure("Switch to Dory's engine before changing custom domains.") + return + } + guard !customDomainRoutesBusy else { return } + customDomainRoutesBusy = true + defer { customDomainRoutesBusy = false } + do { + _ = try await dorydClient.networkReplaceRoutes(routes) + let status = try await dorydClient.networkStatus() + customDomainRoutes = status.customRoutes.sorted { $0.hostname < $1.hostname } + let configured = Set(customDomainRoutes.map(\.hostname)) + customDomainActiveHostnames = Set(status.routes.map(\.hostname)).intersection(configured) + showSettingsSuccess(success) + } catch { + showSettingsFailure("Custom domains were not changed: \(error.localizedDescription)") + } + } + private static func persistedRuntimeMode(environment: [String: String]) -> String? { let rawPath = environment["DORY_CONFIG"] ?? "\(NSHomeDirectory())/.dory/config.json" let path = (rawPath as NSString).expandingTildeInPath @@ -2287,11 +2422,19 @@ final class AppStore { defer { networkingAuthorizationInFlight = false } do { - try Self.ensurePrivilegedNetworkDaemon() guard let helper = Self.bundledHelper("dory-network-helper") else { throw NetworkingAuthorizationUIError.helperMissing } let plan = try await dorydClient.networkAuthorizationPlan() + guard let certificatePath = Self.localCACertificatePath(in: plan) else { + throw NetworkingAuthorizationUIError.invalidLocalCARequest + } + var installedTrustForAttempt = false + if !removing { + installedTrustForAttempt = try localCATrustManager.install( + certificateAt: certificatePath + ) + } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let encodedPlan = try encoder.encode(plan).base64EncodedString() @@ -2304,19 +2447,71 @@ final class AppStore { let result = await Shell.runAsyncResult("/usr/bin/osascript", ["-e", script]) if result.exit == 0 { if removing { - networkingAuthorizationMessage = "Dory-owned resolver, PF reference, and local CA trust were removed for \(plan.suffix)." - } else { - networkingAuthorizationMessage = "Dory networking is authorized for \(plan.suffix). \(Self.networkingAuthorizationSummary(plan))" + do { + _ = try localCATrustManager.remove(certificateAt: certificatePath) + } catch { + networkingAuthorizationMessage = "Dory removed its resolver and PF rules, but could not remove the local CA from your login keychain: \(error.localizedDescription) Try Remove authorization again." + return + } } + var backgroundServiceNotice: String? + if !removing { + do { + try Self.ensurePrivilegedNetworkDaemon() + } catch { + backgroundServiceNotice = error.localizedDescription + } + } + networkingAuthorizationMessage = Self.networkingAuthorizationSuccessMessage( + plan, + removing: removing, + backgroundServiceNotice: backgroundServiceNotice + ) } else { let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) - networkingAuthorizationMessage = output.isEmpty ? "Local domain authorization was cancelled or failed." : output + var message = output.isEmpty ? "Local domain authorization was cancelled or failed." : output + if installedTrustForAttempt { + do { + _ = try localCATrustManager.remove(certificateAt: certificatePath) + } catch { + message += " Dory could not remove the local CA it added to your login keychain: \(error.localizedDescription)" + } + } + networkingAuthorizationMessage = message } } catch { networkingAuthorizationMessage = "Local domain authorization failed: \(error.localizedDescription)" } } + nonisolated static func networkingAuthorizationSuccessMessage( + _ plan: DorydNetworkingAuthorizationPlan, + removing: Bool, + backgroundServiceNotice: String? = nil + ) -> String { + if removing { + return "Dory-owned resolver, PF reference, and local CA trust were removed for \(plan.suffix)." + } + let authorized = "Dory networking is authorized for \(plan.suffix). \(networkingAuthorizationSummary(plan))" + guard let backgroundServiceNotice, !backgroundServiceNotice.isEmpty else { + return authorized + } + return "\(authorized) Background updates need attention: \(backgroundServiceNotice)" + } + + nonisolated static func localCACertificatePath( + in plan: DorydNetworkingAuthorizationPlan, + home: String = NSHomeDirectory() + ) -> String? { + let requests = plan.requests.filter { $0.id == "trust.local-ca" && $0.kind == "localCATrust" } + guard requests.count == 1, let path = requests[0].filePath else { return nil } + let expected = URL(fileURLWithPath: home) + .appendingPathComponent(".dory/ca/ca.crt") + .standardizedFileURL.path + guard URL(fileURLWithPath: path).standardizedFileURL.path == expected else { return nil } + return expected + } + nonisolated static func networkingAuthorizationSummary(_ plan: DorydNetworkingAuthorizationPlan) -> String { let forwards = plan.privilegedTCPForwards.sorted { if $0.listenPort == $1.listenPort { return $0.targetPort < $1.targetPort } @@ -2580,6 +2775,7 @@ final class AppStore { private enum NetworkingAuthorizationUIError: LocalizedError { case helperMissing + case invalidLocalCARequest case daemonMissing case daemonApprovalRequired case daemonUnavailable @@ -2590,6 +2786,8 @@ final class AppStore { switch self { case .helperMissing: return "dory-network-helper is missing from Dory.app." + case .invalidLocalCARequest: + return "Dory's networking service returned an invalid local CA request." case .daemonMissing: return "Dory's privileged networking service is missing from the app bundle. Reinstall Dory." case .daemonApprovalRequired: @@ -2817,7 +3015,7 @@ final class AppStore { static func kubeErrorText(_ error: KubeError) -> String { switch error { - case .kubectlMissing: "kubectl not found in Dory's bundled tools. Restart Dory so doryd can repair terminal integration, or reinstall the app bundle." + case .kubectlMissing: "kubectl is unavailable. Install or repair the Kubernetes component, then try again." case .nonZero(_, let stderr): stderr.trimmingCharacters(in: .whitespacesAndNewlines) case .decode: "Could not read the cluster response." } @@ -2827,6 +3025,12 @@ final class AppStore { /// One-click Kubernetes: bootstraps k3s inside Dory's shared VM and wires the host kubeconfig. func enableKubernetes() async { + guard AppInfo.componentAvailable(.kubernetes) else { + kubernetesInfo = "Install Kubernetes in Components before enabling the cluster." + actionError = kubernetesInfo + section = .components + return + } guard runtimeKind == .sharedVM else { kubernetesInfo = "Kubernetes needs Dory's shared VM engine"; return } guard !kubernetesBusy else { return } kubernetesBusy = true @@ -2898,10 +3102,7 @@ final class AppStore { if runtimeKind == .mock, pods != snap.pods { pods = snap.pods } if engineRunning != snap.engineRunning { engineRunning = snap.engineRunning } if engineVersion != snap.engineVersion { engineVersion = snap.engineVersion } - if selectedContainerID == nil || !containers.contains(where: { $0.id == selectedContainerID }) { - let first = containers.first?.id - if selectedContainerID != first { selectedContainerID = first } - } + reconcileContainerSelection() let liveIDs = Set(containers.map(\.id)) for container in containers where container.isRunning { recordCPU(container.id, container.cpuPercent) @@ -3102,7 +3303,27 @@ final class AppStore { } var selectedContainer: Container? { - containers.first { $0.id == selectedContainerID } ?? containers.first + filteredContainers.first { $0.id == selectedContainerID } + } + + func reconcileContainerSelection() { + let visible = filteredContainers + guard !visible.isEmpty else { + selectedContainerID = nil + return + } + if let selectedContainerID, visible.contains(where: { $0.id == selectedContainerID }) { return } + selectedContainerID = visible[0].id + } + + func revealContainer(_ container: Container, scope: ContainerScope) { + section = .containers + setContainerScope(scope) + if !filteredContainers.contains(where: { $0.id == container.id }) { + containerFilter = .all + } + selectedContainerID = container.id + isContainerInspectorVisible = true } var runningCount: Int { containers.filter(\.isRunning).count } @@ -3161,6 +3382,7 @@ final class AppStore { machineSubtitle(for: .desktop, noun: "desktop") case .machines: machineSubtitle(for: .headless, noun: "server") + case .components: "Docker Core with optional, removable feature packs" case .health: healthSubtitle case .settings: "Dory v\(AppInfo.version)" } @@ -3941,7 +4163,7 @@ final class AppStore { func applyKubernetesYAML(_ yaml: String) async -> String? { guard runtimeKind == .sharedVM else { return "Enable Kubernetes on Dory's shared VM first" } guard !yaml.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "Paste or open a YAML manifest" } - guard let kubectl = KubeServiceProxy.kubectl() else { return "kubectl not found in Dory's bundled tools. Restart Dory so doryd can repair terminal integration, or reinstall the app bundle." } + guard let kubectl = KubeServiceProxy.kubectl() else { return "kubectl is unavailable. Install or repair the Kubernetes component, then try again." } let kubeconfig = NSHomeDirectory() + "/.kube/dory-config" let result: String? = await Task.detached { Self.runKubectlApply(kubectl: kubectl, kubeconfig: kubeconfig, yaml: yaml) @@ -4250,11 +4472,18 @@ final class AppStore { case .compose: openComposeFile() case .desktops: guard AppInfo.includesDesktopLinux else { - actionError = "Creating graphical Linux desktops requires the all-inclusive Dory Desktop build." + actionError = "Install the Linux Desktop runtime and at least one distribution in Components." + self.section = .components return } activeSheet = .newDesktop - case .machines: activeSheet = .newMachine + case .machines: + guard AppInfo.componentAvailable(.linuxMachines) else { + actionError = "Install Linux Machines in Components before creating a server." + self.section = .components + return + } + activeSheet = .newMachine default: break } } @@ -4662,6 +4891,12 @@ final class AppStore { .first { FileManager.default.fileExists(atPath: $0) } } + nonisolated private static func installedMachinePath(_ names: [String]) -> String? { + names.lazy.compactMap { + DoryComponentStore.activeAssetPath(component: .linuxMachines, path: $0) + }.first + } + nonisolated private static var hostMachineAssetArch: String { #if arch(arm64) return "arm64" @@ -4681,9 +4916,14 @@ final class AppStore { let arch = hostMachineAssetArch let kernel = assets?.kernelPath ?? firstMachinePath(["DORYD_MACHINE_KERNEL", "DORYD_GUEST_KERNEL"], environment: environment) + ?? installedMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) ?? (useBundledAssets ? bundledMachinePath(["dory-hv-kernel-\(arch)", "dory-hv-kernel"]) : nil) let rootfs = assets?.rootfsPath ?? firstMachinePath(["DORYD_MACHINE_ROOTFS", "DORYD_GUEST_ROOTFS"], environment: environment) + ?? installedMachinePath([ + "dory-machine-rootfs-\(arch).ext4", + "dory-machine-rootfs.ext4", + ]) ?? (useBundledAssets ? bundledMachinePath([ "dory-machine-rootfs-\(arch).ext4", "dory-machine-rootfs.ext4", @@ -4730,6 +4970,21 @@ final class AppStore { actionError = "Invalid machine name: use letters, digits, and _ . - (must start alphanumeric)" return "Invalid machine name" } + if settings.displayMode == .desktop { + let distro = DesktopMachineDistro.resolve(settings.env["DORY_DESKTOP_DISTRO"]) + guard AppInfo.componentAvailable(.linuxDesktop), + AppInfo.componentAvailable(distro.componentID) else { + let message = "Install the Linux Desktop runtime and \(distro.displayName) in Components first." + actionError = message + section = .components + return message + } + } else if !AppInfo.componentAvailable(.linuxMachines) { + let message = "Install Linux Machines in Components before creating a server." + actionError = message + section = .components + return message + } guard requireDorydMachines() else { return actionError } let resolvedEnv = await machineEnvResolver(machineEnvAllowList) let effectiveSettings = Self.mergingEnv(settings, resolved: resolvedEnv) diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index c7fc5d7e..7de346e0 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -1,7 +1,7 @@ import SwiftUI enum AppSection: String, CaseIterable, Identifiable, Sendable { - case containers, images, volumes, networks, compose, kubernetes, desktops, machines, health, settings + case containers, images, volumes, networks, compose, kubernetes, desktops, machines, components, health, settings var id: String { rawValue } var title: String { @@ -14,6 +14,7 @@ enum AppSection: String, CaseIterable, Identifiable, Sendable { case .kubernetes: "Kubernetes" case .desktops: "Linux Desktops" case .machines: "Linux Servers" + case .components: "Components" case .health: "Health" case .settings: "Settings" } @@ -29,6 +30,7 @@ enum AppSection: String, CaseIterable, Identifiable, Sendable { case .kubernetes: nil case .desktops: "New Desktop" case .machines: "New Server" + case .components: nil case .health: nil case .settings: nil } @@ -443,11 +445,12 @@ struct LocalDorydCapability: Identifiable, Equatable, Sendable { } enum SettingsTab: String, CaseIterable, Identifiable, Sendable { - case general, engine, resources, machines, autoIdle, network, usb, localTools, migrate, managed, about + case general, components, engine, resources, machines, autoIdle, network, usb, localTools, migrate, managed, about var id: String { rawValue } var label: String { switch self { case .general: "General" + case .components: "Components" case .engine: "Engine & Daemon" case .resources: "Resources" case .machines: "Machines" diff --git a/Dory/Net/LocalCA.swift b/Dory/Net/LocalCA.swift index 3390b34d..7ad6d82d 100644 --- a/Dory/Net/LocalCA.swift +++ b/Dory/Net/LocalCA.swift @@ -76,9 +76,8 @@ nonisolated struct CertificatePair: Sendable { } /// Generates a local certificate authority and issues per-domain TLS certificates for -/// `*.dory.local` development domains. Installing the CA into the system trust store is a -/// privileged, security-sensitive action and is performed ONLY via `installInSystemTrust`, -/// which must be invoked from an explicit, consented user action — never automatically. +/// `*.dory.local` development domains. LocalCATrustManager handles the separate, +/// explicitly consented login-keychain trust step. nonisolated struct LocalCA: Sendable { let directory: URL @@ -161,11 +160,4 @@ nonisolated struct LocalCA: Sendable { return try Shell.run(openssl, ["x509", "-in", certificate.path, "-noout", "-text"]) } - // MARK: Gated system-trust install (requires explicit user consent + admin privileges) - - /// The command a consented install would run. Surfaced to the user; NOT executed automatically. - func systemTrustInstallCommand() -> [String] { - ["security", "add-trusted-cert", "-d", "-r", "trustRoot", - "-k", "/Library/Keychains/System.keychain", caCertificate.path] - } } diff --git a/Dory/Net/LocalCATrust.swift b/Dory/Net/LocalCATrust.swift new file mode 100644 index 00000000..e1d5b6c7 --- /dev/null +++ b/Dory/Net/LocalCATrust.swift @@ -0,0 +1,193 @@ +import Foundation +import Security +import Darwin + +nonisolated protocol LocalCATrustManaging { + /// Returns true when this call added trust that should be rolled back if the + /// surrounding networking transaction fails. + func install(certificateAt path: String) throws -> Bool + + /// Returns true when trust or a matching certificate was removed. + func remove(certificateAt path: String) throws -> Bool +} + +nonisolated enum LocalCATrustError: LocalizedError, Equatable { + case unreadableCertificate(String) + case invalidCertificate + case unexpectedCertificate(String) + case keychainOperation(String, OSStatus) + case trustOperation(String, OSStatus) + + var errorDescription: String? { + switch self { + case .unreadableCertificate(let path): + return "Dory could not read its local CA certificate at \(path)." + case .invalidCertificate: + return "Dory's local CA certificate is not a valid X.509 certificate." + case .unexpectedCertificate(let name): + return "Dory refused to trust an unexpected certificate named \(name)." + case .keychainOperation(let action, let status): + return "Dory could not \(action) its local CA certificate: \(Self.message(for: status))." + case .trustOperation(let action, let status): + return "Dory could not \(action) local HTTPS trust: \(Self.message(for: status))." + } + } + + private static func message(for status: OSStatus) -> String { + SecCopyErrorMessageString(status, nil) as String? ?? "Security error \(status)" + } +} + +nonisolated struct LocalCATrustManager: LocalCATrustManaging { + private static let expectedSubject = "Dory Local CA" + + func install(certificateAt path: String) throws -> Bool { + let loaded = try loadCertificate(at: path) + if try isTrusted(loaded.certificate) { return false } + + let addQuery: [CFString: Any] = [ + kSecClass: kSecClassCertificate, + kSecValueRef: loaded.certificate, + ] + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + let addedCertificate = addStatus == errSecSuccess + guard addedCertificate || addStatus == errSecDuplicateItem else { + throw LocalCATrustError.keychainOperation("store", addStatus) + } + + let trustStatus = SecTrustSettingsSetTrustSettings(loaded.certificate, .user, nil) + guard trustStatus == errSecSuccess else { + if addedCertificate { + _ = try? deleteMatchingCertificate(der: loaded.der) + } + throw LocalCATrustError.trustOperation("enable", trustStatus) + } + return true + } + + func remove(certificateAt path: String) throws -> Bool { + let loaded = try loadCertificate(at: path) + let trusted = try isTrusted(loaded.certificate) + if trusted { + let status = SecTrustSettingsRemoveTrustSettings(loaded.certificate, .user) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw LocalCATrustError.trustOperation("remove", status) + } + } + + let removedCertificate = try deleteMatchingCertificate(der: loaded.der) + return trusted || removedCertificate + } + + private func isTrusted(_ certificate: SecCertificate) throws -> Bool { + var settings: CFArray? + let status = SecTrustSettingsCopyTrustSettings(certificate, .user, &settings) + if status == errSecItemNotFound { return false } + guard status == errSecSuccess else { + throw LocalCATrustError.trustOperation("inspect", status) + } + return true + } + + private func loadCertificate(at path: String) throws -> (certificate: SecCertificate, der: Data) { + let raw = try readCertificateFile(at: path) + return try Self.validatedCertificate(from: raw) + } + + static func validatedCertificate(from raw: Data) throws -> (certificate: SecCertificate, der: Data) { + let der: Data + if let pem = String(data: raw, encoding: .utf8), pem.contains("-----BEGIN CERTIFICATE-----") { + let body = pem + .replacingOccurrences(of: "-----BEGIN CERTIFICATE-----", with: "") + .replacingOccurrences(of: "-----END CERTIFICATE-----", with: "") + .components(separatedBy: .whitespacesAndNewlines) + .joined() + guard let decoded = Data(base64Encoded: body), !decoded.isEmpty else { + throw LocalCATrustError.invalidCertificate + } + der = decoded + } else { + der = raw + } + + guard let certificate = SecCertificateCreateWithData(nil, der as CFData) else { + throw LocalCATrustError.invalidCertificate + } + let subject = SecCertificateCopySubjectSummary(certificate) as String? ?? "unknown" + guard subject == Self.expectedSubject else { + throw LocalCATrustError.unexpectedCertificate(subject) + } + return (certificate, der) + } + + private func readCertificateFile(at path: String) throws -> Data { + let descriptor = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard descriptor >= 0 else { + throw LocalCATrustError.unreadableCertificate(path) + } + defer { close(descriptor) } + + var info = stat() + let maximumBytes = 1 << 20 + guard fstat(descriptor, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + info.st_size > 0, + info.st_size <= maximumBytes else { + throw LocalCATrustError.unreadableCertificate(path) + } + + var data = Data(count: Int(info.st_size)) + var offset = 0 + let count = data.count + let readSucceeded = data.withUnsafeMutableBytes { buffer -> Bool in + guard let base = buffer.baseAddress else { return false } + while offset < count { + let amount = read(descriptor, base.advanced(by: offset), count - offset) + if amount > 0 { + offset += amount + } else if amount < 0, errno == EINTR { + continue + } else { + return false + } + } + return true + } + guard readSucceeded else { + throw LocalCATrustError.unreadableCertificate(path) + } + return data + } + + private func deleteMatchingCertificate(der: Data) throws -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassCertificate, + kSecMatchLimit: kSecMatchLimitAll, + kSecReturnRef: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return false } + guard status == errSecSuccess else { + throw LocalCATrustError.keychainOperation("find", status) + } + + let certificates = result as? [SecCertificate] ?? [] + var removed = false + for certificate in certificates where SecCertificateCopyData(certificate) as Data == der { + // Trust removal invalidates detached certificate references. Delete the fresh + // keychain-backed item returned above instead of reusing the parsed certificate. + let deleteStatus = SecItemDelete([ + kSecClass: kSecClassCertificate, + kSecValueRef: certificate, + ] as CFDictionary) + guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else { + throw LocalCATrustError.keychainOperation("remove", deleteStatus) + } + removed = removed || deleteStatus == errSecSuccess + } + return removed + } +} diff --git a/Dory/Runtime/Docker/HostDockerCLI.swift b/Dory/Runtime/Docker/HostDockerCLI.swift index a721a739..8b95b446 100644 --- a/Dory/Runtime/Docker/HostDockerCLI.swift +++ b/Dory/Runtime/Docker/HostDockerCLI.swift @@ -1,3 +1,4 @@ +import DoryOperations import Foundation /// Makes `docker` and `docker compose` work in the user's own terminal with zero prerequisites. @@ -36,6 +37,16 @@ enum HostDockerCLI { return true } + static func reconcileOptionalTools(enabled: Bool) { + let destination = binDir + "/kubectl" + if enabled, let source = bundledTool("kubectl") { + try? FileManager.default.createDirectory(atPath: binDir, withIntermediateDirectories: true) + symlink(source, to: destination) + return + } + removeOwnedOptionalToolSymlink(at: destination) + } + @discardableResult static func installComposePlugin() -> Bool { guard let compose = bundledTool("docker-compose") else { return false } @@ -94,9 +105,31 @@ enum HostDockerCLI { FileManager.default.isExecutableFile(atPath: auxiliary) { return auxiliary } + if name == "kubectl", + let installed = DoryComponentStore.activeAssetPath(component: .kubernetes, path: "kubectl"), + FileManager.default.isExecutableFile(atPath: installed) { + return installed + } return nil } + static func removeOwnedOptionalToolSymlink( + at destination: String, + home: String = NSHomeDirectory(), + bundleRoot: String = Bundle.main.bundleURL.path, + componentRoot: String? = (try? DoryComponentStore.selected())?.root, + fileManager: FileManager = .default + ) { + guard let rawTarget = try? fileManager.destinationOfSymbolicLink(atPath: destination) else { return } + let target = resolvedSymlinkTarget(rawTarget, at: destination) + let candidate = standardized(target) + let owned = isInside(candidate, root: standardized(home + "/.dory")) + || isInside(candidate, root: standardized(bundleRoot)) + || componentRoot.map { isInside(candidate, root: standardized($0)) } == true + guard owned else { return } + try? fileManager.removeItem(atPath: destination) + } + private static func symlink(_ source: String, to destination: String) { guard source != destination else { return } let fileManager = FileManager.default diff --git a/Dory/Runtime/Doryd/DorydClient.swift b/Dory/Runtime/Doryd/DorydClient.swift index 580ece79..e1bc6a15 100644 --- a/Dory/Runtime/Doryd/DorydClient.swift +++ b/Dory/Runtime/Doryd/DorydClient.swift @@ -285,6 +285,7 @@ nonisolated struct DorydNetworkingStatus: Sendable, Equatable { var httpsProxyPort: UInt16? var httpsProxyRunning: Bool var routes: [DorydDomainRoute] + var customRoutes: [DorydDomainRoute] } nonisolated struct DorydNetworkingAuthorizationRequest: Sendable, Equatable, Codable { @@ -1291,6 +1292,9 @@ nonisolated final class DorydClient: @unchecked Sendable { } let routes = rawRoutes.compactMap(domainRoute(from:)) guard routes.count == rawRoutes.count else { return nil } + let rawCustomRoutes = (dictionary["customRoutes"] as? [NSDictionary]) ?? [] + let customRoutes = rawCustomRoutes.compactMap(domainRoute(from:)) + guard customRoutes.count == rawCustomRoutes.count else { return nil } return DorydNetworkingStatus( mode: mode, suffix: suffix, @@ -1301,7 +1305,8 @@ nonisolated final class DorydClient: @unchecked Sendable { httpProxyRunning: (dictionary["httpProxyRunning"] as? Bool) ?? false, httpsProxyPort: uint16(dictionary["httpsProxyPort"]), httpsProxyRunning: (dictionary["httpsProxyRunning"] as? Bool) ?? false, - routes: routes + routes: routes, + customRoutes: customRoutes ) } diff --git a/Dory/Runtime/HostTools.swift b/Dory/Runtime/HostTools.swift index 9766723a..dfb4cd50 100644 --- a/Dory/Runtime/HostTools.swift +++ b/Dory/Runtime/HostTools.swift @@ -1,9 +1,9 @@ +import DoryOperations import Foundation -/// Resolves host-side CLI tools (kubectl, docker) that Dory shells out to. Prefers a copy bundled -/// inside the app so a fresh download needs nothing installed; falls back to a system install for -/// development builds. Everything Dory's engine and GUI do runs through the in-process Docker -/// client — these tools are only for the Kubernetes shell-out and the optional docker-CLI context. +/// Resolves host-side CLI tools that Dory shells out to. Core tools come from the app, optional +/// tools can come from a verified component, and development builds may fall back to a system +/// install. Dory's engine and GUI otherwise use the in-process Docker client. enum HostTools { static func kubectl() -> String? { resolve("kubectl", systemCandidates: [ "/usr/local/bin/kubectl", "/opt/homebrew/bin/kubectl", @@ -32,6 +32,11 @@ enum HostTools { private static func resolve(_ name: String, systemCandidates: [String]) -> String? { if let bundled = bundledPath(named: name) { return bundled } + if name == "kubectl", + let installed = DoryComponentStore.activeAssetPath(component: .kubernetes, path: "kubectl"), + FileManager.default.isExecutableFile(atPath: installed) { + return installed + } return Shell.find(name, candidates: systemCandidates) } diff --git a/Dory/Runtime/Machines/DesktopMachineAssets.swift b/Dory/Runtime/Machines/DesktopMachineAssets.swift index ccb40f44..c6435087 100644 --- a/Dory/Runtime/Machines/DesktopMachineAssets.swift +++ b/Dory/Runtime/Machines/DesktopMachineAssets.swift @@ -124,6 +124,8 @@ nonisolated enum DesktopMachineAssetProvisioner { "DORYD_GUEST_KERNEL", ], resourceNames: ["dory-desktop-kernel-\(arch)"], + component: .linuxDesktop, + componentAssetNames: ["dory-desktop-kernel-\(arch).lzfse", "dory-desktop-kernel-\(arch)"], kind: "kernel", environment: environment, resourceDirectory: resourceDirectory @@ -137,6 +139,8 @@ nonisolated enum DesktopMachineAssetProvisioner { "DORYD_GUEST_ROOTFS", ], resourceNames: rootfsResourceNames(for: distro, arch: arch), + component: distro.componentID, + componentAssetNames: rootfsResourceNames(for: distro, arch: arch).flatMap { ["\($0).lzfse", $0] }, kind: "root filesystem", environment: environment, resourceDirectory: resourceDirectory @@ -170,6 +174,8 @@ nonisolated enum DesktopMachineAssetProvisioner { private static func source( overrideKeys: [String], resourceNames: [String], + component: DoryComponentID, + componentAssetNames: [String], kind: String, environment: [String: String], resourceDirectory: String? @@ -182,6 +188,12 @@ nonisolated enum DesktopMachineAssetProvisioner { } return Source(path: path, compressed: path.hasSuffix(".lzfse")) } + for name in componentAssetNames { + if let path = DoryComponentStore.activeAssetPath(component: component, path: name), + isRegularFile(path) { + return Source(path: path, compressed: path.hasSuffix(".lzfse")) + } + } if environment["DORYD_DISABLE_BUNDLED_MACHINE_ASSETS"] == "1" { throw DesktopMachineAssetError.missingAsset(kind) } @@ -401,3 +413,13 @@ nonisolated enum DesktopMachineAssetProvisioner { "\(operation): \(String(cString: strerror(errno)))" } } + +extension DesktopMachineDistro { + nonisolated var componentID: DoryComponentID { + switch self { + case .debian: .desktopDebian + case .ubuntu: .desktopUbuntu + case .kali: .desktopKali + } + } +} diff --git a/Dory/Runtime/ManagedSettings.swift b/Dory/Runtime/ManagedSettings.swift index cc99e74e..75e83ae3 100644 --- a/Dory/Runtime/ManagedSettings.swift +++ b/Dory/Runtime/ManagedSettings.swift @@ -26,6 +26,12 @@ struct ManagedNetworkSettings: Codable, Equatable, Sendable { var dnsPort: UInt16 var httpProxyPort: UInt16 var httpsProxyPort: UInt16 + var customDomains: [ManagedCustomDomainRoute] = [] +} + +struct ManagedCustomDomainRoute: Codable, Equatable, Sendable { + var hostname: String + var publishedPort: UInt16 } struct ManagedAutoIdleSettings: Codable, Equatable, Sendable { diff --git a/DoryTests/AgentModeTests.swift b/DoryTests/AgentModeTests.swift index 19993bb3..feb92f5a 100644 --- a/DoryTests/AgentModeTests.swift +++ b/DoryTests/AgentModeTests.swift @@ -28,6 +28,16 @@ struct AgentModeTests { #expect(delegate.applicationShouldTerminateAfterLastWindowClosed(NSApplication.shared) == false) } + @Test func reopeningTheMenuBarAppRequestsTheMainWindow() { + let delegate = DoryAppDelegate() + #expect( + delegate.applicationShouldHandleReopen( + NSApplication.shared, + hasVisibleWindows: false + ) == false + ) + } + @Test func mainWindowIDIsStable() { #expect(DoryApp.mainWindowID == "dory-main") } @@ -82,16 +92,16 @@ struct AgentModeTests { #expect(delegate.responds(to: #selector(NSApplicationDelegate.applicationWillTerminate(_:)))) } - @Test func daemonStaysAvailableAfterQuitByDefaultAndHonorsExplicitOptOut() throws { + @Test func daemonStopsAfterQuitByDefaultAndHonorsExplicitOptIn() throws { let suite = "DoryTests.keepDoryd.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suite)) defer { defaults.removePersistentDomain(forName: suite) } - #expect(AppStore.resolvedKeepDorydRunningAfterQuit(defaults: defaults)) - defaults.set(false, forKey: AppStore.keepDorydRunningAfterQuitKey) #expect(!AppStore.resolvedKeepDorydRunningAfterQuit(defaults: defaults)) defaults.set(true, forKey: AppStore.keepDorydRunningAfterQuitKey) #expect(AppStore.resolvedKeepDorydRunningAfterQuit(defaults: defaults)) + defaults.set(false, forKey: AppStore.keepDorydRunningAfterQuitKey) + #expect(!AppStore.resolvedKeepDorydRunningAfterQuit(defaults: defaults)) } @Test func userRequestedWindowSkipsLaunchGate() { diff --git a/DoryTests/ContainerListStateTests.swift b/DoryTests/ContainerListStateTests.swift index 135e0354..e19d7929 100644 --- a/DoryTests/ContainerListStateTests.swift +++ b/DoryTests/ContainerListStateTests.swift @@ -6,6 +6,8 @@ import Foundation struct ContainerListStateTests { private func make(_ containers: [Container]) -> AppStore { let store = AppStore() + store.setContainerScope(.all) + store.filter = "" store.containers = containers return store } @@ -26,6 +28,56 @@ struct ContainerListStateTests { #expect(store.selectedContainerID == nil) } + @Test func stoppedContainersDoNotLeaveDetailsInTheRunningView() { + let store = AppStore() + store.containerFilter = .running + store.containers = [container("old", running: false)] + store.selectedContainerID = "old" + + #expect(store.filteredContainers.isEmpty) + #expect(store.selectedContainer == nil) + } + + @Test func selectionReconcilesWhenTheVisibleContainerDisappears() { + let store = AppStore() + store.containerFilter = .all + store.containers = [container("old", running: true), container("next", running: true)] + store.selectedContainerID = "old" + + store.containers = [container("next", running: true)] + + #expect(store.selectedContainerID == "next") + #expect(store.selectedContainer?.id == "next") + } + + @Test func changingFiltersSelectsOnlyAVisibleContainer() { + let store = AppStore() + store.containerFilter = .all + store.containers = [container("running", running: true), container("stopped", running: false)] + store.selectedContainerID = "running" + + store.containerFilter = .stopped + + #expect(store.selectedContainerID == "stopped") + #expect(store.selectedContainer?.id == "stopped") + } + + @Test func revealingAStoppedContainerMakesItVisible() { + let store = AppStore() + defer { store.setContainerScope(.all) } + let stopped = container("stopped", running: false, project: "site") + store.containerFilter = .running + store.containers = [stopped] + + store.revealContainer(stopped, scope: .compose) + + #expect(store.section == .containers) + #expect(store.containerScope == .compose) + #expect(store.containerFilter == .all) + #expect(store.selectedContainer?.id == "stopped") + #expect(store.isContainerInspectorVisible) + } + @Test func runningFilterShowsOnlyRunning() { let store = make([container("a", running: true), container("b", running: false)]) store.containerFilter = .running diff --git a/DoryTests/DoryTests.swift b/DoryTests/DoryTests.swift index 52a49314..7241bd2f 100644 --- a/DoryTests/DoryTests.swift +++ b/DoryTests/DoryTests.swift @@ -1,19 +1,35 @@ -// -// DoryTests.swift -// DoryTests -// -// Created by Augustus Otu on 18/06/2026. -// - +import Foundation import Testing @testable import Dory +@MainActor struct DoryTests { + @Test func componentSelectionLinkOpensComponentsWithCanonicalSelection() throws { + let store = AppStore(runtime: MockRuntime()) + let url = try #require(URL( + string: "dory://components/install?ids=desktop-ubuntu,linux-desktop,kubernetes" + )) - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing + #expect(store.handleComponentSelectionURL(url)) + #expect(store.section == .components) + #expect(store.requestedComponentIDs.map(\.rawValue) == [ + "kubernetes", + "linux-desktop", + "desktop-ubuntu", + ]) + #expect(store.windowOpenRequested) } + @Test func malformedComponentSelectionLinkDoesNotChangeNavigation() throws { + let store = AppStore(runtime: MockRuntime()) + store.section = .images + let url = try #require(URL( + string: "dory://components/install?ids=kubernetes,kubernetes" + )) + + #expect(!store.handleComponentSelectionURL(url)) + #expect(store.section == .images) + #expect(store.requestedComponentIDs.isEmpty) + #expect(!store.windowOpenRequested) + } } diff --git a/DoryTests/DorydClientTests.swift b/DoryTests/DorydClientTests.swift index 8064c704..f25bab0c 100644 --- a/DoryTests/DorydClientTests.swift +++ b/DoryTests/DorydClientTests.swift @@ -12,6 +12,14 @@ struct DorydClientTests { #expect(!AppStore.dorydEngineEnabled(environment: ["DORY_APP_DISABLE_DORYD": "1"])) } + @Test func customDomainPatternsAcceptExactAndLeftmostWildcardOnly() { + #expect(AppStore.normalizedCustomDomainPattern(" Admin.MyProject.Local. ") == "admin.myproject.local") + #expect(AppStore.normalizedCustomDomainPattern("*.Tenant.Test") == "*.tenant.test") + #expect(AppStore.normalizedCustomDomainPattern("localhost") == nil) + #expect(AppStore.normalizedCustomDomainPattern("admin.*.local") == nil) + #expect(AppStore.normalizedCustomDomainPattern("-admin.myproject.local") == nil) + } + @Test func doryCLIResolverPrefersBundledHelperOverAuxiliaryExecutable() throws { let root = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("DoryCLI-\(UUID().uuidString).app", isDirectory: true) @@ -255,6 +263,7 @@ struct DorydClientTests { pathPrefix: "/api/v1/namespaces/default/services/web:80/proxy" ), ]) + #expect(networkStatus.customRoutes == networkStatus.routes) #expect(networkPlan.suffix == "dory.local") #expect(networkPlan.dnsBindAddress == "127.0.0.1") #expect(networkPlan.dnsPort == 15353) @@ -1372,16 +1381,19 @@ struct DorydClientTests { else { UserDefaults.standard.removeObject(forKey: key) } } let removal = AuthorizedNetworkingRemovalRecorder() + let trust = LocalCATrustRemovalRecorder() let launchAgent = LaunchAgentConfigurationRecorder() let store = AppStore( dorydLaunchAgentEnsurer: { configuration in launchAgent.ensure(configuration) }, - authorizedNetworkingRemover: { try removal.remove() } + authorizedNetworkingRemover: { try removal.remove() }, + localCATrustManager: trust ) store.applyNetworkingSettings(domainsEnabled: false) try await waitUntil { !store.networkingAuthorizationInFlight } #expect(removal.callCount == 1) + #expect(trust.removeCallCount == 1) #expect(launchAgent.configurations.map(\.domainsEnabled) == [false]) #expect(!store.domainsEnabled) #expect(UserDefaults.standard.object(forKey: key) as? Bool == false) @@ -1397,22 +1409,52 @@ struct DorydClientTests { else { UserDefaults.standard.removeObject(forKey: key) } } let removal = AuthorizedNetworkingRemovalRecorder(fails: true) + let trust = LocalCATrustRemovalRecorder() let launchAgent = LaunchAgentConfigurationRecorder() let store = AppStore( dorydLaunchAgentEnsurer: { configuration in launchAgent.ensure(configuration) }, - authorizedNetworkingRemover: { try removal.remove() } + authorizedNetworkingRemover: { try removal.remove() }, + localCATrustManager: trust ) store.applyNetworkingSettings(domainsEnabled: false) try await waitUntil { !store.networkingAuthorizationInFlight } #expect(removal.callCount == 1) + #expect(trust.removeCallCount == 0) #expect(launchAgent.configurations.map(\.domainsEnabled) == [true]) #expect(store.domainsEnabled) #expect(UserDefaults.standard.object(forKey: key) as? Bool == true) #expect(store.networkingAuthorizationMessage?.contains("stayed enabled") == true) } + @MainActor + @Test func failedLocalCATrustCleanupStillLeavesDomainsSafelyDisabled() async throws { + let key = AppStore.domainsEnabledKey + let previous = UserDefaults.standard.object(forKey: key) + defer { + if let previous { UserDefaults.standard.set(previous, forKey: key) } + else { UserDefaults.standard.removeObject(forKey: key) } + } + let removal = AuthorizedNetworkingRemovalRecorder() + let trust = LocalCATrustRemovalRecorder(fails: true) + let launchAgent = LaunchAgentConfigurationRecorder() + let store = AppStore( + dorydLaunchAgentEnsurer: { configuration in launchAgent.ensure(configuration) }, + authorizedNetworkingRemover: { try removal.remove() }, + localCATrustManager: trust + ) + + store.applyNetworkingSettings(domainsEnabled: false) + try await waitUntil { !store.networkingAuthorizationInFlight } + + #expect(removal.callCount == 1) + #expect(trust.removeCallCount == 1) + #expect(!store.domainsEnabled) + #expect(store.networkingAuthorizationMessage?.contains("login keychain") == true) + #expect(store.settingsNotice?.kind == .failure) + } + @MainActor @Test func rejectedDomainDisableRestoresEnabledDaemonConfiguration() async throws { let key = AppStore.domainsEnabledKey @@ -1422,16 +1464,19 @@ struct DorydClientTests { else { UserDefaults.standard.removeObject(forKey: key) } } let removal = AuthorizedNetworkingRemovalRecorder() + let trust = LocalCATrustRemovalRecorder() let launchAgent = LaunchAgentConfigurationRecorder(rejectDisabledDomains: true) let store = AppStore( dorydLaunchAgentEnsurer: { configuration in launchAgent.ensure(configuration) }, - authorizedNetworkingRemover: { try removal.remove() } + authorizedNetworkingRemover: { try removal.remove() }, + localCATrustManager: trust ) store.applyNetworkingSettings(domainsEnabled: false) try await waitUntil { !store.networkingAuthorizationInFlight } #expect(removal.callCount == 1) + #expect(trust.removeCallCount == 1) #expect(launchAgent.configurations.map(\.domainsEnabled) == [false, true]) #expect(store.domainsEnabled) #expect(store.networkingAuthorizationMessage?.contains("Reauthorize") == true) @@ -1599,6 +1644,36 @@ private final class AuthorizedNetworkingRemovalRecorder: @unchecked Sendable { } } +private final class LocalCATrustRemovalRecorder: LocalCATrustManaging, @unchecked Sendable { + private let lock = NSLock() + private let fails: Bool + private var removeCalls = 0 + + init(fails: Bool = false) { + self.fails = fails + } + + var removeCallCount: Int { + lock.lock() + defer { lock.unlock() } + return removeCalls + } + + func install(certificateAt path: String) throws -> Bool { false } + + func remove(certificateAt path: String) throws -> Bool { + lock.lock() + removeCalls += 1 + lock.unlock() + if fails { throw LocalCATrustRemovalError.injectedFailure } + return true + } +} + +private enum LocalCATrustRemovalError: Error { + case injectedFailure +} + private enum AuthorizedNetworkingRemovalError: Error { case injectedFailure } @@ -2158,9 +2233,10 @@ private final class FakeDorydService: NSObject, DorydControlXPC { } func networkStatus(reply: @escaping (NSDictionary, String) -> Void) { - let routes = latestNetworkRoutes.isEmpty + let customRoutes = latestNetworkRoutes + let routes = customRoutes.isEmpty ? [DorydDomainRoute(hostname: "web.dory.local", address: "127.0.0.42", port: 8080)] - : latestNetworkRoutes + : customRoutes reply([ "mode": "high-port-dns-http-https-proxy", "suffix": "dory.local", @@ -2172,6 +2248,7 @@ private final class FakeDorydService: NSObject, DorydControlXPC { "httpsProxyPort": 18443, "httpsProxyRunning": true, "routes": routes.map(Self.dictionary), + "customRoutes": customRoutes.map(Self.dictionary), ] as NSDictionary, "") } diff --git a/DoryTests/HostDockerCLITests.swift b/DoryTests/HostDockerCLITests.swift index 2a97b0d2..168088d5 100644 --- a/DoryTests/HostDockerCLITests.swift +++ b/DoryTests/HostDockerCLITests.swift @@ -116,4 +116,42 @@ struct HostDockerCLITests { ) #expect(!fileManager.fileExists(atPath: destination.path)) } + + @Test func optionalToolRemovalOnlyTouchesDoryOwnedLinks() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent("dory-optional-cli-\(UUID().uuidString)") + let home = root.appendingPathComponent("home") + let bundle = root.appendingPathComponent("Dory.app") + let components = root.appendingPathComponent("Dory.dorydrive/components") + let destination = home.appendingPathComponent(".dory/bin/kubectl") + try fileManager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.createDirectory(at: components, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: root) } + + let componentTool = components.appendingPathComponent("installed/kubernetes/kubectl") + try fileManager.createDirectory(at: componentTool.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("kubectl".utf8).write(to: componentTool) + try fileManager.createSymbolicLink(atPath: destination.path, withDestinationPath: componentTool.path) + HostDockerCLI.removeOwnedOptionalToolSymlink( + at: destination.path, + home: home.path, + bundleRoot: bundle.path, + componentRoot: components.path, + fileManager: fileManager + ) + #expect((try? fileManager.destinationOfSymbolicLink(atPath: destination.path)) == nil) + + let thirdParty = root.appendingPathComponent("third-party/kubectl") + try fileManager.createDirectory(at: thirdParty.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("kubectl".utf8).write(to: thirdParty) + try fileManager.createSymbolicLink(atPath: destination.path, withDestinationPath: thirdParty.path) + HostDockerCLI.removeOwnedOptionalToolSymlink( + at: destination.path, + home: home.path, + bundleRoot: bundle.path, + componentRoot: components.path, + fileManager: fileManager + ) + #expect((try? fileManager.destinationOfSymbolicLink(atPath: destination.path)) == thirdParty.path) + } } diff --git a/DoryTests/LocalCATests.swift b/DoryTests/LocalCATests.swift index 32ca70c1..d2ef97a3 100644 --- a/DoryTests/LocalCATests.swift +++ b/DoryTests/LocalCATests.swift @@ -1,5 +1,6 @@ import Testing import Foundation +import Security @testable import Dory struct LocalCATests { @@ -27,11 +28,38 @@ struct LocalCATests { #expect(text.contains("Dory Local CA")) } - @Test func installCommandIsGatedAndWellFormed() { - let ca = LocalCA(directory: URL(fileURLWithPath: "/tmp/dory-ca-test")) - let command = ca.systemTrustInstallCommand() - #expect(command.first == "security") - #expect(command.contains("add-trusted-cert")) - #expect(command.contains("/Library/Keychains/System.keychain")) + @Test func localTrustParserAcceptsOnlyAValidDoryCA() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-trust-parser-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + + let ca = LocalCA(directory: directory) + guard ca.opensslPath != nil else { return } + try ca.ensureCA() + + let raw = try Data(contentsOf: ca.caCertificate) + let parsed = try LocalCATrustManager.validatedCertificate(from: raw) + #expect(!parsed.der.isEmpty) + #expect(SecCertificateCopySubjectSummary(parsed.certificate) as String? == "Dory Local CA") + + #expect(throws: LocalCATrustError.invalidCertificate) { + try LocalCATrustManager.validatedCertificate(from: Data("not a certificate".utf8)) + } } + + @Test func localTrustManagerRefusesCertificateSymlinksBeforeKeychainAccess() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-trust-symlink-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let target = directory.appendingPathComponent("target.crt") + let link = directory.appendingPathComponent("ca.crt") + try Data("not a certificate".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + #expect(throws: LocalCATrustError.unreadableCertificate(link.path)) { + try LocalCATrustManager().install(certificateAt: link.path) + } + } + } diff --git a/DoryTests/ManagedSettingsTests.swift b/DoryTests/ManagedSettingsTests.swift index 4bef5570..98eae778 100644 --- a/DoryTests/ManagedSettingsTests.swift +++ b/DoryTests/ManagedSettingsTests.swift @@ -15,6 +15,9 @@ struct ManagedSettingsTests { store.dnsPort = 15453 store.httpProxyPort = 18080 store.httpsProxyPort = 18443 + store.customDomainRoutes = [ + DorydDomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: 80), + ] store.runtimeMode = "auto-idle" store.idlePolicy = IdlePolicy( sleepAfterMinutes: 30, @@ -37,6 +40,9 @@ struct ManagedSettingsTests { #expect(profile.engine.memoryMB == 6144) #expect(profile.network.domainSuffix == "corp.dory.local") #expect(profile.network.dnsPort == 15453) + #expect(profile.network.customDomains == [ + ManagedCustomDomainRoute(hostname: "admin.myproject.local", publishedPort: 80), + ]) #expect(profile.autoIdle.mode == "auto-idle") #expect(profile.autoIdle.sleepAfterMinutes == 30) #expect(profile.autoIdle.keepPublishedPortsAwake == false) @@ -52,5 +58,6 @@ struct ManagedSettingsTests { #expect(json.contains(#""mode" : "none""#)) #expect(json.contains(#""cpuCount" : 4"#)) #expect(json.contains(#""memoryMB" : 6144"#)) + #expect(json.contains(#""hostname" : "admin.myproject.local""#)) } } diff --git a/DoryTests/NetworkingTests.swift b/DoryTests/NetworkingTests.swift index e72fb330..776211bb 100644 --- a/DoryTests/NetworkingTests.swift +++ b/DoryTests/NetworkingTests.swift @@ -135,6 +135,80 @@ struct NetworkingTests { #expect(AppStore.networkingAuthorizationSummary(plan).contains("no extra low TCP publishes")) } + @Test func networkingAuthorizationSuccessDoesNotFailOnPendingBackgroundApproval() { + let plan = DorydNetworkingAuthorizationPlan( + degradedMode: "high-port-dns-only", + authorizedMode: "system-resolver-proxy-tls", + suffix: "dory.local", + dnsBindAddress: "127.0.0.1", + dnsPort: 15353, + httpProxyPort: 8080, + httpsProxyPort: 8443, + requests: [] + ) + + let message = AppStore.networkingAuthorizationSuccessMessage( + plan, + removing: false, + backgroundServiceNotice: "Approve Dory's networking service in System Settings." + ) + #expect(message.hasPrefix("Dory networking is authorized for dory.local.")) + #expect(message.contains("Background updates need attention")) + #expect(message.contains("System Settings")) + } + + @Test func localCARequestMustUseTheCurrentUsersCanonicalDoryPath() { + let request = DorydNetworkingAuthorizationRequest( + id: "trust.local-ca", + kind: "localCATrust", + title: "Trust Dory Local CA for this account", + reason: "Local HTTPS", + requiresAdmin: false, + filePath: "/Users/test/.dory/ca/ca.crt", + command: [] + ) + let valid = DorydNetworkingAuthorizationPlan( + degradedMode: "high-port-dns-only", + authorizedMode: "system-resolver-proxy-tls", + suffix: "dory.local", + dnsBindAddress: "127.0.0.1", + dnsPort: 15353, + httpProxyPort: 18080, + httpsProxyPort: 18443, + requests: [request] + ) + #expect(AppStore.localCACertificatePath(in: valid, home: "/Users/test") == "/Users/test/.dory/ca/ca.crt") + + var wrongPath = valid + wrongPath.requests[0].filePath = "/tmp/ca.crt" + #expect(AppStore.localCACertificatePath(in: wrongPath, home: "/Users/test") == nil) + + var duplicate = valid + duplicate.requests.append(request) + #expect(AppStore.localCACertificatePath(in: duplicate, home: "/Users/test") == nil) + } + + @Test func networkingDeauthorizationDoesNotDependOnBackgroundService() { + let plan = DorydNetworkingAuthorizationPlan( + degradedMode: "high-port-dns-only", + authorizedMode: "system-resolver-proxy-tls", + suffix: "dory.local", + dnsBindAddress: "127.0.0.1", + dnsPort: 15353, + httpProxyPort: 8080, + httpsProxyPort: 8443, + requests: [] + ) + + #expect( + AppStore.networkingAuthorizationSuccessMessage( + plan, + removing: true, + backgroundServiceNotice: "ignored" + ) == "Dory-owned resolver, PF reference, and local CA trust were removed for dory.local." + ) + } + @Test func missingServiceManagementRecordStillTriggersRegistration() { #expect(AppStore.privilegedNetworkDaemonNeedsRegistration(.notRegistered)) #expect(AppStore.privilegedNetworkDaemonNeedsRegistration(.notFound)) diff --git a/DoryUITests/DoryScreensUITests.swift b/DoryUITests/DoryScreensUITests.swift index 260e553d..0e1480ca 100644 --- a/DoryUITests/DoryScreensUITests.swift +++ b/DoryUITests/DoryScreensUITests.swift @@ -73,6 +73,18 @@ final class DoryScreensUITests: XCTestCase { assertText("STARTUP") } + func testSidebarCanBeHiddenAndRestoredFromTheToolbar() { + let toggle = app.buttons["toggle-sidebar"] + XCTAssertTrue(toggle.waitForExistence(timeout: 4)) + XCTAssertTrue(app.buttons["nav-containers"].exists) + + toggle.click() + XCTAssertFalse(app.buttons["nav-containers"].exists) + + toggle.click() + XCTAssertTrue(app.buttons["nav-containers"].waitForExistence(timeout: 4)) + } + func testOnboardingOverlay() { app.buttons["brand"].click() XCTAssertTrue(app.buttons["onboarding-start"].waitForExistence(timeout: 4), "onboarding overlay should appear") diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift index f039f598..09e49c8a 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift @@ -231,6 +231,9 @@ public final class HostFS: @unchecked Sendable { /// Compare case-insensitively so the denylist cannot be bypassed through a case-insensitive /// APFS lookup such as `.SSH` resolving the host's `.ssh` directory. private let hiddenNameKeys: Set + /// Entry names hidden only as direct children of the export root. Safe whole-home shares use + /// this set so home credentials remain private without shadowing normal project directories. + private let rootHiddenNameKeys: Set private var nextNodeID: UInt64 = 2 private var nodes: [UInt64: Node] = [:] private var idsByFileKey: [FileKey: Set] = [:] @@ -270,7 +273,14 @@ public final class HostFS: @unchecked Sendable { } } - public init(rootPath: String, guestUID: UInt32 = 1000, guestGID: UInt32 = 1000, readOnly: Bool = false, hiddenNames: Set = []) throws { + public init( + rootPath: String, + guestUID: UInt32 = 1000, + guestGID: UInt32 = 1000, + readOnly: Bool = false, + hiddenNames: Set = [], + rootHiddenNames: Set = [] + ) throws { var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) guard realpath(rootPath, &resolved) != nil else { throw HostFSError.invalidRoot(rootPath) @@ -290,6 +300,7 @@ public final class HostFS: @unchecked Sendable { self.guestGID = guestGID self.readOnly = readOnly self.hiddenNameKeys = Set(hiddenNames.map(Self.hiddenNameKey)) + self.rootHiddenNameKeys = Set(rootHiddenNames.map(Self.hiddenNameKey)) var st = stat() guard fstat(fd, &st) == 0 else { @@ -686,10 +697,10 @@ public final class HostFS: @unchecked Sendable { break } } - guard let relativePath, - !relativePath.split(separator: "/").contains(where: { - isHiddenName(String($0)) - }) else { + guard let relativePath else { return nil } + let components = relativePath.split(separator: "/").map(String.init) + guard !components.contains(where: isHiddenAtAnyDepth), + components.first.map({ !rootHiddenNameKeys.contains(Self.hiddenNameKey($0)) }) ?? true else { return nil } return relativePath @@ -728,7 +739,7 @@ public final class HostFS: @unchecked Sendable { public func lookupIfExists(parent: UInt64, name: String) throws -> HostFSEntry? { try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) @@ -1447,7 +1458,7 @@ public final class HostFS: @unchecked Sendable { ) throws -> (entry: HostFSEntry, fd: Int32) { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) @@ -1535,7 +1546,7 @@ public final class HostFS: @unchecked Sendable { ) throws -> HostFSEntry { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) @@ -1568,7 +1579,7 @@ public final class HostFS: @unchecked Sendable { public func symlink(parent: UInt64, name: String, target: String) throws -> HostFSEntry { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) guard !target.isEmpty, !target.utf8.contains(0) else { throw HostFSError.invalidName(target) } @@ -1606,7 +1617,7 @@ public final class HostFS: @unchecked Sendable { public func link(nodeID: UInt64, newParent: UInt64, name: String) throws -> HostFSEntry { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: newParent) let source = try attachedNode(for: nodeID) guard !source.attributes.isDirectory else { throw HostFSError.systemCall("link directory", EPERM) @@ -1729,7 +1740,7 @@ public final class HostFS: @unchecked Sendable { public func unlink(parent: UInt64, name: String) throws { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) @@ -1761,7 +1772,7 @@ public final class HostFS: @unchecked Sendable { public func rmdir(parent: UInt64, name: String) throws { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) - try requireVisible(name) + try requireVisible(name, parent: parent) let parentNode = try attachedNode(for: parent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } let relative = join(parentNode.relativePath, name) @@ -1781,8 +1792,8 @@ public final class HostFS: @unchecked Sendable { guard !readOnly else { throw HostFSError.readOnly } try validateComponent(name) try validateComponent(newName) - try requireVisible(name) - try requireVisible(newName) + try requireVisible(name, parent: parent) + try requireVisible(newName, parent: newParent) let parentNode = try attachedNode(for: parent) let newParentNode = try attachedNode(for: newParent) guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } @@ -1844,7 +1855,7 @@ public final class HostFS: @unchecked Sendable { let name = withUnsafeBytes(of: entry.pointee.d_name) { bytes in String(decoding: bytes.prefix(length), as: UTF8.self) } - if name != ".", name != "..", !isHiddenName(name) { + if name != ".", name != "..", !isHiddenName(name, parent: nodeID) { names.append(name) } errno = 0 @@ -2043,13 +2054,18 @@ public final class HostFS: @unchecked Sendable { } } - private func requireVisible(_ name: String) throws { - guard !isHiddenName(name) else { + private func requireVisible(_ name: String, parent: UInt64) throws { + guard !isHiddenName(name, parent: parent) else { throw HostFSError.notFound(name) } } - private func isHiddenName(_ name: String) -> Bool { + private func isHiddenName(_ name: String, parent: UInt64) -> Bool { + isHiddenAtAnyDepth(name) + || (parent == Self.rootNodeID && rootHiddenNameKeys.contains(Self.hiddenNameKey(name))) + } + + private func isHiddenAtAnyDepth(_ name: String) -> Bool { hiddenNameKeys.contains(Self.hiddenNameKey(name)) } diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift index 071c7700..0a5fbcc7 100644 --- a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFSShareConfiguration.swift @@ -15,14 +15,16 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { /// that absolute guest path so a host directory can appear at its identical macOS path (e.g. /// `$HOME` at `$HOME`), which is what makes `-v /Users/…:/…` bind mounts resolve transparently. public var guestMountPoint: String? - /// Entry names hidden from the guest at any depth (see `HostFS.hiddenNames`). The `:safe` share - /// option applies `sensitiveNames` so a whole-home share never exposes credential stores or - /// shell rc files to containers. + /// Entry names explicitly hidden from the guest at any depth (see `HostFS.hiddenNames`). public var hiddenNames: Set + /// Entry names hidden only when they are direct children of the share root. The `:safe` share + /// option applies `sensitiveNames` here so a whole-home share protects the user's credential + /// stores and shell files without hiding ordinary names inside projects. + public var rootHiddenNames: Set /// Credential stores, cloud/CLI secrets, and shell rc files that must never be exposed by a - /// broad host share. Hidden by name at any depth. This is a defense-in-depth default for the - /// convenience home share; the stronger guarantee is per-bind-mount on-demand sharing. + /// broad host share. These names are anchored to the share root, which is the user's home for + /// the convenience home share. The stronger guarantee is per-bind-mount on-demand sharing. public static let sensitiveNames: Set = [ ".ssh", ".aws", ".gcloud", ".azure", ".kube", ".docker", ".dory", ".gnupg", ".config", ".codex", ".claude", ".colima", ".lima", ".orbstack", ".podman", ".rd", @@ -32,7 +34,15 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { "Library", ] - public init(tag: String, path: String, readOnly: Bool = false, dax: Bool = false, guestMountPoint: String? = nil, hiddenNames: Set = []) throws { + public init( + tag: String, + path: String, + readOnly: Bool = false, + dax: Bool = false, + guestMountPoint: String? = nil, + hiddenNames: Set = [], + rootHiddenNames: Set = [] + ) throws { guard !tag.isEmpty, Array(tag.utf8).count < VirtioFS.tagByteCount else { throw VMError.invalidConfiguration("invalid virtio-fs share tag: \(tag)") } @@ -50,7 +60,8 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { "virtio-fs share \(tag) guest mount point must be a canonical absolute path below '/': \(guestMountPoint)" ) } - guard hiddenNames.allSatisfy(Self.isValidHiddenName) else { + guard hiddenNames.allSatisfy(Self.isValidHiddenName), + rootHiddenNames.allSatisfy(Self.isValidHiddenName) else { throw VMError.invalidConfiguration( "virtio-fs share \(tag) hidden names must be individual path components" ) @@ -64,6 +75,7 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { self.dax = dax self.guestMountPoint = guestMountPoint self.hiddenNames = hiddenNames + self.rootHiddenNames = rootHiddenNames } public init(argument: String) throws { @@ -78,12 +90,13 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { var dax = false var guestMountPoint: String? var hiddenNames: Set = [] + var rootHiddenNames: Set = [] for option in components { switch option { case "ro": readOnly = true case "rw": readOnly = false case "dax": dax = true - case "safe": hiddenNames.formUnion(Self.sensitiveNames) + case "safe": rootHiddenNames.formUnion(Self.sensitiveNames) case "": path += ":" case let option where option.hasPrefix("at="): guestMountPoint = String(option.dropFirst(3)) @@ -97,7 +110,15 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { throw VMError.invalidConfiguration("unknown virtio-fs share option ':\(option)' (expected ro, rw, safe, hide=a,b, or at=/guest/path)") } } - try self.init(tag: tag, path: path, readOnly: readOnly, dax: dax, guestMountPoint: guestMountPoint, hiddenNames: hiddenNames) + try self.init( + tag: tag, + path: path, + readOnly: readOnly, + dax: dax, + guestMountPoint: guestMountPoint, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames + ) } /// Distinct virtio-fs devices over an overlapping host subtree cannot preserve guest-originated @@ -152,7 +173,12 @@ public struct VirtioFSShareConfiguration: Equatable, Sendable { guard !dax else { throw VMError.invalidConfiguration(Self.daxUnsupportedReason) } - let hostFS = try HostFS(rootPath: path, readOnly: readOnly, hiddenNames: hiddenNames) + let hostFS = try HostFS( + rootPath: path, + readOnly: readOnly, + hiddenNames: hiddenNames, + rootHiddenNames: rootHiddenNames + ) return try VirtioFS(tag: tag, hostFS: hostFS, requestQueueCount: requestQueueCount) } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift index 29a292ef..639a8cd5 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift @@ -221,6 +221,38 @@ struct HostFSTests { #expect(file.attributes.isRegularFile) } + @Test func rootHiddenNamesProtectHomeWithoutShadowingProjectLibraries() throws { + let root = try TestHostFSRoot() + try FileManager.default.createDirectory( + at: root.url.appendingPathComponent("Library"), + withIntermediateDirectories: false + ) + try FileManager.default.createDirectory( + at: root.url.appendingPathComponent("project/vendor/ezyang/htmlpurifier/library"), + withIntermediateDirectories: true + ) + try root.write("autoload", to: "project/vendor/ezyang/htmlpurifier/library/HTMLPurifier.composer.php") + let fs = try HostFS(rootPath: root.url.path, rootHiddenNames: ["Library"]) + + #expect(try fs.readdirplus(nodeID: HostFS.rootNodeID).map(\.name) == ["project"]) + #expect(throws: HostFSError.notFound("Library")) { + _ = try fs.lookup(parent: HostFS.rootNodeID, name: "Library") + } + #expect(throws: HostFSError.notFound("library")) { + _ = try fs.mkdir(parent: HostFS.rootNodeID, name: "library") + } + + let project = try fs.lookup(parent: HostFS.rootNodeID, name: "project") + let created = try fs.mkdir(parent: project.nodeID, name: "library") + #expect(created.attributes.isDirectory) + let vendor = try fs.lookup(parent: project.nodeID, name: "vendor") + let ezyang = try fs.lookup(parent: vendor.nodeID, name: "ezyang") + let purifier = try fs.lookup(parent: ezyang.nodeID, name: "htmlpurifier") + let library = try fs.lookup(parent: purifier.nodeID, name: "library") + let composer = try fs.lookup(parent: library.nodeID, name: "HTMLPurifier.composer.php") + #expect(composer.attributes.isRegularFile) + } + @Test func hiddenNamesCannotBeBypassedWithCaseVariants() throws { let root = try TestHostFSRoot() try FileManager.default.createDirectory( diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift index e22f7eb7..7c9b9a93 100644 --- a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift @@ -80,15 +80,17 @@ struct VirtioFSShareConfigurationTests { @Test func safeOptionAppliesSensitiveNameDenylist() throws { let share = try VirtioFSShareConfiguration(argument: "home=/Users/example:rw:at=/Users/example:safe") - #expect(share.hiddenNames == VirtioFSShareConfiguration.sensitiveNames) - #expect(share.hiddenNames.contains(".ssh")) - #expect(share.hiddenNames.contains(".aws")) - #expect(share.hiddenNames.contains(".dory")) - #expect(share.hiddenNames.contains(".zsh_history")) - #expect(share.hiddenNames.contains(".bash_history")) - #expect(share.hiddenNames.contains(".codex")) - #expect(share.hiddenNames.contains(".orbstack")) - #expect(share.hiddenNames.contains(".colima")) + #expect(share.hiddenNames.isEmpty) + #expect(share.rootHiddenNames == VirtioFSShareConfiguration.sensitiveNames) + #expect(share.rootHiddenNames.contains(".ssh")) + #expect(share.rootHiddenNames.contains(".aws")) + #expect(share.rootHiddenNames.contains(".dory")) + #expect(share.rootHiddenNames.contains(".zsh_history")) + #expect(share.rootHiddenNames.contains(".bash_history")) + #expect(share.rootHiddenNames.contains(".codex")) + #expect(share.rootHiddenNames.contains(".orbstack")) + #expect(share.rootHiddenNames.contains(".colima")) + #expect(share.rootHiddenNames.contains("Library")) } @Test func hideOptionAddsExplicitNames() throws { @@ -99,6 +101,7 @@ struct VirtioFSShareConfigurationTests { @Test func defaultsToNoHiddenNames() throws { let share = try VirtioFSShareConfiguration(argument: "src=/tmp/x") #expect(share.hiddenNames.isEmpty) + #expect(share.rootHiddenNames.isEmpty) } @Test func rejectsRelativeGuestMountPoint() { diff --git a/README.md b/README.md index 0b154af7..e30ec5ee 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ > Dory is built and qualified for Apple Silicon. Intel Mac support will follow after dedicated > hardware validation. Current downloads and the Homebrew cask do not include an Intel build. -> Dory 0.3.1 adds managed Debian, Ubuntu, and Kali Linux desktops, Retina-sharp display windows, -> separate lean and Desktop editions, selectable terminal apps, a configurable Docker bridge, and a -> first-run networking authorization fix. +> Dory 0.3.2 is one smaller Docker Core app with optional, signed Kubernetes, Linux Machines, +> Linux Desktop, Debian, Ubuntu, and Kali components. The website shows the exact total before +> download, carries that choice into Dory for confirmation, and can remove optional payloads later +> without deleting containers, volumes, cluster state, machine disks, snapshots, or exports.

Explore the interactive Dory interface @@ -53,19 +54,20 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you | Kubernetes | One-click k3s with selectable v1.34, v1.35, and v1.36 presets plus a native resource browser | | Migration | Transactional import from Docker Desktop, OrbStack, Colima, Rancher Desktop, Podman, or another Docker-compatible socket | | Storage | One managed `.dorydrive`, external APFS drive support, sparse growth, verified backup, restore, and safe selection | -| Networking | Localhost ports, optional local domains and HTTPS, low ports, host services, custom DNS/proxy ports, and opt-in LAN access | +| Networking | Localhost ports, automatic and user-defined local domains, trusted HTTPS, low ports, host services, custom DNS/proxy ports, and opt-in LAN access | | Operations | Auto-Idle, active diagnostics, targeted repair, safe cleanup, support bundles, wait primitives, and event streams | | Agents | Versioned JSON guide, non-interactive schemas, read-only MCP mode, machine execution, and preview isolated sandbox VMs | ## Why it is different -- **A complete runtime, not a dashboard.** Dory bundles its engine, guest, Docker tools, Compose, - Buildx, `kubectl`, networking, file sharing, and recovery tools. +- **A complete runtime, not a dashboard.** Docker Core includes its engine, guest, Docker tools, + Compose, Buildx, networking, file sharing, and recovery tools. Kubernetes and Linux machine + payloads are signed components rather than permanent app weight. - **One shared container VM.** Containers use one persistent Linux engine. Its memory ceiling is configurable, and free guest pages can be returned to macOS. - **Linux machines beside containers.** Machines are separate VMs with their own disk, address, resources, shell, shares, and snapshots. They are not disguised containers. -- **Every important setting is in the app.** Engine resources, storage, migration, local domains, +- **Every important setting is in the app.** Engine resources, storage, migration, automatic and custom domains, low ports, LAN access, Auto-Idle, machine environment policy, USB, and managed defaults all have a graphical path. - **Automation is a product surface.** JSON schemas, safe dry runs, event streams, wait commands, @@ -81,47 +83,65 @@ or commercial-use tier. Dory is GPL-3.0 software and stores workload data on you brew install --cask Augani/dory/dory ``` -The Homebrew cask installs the recommended Lean edition with containers, Kubernetes, and headless -Linux servers. It does not include the large graphical Linux images. +The Homebrew cask installs Docker Core. Add Kubernetes, Linux Machines, or individual graphical +desktop packs from Dory after it opens. -Open Dory once. The daemon keeps `docker`, `docker compose`, `kubectl`, and `dory` available in -`~/.dory/bin`, creates the `dory` Docker context, and points it at `~/.dory/dory.sock`. Docker -Desktop and a separate Docker CLI install are not required. +Open Dory once. The daemon keeps `docker`, `docker compose`, and `dory` available in `~/.dory/bin`, +creates the `dory` Docker context, and points it at `~/.dory/dory.sock`. Installing the Kubernetes +component adds `kubectl`. Docker Desktop and a separate Docker CLI install are not required. ### Direct download -Choose the edition before downloading. The Desktop edition is intentionally larger because Debian, -Ubuntu, and Kali graphical images are included for offline use. - -| Edition | Download | Installed app | Includes | Direct download | -|---|---:|---:|---|---| -| **Lean, recommended** | about 452 MiB | about 1.6 GiB | Docker, Compose, Kubernetes, migration, and headless Linux machines | [Lean DMG](https://github.com/Augani/dory/releases/download/v0.3.1/Dory-0.3.1-arm64.dmg) | -| **Desktop** | about 1.85 GiB | about 3.0 GiB | Everything in Lean plus offline Debian, Ubuntu, and Kali graphical desktops | [Desktop DMG](https://github.com/Augani/dory/releases/download/v0.3.1/Dory-0.3.1-desktop-arm64.dmg) | - -Drag Dory to Applications and open it. Choose Desktop only when you need graphical Linux machines. -The next feature release will move toward a Docker-only core with focused Kubernetes, Linux -Machines, and Linux Desktop downloads selected before installation. +Start with the one Apple Silicon [Dory 0.3.2 Docker Core +DMG](https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-arm64.dmg). Its exact signed +catalog reports a 224.1 MiB download and 409.3 MiB installed app. Drag Dory to Applications, open +it, then add only the components you want. The website's component selector can open the same +selection in Dory after installation. Dory shows the signed sizes again and waits for explicit +confirmation before downloading any optional payload. + +### Focused components + +Docker Core contains Dory.app, the Docker engine and CLI, Compose, Buildx, networking, storage, +migration, diagnostics, and recovery. The signed component catalog offers: + +| Component | Adds | Depends on | +|---|---|---| +| Kubernetes | `kubectl` and Dory's local k3s workflow | Docker Core | +| Linux Machines | Headless VPS-style Linux guests | Docker Core | +| Linux Desktop Runtime | Shared graphical VM kernel | Docker Core | +| Debian 13 Desktop | Debian 13 Xfce image | Linux Desktop Runtime | +| Ubuntu 24.04 LTS Desktop | Ubuntu Xfce image | Linux Desktop Runtime | +| Kali Linux Desktop | Kali rolling Xfce image | Linux Desktop Runtime | + +Component download and installed sizes come from the signed release catalog, not estimates. Dory +stores installed payloads inside the selected `.dorydrive/components` directory. Removing a +component reclaims only its installed payload. Workload data on the selected drive is preserved. +The Kubernetes component size covers `kubectl`; the selected k3s container image is downloaded on +first cluster creation and then remains in Docker storage on the selected data drive. + +The native Components screen and `dory component` commands install, update, verify, and remove the +same catalog entries. Components are transactional, digest-verified, architecture-checked, and +activated atomically. A cached catalog is used offline only after its signature is verified again. +The Core app reuses its signed engine kernel and rootfs for its macOS 14 fallback through internal +aliases, so compatibility does not make users download duplicate VM payloads. | Release asset | Purpose | |---|---| -| `Dory-x.y.z-arm64.dmg` | Recommended lean installer: containers, Kubernetes, and headless Linux servers | -| `Dory-x.y.z-desktop-arm64.dmg` | All-inclusive installer with Debian, Ubuntu, and Kali graphical desktops | -| `Dory-x.y.z-arm64.zip` | Lean app archive | -| `Dory-x.y.z-desktop-arm64.zip` | All-inclusive app archive | -| `Dory-x.y.z-lite.zip` | Native UI for an existing Docker-compatible engine | +| `Dory-x.y.z-arm64.dmg` | Docker Core installer | +| `Dory-x.y.z-arm64.zip` | Docker Core app archive | | `dory-engine-x.y.z-arm64.tar.gz` | Headless Dory engine bundle | | `release-manifest.json` | Artifact names, hashes, and release provenance | -| `Dory-x.y.z.cdx.json` | CycloneDX software bill of materials for the lean app | -| `Dory-x.y.z-desktop.cdx.json` | CycloneDX software bill of materials for the Desktop app | +| `Dory-x.y.z.cdx.json` | CycloneDX software bill of materials for Docker Core | +| `components/arm64/catalog.json` | Signed component assets, dependencies, hashes, and exact sizes | -Lean and Desktop installations use separate signed update feeds, so future updates keep the edition -you selected instead of adding or removing the graphical Linux payload unexpectedly. +Dory.app uses one signed update feed while optional components update independently on the selected +data drive, so an app update cannot silently add a large Linux payload. -### Upgrading from 0.3.0 +### Upgrading from an older release -Dory 0.3.0 does not contain the new graphical Linux images. Quit Dory, uninstall the old app, then -install the 0.3.1 lean or Desktop edition. Normal uninstall preserves the selected `.dorydrive`, -including images, containers, volumes, networks, machine disks, and snapshots. +Quit Dory, uninstall the old app, then install Dory 0.3.2 Docker Core. Normal uninstall preserves +the selected `.dorydrive`, including images, containers, volumes, networks, machine disks, and +snapshots. Install the optional components you use after the new app opens. ```sh brew uninstall --cask Augani/dory/dory @@ -129,9 +149,9 @@ brew install --cask Augani/dory/dory ``` For a direct installation, run `dory uninstall`, remove the old `Dory.app`, and drag the new -edition into Applications. Keep only one copy of Dory.app so macOS registers the correct bundled -services. Choose the Desktop edition to create new graphical machines; the lean edition can still -manage an existing desktop machine whose disk is already in the selected data drive. +Docker Core app into Applications. Keep only one copy of Dory.app so macOS registers the correct +bundled services. Existing workload data remains on the selected drive, but creating or starting a +graphical machine requires its matching desktop component. ### Requirements @@ -220,9 +240,13 @@ native Compose screen can open a YAML file, start or stop a project, restart run ## Kubernetes +Install the Kubernetes component from the app or with `dory component install kubernetes` before +enabling a cluster in the focused release. Docker-only users do not download `kubectl`. + The Kubernetes screen creates a local k3s cluster inside the shared engine and lets you choose a -supported v1.34, v1.35, or v1.36 preset. Switching versions recreates the cluster and is presented -as a destructive action. +supported v1.34, v1.35, or v1.36 preset. The selected k3s image is downloaded on first enable and +stored with Docker data on the selected Dory drive. Switching versions recreates the cluster and +is presented as a destructive action. The native browser covers: @@ -231,11 +255,14 @@ The native browser covers: - services, ConfigMaps, Secrets, and Ingresses; - namespace filtering, YAML apply, rollout status, and kubeconfig copy. -The bundled `kubectl` and `dory k8s ` target the same cluster. k3s has its own image +The component-managed `kubectl` and `dory k8s ` target the same cluster. k3s has its own image store, so push a built image to a registry or import it into the cluster before using it in a Pod. ## Dory Linux machines +Install Linux Machines for headless guests. Graphical guests additionally need the Linux Desktop +Runtime and the selected Debian, Ubuntu, or Kali distribution component. + Dory Linux machines are persistent, separate VMs rather than containers. The app offers full Xfce desktops based on Debian 13, Ubuntu 24.04 LTS, or Kali Linux rolling for graphical and command-line applications. A lightweight Alpine-based headless profile remains available for services, @@ -349,6 +376,7 @@ binding. Optional system integration adds: - automatic names under `*.dory.local` or a per-user custom suffix; +- exact or leftmost-wildcard custom hostnames routed to a selected published HTTP port; - a local certificate authority for trusted HTTPS; - Dory-owned resolver and packet-filter rules; - built-in forwarding for ports 80, 443, and published TCP ports below 1024; @@ -359,6 +387,19 @@ The Docker bridge subnet, DNS resolver, HTTP proxy, and HTTPS proxy ports are co allows Dory to avoid VPN or local-network conflicts and lets separate macOS accounts choose unique suffixes and local service ports. +Custom hostnames must already resolve to `127.0.0.1` through `/etc/hosts` or local DNS. Add them in +Settings > Network, for example `admin.myproject.local` or `*.myproject.local` to published port 80. +Because `/etc/hosts` does not expand wildcards, use local DNS or list each hostname that should +match a wildcard route. Dory's HTTP and HTTPS proxies then preserve the requested `Host` header and +route both standard ports without a second forwarding app. The same configuration is available +from the terminal: + +```sh +dory network custom-domains +dory network set-custom-domain admin.myproject.local --published-port 80 +dory network remove-custom-domain admin.myproject.local +``` + Containers reach Mac services through `host.dory.internal`. Common host AI endpoints are available without enabling experimental guest GPU support: @@ -485,7 +526,7 @@ Everything below is available without using the command line: | Resources | Data drive, reveal, backup, verify, restore, select, grow, per-process memory, Mac capacity | | Machines | Host environment allow-list and the file-sharing boundary for persistent and sandbox machines | | Auto-Idle | Availability mode, delay, blockers, and wake notifications | -| Network | Domains, suffix, macOS authorization, low ports, Docker bridge subnet, resolver and proxy ports, LAN and Tailscale access | +| Network | Automatic and custom domains, suffix, macOS authorization, low ports, Docker bridge subnet, resolver and proxy ports, LAN and Tailscale access | | USB Devices | Scan, attach, detach, and remember USB/IP attachments per machine | | Local Tools | Stable and preview CLI capabilities with copyable commands | | Migrate & Compare | Source selection, read-only inventory, preflight, import, and product comparison | @@ -567,10 +608,9 @@ scripts/build.sh scripts/test.sh ``` -The source build is lean by default. Use -`DORY_DESKTOP_BUNDLE_MODE=all scripts/build.sh` to include all three verified graphical desktop -images. The two modes are intentionally all-or-none so an app labeled all-inclusive cannot silently -ship a missing distribution. +The source build defaults to Docker Core. `DORY_DESKTOP_BUNDLE_MODE=all scripts/build.sh` remains a +developer-only offline fixture for exercising every graphical image in one local build. Public +releases use Docker Core plus the signed component catalog. `scripts/test.sh` is the single public test entrypoint. It covers the Rust workspace, Swift packages, app tests, UI tests, CLI contracts, and public repository checks. Release qualification adds signed diff --git a/dory-core-swift/README.md b/dory-core-swift/README.md index 37d761e8..2a50121c 100644 --- a/dory-core-swift/README.md +++ b/dory-core-swift/README.md @@ -153,19 +153,20 @@ active suffix, ports, listener state, and route list. `repairSubsystem` gives th `dorydctl network repair` one bounded path for DNS/domain listener restart, route re-derivation, immediate gvproxy port reconciliation, guest-agent RPC recovery, and Docker API verification. -This intentionally does not mutate `/etc/resolver`, pf, or the system trust store. Those remain -separately-authorized privileged helper work. +This intentionally does not mutate `/etc/resolver`, pf, or a user's keychain. Those remain +separately authorized by Dory's explicit networking action. `DoryLocalCA` can generate the local CA, issue per-domain certificates, and export PKCS#12 identities -with private key material at `0600`. Installing the CA into the system trust store is represented only -as `systemTrustInstallCommand()`; doryd does not execute it automatically. +with private key material at `0600`. Dory.app installs the exact CA into the current user's login +keychain only after an interactive trust prompt; doryd does not change trust settings. `dory-network-helper` is the privileged execution path for that plan. It reads a `NetworkingAuthorizationPlan` JSON document, re-derives the expected plan from the scalar configuration, refuses tampered paths/commands, and then writes `/etc/resolver/`, `/etc/pf.anchors/dev.dory`, loads it as `com.apple/dev.dory` under macOS's built-in `com.apple/*` -anchor point, enables pf, and installs LocalCA trust when present. Use `--dry-run --plan-json -` to -validate a plan without touching the system. +anchor point, enables pf, and snapshots the approved CA so background reconciliation cannot rotate it +without another interactive Dory authorization. Use `--dry-run --plan-json -` to validate a plan +without touching the system. ## Machine lifecycle diff --git a/dory-core-swift/Sources/DoryOperations/DoryComponents.swift b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift new file mode 100644 index 00000000..d6e0ddd4 --- /dev/null +++ b/dory-core-swift/Sources/DoryOperations/DoryComponents.swift @@ -0,0 +1,1279 @@ +import Compression +import CryptoKit +import Darwin +import Foundation + +public enum DoryComponentID: String, Codable, CaseIterable, Hashable, Sendable { + case dockerCore = "docker-core" + case kubernetes + case linuxMachines = "linux-machines" + case linuxDesktop = "linux-desktop" + case desktopDebian = "desktop-debian" + case desktopUbuntu = "desktop-ubuntu" + case desktopKali = "desktop-kali" + + public var isRemovable: Bool { self != .dockerCore } +} + +public enum DoryComponentSelectionURL { + public static let scheme = "dory" + public static let host = "components" + public static let path = "/install" + + public static func parse(_ url: URL) -> [DoryComponentID]? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme?.lowercased() == scheme, + components.host?.lowercased() == host, + components.path == path, + components.user == nil, + components.password == nil, + components.port == nil, + components.fragment == nil, + let queryItems = components.queryItems, + queryItems.count == 1, + queryItems[0].name == "ids", + let value = queryItems[0].value, + !value.isEmpty else { + return nil + } + + let rawIDs = value.split(separator: ",", omittingEmptySubsequences: false).map(String.init) + guard !rawIDs.isEmpty else { return nil } + + var selected: Set = [] + for rawID in rawIDs { + guard let id = DoryComponentID(rawValue: rawID), + id.isRemovable, + selected.insert(id).inserted else { + return nil + } + } + return DoryComponentID.allCases.filter(selected.contains) + } + + public static func make(_ ids: some Sequence) -> URL? { + let selected = Set(ids) + guard !selected.isEmpty, selected.allSatisfy(\.isRemovable) else { return nil } + let ordered = DoryComponentID.allCases.filter(selected.contains) + var components = URLComponents() + components.scheme = scheme + components.host = host + components.path = path + components.queryItems = [ + URLQueryItem(name: "ids", value: ordered.map(\.rawValue).joined(separator: ",")), + ] + return components.url + } +} + +public enum DoryComponentDefaults { + public static let catalogURL = URL( + string: "https://augani.github.io/dory/components/arm64/catalog.json" + )! + /// Component catalogs use the same pinned Ed25519 trust root as Dory's signed Sparkle feed. + /// The private key never ships with the app or repository. + public static let publicKey = "AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=" + + public static var architecture: String { + #if arch(arm64) + "arm64" + #else + "x86_64" + #endif + } +} + +public enum DoryComponentCompression: String, Codable, Sendable { + case none + case lzfse +} + +public struct DoryComponentAsset: Codable, Sendable, Equatable { + public let path: String + public let url: String + public let compression: DoryComponentCompression + public let downloadBytes: UInt64 + public let installedBytes: UInt64 + public let sha256: String + public let installedSHA256: String + public let executable: Bool + + public init( + path: String, + url: String, + compression: DoryComponentCompression = .none, + downloadBytes: UInt64, + installedBytes: UInt64, + sha256: String, + installedSHA256: String, + executable: Bool = false + ) { + self.path = path + self.url = url + self.compression = compression + self.downloadBytes = downloadBytes + self.installedBytes = installedBytes + self.sha256 = sha256.lowercased() + self.installedSHA256 = installedSHA256.lowercased() + self.executable = executable + } +} + +public struct DoryComponentRelease: Codable, Sendable, Equatable, Identifiable { + public let id: DoryComponentID + public let version: String + public let displayName: String + public let summary: String + public let dependencies: [DoryComponentID] + public let downloadBytes: UInt64 + public let installedBytes: UInt64 + public let assets: [DoryComponentAsset] + + public init( + id: DoryComponentID, + version: String, + displayName: String, + summary: String, + dependencies: [DoryComponentID] = [.dockerCore], + downloadBytes: UInt64, + installedBytes: UInt64, + assets: [DoryComponentAsset] + ) { + self.id = id + self.version = version + self.displayName = displayName + self.summary = summary + self.dependencies = dependencies + self.downloadBytes = downloadBytes + self.installedBytes = installedBytes + self.assets = assets + } +} + +public struct DoryComponentCatalog: Codable, Sendable, Equatable { + public static let kind = "dev.dory.component-catalog" + public static let schemaVersion = 1 + + public let kind: String + public let schemaVersion: Int + public let releaseVersion: String + public let generatedAt: String + public let minimumAppVersion: String + public let architecture: String + public let components: [DoryComponentRelease] + + public init( + releaseVersion: String, + generatedAt: String, + minimumAppVersion: String, + architecture: String, + components: [DoryComponentRelease] + ) { + kind = Self.kind + schemaVersion = Self.schemaVersion + self.releaseVersion = releaseVersion + self.generatedAt = generatedAt + self.minimumAppVersion = minimumAppVersion + self.architecture = architecture + self.components = components + } + + public func component(_ id: DoryComponentID) -> DoryComponentRelease? { + components.first { $0.id == id } + } +} + +public enum DoryComponentError: Error, Sendable, Equatable, CustomStringConvertible { + case invalidCatalog(String) + case invalidSignature + case incompatibleArchitecture(expected: String, actual: String) + case incompatibleAppVersion(required: String, actual: String) + case unknownComponent(String) + case coreCannotBeChanged + case missingDependency(DoryComponentID) + case componentInUse(DoryComponentID) + case invalidAsset(String) + case download(String) + case digestMismatch(String) + case interrupted(String) + case unsafePath(String) + case filesystem(String) + + public var description: String { + switch self { + case .invalidCatalog(let detail): "invalid component catalog: \(detail)" + case .invalidSignature: "component catalog signature is invalid" + case .incompatibleArchitecture(let expected, let actual): + "component catalog is for \(actual), but this Mac needs \(expected)" + case .incompatibleAppVersion(let required, let actual): + "component catalog requires Dory \(required) or newer; this app is \(actual)" + case .unknownComponent(let id): "unknown Dory component: \(id)" + case .coreCannotBeChanged: "Docker Core is part of Dory.app and cannot be installed or removed separately" + case .missingDependency(let id): "install \(id.rawValue) first" + case .componentInUse(let id): "remove dependent component \(id.rawValue) first" + case .invalidAsset(let path): "invalid component asset: \(path)" + case .download(let detail): "component download failed: \(detail)" + case .digestMismatch(let path): "component verification failed for \(path)" + case .interrupted(let detail): "component installation was interrupted: \(detail)" + case .unsafePath(let path): "refusing unsafe component path: \(path)" + case .filesystem(let detail): detail + } + } +} + +public enum DoryComponentCatalogVerifier { + public static let maximumCatalogBytes = 2 * 1_024 * 1_024 + + public static func verify( + catalogData: Data, + signatureBase64: String, + publicKeyBase64: String, + expectedArchitecture: String, + appVersion: String + ) throws -> DoryComponentCatalog { + guard !catalogData.isEmpty, catalogData.count <= maximumCatalogBytes, + let signature = Data(base64Encoded: signatureBase64.trimmingCharacters(in: .whitespacesAndNewlines)), + signature.count == 64, + let publicKeyData = Data(base64Encoded: publicKeyBase64), + publicKeyData.count == 32, + let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKeyData), + publicKey.isValidSignature(signature, for: catalogData) else { + throw DoryComponentError.invalidSignature + } + let catalog: DoryComponentCatalog + do { + catalog = try JSONDecoder().decode(DoryComponentCatalog.self, from: catalogData) + } catch { + throw DoryComponentError.invalidCatalog("JSON could not be decoded") + } + try validate(catalog, expectedArchitecture: expectedArchitecture, appVersion: appVersion) + return catalog + } + + public static func validate( + _ catalog: DoryComponentCatalog, + expectedArchitecture: String, + appVersion: String + ) throws { + guard catalog.kind == DoryComponentCatalog.kind, + catalog.schemaVersion == DoryComponentCatalog.schemaVersion, + validVersion(catalog.releaseVersion), + validVersion(catalog.minimumAppVersion), + validTimestamp(catalog.generatedAt), + !catalog.components.isEmpty else { + throw DoryComponentError.invalidCatalog("header is incomplete or unsupported") + } + guard catalog.architecture == expectedArchitecture else { + throw DoryComponentError.incompatibleArchitecture( + expected: expectedArchitecture, + actual: catalog.architecture + ) + } + guard compareVersions(appVersion, catalog.minimumAppVersion) != .orderedAscending else { + throw DoryComponentError.incompatibleAppVersion( + required: catalog.minimumAppVersion, + actual: appVersion + ) + } + let ids = catalog.components.map(\.id) + guard Set(ids).count == ids.count, + ids.first == .dockerCore, + catalog.component(.dockerCore)?.assets.isEmpty == true else { + throw DoryComponentError.invalidCatalog("Docker Core must be the first, unique, payload-free entry") + } + for component in catalog.components { + try validate(component, available: Set(ids)) + } + try validateDependencyGraph(catalog.components) + } + + public static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + public static func fileDigest(_ path: String) throws -> String { + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { throw DoryComponentError.invalidAsset(path) } + defer { close(descriptor) } + var hasher = SHA256() + var buffer = [UInt8](repeating: 0, count: 1 << 20) + while true { + let count = buffer.withUnsafeMutableBytes { read(descriptor, $0.baseAddress, $0.count) } + guard count >= 0 else { throw DoryComponentError.filesystem("read component asset \(path): errno \(errno)") } + if count == 0 { break } + hasher.update(data: Data(buffer[0..) throws { + guard validVersion(component.version), + !component.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !component.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + Set(component.dependencies).count == component.dependencies.count, + !component.dependencies.contains(component.id), + component.dependencies.allSatisfy(available.contains) else { + throw DoryComponentError.invalidCatalog("invalid metadata for \(component.id.rawValue)") + } + if component.id == .dockerCore { + guard component.dependencies.isEmpty, + component.downloadBytes > 0, + component.installedBytes > 0 else { + throw DoryComponentError.invalidCatalog("invalid Docker Core metadata") + } + return + } + guard !component.assets.isEmpty, + component.dependencies.contains(.dockerCore), + component.downloadBytes == component.assets.reduce(0, { $0 + $1.downloadBytes }), + component.installedBytes == component.assets.reduce(0, { $0 + $1.installedBytes }) else { + throw DoryComponentError.invalidCatalog("invalid size or dependency totals for \(component.id.rawValue)") + } + let paths = component.assets.map(\.path) + guard Set(paths).count == paths.count else { + throw DoryComponentError.invalidCatalog("duplicate asset path in \(component.id.rawValue)") + } + for asset in component.assets { + guard safeRelativePath(asset.path), + (URL(string: asset.url)?.scheme == "https" || URL(string: asset.url)?.isFileURL == true), + asset.downloadBytes > 0, + asset.installedBytes > 0, + validDigest(asset.sha256), + validDigest(asset.installedSHA256), + asset.installedBytes <= 128 * 1_024 * 1_024 * 1_024 else { + throw DoryComponentError.invalidCatalog("invalid asset in \(component.id.rawValue)") + } + if asset.compression == .none, + (asset.downloadBytes != asset.installedBytes || asset.sha256 != asset.installedSHA256) { + throw DoryComponentError.invalidCatalog("uncompressed asset metadata disagrees for \(asset.path)") + } + } + } + + private static func validateDependencyGraph(_ components: [DoryComponentRelease]) throws { + let byID = Dictionary(uniqueKeysWithValues: components.map { ($0.id, $0) }) + func visit(_ id: DoryComponentID, path: Set) throws { + guard !path.contains(id), let component = byID[id] else { + throw DoryComponentError.invalidCatalog("component dependency cycle") + } + var next = path + next.insert(id) + for dependency in component.dependencies where dependency != .dockerCore { + try visit(dependency, path: next) + } + } + for component in components { try visit(component.id, path: []) } + } + + private static func validDigest(_ value: String) -> Bool { + value.count == 64 && value.allSatisfy { $0.isHexDigit } + } + + private static func validVersion(_ value: String) -> Bool { + !value.isEmpty && value.count <= 64 + && value.allSatisfy { $0.isNumber || $0.isLetter || ".+-_".contains($0) } + } + + private static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult { + lhs.compare(rhs, options: .numeric) + } + + static func validTimestamp(_ value: String) -> Bool { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: value) != nil || ISO8601DateFormatter().date(from: value) != nil + } + + static func safeRelativePath(_ path: String) -> Bool { + guard !path.isEmpty, path.count <= 255, !path.hasPrefix("/"), !path.hasSuffix("/"), + path.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7f }) else { + return false + } + let components = path.split(separator: "/", omittingEmptySubsequences: false) + return components.count == 1 && components[0] != "." && components[0] != ".." && !components[0].isEmpty + } +} + +public struct DoryInstalledComponent: Codable, Sendable, Equatable { + public static let kind = "dev.dory.installed-component" + public static let schemaVersion = 2 + + public let kind: String + public let schemaVersion: Int + public let id: DoryComponentID + public let version: String + public let installationName: String + public let catalogDigest: String + public let installedAt: String + public let assets: [DoryComponentAsset] + public let assetFingerprints: [DoryComponentAssetFingerprint] + + init( + release: DoryComponentRelease, + installationName: String, + catalogDigest: String, + installedAt: Date, + assetFingerprints: [DoryComponentAssetFingerprint] + ) { + kind = Self.kind + schemaVersion = Self.schemaVersion + id = release.id + version = release.version + self.installationName = installationName + self.catalogDigest = catalogDigest + self.installedAt = DoryComponentStore.timestamp(installedAt) + assets = release.assets + self.assetFingerprints = assetFingerprints + } + + var isStructurallyValid: Bool { + kind == Self.kind && schemaVersion == Self.schemaVersion && id.isRemovable + && DoryComponentCatalogVerifier.safeRelativePath(installationName) + && catalogDigest.count == 64 && catalogDigest.allSatisfy(\.isHexDigit) + && DoryComponentCatalogVerifier.validTimestamp(installedAt) + && !assets.isEmpty + && assetFingerprints.map(\.path) == assets.map(\.path) + } +} + +public struct DoryComponentAssetFingerprint: Codable, Sendable, Equatable { + public let path: String + public let device: UInt64 + public let inode: UInt64 + public let size: UInt64 + public let permissions: UInt32 + public let modifiedSeconds: Int64 + public let modifiedNanoseconds: Int64 + public let changedSeconds: Int64 + public let changedNanoseconds: Int64 +} + +public enum DoryComponentState: String, Codable, Sendable { + case bundled + case notInstalled + case installed + case updateAvailable + case invalid +} + +public struct DoryComponentStatus: Codable, Sendable, Equatable, Identifiable { + public let id: DoryComponentID + public let displayName: String + public let summary: String + public let availableVersion: String + public let installedVersion: String? + public let state: DoryComponentState + public let downloadBytes: UInt64 + public let installedBytes: UInt64 + public let dependencies: [DoryComponentID] +} + +public struct DoryComponentStore: Sendable { + public let drive: DoryDataDrive + public var root: String { drive.componentsDirectory } + + public init(drive: DoryDataDrive) { + self.drive = drive + } + + public static func selected(home: String = DoryDataDrive.processHome()) throws -> DoryComponentStore { + let selection = try DoryDataDriveSelectionStore(home: home) + guard let drive = try selection.inspectSelection() else { + throw DoryComponentError.filesystem("no Dory data drive is selected") + } + return DoryComponentStore(drive: drive) + } + + public func prepare(fileManager: FileManager = .default) throws { + try drive.validateManifest(fileManager: fileManager) + for directory in [root, installedRoot, activeRoot, downloadsRoot, stagingRoot] { + try Self.ensurePrivateDirectory(directory, fileManager: fileManager) + } + } + + public func list( + catalog: DoryComponentCatalog, + catalogDigest expectedCatalogDigest: String? = nil + ) -> [DoryComponentStatus] { + catalog.components.map { release in + if release.id == .dockerCore { + return DoryComponentStatus( + id: release.id, + displayName: release.displayName, + summary: release.summary, + availableVersion: release.version, + installedVersion: release.version, + state: .bundled, + downloadBytes: release.downloadBytes, + installedBytes: release.installedBytes, + dependencies: release.dependencies + ) + } + let installed: DoryInstalledComponent? + let installedRecordInvalid: Bool + do { + installed = try installedComponent(release.id) + installedRecordInvalid = false + } catch { + installed = nil + installedRecordInvalid = FileManager.default.fileExists(atPath: activePath(release.id)) + } + let state: DoryComponentState + if installedRecordInvalid { + state = .invalid + } else if let installed { + if (try? validateInstalledAssets(installed)) == nil { + state = .invalid + } else if installed.version == release.version, + expectedCatalogDigest == nil || installed.catalogDigest == expectedCatalogDigest { + state = .installed + } else { + state = .updateAvailable + } + } else { + state = .notInstalled + } + return DoryComponentStatus( + id: release.id, + displayName: release.displayName, + summary: release.summary, + availableVersion: release.version, + installedVersion: installed?.version, + state: state, + downloadBytes: release.downloadBytes, + installedBytes: release.installedBytes, + dependencies: release.dependencies + ) + } + } + + public func installedComponent(_ id: DoryComponentID) throws -> DoryInstalledComponent? { + guard id.isRemovable else { return nil } + let activationPath = activePath(id) + guard FileManager.default.fileExists(atPath: activationPath) else { return nil } + let activation = try readRecord(DoryComponentActivation.self, at: activationPath) + guard activation.isStructurallyValid, activation.id == id else { + throw DoryComponentError.invalidAsset(activationPath) + } + let recordPath = installationRoot(id: id, name: activation.installationName) + "/installed.json" + let record = try readRecord(DoryInstalledComponent.self, at: recordPath) + guard record.isStructurallyValid, + record.id == id, + record.version == activation.version, + record.installationName == activation.installationName, + record.catalogDigest == activation.catalogDigest else { + throw DoryComponentError.invalidAsset(recordPath) + } + return record + } + + @discardableResult + public func install( + _ release: DoryComponentRelease, + catalogDigest: String, + downloadedAssets: [String: String], + installedAt: Date = Date(), + fileManager: FileManager = .default + ) throws -> DoryInstalledComponent { + guard release.id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + guard catalogDigest.count == 64, catalogDigest.allSatisfy(\.isHexDigit) else { + throw DoryComponentError.invalidCatalog("catalog digest is invalid") + } + try prepare(fileManager: fileManager) + let lock = try EngineStateDirectoryLock(stateDirectory: root, lockFileName: "store.lock") + defer { withExtendedLifetime(lock) {} } + try pruneStaging(fileManager: fileManager) + for dependency in release.dependencies where dependency != .dockerCore { + guard (try installedComponent(dependency)) != nil else { + throw DoryComponentError.missingDependency(dependency) + } + } + if let current = try? installedComponent(release.id), + current.version == release.version, + current.catalogDigest == catalogDigest, + (try? verify(current)) != nil { + return current + } + + let operationID = UUID().uuidString.lowercased() + let installationName = "\(release.version)-\(operationID)" + let staging = stagingRoot + "/\(release.id.rawValue)-\(operationID)" + let payload = staging + "/payload" + let destination = installationRoot(id: release.id, name: installationName) + var published = false + do { + try Self.ensurePrivateDirectory(staging, fileManager: fileManager) + try Self.ensurePrivateDirectory(payload, fileManager: fileManager) + for asset in release.assets { + guard let source = downloadedAssets[asset.path] else { + throw DoryComponentError.invalidAsset(asset.path) + } + try verifyFile(source, bytes: asset.downloadBytes, digest: asset.sha256) + let output = payload + "/" + asset.path + try Self.ensurePrivateDirectory( + URL(fileURLWithPath: output).deletingLastPathComponent().path, + fileManager: fileManager + ) + switch asset.compression { + case .none: + try fileManager.copyItem(atPath: source, toPath: output) + case .lzfse: + try Self.decompressLZFSE( + source: source, + destination: output, + maximumBytes: asset.installedBytes + ) + } + try fileManager.setAttributes( + [.posixPermissions: asset.executable ? 0o700 : 0o600], + ofItemAtPath: output + ) + try verifyFile(output, bytes: asset.installedBytes, digest: asset.installedSHA256) + try Self.syncFile(output) + } + let record = DoryInstalledComponent( + release: release, + installationName: installationName, + catalogDigest: catalogDigest, + installedAt: installedAt, + assetFingerprints: try release.assets.map { + try Self.assetFingerprint(payload + "/" + $0.path, asset: $0) + } + ) + try writeRecord(record, at: staging + "/installed.json", fileManager: fileManager) + try Self.ensurePrivateDirectory(installedRoot + "/\(release.id.rawValue)", fileManager: fileManager) + try Self.syncDirectory(payload) + try Self.syncDirectory(staging) + try fileManager.moveItem(atPath: staging, toPath: destination) + published = true + try Self.syncDirectory(URL(fileURLWithPath: destination).deletingLastPathComponent().path) + try writeRecord( + DoryComponentActivation(record), + at: activePath(release.id), + fileManager: fileManager + ) + try? pruneInactiveInstallations(for: release.id, keeping: installationName, fileManager: fileManager) + return record + } catch { + if !published { try? fileManager.removeItem(atPath: staging) } + throw error + } + } + + @discardableResult + public func verify(_ id: DoryComponentID) throws -> DoryInstalledComponent { + guard let installed = try installedComponent(id) else { + throw DoryComponentError.unknownComponent(id.rawValue) + } + try verify(installed) + return installed + } + + public func verify(_ installed: DoryInstalledComponent) throws { + try validateInstalledAssets(installed) + let payload = installationRoot(id: installed.id, name: installed.installationName) + "/payload" + for asset in installed.assets { + try verifyFile( + payload + "/" + asset.path, + bytes: asset.installedBytes, + digest: asset.installedSHA256 + ) + } + } + + public func isInstalledAndValid(_ id: DoryComponentID) -> Bool { + guard let installed = try? installedComponent(id) else { return false } + return (try? validateInstalledAssets(installed)) != nil + } + + public func remove( + _ id: DoryComponentID, + catalog: DoryComponentCatalog, + fileManager: FileManager = .default + ) throws { + guard id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + try prepare(fileManager: fileManager) + let lock = try EngineStateDirectoryLock(stateDirectory: root, lockFileName: "store.lock") + defer { withExtendedLifetime(lock) {} } + try pruneStaging(fileManager: fileManager) + for release in catalog.components where release.dependencies.contains(id) { + if (try installedComponent(release.id)) != nil { + throw DoryComponentError.componentInUse(release.id) + } + } + let activation = activePath(id) + let componentRoot = installedRoot + "/\(id.rawValue)" + guard fileManager.fileExists(atPath: activation) || fileManager.fileExists(atPath: componentRoot) else { + return + } + if fileManager.fileExists(atPath: activation) { + try fileManager.removeItem(atPath: activation) + try Self.syncDirectory(activeRoot) + } + if fileManager.fileExists(atPath: componentRoot) { + try fileManager.removeItem(atPath: componentRoot) + try Self.syncDirectory(installedRoot) + } + } + + public func assetPath(component id: DoryComponentID, path: String) -> String? { + guard DoryComponentCatalogVerifier.safeRelativePath(path), + let installed = try? installedComponent(id), + let asset = installed.assets.first(where: { $0.path == path }) else { + return nil + } + let candidate = installationRoot(id: id, name: installed.installationName) + "/payload/" + path + guard let expected = installed.assetFingerprints.first(where: { $0.path == path }), + (try? Self.assetFingerprint(candidate, asset: asset)) == expected else { + return nil + } + return candidate + } + + public func activePayloadDirectories() -> [String] { + DoryComponentID.allCases.compactMap { id in + guard let installed = try? installedComponent(id), + (try? validateInstalledAssets(installed)) != nil else { return nil } + return installationRoot(id: id, name: installed.installationName) + "/payload" + } + } + + public static func activeAssetPath( + component: DoryComponentID, + path: String, + home: String = DoryDataDrive.processHome() + ) -> String? { + (try? selected(home: home))?.assetPath(component: component, path: path) + } + + public static func activePayloadDirectories( + home: String = DoryDataDrive.processHome() + ) -> [String] { + (try? selected(home: home))?.activePayloadDirectories() ?? [] + } + + public func cachedCatalog( + publicKey: String, + expectedArchitecture: String, + appVersion: String + ) throws -> (catalog: DoryComponentCatalog, data: Data, signature: String)? { + let catalogPath = root + "/catalog.json" + let signaturePath = root + "/catalog.sig" + guard FileManager.default.fileExists(atPath: catalogPath), + FileManager.default.fileExists(atPath: signaturePath), + let data = try? PrivateRecordFile.read( + at: catalogPath, + maximumBytes: DoryComponentCatalogVerifier.maximumCatalogBytes + ), + let signatureData = try? PrivateRecordFile.read(at: signaturePath, maximumBytes: 1_024), + let signature = String(data: signatureData, encoding: .utf8) else { + return nil + } + let catalog = try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + return (catalog, data, signature.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + public func cacheCatalog( + data: Data, + signature: String, + publicKey: String, + expectedArchitecture: String, + appVersion: String, + fileManager: FileManager = .default + ) throws -> DoryComponentCatalog { + let catalog = try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + try prepare(fileManager: fileManager) + try Self.writePrivateFile(data, to: root + "/catalog.json", fileManager: fileManager) + try Self.writePrivateFile(Data(signature.utf8), to: root + "/catalog.sig", fileManager: fileManager) + return catalog + } + + public var downloadsDirectory: String { downloadsRoot } + + static func timestamp(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + private struct DoryComponentActivation: Codable, Sendable { + static let kind = "dev.dory.component-activation" + static let schemaVersion = 1 + + let kind: String + let schemaVersion: Int + let id: DoryComponentID + let version: String + let installationName: String + let catalogDigest: String + + init(_ component: DoryInstalledComponent) { + kind = Self.kind + schemaVersion = Self.schemaVersion + id = component.id + version = component.version + installationName = component.installationName + catalogDigest = component.catalogDigest + } + + var isStructurallyValid: Bool { + kind == Self.kind && schemaVersion == Self.schemaVersion && id.isRemovable + && DoryComponentCatalogVerifier.safeRelativePath(installationName) + && catalogDigest.count == 64 && catalogDigest.allSatisfy(\.isHexDigit) + } + } + + private var installedRoot: String { root + "/installed" } + private var activeRoot: String { root + "/active" } + private var downloadsRoot: String { root + "/downloads" } + private var stagingRoot: String { root + "/staging" } + private func activePath(_ id: DoryComponentID) -> String { activeRoot + "/\(id.rawValue).json" } + private func installationRoot(id: DoryComponentID, name: String) -> String { + installedRoot + "/\(id.rawValue)/" + name + } + + private func verifyFile(_ path: String, bytes: UInt64, digest: String) throws { + guard try Self.regularFileSize(path) == bytes else { + throw DoryComponentError.digestMismatch(path) + } + guard try DoryComponentCatalogVerifier.fileDigest(path) == digest else { + throw DoryComponentError.digestMismatch(path) + } + } + + private func validateInstalledAssets(_ installed: DoryInstalledComponent) throws { + let payload = installationRoot(id: installed.id, name: installed.installationName) + "/payload" + guard installed.assets.count == installed.assetFingerprints.count else { + throw DoryComponentError.invalidAsset(payload) + } + for (asset, expected) in zip(installed.assets, installed.assetFingerprints) { + guard expected.path == asset.path, + try Self.assetFingerprint(payload + "/" + asset.path, asset: asset) == expected else { + throw DoryComponentError.digestMismatch(payload + "/" + asset.path) + } + } + } + + private static func assetFingerprint( + _ path: String, + asset: DoryComponentAsset + ) throws -> DoryComponentAssetFingerprint { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + info.st_size >= 0, + UInt64(info.st_size) == asset.installedBytes, + info.st_mode & 0o077 == 0, + asset.executable ? info.st_mode & 0o100 != 0 : info.st_mode & 0o111 == 0 else { + throw DoryComponentError.invalidAsset(path) + } + return DoryComponentAssetFingerprint( + path: asset.path, + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino), + size: UInt64(info.st_size), + permissions: UInt32(info.st_mode & 0o7777), + modifiedSeconds: Int64(info.st_mtimespec.tv_sec), + modifiedNanoseconds: Int64(info.st_mtimespec.tv_nsec), + changedSeconds: Int64(info.st_ctimespec.tv_sec), + changedNanoseconds: Int64(info.st_ctimespec.tv_nsec) + ) + } + + private func readRecord(_ type: T.Type, at path: String) throws -> T { + guard let data = try? PrivateRecordFile.read(at: path, maximumBytes: 2 * 1_024 * 1_024), + let value = try? JSONDecoder().decode(type, from: data) else { + throw DoryComponentError.invalidAsset(path) + } + return value + } + + private func writeRecord( + _ value: T, + at path: String, + fileManager: FileManager + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + try Self.writePrivateFile(try encoder.encode(value) + Data("\n".utf8), to: path, fileManager: fileManager) + } + + private func pruneStaging(fileManager: FileManager) throws { + for entry in (try? fileManager.contentsOfDirectory(atPath: stagingRoot)) ?? [] { + try fileManager.removeItem(atPath: stagingRoot + "/" + entry) + } + } + + private func pruneInactiveInstallations( + for id: DoryComponentID, + keeping: String?, + fileManager: FileManager + ) throws { + let directory = installedRoot + "/\(id.rawValue)" + guard FileManager.default.fileExists(atPath: directory) else { return } + for entry in try fileManager.contentsOfDirectory(atPath: directory) where entry != keeping { + try fileManager.removeItem(atPath: directory + "/" + entry) + } + } + + private static func ensurePrivateDirectory(_ path: String, fileManager: FileManager) throws { + if !fileManager.fileExists(atPath: path) { + do { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: path) + } catch { + throw DoryComponentError.filesystem("create component directory \(path): \(error)") + } + } + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFDIR, + info.st_uid == getuid(), + info.st_mode & 0o077 == 0 else { + throw DoryComponentError.unsafePath(path) + } + } + + private static func regularFileSize(_ path: String) throws -> UInt64 { + var info = stat() + guard lstat(path, &info) == 0, + info.st_mode & S_IFMT == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + info.st_size >= 0 else { + throw DoryComponentError.invalidAsset(path) + } + return UInt64(info.st_size) + } + + private static func writePrivateFile( + _ data: Data, + to path: String, + fileManager: FileManager + ) throws { + let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path + try ensurePrivateDirectory(parent, fileManager: fileManager) + let temporary = parent + "/.\(URL(fileURLWithPath: path).lastPathComponent).\(UUID().uuidString).tmp" + do { + try data.write(to: URL(fileURLWithPath: temporary), options: .withoutOverwriting) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary) + try syncFile(temporary) + guard rename(temporary, path) == 0 else { + throw DoryComponentError.filesystem("publish component record \(path): errno \(errno)") + } + try syncDirectory(parent) + } catch { + try? fileManager.removeItem(atPath: temporary) + throw error + } + } + + private static func syncFile(_ path: String) throws { + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { throw DoryComponentError.filesystem("open \(path) for sync: errno \(errno)") } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { throw DoryComponentError.filesystem("sync \(path): errno \(errno)") } + } + + private static func syncDirectory(_ path: String) throws { + let descriptor = path.withCString { open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { throw DoryComponentError.filesystem("open \(path) for sync: errno \(errno)") } + defer { close(descriptor) } + guard fsync(descriptor) == 0 else { throw DoryComponentError.filesystem("sync \(path): errno \(errno)") } + } + + private static func decompressLZFSE( + source: String, + destination: String, + maximumBytes: UInt64 + ) throws { + guard maximumBytes > 0, maximumBytes <= UInt64(Int.max) else { + throw DoryComponentError.invalidAsset("\(source) (invalid declared LZFSE size)") + } + let input = source.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard input >= 0 else { throw DoryComponentError.invalidAsset(source) } + defer { close(input) } + var inputInfo = stat() + guard fstat(input, &inputInfo) == 0, + inputInfo.st_mode & S_IFMT == S_IFREG, + inputInfo.st_size > 0, + UInt64(inputInfo.st_size) <= UInt64(Int.max) else { + throw DoryComponentError.invalidAsset(source) + } + let output = destination.withCString { + open($0, O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, mode_t(0o600)) + } + guard output >= 0 else { + throw DoryComponentError.filesystem("create decompressed component asset: errno \(errno)") + } + defer { close(output) } + guard ftruncate(output, off_t(maximumBytes)) == 0 else { + throw DoryComponentError.filesystem("size decompressed component asset: errno \(errno)") + } + let inputSize = Int(inputInfo.st_size) + let outputSize = Int(maximumBytes) + let inputMap = mmap(nil, inputSize, PROT_READ, MAP_PRIVATE, input, 0) + guard inputMap != MAP_FAILED else { + throw DoryComponentError.filesystem("map compressed component asset: errno \(errno)") + } + defer { munmap(inputMap, inputSize) } + let outputMap = mmap(nil, outputSize, PROT_READ | PROT_WRITE, MAP_SHARED, output, 0) + guard outputMap != MAP_FAILED else { + throw DoryComponentError.filesystem("map decompressed component asset: errno \(errno)") + } + defer { munmap(outputMap, outputSize) } + let decoded = compression_decode_buffer( + outputMap!.assumingMemoryBound(to: UInt8.self), + outputSize, + inputMap!.assumingMemoryBound(to: UInt8.self), + inputSize, + nil, + COMPRESSION_LZFSE + ) + guard decoded == outputSize else { + throw DoryComponentError.invalidAsset("\(source) (LZFSE output does not match its signed size)") + } + guard msync(outputMap, outputSize, MS_SYNC) == 0 else { + throw DoryComponentError.filesystem("sync decompressed component asset: errno \(errno)") + } + } +} + +public struct DoryComponentProgress: Sendable, Equatable { + public enum Phase: String, Sendable { + case downloading + case verifying + case installing + case complete + } + + public let component: DoryComponentID + public let phase: Phase + public let completedBytes: UInt64 + public let totalBytes: UInt64 +} + +public actor DoryComponentInstaller { + public typealias Progress = @Sendable (DoryComponentProgress) -> Void + + private let store: DoryComponentStore + private let session: URLSession + + public init(store: DoryComponentStore, session: URLSession = .shared) { + self.store = store + self.session = session + } + + public func install( + _ release: DoryComponentRelease, + catalogData: Data, + progress: @escaping Progress = { _ in } + ) async throws -> DoryInstalledComponent { + guard release.id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + try store.prepare() + var downloaded: [String: String] = [:] + var completed: UInt64 = 0 + for asset in release.assets { + let completedBeforeAsset = completed + progress(DoryComponentProgress( + component: release.id, + phase: .downloading, + completedBytes: completed, + totalBytes: release.downloadBytes + )) + let path = try await download(asset) { assetBytes in + progress(DoryComponentProgress( + component: release.id, + phase: .downloading, + completedBytes: completedBeforeAsset + assetBytes, + totalBytes: release.downloadBytes + )) + } + completed += asset.downloadBytes + downloaded[asset.path] = path + } + progress(DoryComponentProgress( + component: release.id, + phase: .verifying, + completedBytes: release.downloadBytes, + totalBytes: release.downloadBytes + )) + let installed = try store.install( + release, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + downloadedAssets: downloaded + ) + for path in downloaded.values { try? FileManager.default.removeItem(atPath: path) } + progress(DoryComponentProgress( + component: release.id, + phase: .complete, + completedBytes: release.downloadBytes, + totalBytes: release.downloadBytes + )) + return installed + } + + private func download( + _ asset: DoryComponentAsset, + progress: @escaping @Sendable (UInt64) -> Void + ) async throws -> String { + guard let url = URL(string: asset.url) else { throw DoryComponentError.download(asset.url) } + let destination = store.downloadsDirectory + "/\(asset.sha256).part" + if url.isFileURL { + try? FileManager.default.removeItem(atPath: destination) + try FileManager.default.copyItem(at: url, to: URL(fileURLWithPath: destination)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination) + progress(asset.downloadBytes) + return destination + } + guard url.scheme == "https" else { throw DoryComponentError.download("HTTPS is required") } + var offset: UInt64 = 0 + var partialInfo = stat() + if lstat(destination, &partialInfo) == 0 { + guard partialInfo.st_mode & S_IFMT == S_IFREG, + partialInfo.st_uid == getuid(), + partialInfo.st_nlink == 1, + partialInfo.st_size >= 0 else { + throw DoryComponentError.unsafePath(destination) + } + offset = UInt64(partialInfo.st_size) + } else if errno != ENOENT { + throw DoryComponentError.filesystem("inspect partial component download: errno \(errno)") + } + if offset > asset.downloadBytes { + try FileManager.default.removeItem(atPath: destination) + offset = 0 + } + if offset == asset.downloadBytes, + (try? DoryComponentCatalogVerifier.fileDigest(destination)) == asset.sha256 { + progress(offset) + return destination + } + if offset == asset.downloadBytes { + try FileManager.default.removeItem(atPath: destination) + offset = 0 + } + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 300 + if offset > 0 { request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") } + let (bytes, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse, + http.statusCode == 200 || http.statusCode == 206 else { + throw DoryComponentError.download("server rejected \(url.absoluteString)") + } + if offset > 0, http.statusCode == 206 { + let contentRange = http.value(forHTTPHeaderField: "Content-Range") ?? "" + guard contentRange.hasPrefix("bytes \(offset)-") else { + try? FileManager.default.removeItem(atPath: destination) + throw DoryComponentError.download("server returned an invalid resume range") + } + } + if offset > 0, http.statusCode != 206 { + try? FileManager.default.removeItem(atPath: destination) + offset = 0 + } + if !FileManager.default.fileExists(atPath: destination) { + FileManager.default.createFile(atPath: destination, contents: nil, attributes: [.posixPermissions: 0o600]) + } + let handle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination)) + defer { try? handle.close() } + if offset == 0 { try handle.truncate(atOffset: 0) } else { try handle.seekToEnd() } + var buffer = Data() + buffer.reserveCapacity(256 * 1_024) + var received = offset + do { + for try await byte in bytes { + buffer.append(byte) + if buffer.count >= 256 * 1_024 { + try handle.write(contentsOf: buffer) + received += UInt64(buffer.count) + buffer.removeAll(keepingCapacity: true) + guard received <= asset.downloadBytes else { + throw DoryComponentError.download("server exceeded declared size") + } + progress(received) + } + } + if !buffer.isEmpty { + try handle.write(contentsOf: buffer) + received += UInt64(buffer.count) + progress(received) + } + try handle.synchronize() + } catch is CancellationError { + throw DoryComponentError.interrupted(asset.path) + } + guard received == asset.downloadBytes, + try DoryComponentCatalogVerifier.fileDigest(destination) == asset.sha256 else { + try? FileManager.default.removeItem(atPath: destination) + throw DoryComponentError.digestMismatch(asset.path) + } + return destination + } +} + +public actor DoryComponentCatalogClient { + private let catalogURL: URL + private let publicKey: String + private let expectedArchitecture: String + private let appVersion: String + private let session: URLSession + + public init( + catalogURL: URL, + publicKey: String, + expectedArchitecture: String, + appVersion: String, + session: URLSession = .shared + ) { + self.catalogURL = catalogURL + self.publicKey = publicKey + self.expectedArchitecture = expectedArchitecture + self.appVersion = appVersion + self.session = session + } + + public func fetch() async throws -> (catalog: DoryComponentCatalog, data: Data, signature: String) { + let signatureURL = catalogURL.appendingPathExtension("sig") + async let catalogResult = session.data(from: catalogURL) + async let signatureResult = session.data(from: signatureURL) + let ((data, catalogResponse), (signatureData, signatureResponse)) = try await (catalogResult, signatureResult) + try validateResponse(catalogResponse, url: catalogURL) + try validateResponse(signatureResponse, url: signatureURL) + guard data.count <= DoryComponentCatalogVerifier.maximumCatalogBytes, + signatureData.count <= 1_024, + let signature = String(data: signatureData, encoding: .utf8) else { + throw DoryComponentError.invalidCatalog("downloaded metadata is too large") + } + let catalog = try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: expectedArchitecture, + appVersion: appVersion + ) + return (catalog, data, signature.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + private func validateResponse(_ response: URLResponse, url: URL) throws { + if url.isFileURL { return } + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw DoryComponentError.download("metadata server returned an error") + } + } +} diff --git a/dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift b/dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift index 29e8c5ee..f70ad1ab 100644 --- a/dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift +++ b/dory-core-swift/Sources/DoryOperations/DoryDataDrive.swift @@ -239,6 +239,9 @@ public struct DoryDataDrive: Sendable, Equatable { public var engineDirectory: String { root + "/engine" } public var engineDataDiskPath: String { engineDirectory + "/docker-data.ext4" } public var kubernetesDirectory: String { root + "/kubernetes" } + /// Verified, replaceable product payloads. Workload state never lives below this directory, so + /// removing a component cannot remove containers, images, volumes, machine disks, or snapshots. + public var componentsDirectory: String { root + "/components" } public var machinesDirectory: String { root + "/machines" } public var snapshotsDirectory: String { root + "/snapshots" } public var exportsDirectory: String { root + "/exports" } @@ -405,6 +408,7 @@ public struct DoryDataDrive: Sendable, Equatable { root, root + "/engine", root + "/kubernetes", + root + "/components", root + "/machines", root + "/snapshots", root + "/exports", diff --git a/dory-core-swift/Sources/DorydKit/AuthorizedNetworkingReconciler.swift b/dory-core-swift/Sources/DorydKit/AuthorizedNetworkingReconciler.swift index 86344629..9119c2bd 100644 --- a/dory-core-swift/Sources/DorydKit/AuthorizedNetworkingReconciler.swift +++ b/dory-core-swift/Sources/DorydKit/AuthorizedNetworkingReconciler.swift @@ -57,8 +57,9 @@ public final class AuthorizedNetworkingClient: AuthorizedNetworkingApplying, @un } } - /// Removes only the caller's persisted resolver, trusted CA, and system PF authorization. - /// Source-preserving LAN remains available while Dory is installed. + /// Removes only the caller's persisted resolver, CA snapshot, and system PF authorization. + /// Dory.app separately removes user trust after this succeeds. Source-preserving LAN remains + /// available while Dory is installed. public func removeAuthorizedNetworking() throws -> Bool { try performRemoval { proxy, reply in proxy.removeAuthorizedNetworking(withReply: reply) diff --git a/dory-core-swift/Sources/DorydKit/CustomDomainRouteStore.swift b/dory-core-swift/Sources/DorydKit/CustomDomainRouteStore.swift new file mode 100644 index 00000000..6e6c478e --- /dev/null +++ b/dory-core-swift/Sources/DorydKit/CustomDomainRouteStore.swift @@ -0,0 +1,236 @@ +import Darwin +import Foundation + +public struct CustomDomainRouteConfiguration: Codable, Equatable, Sendable { + public var hostname: String + public var publishedPort: UInt16 + + public init(hostname: String, publishedPort: UInt16) { + self.hostname = hostname + self.publishedPort = publishedPort + } + + public var xpcDictionary: NSDictionary { + [ + "hostname": hostname, + "address": "127.0.0.1", + "port": publishedPort, + ] as NSDictionary + } +} + +public final class CustomDomainRouteStore: @unchecked Sendable { + public enum StoreError: Error, CustomStringConvertible { + case invalidRoute(String) + case tooManyRoutes + case unsafePath(String) + case unreadable(String) + case writeFailed(String) + + public var description: String { + switch self { + case let .invalidRoute(detail): "invalid custom domain route: \(detail)" + case .tooManyRoutes: "custom domain routes are limited to 128 entries" + case let .unsafePath(path): "custom domain route path is unsafe: \(path)" + case let .unreadable(detail): "custom domain routes could not be read: \(detail)" + case let .writeFailed(detail): "custom domain routes could not be saved: \(detail)" + } + } + } + + private struct Document: Codable { + var schema = "dev.dory.custom-domains" + var version = 1 + var routes: [CustomDomainRouteConfiguration] + } + + private static let maximumRoutes = 128 + private static let maximumBytes = 256 * 1024 + private let lock = NSLock() + public let path: String + + public init( + home: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment + ) { + path = ((environment["DORY_CUSTOM_DOMAIN_ROUTES"] ?? "\(home)/.dory/custom-domains.json") as NSString) + .expandingTildeInPath + } + + public func configuredRoutes() throws -> [CustomDomainRouteConfiguration] { + lock.lock() + defer { lock.unlock() } + return try loadLocked() + } + + @discardableResult + public func replace( + _ routes: [DomainRoute], + automaticSuffix: String + ) throws -> [CustomDomainRouteConfiguration] { + guard routes.count <= Self.maximumRoutes else { throw StoreError.tooManyRoutes } + let normalizedSuffix = DomainRouter.normalize(automaticSuffix) + var unique: [String: CustomDomainRouteConfiguration] = [:] + for route in routes { + guard route.address == "127.0.0.1" else { + throw StoreError.invalidRoute("targets must use Dory's loopback-published ports") + } + guard route.pathPrefix.isEmpty else { + throw StoreError.invalidRoute("path prefixes are not supported for custom domains") + } + let hostname = DomainRouter.normalize(route.hostname) + guard DomainRouter.isValidHostnamePattern(hostname) else { + throw StoreError.invalidRoute("\(route.hostname) is not a DNS hostname or leftmost wildcard") + } + let comparable = hostname.hasPrefix("*.") ? String(hostname.dropFirst(2)) : hostname + guard comparable != normalizedSuffix, !comparable.hasSuffix(".\(normalizedSuffix)") else { + throw StoreError.invalidRoute("\(hostname) is already owned by Dory's automatic domain suffix") + } + guard route.port > 0 else { + throw StoreError.invalidRoute("published port must be between 1 and 65535") + } + guard unique[hostname] == nil else { + throw StoreError.invalidRoute("\(hostname) is duplicated") + } + unique[hostname] = CustomDomainRouteConfiguration( + hostname: hostname, + publishedPort: route.port + ) + } + let result = unique.values.sorted { $0.hostname < $1.hostname } + lock.lock() + defer { lock.unlock() } + try saveLocked(result) + return result + } + + public func activeRoutes( + containers: DockerContainerList, + automaticSuffix: String + ) -> [DomainRoute] { + guard case let .ok(rows) = containers, + let configured = try? configuredRoutes() else { + return [] + } + let publishedPorts = Set(rows.lazy.filter(\.isRunning).flatMap { row in + row.ports.compactMap { port -> UInt16? in + let proto = (port.type ?? "tcp").lowercased() + guard proto == "tcp" || proto == "tcp6", + let publicPort = port.publicPort else { + return nil + } + return UInt16(exactly: publicPort) + } + }) + let normalizedSuffix = DomainRouter.normalize(automaticSuffix) + return configured.compactMap { route in + let comparable = route.hostname.hasPrefix("*.") + ? String(route.hostname.dropFirst(2)) + : route.hostname + guard comparable != normalizedSuffix, + !comparable.hasSuffix(".\(normalizedSuffix)"), + publishedPorts.contains(route.publishedPort) else { + return nil + } + return DomainRoute( + hostname: route.hostname, + address: "127.0.0.1", + port: PrivilegedPortMapping.effectiveBackendPort(forPublishedPort: route.publishedPort) + ) + } + } + + private func loadLocked() throws -> [CustomDomainRouteConfiguration] { + guard FileManager.default.fileExists(atPath: path) else { return [] } + try validateRegularFile(path) + do { + let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + guard data.count <= Self.maximumBytes else { + throw StoreError.unreadable("file exceeds \(Self.maximumBytes) bytes") + } + let document = try JSONDecoder().decode(Document.self, from: data) + guard document.schema == "dev.dory.custom-domains", document.version == 1, + document.routes.count <= Self.maximumRoutes else { + throw StoreError.unreadable("schema or route count is invalid") + } + var seen: Set = [] + var normalizedRoutes: [CustomDomainRouteConfiguration] = [] + for route in document.routes { + let hostname = DomainRouter.normalize(route.hostname) + guard DomainRouter.isValidHostnamePattern(hostname), route.publishedPort > 0, + seen.insert(hostname).inserted else { + throw StoreError.unreadable("a route is invalid or duplicated") + } + normalizedRoutes.append(CustomDomainRouteConfiguration( + hostname: hostname, + publishedPort: route.publishedPort + )) + } + return normalizedRoutes.sorted { $0.hostname < $1.hostname } + } catch let error as StoreError { + throw error + } catch { + throw StoreError.unreadable(error.localizedDescription) + } + } + + private func saveLocked(_ routes: [CustomDomainRouteConfiguration]) throws { + let url = URL(fileURLWithPath: path) + let directory = url.deletingLastPathComponent() + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try validateDirectory(directory.path) + var existing = stat() + if lstat(path, &existing) == 0 { + try validateRegularFile(path) + } else if errno != ENOENT { + throw StoreError.unsafePath(path) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(Document(routes: routes)) + try data.write(to: url, options: .atomic) + try validateRegularFile(path, requirePrivatePermissions: false) + guard chmod(path, 0o600) == 0 else { + throw StoreError.writeFailed(String(cString: strerror(errno))) + } + try validateRegularFile(path) + } catch let error as StoreError { + throw error + } catch { + throw StoreError.writeFailed(error.localizedDescription) + } + } + + private func validateDirectory(_ candidate: String) throws { + var info = stat() + guard lstat(candidate, &info) == 0, + (info.st_mode & S_IFMT) == S_IFDIR, + info.st_uid == getuid() else { + throw StoreError.unsafePath(candidate) + } + guard chmod(candidate, 0o700) == 0 else { + throw StoreError.unsafePath(candidate) + } + } + + private func validateRegularFile( + _ candidate: String, + requirePrivatePermissions: Bool = true + ) throws { + var info = stat() + guard lstat(candidate, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_uid == getuid(), + info.st_nlink == 1, + info.st_size >= 0, + info.st_size <= Self.maximumBytes, + !requirePrivatePermissions || (info.st_mode & 0o077) == 0 else { + throw StoreError.unsafePath(candidate) + } + } +} diff --git a/dory-core-swift/Sources/DorydKit/DomainRouter.swift b/dory-core-swift/Sources/DorydKit/DomainRouter.swift index 09c2ffb4..c84d075d 100644 --- a/dory-core-swift/Sources/DorydKit/DomainRouter.swift +++ b/dory-core-swift/Sources/DorydKit/DomainRouter.swift @@ -47,6 +47,46 @@ public struct DomainRouter: Sendable, Equatable { } return normalized } + + public static func matches(pattern rawPattern: String, hostname rawHostname: String) -> Bool { + matchSpecificity(pattern: rawPattern, hostname: rawHostname) != nil + } + + public static func matchSpecificity(pattern rawPattern: String, hostname rawHostname: String) -> Int? { + let pattern = normalize(rawPattern) + let hostname = normalize(rawHostname) + guard pattern.hasPrefix("*.") else { return pattern == hostname ? 2 : nil } + let suffix = String(pattern.dropFirst(2)) + guard hostname.hasSuffix(".\(suffix)") else { return nil } + let prefix = hostname.dropLast(suffix.count + 1) + return !prefix.isEmpty && !prefix.contains(".") ? 1 : nil + } + + public static func isValidHostnamePattern(_ rawValue: String, allowWildcard: Bool = true) -> Bool { + let value = normalize(rawValue) + guard !value.isEmpty, value.count <= 253 else { return false } + let hostname: String + if value.hasPrefix("*.") { + guard allowWildcard else { return false } + hostname = String(value.dropFirst(2)) + } else { + guard !value.contains("*") else { return false } + hostname = value + } + guard IPv4Address(hostname) == nil else { return false } + let labels = hostname.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + return labels.allSatisfy { label in + guard !label.isEmpty, label.count <= 63, + label.first != "-", label.last != "-" else { + return false + } + return label.unicodeScalars.allSatisfy { scalar in + let value = scalar.value + return (48...57).contains(value) || (97...122).contains(value) || value == 45 + } + } + } } public struct IPv4Address: Sendable, Equatable, Hashable { diff --git a/dory-core-swift/Sources/DorydKit/DoryHTTPProxyServer.swift b/dory-core-swift/Sources/DorydKit/DoryHTTPProxyServer.swift index e477dcb6..100441a9 100644 --- a/dory-core-swift/Sources/DorydKit/DoryHTTPProxyServer.swift +++ b/dory-core-swift/Sources/DorydKit/DoryHTTPProxyServer.swift @@ -223,12 +223,17 @@ public final class DoryHTTPProxyServer: @unchecked Sendable { lock.lock() let currentRoutes = routes lock.unlock() - return currentRoutes.first { route in + return currentRoutes.compactMap { route -> (specificity: Int, route: DomainRoute)? in let hostname = DomainRouter.normalize(route.hostname) - return hostname == normalized - && (router.owns(hostname) || Self.isLoopbackHost(hostname)) - && IPv4Address(route.address) != nil - } + guard let specificity = DomainRouter.matchSpecificity(pattern: hostname, hostname: normalized), + router.owns(hostname) + || Self.isLoopbackHost(hostname) + || DomainRouter.isValidHostnamePattern(hostname), + IPv4Address(route.address) != nil else { + return nil + } + return (specificity, route) + }.max { $0.specificity < $1.specificity }?.route } private func writeBadGateway(_ client: Int32, body: String) { diff --git a/dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift b/dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift index 58409eb9..45da5b12 100644 --- a/dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift +++ b/dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift @@ -19,7 +19,7 @@ public enum DoryTLSProxyServerError: Error, Sendable, CustomStringConvertible { public final class DoryTLSProxyServer: @unchecked Sendable { private let requestedPort: UInt16 - private let identity: SecIdentity + private let identityStorage: DoryTLSIdentityStorage private let router: DomainRouter private let connectionBudget: DoryConnectionBudget private let lock = NSLock() @@ -38,11 +38,11 @@ public final class DoryTLSProxyServer: @unchecked Sendable { routes: [DomainRoute] = [], maximumConnections: Int = 256 ) throws { - guard let identity = Self.loadIdentity(p12Path: p12Path, password: password) else { + guard let identityStorage = Self.loadIdentity(p12Path: p12Path, password: password) else { throw DoryTLSProxyServerError.identity(p12Path) } self.requestedPort = port - self.identity = identity + self.identityStorage = identityStorage self.router = router self.routes = routes self.connectionBudget = DoryConnectionBudget(limit: maximumConnections) @@ -85,7 +85,7 @@ public final class DoryTLSProxyServer: @unchecked Sendable { lock.unlock() let tlsOptions = NWProtocolTLS.Options() - guard let secIdentity = sec_identity_create(identity) else { + guard let secIdentity = sec_identity_create(identityStorage.identity) else { throw DoryTLSProxyServerError.identity("SecIdentity") } sec_protocol_options_set_local_identity(tlsOptions.securityProtocolOptions, secIdentity) @@ -267,12 +267,17 @@ public final class DoryTLSProxyServer: @unchecked Sendable { lock.lock() let currentRoutes = routes lock.unlock() - return currentRoutes.first { route in + return currentRoutes.compactMap { route -> (specificity: Int, route: DomainRoute)? in let hostname = DomainRouter.normalize(route.hostname) - return hostname == normalized - && (router.owns(hostname) || DoryHTTPProxyServer.isLoopbackHost(hostname)) - && IPv4Address(route.address) != nil - } + guard let specificity = DomainRouter.matchSpecificity(pattern: hostname, hostname: normalized), + router.owns(hostname) + || DoryHTTPProxyServer.isLoopbackHost(hostname) + || DomainRouter.isValidHostnamePattern(hostname), + IPv4Address(route.address) != nil else { + return nil + } + return (specificity, route) + }.max { $0.specificity < $1.specificity }?.route } private func writeBadGateway(_ client: NWConnection, body: String) { @@ -320,18 +325,63 @@ public final class DoryTLSProxyServer: @unchecked Sendable { } } - private static func loadIdentity(p12Path: String, password: String) -> SecIdentity? { + static func loadIdentity( + p12Path: String, + password: String, + forceTemporaryKeychain: Bool = false + ) -> DoryTLSIdentityStorage? { guard let data = FileManager.default.contents(atPath: p12Path) else { return nil } - let options = [kSecImportExportPassphrase as String: password] as CFDictionary + var options: [String: Any] = [ + kSecImportExportPassphrase as String: password, + ] + var temporaryKeychain: SecKeychain? + var temporaryKeychainPath: String? + if #available(macOS 15.0, *), !forceTemporaryKeychain { + options[kSecImportToMemoryOnly as String] = true + } else { + let path = NSTemporaryDirectory() + "dory-tls-\(getpid())-\(UUID().uuidString).keychain-db" + let keychainPassword = UUID().uuidString + var keychain: SecKeychain? + let status = keychainPassword.utf8CString.withUnsafeBytes { bytes in + SecKeychainCreate( + path, + UInt32(max(0, bytes.count - 1)), + bytes.baseAddress, + false, + nil, + &keychain + ) + } + guard status == errSecSuccess, let keychain else { + if let keychain { + SecKeychainDelete(keychain) + } + try? FileManager.default.removeItem(atPath: path) + return nil + } + temporaryKeychain = keychain + temporaryKeychainPath = path + options[kSecImportExportKeychain as String] = keychain + } var items: CFArray? - guard SecPKCS12Import(data as CFData, options, &items) == errSecSuccess, + guard SecPKCS12Import(data as CFData, options as CFDictionary, &items) == errSecSuccess, let array = items as? [[String: Any]], let identity = array.first?[kSecImportItemIdentity as String], CFGetTypeID(identity as CFTypeRef) == SecIdentityGetTypeID() else { + if let temporaryKeychain { + SecKeychainDelete(temporaryKeychain) + } + if let temporaryKeychainPath { + try? FileManager.default.removeItem(atPath: temporaryKeychainPath) + } return nil } // Safe: the CFTypeID guard above proves this is a SecIdentity. - return (identity as! SecIdentity) + return DoryTLSIdentityStorage( + identity: identity as! SecIdentity, + temporaryKeychain: temporaryKeychain, + temporaryKeychainPath: temporaryKeychainPath + ) } private final class FDOwner: @unchecked Sendable { @@ -376,6 +426,31 @@ public final class DoryTLSProxyServer: @unchecked Sendable { } } +final class DoryTLSIdentityStorage: @unchecked Sendable { + let identity: SecIdentity + let temporaryKeychainPath: String? + private let temporaryKeychain: SecKeychain? + + init( + identity: SecIdentity, + temporaryKeychain: SecKeychain?, + temporaryKeychainPath: String? + ) { + self.identity = identity + self.temporaryKeychain = temporaryKeychain + self.temporaryKeychainPath = temporaryKeychainPath + } + + deinit { + if let temporaryKeychain { + SecKeychainDelete(temporaryKeychain) + } + if let temporaryKeychainPath { + try? FileManager.default.removeItem(atPath: temporaryKeychainPath) + } + } +} + private struct ActiveTLSConnection { var connection: NWConnection var lease: DoryConnectionLease diff --git a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift index 0c6729d0..a73d247e 100644 --- a/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift +++ b/dory-core-swift/Sources/DorydKit/DorydConfiguration.swift @@ -244,6 +244,15 @@ public struct DorydEnvironment: Sendable { if let explicit = executablePath(firstOf: explicitKeys, fallbackCandidates: []) { return explicit } + if name == "kubectl", + let installed = DoryComponentStore.activeAssetPath( + component: .kubernetes, + path: "kubectl", + home: home + ), + FileManager.default.isExecutableFile(atPath: installed) { + return installed + } let candidates = ["\(home)/.dory/bin/\(name)"] + helperCandidates(named: name) return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } @@ -470,8 +479,20 @@ public struct DorydEnvironment: Sendable { /// release asset into doryd's state directory. It intentionally has no headless fallback. private func hvKernelPath(stateDirectory: String) -> String? { guard venusRequested else { - return existingPath(firstOf: ["DORYD_HV_KERNEL", "DORY_HV_KERNEL"]) - ?? bundledResource(named: ["dory-hv-kernel-\(hostGuestArch)", "dory-hv-kernel"]) + let resourceNames = ["dory-hv-kernel-\(hostGuestArch)", "dory-hv-kernel"] + if let kernel = existingPath(firstOf: ["DORYD_HV_KERNEL", "DORY_HV_KERNEL"]) + ?? bundledResource(named: resourceNames) + ?? preparedBundledCompressedResource( + named: resourceNames, + outputName: "dory-hv-kernel-\(hostGuestArch)", + stateDirectory: stateDirectory + ) { + return kernel + } + if compressedResourceSourceExists(named: resourceNames) { + reportEngineConfigurationError("headless kernel is present but could not be prepared") + } + return nil } guard hostGuestArch == "arm64" else { @@ -709,7 +730,7 @@ public struct DorydEnvironment: Sendable { if let explicit = string("DORYD_RESOURCES_DIR"), !explicit.isEmpty { return [explicit] } - return [ + return DoryComponentStore.activePayloadDirectories(home: home) + [ bundleResourcesDirectory, "\(cwd)/Resources", "\(cwd)/../Resources", diff --git a/dory-core-swift/Sources/DorydKit/DorydService.swift b/dory-core-swift/Sources/DorydKit/DorydService.swift index 9f300ea3..7801e6f8 100644 --- a/dory-core-swift/Sources/DorydKit/DorydService.swift +++ b/dory-core-swift/Sources/DorydKit/DorydService.swift @@ -10,6 +10,7 @@ public final class DorydService: NSObject, DorydControl { private let remoteManager: RemoteMachineManager? private let networkingController: NetworkingController? private let networkRouteRepair: (@Sendable () -> Int)? + private let customDomainRouteStore: CustomDomainRouteStore? private let balloonController: BalloonController private let idlePolicyStore: IdlePolicyStore private let idleSleepScheduler: IdleSleepScheduler? @@ -24,6 +25,7 @@ public final class DorydService: NSObject, DorydControl { remoteManager: RemoteMachineManager? = nil, networkingController: NetworkingController? = nil, networkRouteRepair: (@Sendable () -> Int)? = nil, + customDomainRouteStore: CustomDomainRouteStore? = nil, balloonController: BalloonController? = nil, idlePolicyStore: IdlePolicyStore? = nil, idleSleepScheduler: IdleSleepScheduler? = nil, @@ -36,6 +38,7 @@ public final class DorydService: NSObject, DorydControl { self.remoteManager = remoteManager self.networkingController = networkingController self.networkRouteRepair = networkRouteRepair + self.customDomainRouteStore = customDomainRouteStore self.balloonController = balloonController ?? BalloonController( actuator: DorydBalloonActuator(machineManager: machineManager) ) @@ -558,13 +561,23 @@ public final class DorydService: NSObject, DorydControl { return } do { - networkingController.replaceRoutes(try routes.compactMap { item in + let decoded = try routes.compactMap { item in guard let dictionary = item as? NSDictionary else { throw XPCNetworkRouteError.invalid("route") } return try DomainRoute(xpcDictionary: dictionary) - }) - incidentWriter?.record(type: "network.routes", detail: "\(routes.count) routes") + } + if let customDomainRouteStore { + _ = try customDomainRouteStore.replace( + decoded, + automaticSuffix: networkingController.status().suffix + ) + _ = networkRouteRepair?() + incidentWriter?.record(type: "network.custom_domains", detail: "\(routes.count) routes") + } else { + networkingController.replaceRoutes(decoded) + incidentWriter?.record(type: "network.routes", detail: "\(routes.count) routes") + } reply(true, "") } catch { reply(false, "\(error)") @@ -576,7 +589,13 @@ public final class DorydService: NSObject, DorydControl { reply([:], "networking is not configured") return } - reply(networkingController.status().xpcDictionary, "") + let status = NSMutableDictionary(dictionary: networkingController.status().xpcDictionary) + do { + status["customRoutes"] = try customDomainRouteStore?.configuredRoutes().map(\.xpcDictionary) ?? [] + reply(status, "") + } catch { + reply([:], "\(error)") + } } public func networkAuthorizationPlan(reply: @escaping (NSDictionary, String) -> Void) { diff --git a/dory-core-swift/Sources/DorydKit/HostCLIInstaller.swift b/dory-core-swift/Sources/DorydKit/HostCLIInstaller.swift index 2bca854b..06c0cfed 100644 --- a/dory-core-swift/Sources/DorydKit/HostCLIInstaller.swift +++ b/dory-core-swift/Sources/DorydKit/HostCLIInstaller.swift @@ -1,3 +1,4 @@ +import DoryCore import Foundation public struct HostCLIInstallResult: Sendable, Equatable { @@ -24,7 +25,8 @@ public struct HostCLIRemoveResult: Sendable, Equatable { } /// Per-user terminal integration owned by doryd. When the daemon is running from the app bundle, -/// fresh terminals should already have Dory's docker, Compose, kubectl, dory, and support tools. +/// fresh terminals should already have Dory's docker, Compose, dory, and support tools, plus +/// kubectl when the Kubernetes component is installed. public struct HostCLIInstaller: Sendable { private static let beginSentinel = "# >>> dory cli >>>" private static let endSentinel = "# <<< dory cli <<<" @@ -77,6 +79,7 @@ public struct HostCLIInstaller: Sendable { for tool in Self.tools { guard let source = sourcePath(for: tool) else { + removeStaleOwnedToolSymlink(at: "\(binDir)/\(tool)") missing.append(tool) continue } @@ -223,6 +226,15 @@ public struct HostCLIInstaller: Sendable { } private func sourcePath(for tool: String) -> String? { + if tool == "kubectl", + let installed = DoryComponentStore.activeAssetPath( + component: .kubernetes, + path: "kubectl", + home: home + ), + FileManager.default.isExecutableFile(atPath: installed) { + return installed + } guard let helpersDirectory else { return nil } let path = "\(helpersDirectory)/\(tool)" return FileManager.default.isExecutableFile(atPath: path) ? path : nil @@ -246,6 +258,23 @@ public struct HostCLIInstaller: Sendable { } } + private func removeStaleOwnedToolSymlink(at destination: String) { + let fileManager = FileManager.default + guard let rawTarget = try? fileManager.destinationOfSymbolicLink(atPath: destination) else { + return + } + let target = resolvedSymlinkTarget(rawTarget, at: destination) + let ownedRoots = [ + standardized("\(home)/.dory"), + helpersDirectory.map(standardized), + (try? DoryComponentStore.selected(home: home)).map { standardized($0.root) }, + ].compactMap { $0 } + guard ownedRoots.contains(where: { isInside(standardized(target), root: $0) }) else { + return + } + try? fileManager.removeItem(atPath: destination) + } + @discardableResult private func removeOwnedPluginSymlink(at destination: String, desiredSource: String?) -> Bool { let fileManager = FileManager.default diff --git a/dory-core-swift/Sources/DorydKit/LocalCA.swift b/dory-core-swift/Sources/DorydKit/LocalCA.swift index e42ac3fe..af9cd183 100644 --- a/dory-core-swift/Sources/DorydKit/LocalCA.swift +++ b/dory-core-swift/Sources/DorydKit/LocalCA.swift @@ -204,12 +204,6 @@ public struct DoryLocalCA { return try DoryShell.run(openssl, ["x509", "-in", certificate.path, "-noout", "-text"]) } - public func systemTrustInstallCommand() -> [String] { - [ - "/usr/bin/security", "add-trusted-cert", "-d", "-r", "trustRoot", - "-k", "/Library/Keychains/System.keychain", caCertificate.path, - ] - } } public enum DoryShell { diff --git a/dory-core-swift/Sources/DorydKit/NetworkRouteReconciler.swift b/dory-core-swift/Sources/DorydKit/NetworkRouteReconciler.swift index 46c0ea13..c68e5c44 100644 --- a/dory-core-swift/Sources/DorydKit/NetworkRouteReconciler.swift +++ b/dory-core-swift/Sources/DorydKit/NetworkRouteReconciler.swift @@ -3,7 +3,10 @@ import Foundation public final class NetworkRouteReconciler: @unchecked Sendable { public typealias ContainerProvider = @Sendable () -> DockerContainerList public typealias MachineProvider = @Sendable () -> [DoryMachineStatus] - public typealias AdditionalRouteProvider = @Sendable (_ suffix: String) -> [DomainRoute] + public typealias AdditionalRouteProvider = @Sendable ( + _ suffix: String, + _ containers: DockerContainerList + ) -> [DomainRoute] private let networkingController: NetworkingController private let suffix: String @@ -19,7 +22,7 @@ public final class NetworkRouteReconciler: @unchecked Sendable { suffix: String, containerProvider: @escaping ContainerProvider, machineProvider: @escaping MachineProvider, - additionalRouteProvider: @escaping AdditionalRouteProvider = { _ in [] }, + additionalRouteProvider: @escaping AdditionalRouteProvider = { _, _ in [] }, interval: TimeInterval = 5 ) { self.networkingController = networkingController @@ -32,11 +35,12 @@ public final class NetworkRouteReconciler: @unchecked Sendable { @discardableResult public func reconcileNow() -> [DomainRoute] { + let containers = containerProvider() let routes = Self.routes( - containers: containerProvider(), + containers: containers, machines: machineProvider(), suffix: suffix, - additionalRoutes: additionalRouteProvider(suffix) + additionalRoutes: additionalRouteProvider(suffix, containers) ) networkingController.replaceRoutes(routes) return routes diff --git a/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationApplier.swift b/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationApplier.swift index e3f2a8c8..05f12deb 100644 --- a/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationApplier.swift +++ b/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationApplier.swift @@ -7,6 +7,7 @@ public enum NetworkingAuthorizationApplyError: Error, Sendable, Equatable, Custo case commandFailed(String, String) case ownerMismatch(expected: uid_t, actual: uid_t) case notAuthorized + case certificateTrustRequiresInteraction public var description: String { switch self { @@ -20,6 +21,8 @@ public enum NetworkingAuthorizationApplyError: Error, Sendable, Equatable, Custo return "networking authorization belongs to uid \(expected), not uid \(actual)" case .notAuthorized: return "system networking has not been authorized" + case .certificateTrustRequiresInteraction: + return "the local CA changed and must be trusted again from the Dory app" } } } @@ -101,6 +104,9 @@ public struct NetworkingAuthorizationApplier: Sendable { if installedState == nil, oldCertificate != nil { throw NetworkingAuthorizationApplyError.unsafeRequest(Self.trustedCASnapshotPath) } + if requiresExistingAuthorization, oldCertificate != newCertificate { + throw NetworkingAuthorizationApplyError.certificateTrustRequiresInteraction + } let oldPlan = installedState?.plan let managedPaths = Set((plan.requests + (oldPlan?.requests ?? [])).compactMap { request in @@ -119,18 +125,12 @@ public struct NetworkingAuthorizationApplier: Sendable { let newResolverPath = plan.requests.first { $0.kind == .resolverFile }?.filePath let hadPFToken = try readPFToken() != nil var acquiredPFToken = false - var newTrustAddAttempted = false - var oldTrustRemoved = false let trustChanged = oldCertificate != newCertificate do { if let oldResolverPath, oldResolverPath != newResolverPath { try removeManagedFile(path: oldResolverPath) } - if trustChanged, oldCertificate != nil { - try removeSystemTrust(certificatePath: Self.trustedCASnapshotPath) - oldTrustRemoved = true - } if trustChanged, let newCertificate { try writeManagedFile( path: Self.trustedCASnapshotPath, @@ -155,13 +155,9 @@ public struct NetworkingAuthorizationApplier: Sendable { guard request.filePath != nil else { throw NetworkingAuthorizationApplyError.missingPayload(request.id) } - if trustChanged { - newTrustAddAttempted = true - try addSystemTrust( - certificatePath: Self.trustedCASnapshotPath, - requestID: request.id - ) - } + // Trust settings require an interactive user session on current macOS. + // Dory.app installs this exact certificate into the owner's login + // keychain before invoking the root transaction. } results.append(try result(for: request, removing: false)) } @@ -170,9 +166,6 @@ public struct NetworkingAuthorizationApplier: Sendable { ) return results } catch { - if newTrustAddAttempted { - try? removeSystemTrust(certificatePath: Self.trustedCASnapshotPath) - } if let certificateSnapshot { try? writeManagedFile( path: Self.trustedCASnapshotPath, @@ -182,12 +175,6 @@ public struct NetworkingAuthorizationApplier: Sendable { } else { try? removeManagedFile(path: Self.trustedCASnapshotPath) } - if oldTrustRemoved, certificateSnapshot != nil { - try? addSystemTrust( - certificatePath: Self.trustedCASnapshotPath, - requestID: "trust.local-ca.rollback" - ) - } if acquiredPFToken { _ = try? releaseOwnedPFToken() } @@ -304,24 +291,8 @@ public struct NetworkingAuthorizationApplier: Sendable { } let stateSnapshot = try readManagedFile(path: Self.authorizationStatePath) let hadPFToken = try readPFToken() != nil - var trustPath: String? - if let trust = installedPlan.requests.first(where: { $0.kind == .localCATrust }), - let path = trust.filePath { - if persistedCertificate != nil { - trustPath = Self.trustedCASnapshotPath - } else if try isSafeRegularFile(path) { - trustPath = path - } else { - throw NetworkingAuthorizationApplyError.missingPayload(trust.id) - } - } - var trustRemoved = false var tokenReleased = false do { - if let trustPath { - try removeSystemTrust(certificatePath: trustPath) - trustRemoved = true - } _ = try runOutput( ["/sbin/pfctl", "-a", Self.pfAnchorName, "-F", "all"], requestID: "pf.dev.dory.disable" @@ -363,18 +334,12 @@ public struct NetworkingAuthorizationApplier: Sendable { "/sbin/pfctl", "-a", Self.pfAnchorName, "-f", Self.pfAnchorPath, ]) } - if trustRemoved, let trustPath { - try? addSystemTrust( - certificatePath: trustPath, - requestID: "trust.local-ca.remove.rollback" - ) - } throw error } return try installedPlan.requests.reversed().map { try result(for: $0, removing: true) } } - /// The resolver and CA trust are persistent files, while PF's enable reference and loaded + /// The resolver and CA snapshot are persistent files, while PF's enable reference and loaded /// anchor are boot-scoped. The root launch daemon calls this on every launch so an explicitly /// authorized installation survives reboot without accumulating PF references. public func restorePFIfAuthorized() throws { @@ -483,19 +448,6 @@ public struct NetworkingAuthorizationApplier: Sendable { ) } - private func addSystemTrust(certificatePath: String, requestID: String) throws { - _ = try runOutput([ - "/usr/bin/security", "add-trusted-cert", "-d", "-r", "trustRoot", - "-k", "/Library/Keychains/System.keychain", rootedPath(certificatePath), - ], requestID: requestID) - } - - private func removeSystemTrust(certificatePath: String) throws { - _ = try runOutput([ - "/usr/bin/security", "remove-trusted-cert", "-d", rootedPath(certificatePath), - ], requestID: "trust.local-ca.remove") - } - private func result( for request: NetworkingAuthorizationRequest, removing: Bool @@ -527,8 +479,8 @@ public struct NetworkingAuthorizationApplier: Sendable { return NetworkingAuthorizationApplyResult( id: request.id, kind: request.kind, - action: removing ? "remove-trust" : "run-command", - target: removing ? filePath : request.command.joined(separator: " "), + action: removing ? "remove-user-trust" : "require-user-trust", + target: filePath, dryRun: dryRun ) } diff --git a/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationPlan.swift b/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationPlan.swift index 38606f4e..c11cc92c 100644 --- a/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationPlan.swift +++ b/dory-core-swift/Sources/DorydKit/NetworkingAuthorizationPlan.swift @@ -179,11 +179,11 @@ public struct NetworkingAuthorizationPlan: Sendable, Equatable, Codable { requests.append(NetworkingAuthorizationRequest( id: "trust.local-ca", kind: .localCATrust, - title: "Trust Dory Local CA", - reason: "Allow HTTPS certificates issued for *.\(suffix) to validate in browsers and developer tools.", + title: "Trust Dory Local CA for this account", + reason: "Allow HTTPS certificates issued for *.\(suffix) to validate in this user's browsers and developer tools.", + requiresAdmin: false, filePath: caPath, - command: DoryLocalCA(directory: URL(fileURLWithPath: caPath).deletingLastPathComponent()) - .systemTrustInstallCommand() + command: [] )) } @@ -303,7 +303,7 @@ public struct NetworkingAuthorizationPlan: Sendable, Equatable, Codable { } } - // The CA path is added to the System keychain as a trusted root, so it must be + // The CA path is added to the owning user's login keychain as a trusted root, so it must be // the canonical `~/.dory/ca/ca.crt` (see DoryLocalCA). Constrain the trailing // components structurally so a tampered plan cannot promote an arbitrary // certificate to a trusted root. Home-relative rather than absolute because the diff --git a/dory-core-swift/Sources/DorydKit/NetworkingController.swift b/dory-core-swift/Sources/DorydKit/NetworkingController.swift index 0c58f5c3..6f8a9fc8 100644 --- a/dory-core-swift/Sources/DorydKit/NetworkingController.swift +++ b/dory-core-swift/Sources/DorydKit/NetworkingController.swift @@ -88,6 +88,7 @@ public final class NetworkingController: @unchecked Sendable { private let httpProxy: DoryHTTPProxyServer private let controlLock = NSLock() private var tlsProxy: DoryTLSProxyServer? + var tlsRouteNames: Set = [] public init(configuration: NetworkingConfiguration = NetworkingConfiguration()) { self.configuration = configuration @@ -115,29 +116,11 @@ public final class NetworkingController: @unchecked Sendable { do { try dnsServer.start() try httpProxy.start() - if let localCACertificatePath = configuration.localCACertificatePath { - let ca = DoryLocalCA(directory: URL(fileURLWithPath: localCACertificatePath).deletingLastPathComponent()) - // Ephemeral per-issuance password: the p12 only exists to hand the identity - // to the local TLS proxy, so a fixed password on disk buys nothing. - let p12Password = Self.ephemeralPassword() - let p12 = try ca.issuePKCS12( - domain: configuration.suffix, - password: p12Password, - extraSANs: [ - "*.k8s.\(configuration.suffix)", - "*.default.k8s.\(configuration.suffix)", - "*.kube-system.k8s.\(configuration.suffix)", - ] - ) - let proxy = try DoryTLSProxyServer( - port: configuration.httpsProxyPort, - p12Path: p12.path, - password: p12Password, - router: router, - routes: dnsServer.currentRoutes() - ) + if configuration.localCACertificatePath != nil { + let (proxy, routeNames) = try makeTLSProxy(routes: dnsServer.currentRoutes()) try proxy.start() tlsProxy = proxy + tlsRouteNames = routeNames } } catch { dnsServer.stop() @@ -159,6 +142,7 @@ public final class NetworkingController: @unchecked Sendable { httpProxy.stop() tlsProxy?.stop() tlsProxy = nil + tlsRouteNames = [] } public func replaceRoutes(_ routes: [DomainRoute]) { @@ -170,7 +154,7 @@ public final class NetworkingController: @unchecked Sendable { private func replaceRoutesLocked(_ routes: [DomainRoute]) { dnsServer.updateRoutes(routes) httpProxy.updateRoutes(routes) - tlsProxy?.updateRoutes(routes) + refreshTLSProxyLocked(routes: routes) } public func status() -> NetworkingStatus { @@ -228,6 +212,70 @@ public final class NetworkingController: @unchecked Sendable { .joined() } + private func makeTLSProxy(routes: [DomainRoute]) throws -> (DoryTLSProxyServer, Set) { + guard let localCACertificatePath = configuration.localCACertificatePath else { + throw NetworkingControllerError.tlsUnavailable + } + let routeNames = tlsNames(for: routes) + let ca = DoryLocalCA(directory: URL(fileURLWithPath: localCACertificatePath).deletingLastPathComponent()) + let password = Self.ephemeralPassword() + let p12 = try ca.issuePKCS12( + domain: configuration.suffix, + password: password, + extraSANs: Array(routeNames).sorted() + ) + return (try DoryTLSProxyServer( + port: configuration.httpsProxyPort, + p12Path: p12.path, + password: password, + router: router, + routes: routes + ), routeNames) + } + + private func tlsNames(for routes: [DomainRoute]) -> Set { + var names: Set = [ + "*.k8s.\(configuration.suffix)", + "*.default.k8s.\(configuration.suffix)", + "*.kube-system.k8s.\(configuration.suffix)", + ] + for route in routes { + let hostname = DomainRouter.normalize(route.hostname) + guard !router.owns(hostname), + !DoryHTTPProxyServer.isLoopbackHost(hostname), + DomainRouter.isValidHostnamePattern(hostname) else { + continue + } + names.insert(hostname) + } + return names + } + + private func refreshTLSProxyLocked(routes: [DomainRoute]) { + guard configuration.localCACertificatePath != nil else { return } + let desiredNames = tlsNames(for: routes) + guard !desiredNames.isSubset(of: tlsRouteNames) else { + tlsProxy?.updateRoutes(routes) + return + } + do { + let (candidate, routeNames) = try makeTLSProxy(routes: routes) + let previous = tlsProxy + previous?.stop() + do { + try candidate.start() + tlsProxy = candidate + tlsRouteNames = routeNames + } catch { + try? previous?.start() + previous?.updateRoutes(routes) + tlsProxy = previous + } + } catch { + tlsProxy?.updateRoutes(routes) + } + } + public func authorizationPlan(additionalPrivilegedTCPForwards: [PrivilegedTCPForward] = []) throws -> NetworkingAuthorizationPlan { controlLock.lock() defer { controlLock.unlock() } @@ -253,3 +301,7 @@ public final class NetworkingController: @unchecked Sendable { stop() } } + +private enum NetworkingControllerError: Error { + case tlsUnavailable +} diff --git a/dory-core-swift/Sources/doryd/main.swift b/dory-core-swift/Sources/doryd/main.swift index 2fa92db1..a5039032 100644 --- a/dory-core-swift/Sources/doryd/main.swift +++ b/dory-core-swift/Sources/doryd/main.swift @@ -67,6 +67,7 @@ let machineManager = dorydEnvironment.machineManagerConfiguration().map { Machin let remoteManager = RemoteMachineManager() let networkingConfiguration = dorydEnvironment.networkingConfiguration() let networkingController = networkingConfiguration.map(NetworkingController.init(configuration:)) +let customDomainRouteStore = CustomDomainRouteStore(home: dorydEnvironment.home, environment: env) let kubernetesRouteProvider = networkingController.map { _ in KubernetesServiceRouteProvider(configuration: dorydEnvironment.kubernetesServiceRouteProviderConfiguration()) } @@ -82,8 +83,9 @@ let networkRouteReconciler = networkingController.map { controller in machineProvider: { machineManager?.list() ?? [] }, - additionalRouteProvider: { suffix in - kubernetesRouteProvider?.routes(suffix: suffix) ?? [] + additionalRouteProvider: { suffix, containers in + (kubernetesRouteProvider?.routes(suffix: suffix) ?? []) + + customDomainRouteStore.activeRoutes(containers: containers, automaticSuffix: suffix) }, interval: dorydEnvironment.networkRouteReconcileIntervalSeconds ) @@ -160,6 +162,7 @@ let service = DorydService( remoteManager: remoteManager, networkingController: networkingController, networkRouteRepair: repairRoutes, + customDomainRouteStore: customDomainRouteStore, idlePolicyStore: idlePolicyStore, idleSleepScheduler: idleSleepScheduler, incidentWriter: incidentWriter diff --git a/dory-core-swift/Sources/dorydctl/main.swift b/dory-core-swift/Sources/dorydctl/main.swift index 4c4c25b0..765a7074 100644 --- a/dory-core-swift/Sources/dorydctl/main.swift +++ b/dory-core-swift/Sources/dorydctl/main.swift @@ -141,6 +141,12 @@ struct ArgumentCursor { } return result } + + mutating func takeFlag(_ name: String) -> Bool { + guard let index = values.firstIndex(of: name) else { return false } + values.remove(at: index) + return true + } } func usage(exitCode: Int32 = 2) -> Never { @@ -167,12 +173,17 @@ func usage(exitCode: Int32 = 2) -> Never { dorydctl [global] machine delete-snapshot NAME SNAPSHOT_ID dorydctl [global] machine export-snapshot NAME SNAPSHOT_ID PATH dorydctl [global] machine import-snapshot PATH + dorydctl [global] component list [--json] [--offline] + dorydctl [global] component install|update ID [--json] + dorydctl [global] component verify [ID|all] [--json] [--offline] + dorydctl [global] component remove ID [--json] [--offline] dorydctl [global] remote connect NAME --host HOST --user USER --private-key-id ID --remote-root PATH (--host-key KEY | --known-hosts PATH) [--port N] [--endpoint-unix PATH | --endpoint-tcp HOST:PORT] dorydctl [global] remote push NAME --local-root PATH [--remote-root PATH] dorydctl [global] remote status NAME - dorydctl [global] network status|authorization-plan|repair + dorydctl [global] network status|custom-domains|authorization-plan|repair + dorydctl [global] network set-custom-domain HOST --published-port N + dorydctl [global] network remove-custom-domain HOST dorydctl [global] network replace-routes --json PATH|- - dorydctl [global] network set-route HOST ADDRESS [--port N] dorydctl [global] balloon status|reconcile dorydctl [global] idle status|history|set|mode dorydctl [global] health @@ -199,6 +210,259 @@ func emitCommandResult(_ value: NSDictionary) throws { } } +private struct ComponentCatalogBundle: Sendable { + let catalog: DoryComponentCatalog + let data: Data + let signature: String +} + +private func componentAppVersion() -> String { + if let override = ProcessInfo.processInfo.environment["DORY_COMPONENT_APP_VERSION"], + !override.isEmpty { + return override + } + let executable = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() + let infoPath = executable.deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Info.plist").path + if let info = NSDictionary(contentsOfFile: infoPath), + let version = info["CFBundleShortVersionString"] as? String, + !version.isEmpty { + return version + } + return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0-dev" +} + +private func componentCatalogURL() -> URL { + if let override = ProcessInfo.processInfo.environment["DORY_COMPONENT_CATALOG_URL"], + let url = URL(string: override), + url.scheme == "https" || url.isFileURL { + return url + } + return DoryComponentDefaults.catalogURL +} + +private func awaitComponentOperation( + _ operation: @escaping @Sendable () async throws -> T +) throws -> T { + let box = ReplyBox() + Task.detached { + do { box.resume(.success(try await operation())) } + catch { box.resume(.failure(error)) } + } + return try box.wait(timeout: 7_200).get() +} + +private func loadComponentCatalog( + store: DoryComponentStore, + offline: Bool +) throws -> ComponentCatalogBundle { + let appVersion = componentAppVersion() + if !offline { + do { + let client = DoryComponentCatalogClient( + catalogURL: componentCatalogURL(), + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: appVersion + ) + let fetched = try awaitComponentOperation { try await client.fetch() } + _ = try store.cacheCatalog( + data: fetched.data, + signature: fetched.signature, + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: appVersion + ) + return ComponentCatalogBundle( + catalog: fetched.catalog, + data: fetched.data, + signature: fetched.signature + ) + } catch { + if let cached = try store.cachedCatalog( + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: appVersion + ) { + return ComponentCatalogBundle( + catalog: cached.catalog, + data: cached.data, + signature: cached.signature + ) + } + throw error + } + } + guard let cached = try store.cachedCatalog( + publicKey: DoryComponentDefaults.publicKey, + expectedArchitecture: DoryComponentDefaults.architecture, + appVersion: appVersion + ) else { + throw DoryComponentError.invalidCatalog("no verified catalog is cached for offline use") + } + return ComponentCatalogBundle( + catalog: cached.catalog, + data: cached.data, + signature: cached.signature + ) +} + +private func componentStatusJSON(_ status: DoryComponentStatus) -> NSDictionary { + [ + "id": status.id.rawValue, + "name": status.displayName, + "summary": status.summary, + "state": status.state.rawValue, + "availableVersion": status.availableVersion, + "installedVersion": status.installedVersion ?? NSNull(), + "downloadBytes": NSNumber(value: status.downloadBytes), + "installedBytes": NSNumber(value: status.installedBytes), + "dependencies": status.dependencies.map(\.rawValue), + ] as NSDictionary +} + +private func componentResultJSON( + action: String, + catalog: ComponentCatalogBundle, + statuses: [DoryComponentStatus] +) -> NSDictionary { + [ + "schema": "dev.dory.components", + "schemaVersion": 1, + "action": action, + "catalogVersion": catalog.catalog.releaseVersion, + "catalogDigest": DoryComponentCatalogVerifier.digest(catalog.data), + "architecture": catalog.catalog.architecture, + "components": statuses.map(componentStatusJSON), + ] as NSDictionary +} + +private func componentBytes(_ bytes: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(clamping: bytes), countStyle: .file) +} + +private func componentInstallationOrder( + _ id: DoryComponentID, + catalog: DoryComponentCatalog +) throws -> [DoryComponentRelease] { + var visited: Set = [] + var ordered: [DoryComponentRelease] = [] + func append(_ current: DoryComponentID) throws { + guard current != .dockerCore, !visited.contains(current) else { return } + guard let release = catalog.component(current) else { + throw DoryComponentError.unknownComponent(current.rawValue) + } + for dependency in release.dependencies { try append(dependency) } + visited.insert(current) + ordered.append(release) + } + try append(id) + return ordered +} + +private func runComponent(cursor: inout ArgumentCursor) throws { + let subcommand = try cursor.take("usage: dorydctl component list|install|update|verify|remove") + let json = cursor.takeFlag("--json") + let offline = cursor.takeFlag("--offline") + let store = try DoryComponentStore.selected() + try store.prepare() + if subcommand == "path" { + let rawID = try cursor.take("usage: dorydctl component path ID ASSET") + let asset = try cursor.take("usage: dorydctl component path ID ASSET") + guard cursor.values.isEmpty, + let id = DoryComponentID(rawValue: rawID), + let path = store.assetPath(component: id, path: asset) else { + throw DoryComponentError.unknownComponent(rawID) + } + print(path) + return + } + let catalog = try loadComponentCatalog(store: store, offline: offline) + let digest = DoryComponentCatalogVerifier.digest(catalog.data) + + switch subcommand { + case "list": + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("usage: dorydctl component list [--json] [--offline]") + } + let statuses = store.list(catalog: catalog.catalog, catalogDigest: digest) + if json { + try emitJSON(componentResultJSON(action: "list", catalog: catalog, statuses: statuses)) + } else { + for status in statuses { + let version = status.installedVersion.map { "installed \($0)" } ?? "available \(status.availableVersion)" + print("\(status.id.rawValue)\t\(status.state.rawValue)\t\(version)\t\(componentBytes(status.downloadBytes)) download") + } + } + case "install", "update": + guard !offline else { throw DorydCtlError.usage("component downloads cannot use --offline") } + let rawID = try cursor.take("usage: dorydctl component \(subcommand) ID [--json]") + guard cursor.values.isEmpty, let id = DoryComponentID(rawValue: rawID) else { + throw DoryComponentError.unknownComponent(rawID) + } + guard id.isRemovable else { throw DoryComponentError.coreCannotBeChanged } + let installer = DoryComponentInstaller(store: store) + for release in try componentInstallationOrder(id, catalog: catalog.catalog) { + let current = try store.installedComponent(release.id) + if current?.version == release.version, current?.catalogDigest == digest, + (try? store.verify(release.id)) != nil { + continue + } + let showProgress = !json + _ = try awaitComponentOperation { + try await installer.install(release, catalogData: catalog.data) { update in + guard showProgress else { return } + let message = "\r\(release.displayName): \(update.phase.rawValue) " + + "\(componentBytes(update.completedBytes)) / \(componentBytes(update.totalBytes))" + FileHandle.standardError.write(Data(message.utf8)) + } + } + if !json { FileHandle.standardError.write(Data("\n".utf8)) } + } + let statuses = store.list(catalog: catalog.catalog, catalogDigest: digest) + if json { + try emitJSON(componentResultJSON(action: subcommand, catalog: catalog, statuses: statuses)) + } else { + print("\(rawID) is installed and verified.") + } + case "verify": + let rawID = cursor.values.isEmpty ? "all" : try cursor.take("usage: dorydctl component verify [ID|all] [--json] [--offline]") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("usage: dorydctl component verify [ID|all] [--json] [--offline]") + } + let ids: [DoryComponentID] + if rawID == "all" { + ids = DoryComponentID.allCases.filter { $0.isRemovable && (try? store.installedComponent($0)) != nil } + } else if let id = DoryComponentID(rawValue: rawID), id.isRemovable { + ids = [id] + } else { + throw DoryComponentError.unknownComponent(rawID) + } + for id in ids { _ = try store.verify(id) } + let statuses = store.list(catalog: catalog.catalog, catalogDigest: digest) + if json { + try emitJSON(componentResultJSON(action: "verify", catalog: catalog, statuses: statuses)) + } else { + print(ids.isEmpty ? "No optional components are installed." : "Verified: \(ids.map(\.rawValue).joined(separator: ", "))") + } + case "remove": + let rawID = try cursor.take("usage: dorydctl component remove ID [--json] [--offline]") + guard cursor.values.isEmpty, let id = DoryComponentID(rawValue: rawID) else { + throw DoryComponentError.unknownComponent(rawID) + } + try store.remove(id, catalog: catalog.catalog) + let statuses = store.list(catalog: catalog.catalog, catalogDigest: digest) + if json { + try emitJSON(componentResultJSON(action: "remove", catalog: catalog, statuses: statuses)) + } else { + print("Removed \(rawID) payload. Workload data was preserved.") + } + default: + throw DorydCtlError.usage("unknown component command: \(subcommand)") + } +} + func positiveUInt64(_ raw: String, option: String) throws -> UInt64 { guard let value = UInt64(raw), value > 0 else { throw DorydCtlError.usage("\(option) must be a positive integer") @@ -359,6 +623,8 @@ func run(command: String, cursor: inout ArgumentCursor, client: DorydCtlClient) try runDocker(cursor: &cursor, client: client) case "machine": try runMachine(cursor: &cursor, client: client) + case "component": + try runComponent(cursor: &cursor) case "remote": try runRemote(cursor: &cursor, client: client) case "network": @@ -562,7 +828,7 @@ func runRemote(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } func runNetwork(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { - let subcommand = try cursor.take("usage: dorydctl network status|authorization-plan|repair|replace-routes|set-route") + let subcommand = try cursor.take("usage: dorydctl network status|custom-domains|set-custom-domain|remove-custom-domain|authorization-plan|repair|replace-routes") switch subcommand { case "status": let status: NSDictionary = try client.call { proxy, finish in @@ -578,6 +844,60 @@ func runNetwork(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { } } try emitJSON(plan) + case "custom-domains": + let status: NSDictionary = try client.call { proxy, finish in + proxy.networkStatus { body, message in + message.isEmpty ? finish(.success(body)) : finish(.failure(DorydCtlError.daemon(message))) + } + } + try emitJSON((status["customRoutes"] as? NSArray) ?? []) + case "set-custom-domain": + let hostname = try cursor.take("usage: dorydctl network set-custom-domain HOST --published-port N") + let rawPort = try requiredOption( + "--published-port", + cursor: &cursor, + usage: "usage: dorydctl network set-custom-domain HOST --published-port N" + ) + let port = try positiveUInt16(rawPort, option: "--published-port") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("usage: dorydctl network set-custom-domain HOST --published-port N") + } + let status: NSDictionary = try client.call { proxy, finish in + proxy.networkStatus { body, message in + message.isEmpty ? finish(.success(body)) : finish(.failure(DorydCtlError.daemon(message))) + } + } + let normalized = DomainRouter.normalize(hostname) + let existing = (status["customRoutes"] as? [NSDictionary] ?? []).filter { + guard let value = $0["hostname"] as? String else { return false } + return DomainRouter.normalize(value) != normalized + } + let routes = existing + [[ + "hostname": normalized, + "address": "127.0.0.1", + "port": port, + ] as NSDictionary] + try emitCommandResult(try client.command { proxy, reply in + proxy.networkReplaceRoutes(routes as NSArray, reply: reply) + }) + case "remove-custom-domain": + let hostname = try cursor.take("usage: dorydctl network remove-custom-domain HOST") + guard cursor.values.isEmpty else { + throw DorydCtlError.usage("usage: dorydctl network remove-custom-domain HOST") + } + let status: NSDictionary = try client.call { proxy, finish in + proxy.networkStatus { body, message in + message.isEmpty ? finish(.success(body)) : finish(.failure(DorydCtlError.daemon(message))) + } + } + let normalized = DomainRouter.normalize(hostname) + let routes = (status["customRoutes"] as? [NSDictionary] ?? []).filter { + guard let value = $0["hostname"] as? String else { return false } + return DomainRouter.normalize(value) != normalized + } + try emitCommandResult(try client.command { proxy, reply in + proxy.networkReplaceRoutes(routes as NSArray, reply: reply) + }) case "repair": let target = try cursor.take("usage: dorydctl network repair dns|domains|routes|ports|guest-agent|docker-api") guard ["dns", "domains", "routes", "ports", "guest-agent", "docker-api"].contains(target), @@ -599,18 +919,6 @@ func runNetwork(cursor: inout ArgumentCursor, client: DorydCtlClient) throws { try emitCommandResult(try client.command { proxy, reply in proxy.networkReplaceRoutes(routes, reply: reply) }) - case "set-route": - let hostname = try cursor.take("usage: dorydctl network set-route HOST ADDRESS [--port N]") - let address = try cursor.take("usage: dorydctl network set-route HOST ADDRESS [--port N]") - let port = try cursor.optionValue("--port").map { try positiveUInt16($0, option: "--port") } ?? 80 - let routes: NSArray = [[ - "hostname": hostname, - "address": address, - "port": port, - ] as NSDictionary] - try emitCommandResult(try client.command { proxy, reply in - proxy.networkReplaceRoutes(routes, reply: reply) - }) default: throw DorydCtlError.usage("unknown network command: \(subcommand)") } diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift new file mode 100644 index 00000000..9849b1ac --- /dev/null +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryComponentsTests.swift @@ -0,0 +1,528 @@ +@testable import DoryOperations +import CryptoKit +import Foundation +import XCTest + +final class DoryComponentsTests: XCTestCase { + func testComponentSelectionURLRoundTripsInCanonicalOrder() throws { + let url = try XCTUnwrap(DoryComponentSelectionURL.make([ + .desktopUbuntu, + .kubernetes, + .linuxDesktop, + ])) + + XCTAssertEqual( + DoryComponentSelectionURL.parse(url), + [.kubernetes, .linuxDesktop, .desktopUbuntu] + ) + XCTAssertEqual( + url.absoluteString, + "dory://components/install?ids=kubernetes,linux-desktop,desktop-ubuntu" + ) + } + + func testComponentSelectionURLRejectsMalformedAndUnsafeSelections() throws { + let rejected = [ + "https://components/install?ids=kubernetes", + "dory://other/install?ids=kubernetes", + "dory://components/remove?ids=kubernetes", + "dory://components/install", + "dory://components/install?ids=", + "dory://components/install?ids=docker-core", + "dory://components/install?ids=unknown", + "dory://components/install?ids=kubernetes,kubernetes", + "dory://components/install?ids=kubernetes,", + "dory://components/install?ids=kubernetes&other=value", + "dory://user@components/install?ids=kubernetes", + "dory://components:443/install?ids=kubernetes", + "dory://components/install?ids=kubernetes#fragment", + ] + + for rawURL in rejected { + let url = try XCTUnwrap(URL(string: rawURL)) + XCTAssertNil(DoryComponentSelectionURL.parse(url), rawURL) + } + XCTAssertNil(DoryComponentSelectionURL.make([])) + XCTAssertNil(DoryComponentSelectionURL.make([.dockerCore])) + } + + func testSignedCatalogRejectsTamperingWrongArchitectureAndOldApp() throws { + let key = Curve25519.Signing.PrivateKey() + let catalog = catalog(components: [core()]) + let data = try encoded(catalog) + let signature = try key.signature(for: data).base64EncodedString() + let publicKey = key.publicKey.rawRepresentation.base64EncodedString() + + XCTAssertEqual( + try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + ), + catalog + ) + + var tampered = data + tampered.append(0x20) + XCTAssertThrowsError(try DoryComponentCatalogVerifier.verify( + catalogData: tampered, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) { error in + XCTAssertEqual(error as? DoryComponentError, .invalidSignature) + } + XCTAssertThrowsError(try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: "amd64", + appVersion: "0.4.0" + )) { error in + XCTAssertEqual( + error as? DoryComponentError, + .incompatibleArchitecture(expected: "amd64", actual: "arm64") + ) + } + XCTAssertThrowsError(try DoryComponentCatalogVerifier.verify( + catalogData: data, + signatureBase64: signature, + publicKeyBase64: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.3.1" + )) { error in + XCTAssertEqual( + error as? DoryComponentError, + .incompatibleAppVersion(required: "0.4.0", actual: "0.3.1") + ) + } + } + + func testCatalogRejectsDuplicatePathsCyclesAndDisagreeingSizes() throws { + let payload = Data("kubectl".utf8) + let asset = try plainAsset(path: "kubectl", data: payload) + let duplicate = DoryComponentRelease( + id: .kubernetes, + version: "1.0.0", + displayName: "Kubernetes", + summary: "Local Kubernetes", + downloadBytes: UInt64(payload.count * 2), + installedBytes: UInt64(payload.count * 2), + assets: [asset, asset] + ) + XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate( + catalog(components: [core(), duplicate]), + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) + + let desktop = release( + id: .linuxDesktop, + data: Data("desktop".utf8), + dependencies: [.dockerCore, .desktopUbuntu] + ) + let ubuntu = release( + id: .desktopUbuntu, + data: Data("ubuntu".utf8), + dependencies: [.dockerCore, .linuxDesktop] + ) + XCTAssertThrowsError(try DoryComponentCatalogVerifier.validate( + catalog(components: [core(), desktop, ubuntu]), + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) { error in + XCTAssertEqual(error as? DoryComponentError, .invalidCatalog("component dependency cycle")) + } + } + + func testInstallVerifyAndRemovePreserveEveryWorkloadDirectory() throws { + let fixture = try Fixture(name: "preserve") + defer { fixture.cleanup() } + let payload = Data("verified kubectl payload".utf8) + let source = try fixture.write(payload, name: "kubectl-source") + let kubernetes = release(id: .kubernetes, data: payload, assetPath: "kubectl") + let catalog = catalog(components: [core(), kubernetes]) + let catalogData = try encoded(catalog) + let workloadPaths = [ + fixture.drive.engineDirectory + "/container-state", + fixture.drive.kubernetesDirectory + "/cluster-state", + fixture.drive.machinesDirectory + "/machine-disk", + fixture.drive.snapshotsDirectory + "/snapshot", + fixture.drive.exportsDirectory + "/backup", + ] + for path in workloadPaths { try Data("user data".utf8).write(to: URL(fileURLWithPath: path)) } + + let installed = try fixture.store.install( + kubernetes, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + downloadedAssets: ["kubectl": source.path] + ) + + XCTAssertEqual(installed.id, .kubernetes) + XCTAssertEqual(try fixture.store.verify(.kubernetes), installed) + XCTAssertEqual( + fixture.store.assetPath(component: .kubernetes, path: "kubectl").flatMap { try? Data(contentsOf: URL(fileURLWithPath: $0)) }, + payload + ) + XCTAssertEqual( + fixture.store.list( + catalog: catalog, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + ).first(where: { $0.id == .kubernetes })?.state, + .installed + ) + + try fixture.store.remove(.kubernetes, catalog: catalog) + XCTAssertNil(try fixture.store.installedComponent(.kubernetes)) + XCTAssertNil(fixture.store.assetPath(component: .kubernetes, path: "kubectl")) + for path in workloadPaths { + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: path)), Data("user data".utf8)) + } + } + + func testDependenciesBlockInstallAndRemovalInTheWrongOrder() throws { + let fixture = try Fixture(name: "dependencies") + defer { fixture.cleanup() } + let desktopData = Data("desktop kernel".utf8) + let ubuntuData = Data("ubuntu rootfs".utf8) + let desktop = release(id: .linuxDesktop, data: desktopData) + let ubuntu = release( + id: .desktopUbuntu, + data: ubuntuData, + dependencies: [.dockerCore, .linuxDesktop] + ) + let catalog = catalog(components: [core(), desktop, ubuntu]) + let digest = DoryComponentCatalogVerifier.digest(try encoded(catalog)) + let desktopSource = try fixture.write(desktopData, name: "desktop") + let ubuntuSource = try fixture.write(ubuntuData, name: "ubuntu") + + XCTAssertThrowsError(try fixture.store.install( + ubuntu, + catalogDigest: digest, + downloadedAssets: [ubuntu.assets[0].path: ubuntuSource.path] + )) { error in + XCTAssertEqual(error as? DoryComponentError, .missingDependency(.linuxDesktop)) + } + + try fixture.store.install( + desktop, + catalogDigest: digest, + downloadedAssets: [desktop.assets[0].path: desktopSource.path] + ) + try fixture.store.install( + ubuntu, + catalogDigest: digest, + downloadedAssets: [ubuntu.assets[0].path: ubuntuSource.path] + ) + XCTAssertThrowsError(try fixture.store.remove(.linuxDesktop, catalog: catalog)) { error in + XCTAssertEqual(error as? DoryComponentError, .componentInUse(.desktopUbuntu)) + } + try fixture.store.remove(.desktopUbuntu, catalog: catalog) + try fixture.store.remove(.linuxDesktop, catalog: catalog) + XCTAssertNil(try fixture.store.installedComponent(.linuxDesktop)) + } + + func testFailedUpdateLeavesPreviousVersionActive() throws { + let fixture = try Fixture(name: "failed-update") + defer { fixture.cleanup() } + let firstData = Data("version one".utf8) + let secondData = Data("version two".utf8) + let first = release(id: .linuxMachines, version: "1.0.0", data: firstData) + let second = release(id: .linuxMachines, version: "2.0.0", data: secondData) + let firstSource = try fixture.write(firstData, name: "first") + let corruptSource = try fixture.write(Data("corrupt".utf8), name: "corrupt") + + try fixture.store.install( + first, + catalogDigest: String(repeating: "1", count: 64), + downloadedAssets: [first.assets[0].path: firstSource.path] + ) + XCTAssertThrowsError(try fixture.store.install( + second, + catalogDigest: String(repeating: "2", count: 64), + downloadedAssets: [second.assets[0].path: corruptSource.path] + )) + + let active = try XCTUnwrap(fixture.store.installedComponent(.linuxMachines)) + XCTAssertEqual(active.version, "1.0.0") + XCTAssertEqual(try fixture.store.verify(.linuxMachines), active) + } + + func testCorruptionFailsClosedAndIsReportedInvalid() throws { + let fixture = try Fixture(name: "corruption") + defer { fixture.cleanup() } + let payload = Data("machine image".utf8) + let component = release(id: .linuxMachines, data: payload) + let catalog = catalog(components: [core(), component]) + let catalogData = try encoded(catalog) + let source = try fixture.write(payload, name: "machine") + try fixture.store.install( + component, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData), + downloadedAssets: [component.assets[0].path: source.path] + ) + let path = try XCTUnwrap(fixture.store.assetPath( + component: .linuxMachines, + path: component.assets[0].path + )) + try Data("tampered data".utf8).write(to: URL(fileURLWithPath: path)) + + XCTAssertThrowsError(try fixture.store.verify(.linuxMachines)) + XCTAssertFalse(fixture.store.isInstalledAndValid(.linuxMachines)) + XCTAssertTrue(fixture.store.activePayloadDirectories().isEmpty) + XCTAssertEqual( + fixture.store.list( + catalog: catalog, + catalogDigest: DoryComponentCatalogVerifier.digest(catalogData) + ).first(where: { $0.id == .linuxMachines })?.state, + .invalid + ) + } + + func testCorruptComponentRecordsCanBeRepairedAndRemoved() throws { + let fixture = try Fixture(name: "corrupt-record-recovery") + defer { fixture.cleanup() } + let payload = Data("kubectl payload".utf8) + let component = release(id: .kubernetes, data: payload, assetPath: "kubectl") + let catalog = catalog(components: [core(), component]) + let catalogData = try encoded(catalog) + let digest = DoryComponentCatalogVerifier.digest(catalogData) + let source = try fixture.write(payload, name: "kubectl") + let installed = try fixture.store.install( + component, + catalogDigest: digest, + downloadedAssets: ["kubectl": source.path] + ) + let active = fixture.store.root + "/active/kubernetes.json" + try Data("invalid activation".utf8).write(to: URL(fileURLWithPath: active)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: active) + + XCTAssertEqual( + fixture.store.list(catalog: catalog, catalogDigest: digest) + .first(where: { $0.id == .kubernetes })?.state, + .invalid + ) + let repaired = try fixture.store.install( + component, + catalogDigest: digest, + downloadedAssets: ["kubectl": source.path] + ) + XCTAssertNotEqual(repaired.installationName, installed.installationName) + XCTAssertTrue(fixture.store.isInstalledAndValid(.kubernetes)) + + let record = fixture.store.root + "/installed/kubernetes/\(repaired.installationName)/installed.json" + try Data("invalid installed record".utf8).write(to: URL(fileURLWithPath: record)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: record) + try fixture.store.remove(.kubernetes, catalog: catalog) + XCTAssertFalse(FileManager.default.fileExists(atPath: active)) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.store.root + "/installed/kubernetes")) + } + + func testLZFSEAssetIsBoundedVerifiedAndMadeExecutable() throws { + let fixture = try Fixture(name: "lzfse") + defer { fixture.cleanup() } + let payload = Data(repeating: 0x41, count: 128 * 1_024) + let compressed = try compressLZFSE(payload) + let source = try fixture.write(compressed, name: "tool.lzfse") + let asset = DoryComponentAsset( + path: "machine-tool", + url: source.absoluteString, + compression: .lzfse, + downloadBytes: UInt64(compressed.count), + installedBytes: UInt64(payload.count), + sha256: digest(compressed), + installedSHA256: digest(payload), + executable: true + ) + let component = DoryComponentRelease( + id: .linuxMachines, + version: "1.0.0", + displayName: "Linux Machines", + summary: "Headless Linux machines", + downloadBytes: asset.downloadBytes, + installedBytes: asset.installedBytes, + assets: [asset] + ) + + try fixture.store.install( + component, + catalogDigest: String(repeating: "a", count: 64), + downloadedAssets: [asset.path: source.path] + ) + let output = try XCTUnwrap(fixture.store.assetPath(component: .linuxMachines, path: asset.path)) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: output)), payload) + let attributes = try FileManager.default.attributesOfItem(atPath: output) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o700) + } + + func testCachedCatalogIsReverifiedEveryTime() throws { + let fixture = try Fixture(name: "catalog-cache") + defer { fixture.cleanup() } + let key = Curve25519.Signing.PrivateKey() + let publicKey = key.publicKey.rawRepresentation.base64EncodedString() + let catalog = catalog(components: [core()]) + let data = try encoded(catalog) + let signature = try key.signature(for: data).base64EncodedString() + + _ = try fixture.store.cacheCatalog( + data: data, + signature: signature, + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + ) + XCTAssertEqual( + try fixture.store.cachedCatalog( + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )?.catalog, + catalog + ) + + let path = fixture.drive.componentsDirectory + "/catalog.json" + var tampered = try Data(contentsOf: URL(fileURLWithPath: path)) + tampered.append(0x20) + try tampered.write(to: URL(fileURLWithPath: path), options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path) + XCTAssertThrowsError(try fixture.store.cachedCatalog( + publicKey: publicKey, + expectedArchitecture: "arm64", + appVersion: "0.4.0" + )) { error in + XCTAssertEqual(error as? DoryComponentError, .invalidSignature) + } + } + + func testSymlinkSourceIsRejectedWithoutReadingItsTarget() throws { + let fixture = try Fixture(name: "symlink") + defer { fixture.cleanup() } + let payload = Data("outside".utf8) + let target = try fixture.write(payload, name: "outside") + let link = fixture.root.appendingPathComponent("link") + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: target.path) + let component = release(id: .kubernetes, data: payload, assetPath: "kubectl") + + XCTAssertThrowsError(try fixture.store.install( + component, + catalogDigest: String(repeating: "f", count: 64), + downloadedAssets: ["kubectl": link.path] + )) + XCTAssertEqual(try Data(contentsOf: target), payload) + XCTAssertNil(try fixture.store.installedComponent(.kubernetes)) + } + + private func core() -> DoryComponentRelease { + DoryComponentRelease( + id: .dockerCore, + version: "0.4.0", + displayName: "Docker Core", + summary: "Docker, Compose, Buildx, networking, and storage", + dependencies: [], + downloadBytes: 100, + installedBytes: 200, + assets: [] + ) + } + + private func release( + id: DoryComponentID, + version: String = "1.0.0", + data: Data, + assetPath: String? = nil, + dependencies: [DoryComponentID] = [.dockerCore] + ) -> DoryComponentRelease { + let path = assetPath ?? "\(id.rawValue)-payload" + let asset = try! plainAsset(path: path, data: data) + return DoryComponentRelease( + id: id, + version: version, + displayName: id.rawValue, + summary: "Optional \(id.rawValue)", + dependencies: dependencies, + downloadBytes: UInt64(data.count), + installedBytes: UInt64(data.count), + assets: [asset] + ) + } + + private func catalog(components: [DoryComponentRelease]) -> DoryComponentCatalog { + DoryComponentCatalog( + releaseVersion: "0.4.0", + generatedAt: "2026-07-16T12:00:00Z", + minimumAppVersion: "0.4.0", + architecture: "arm64", + components: components + ) + } + + private func plainAsset(path: String, data: Data) throws -> DoryComponentAsset { + DoryComponentAsset( + path: path, + url: "file:///tmp/\(path)", + downloadBytes: UInt64(data.count), + installedBytes: UInt64(data.count), + sha256: digest(data), + installedSHA256: digest(data) + ) + } + + private func encoded(_ catalog: DoryComponentCatalog) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(catalog) + Data("\n".utf8) + } + + private func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func compressLZFSE(_ data: Data) throws -> Data { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-component-compression-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let input = directory.appendingPathComponent("input") + let output = directory.appendingPathComponent("output.lzfse") + try data.write(to: input) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/compression_tool") + process.arguments = ["-encode", "-a", "lzfse", "-i", input.path, "-o", output.path] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw DoryComponentError.invalidAsset("test compression") + } + return try Data(contentsOf: output) + } + + private final class Fixture { + let root: URL + let drive: DoryDataDrive + let store: DoryComponentStore + + init(name: String) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("dory-components-\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + drive = try DoryDataDrive(home: root.path) + try drive.prepare() + store = DoryComponentStore(drive: drive) + try store.prepare() + } + + func write(_ data: Data, name: String) throws -> URL { + let url = root.appendingPathComponent(name) + try data.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + return url + } + + func cleanup() { + try? FileManager.default.removeItem(at: root) + } + } +} diff --git a/dory-core-swift/Tests/DoryOperationsTests/DoryDataDriveTests.swift b/dory-core-swift/Tests/DoryOperationsTests/DoryDataDriveTests.swift index 22b41b9f..5d8cdf5f 100644 --- a/dory-core-swift/Tests/DoryOperationsTests/DoryDataDriveTests.swift +++ b/dory-core-swift/Tests/DoryOperationsTests/DoryDataDriveTests.swift @@ -9,6 +9,7 @@ final class DoryDataDriveTests: XCTestCase { XCTAssertEqual(drive.root, "/Users/test/Library/Application Support/Dory/Dory.dorydrive") XCTAssertEqual(drive.engineDataDiskPath, drive.root + "/engine/docker-data.ext4") XCTAssertEqual(drive.kubernetesDirectory, drive.root + "/kubernetes") + XCTAssertEqual(drive.componentsDirectory, drive.root + "/components") XCTAssertEqual(drive.machinesDirectory, drive.root + "/machines") XCTAssertEqual(drive.snapshotsDirectory, drive.root + "/snapshots") XCTAssertEqual(drive.exportsDirectory, drive.root + "/exports") @@ -121,6 +122,7 @@ final class DoryDataDriveTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: drive.engineDirectory)) XCTAssertTrue(FileManager.default.fileExists(atPath: drive.kubernetesDirectory)) + XCTAssertTrue(FileManager.default.fileExists(atPath: drive.componentsDirectory)) XCTAssertTrue(FileManager.default.fileExists(atPath: drive.machinesDirectory)) XCTAssertTrue(FileManager.default.fileExists(atPath: drive.snapshotsDirectory)) XCTAssertTrue(FileManager.default.fileExists(atPath: drive.exportsDirectory)) diff --git a/dory-core-swift/Tests/DorydKitTests/CustomDomainRouteStoreTests.swift b/dory-core-swift/Tests/DorydKitTests/CustomDomainRouteStoreTests.swift new file mode 100644 index 00000000..4d0ba7a9 --- /dev/null +++ b/dory-core-swift/Tests/DorydKitTests/CustomDomainRouteStoreTests.swift @@ -0,0 +1,116 @@ +import Darwin +@testable import DorydKit +import XCTest + +final class CustomDomainRouteStoreTests: XCTestCase { + func testPersistsNormalizedExactAndWildcardMappings() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + let saved = try fixture.store.replace([ + DomainRoute(hostname: "Admin.MyProject.Local.", address: "127.0.0.1", port: 80), + DomainRoute(hostname: "*.Tenant.Test", address: "127.0.0.1", port: 8080), + ], automaticSuffix: "dory.local") + + XCTAssertEqual(saved, [ + CustomDomainRouteConfiguration(hostname: "*.tenant.test", publishedPort: 8080), + CustomDomainRouteConfiguration(hostname: "admin.myproject.local", publishedPort: 80), + ]) + XCTAssertEqual(try CustomDomainRouteStore( + environment: ["DORY_CUSTOM_DOMAIN_ROUTES": fixture.path] + ).configuredRoutes(), saved) + } + + func testActivatesOnlyRunningPublishedTCPPortsAndUsesPrivilegedBackend() throws { + let fixture = try Fixture() + defer { fixture.remove() } + _ = try fixture.store.replace([ + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: 80), + DomainRoute(hostname: "missing.myproject.local", address: "127.0.0.1", port: 443), + ], automaticSuffix: "dory.local") + + let rows = try JSONDecoder().decode([DockerContainerSummary].self, from: Data(""" + [{ + "Id":"web", + "Names":["/web"], + "State":"running", + "Ports":[{"PublicPort":80,"PrivatePort":8080,"Type":"tcp"}], + "Labels":{} + }] + """.utf8)) + + XCTAssertEqual(fixture.store.activeRoutes( + containers: .ok(rows), + automaticSuffix: "dory.local" + ), [ + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: 60_080), + ]) + } + + func testRejectsUnsafeTargetsAutomaticDomainsDuplicatesAndInvalidWildcards() throws { + let fixture = try Fixture() + defer { fixture.remove() } + + XCTAssertThrowsError(try fixture.store.replace([ + DomainRoute(hostname: "admin.myproject.local", address: "192.168.1.2", port: 80), + ], automaticSuffix: "dory.local")) + XCTAssertThrowsError(try fixture.store.replace([ + DomainRoute(hostname: "web.dory.local", address: "127.0.0.1", port: 80), + ], automaticSuffix: "dory.local")) + XCTAssertThrowsError(try fixture.store.replace([ + DomainRoute(hostname: "a.*.local", address: "127.0.0.1", port: 80), + ], automaticSuffix: "dory.local")) + XCTAssertThrowsError(try fixture.store.replace([ + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: 80), + DomainRoute(hostname: "ADMIN.MYPROJECT.LOCAL", address: "127.0.0.1", port: 8080), + ], automaticSuffix: "dory.local")) + } + + func testRefusesSymlinkedConfigurationFile() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let target = fixture.root + "/target.json" + try Data("{}".utf8).write(to: URL(fileURLWithPath: target)) + try FileManager.default.createSymbolicLink(atPath: fixture.path, withDestinationPath: target) + + XCTAssertThrowsError(try fixture.store.configuredRoutes()) + XCTAssertThrowsError(try fixture.store.replace([], automaticSuffix: "dory.local")) + } + + func testRefusesHardLinkedPublicAndOversizedConfigurationFiles() throws { + let fixture = try Fixture() + defer { fixture.remove() } + _ = try fixture.store.replace([], automaticSuffix: "dory.local") + + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: fixture.path) + XCTAssertThrowsError(try fixture.store.configuredRoutes()) + + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fixture.path) + let hardLink = fixture.root + "/hard-link.json" + XCTAssertEqual(link(fixture.path, hardLink), 0) + XCTAssertThrowsError(try fixture.store.configuredRoutes()) + try FileManager.default.removeItem(atPath: hardLink) + + let oversized = Data(repeating: 0x20, count: 256 * 1024 + 1) + try oversized.write(to: URL(fileURLWithPath: fixture.path)) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fixture.path) + XCTAssertThrowsError(try fixture.store.configuredRoutes()) + } + + private struct Fixture { + let root: String + let path: String + let store: CustomDomainRouteStore + + init() throws { + root = NSTemporaryDirectory() + "dory-custom-domains-\(UUID().uuidString)" + path = root + "/custom-domains.json" + try FileManager.default.createDirectory(atPath: root, withIntermediateDirectories: true) + store = CustomDomainRouteStore(environment: ["DORY_CUSTOM_DOMAIN_ROUTES": path]) + } + + func remove() { + try? FileManager.default.removeItem(atPath: root) + } + } +} diff --git a/dory-core-swift/Tests/DorydKitTests/DomainRouterTests.swift b/dory-core-swift/Tests/DorydKitTests/DomainRouterTests.swift index 9de29946..d7c65033 100644 --- a/dory-core-swift/Tests/DorydKitTests/DomainRouterTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DomainRouterTests.swift @@ -20,4 +20,29 @@ final class DomainRouterTests: XCTestCase { XCTAssertEqual(router.resolve("API.DORY.LOCAL.", in: routes), "10.0.0.5") XCTAssertNil(router.resolve("api.example.com", in: routes)) } + + func testMatchesExactAndSingleLabelWildcardHosts() { + XCTAssertTrue(DomainRouter.matches(pattern: "Admin.MyProject.Local.", hostname: "admin.myproject.local")) + XCTAssertTrue(DomainRouter.matches(pattern: "*.myproject.local", hostname: "tenant.myproject.local")) + XCTAssertFalse(DomainRouter.matches(pattern: "*.myproject.local", hostname: "deep.tenant.myproject.local")) + XCTAssertFalse(DomainRouter.matches(pattern: "*.myproject.local", hostname: "myproject.local")) + XCTAssertEqual(DomainRouter.matchSpecificity( + pattern: "admin.myproject.local", + hostname: "admin.myproject.local" + ), 2) + XCTAssertEqual(DomainRouter.matchSpecificity( + pattern: "*.myproject.local", + hostname: "admin.myproject.local" + ), 1) + } + + func testValidatesOnlyDNSHostnamesAndLeftmostWildcards() { + XCTAssertTrue(DomainRouter.isValidHostnamePattern("admin.myproject.local")) + XCTAssertTrue(DomainRouter.isValidHostnamePattern("*.myproject.local")) + XCTAssertFalse(DomainRouter.isValidHostnamePattern("localhost")) + XCTAssertFalse(DomainRouter.isValidHostnamePattern("127.0.0.1")) + XCTAssertFalse(DomainRouter.isValidHostnamePattern("*.127.0.0.1")) + XCTAssertFalse(DomainRouter.isValidHostnamePattern("admin.*.local")) + XCTAssertFalse(DomainRouter.isValidHostnamePattern("-admin.myproject.local")) + } } diff --git a/dory-core-swift/Tests/DorydKitTests/DoryHTTPProxyServerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryHTTPProxyServerTests.swift index 75d0f464..e04900b0 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryHTTPProxyServerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryHTTPProxyServerTests.swift @@ -68,6 +68,46 @@ final class DoryHTTPProxyServerTests: XCTestCase { XCTAssertTrue(response.contains("hello from low port backend")) } + func testCustomDomainRouteIsAcceptedOutsideAutomaticSuffix() throws { + let backend = TinyHTTPBackend(responseBody: "hello from custom domain") + try backend.start() + defer { backend.stop() } + + let proxy = DoryHTTPProxyServer(port: 0, routes: [ + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: backend.port), + ]) + try proxy.start() + defer { proxy.stop() } + + let response = try sendHTTP(port: proxy.port, host: "admin.myproject.local") + + XCTAssertTrue(response.contains("HTTP/1.1 200 OK")) + XCTAssertTrue(response.contains("hello from custom domain")) + } + + func testExactCustomDomainOverridesEarlierWildcardRoute() throws { + let wildcard = TinyHTTPBackend(responseBody: "wildcard backend") + let exact = TinyHTTPBackend(responseBody: "exact backend") + try wildcard.start() + try exact.start() + defer { + wildcard.stop() + exact.stop() + } + + let proxy = DoryHTTPProxyServer(port: 0, routes: [ + DomainRoute(hostname: "*.myproject.local", address: "127.0.0.1", port: wildcard.port), + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: exact.port), + ]) + try proxy.start() + defer { proxy.stop() } + + let response = try sendHTTP(port: proxy.port, host: "admin.myproject.local") + + XCTAssertTrue(response.contains("exact backend")) + XCTAssertFalse(response.contains("wildcard backend")) + } + func testHostHeaderParsingStripsPort() { let request = Data("GET / HTTP/1.1\r\nHost: Web.Dory.Local:8080\r\n\r\n".utf8) XCTAssertEqual(DoryHTTPProxyServer.hostHeader(request), "web.dory.local") diff --git a/dory-core-swift/Tests/DorydKitTests/DoryTLSProxyServerTests.swift b/dory-core-swift/Tests/DorydKitTests/DoryTLSProxyServerTests.swift index 2a544ad9..43de6f11 100644 --- a/dory-core-swift/Tests/DorydKitTests/DoryTLSProxyServerTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DoryTLSProxyServerTests.swift @@ -3,6 +3,25 @@ import Darwin import XCTest final class DoryTLSProxyServerTests: XCTestCase { + func testTemporaryKeychainFallbackUsesPrivateFileAndDeletesIt() throws { + let base = NSTemporaryDirectory() + "doryd-tls-keychain-\(getpid())-\(UUID().uuidString)" + let ca = DoryLocalCA(directory: URL(fileURLWithPath: base).appendingPathComponent("ca")) + defer { try? FileManager.default.removeItem(atPath: base) } + + let p12 = try ca.issuePKCS12(domain: "dory.local", password: "test-password") + var storage: DoryTLSIdentityStorage? = try XCTUnwrap(DoryTLSProxyServer.loadIdentity( + p12Path: p12.path, + password: "test-password", + forceTemporaryKeychain: true + )) + let keychainPath = try XCTUnwrap(storage?.temporaryKeychainPath) + + XCTAssertTrue(FileManager.default.fileExists(atPath: keychainPath)) + + storage = nil + XCTAssertFalse(FileManager.default.fileExists(atPath: keychainPath)) + } + func testCanRestartFixedPortWithoutAddressInUse() throws { let base = NSTemporaryDirectory() + "doryd-tls-restart-\(getpid())-\(UUID().uuidString)" let ca = DoryLocalCA(directory: URL(fileURLWithPath: base).appendingPathComponent("ca")) @@ -59,6 +78,46 @@ final class DoryTLSProxyServerTests: XCTestCase { XCTAssertEqual(response, "hello over tls") XCTAssertTrue(backend.lastRequest.contains("Host: web.dory.local")) } + + func testTerminatesTLSForExplicitCustomDomainSANAndRoute() throws { + let base = NSTemporaryDirectory() + "doryd-custom-tls-\(getpid())-\(UUID().uuidString)" + let ca = DoryLocalCA(directory: URL(fileURLWithPath: base).appendingPathComponent("ca")) + guard FileManager.default.isExecutableFile(atPath: "/usr/bin/curl") else { + throw XCTSkip("/usr/bin/curl unavailable") + } + defer { try? FileManager.default.removeItem(atPath: base) } + + let backend = TinyTLSHTTPBackend(responseBody: "hello custom tls") + try backend.start() + defer { backend.stop() } + + let p12 = try ca.issuePKCS12( + domain: "dory.local", + password: "test-password", + extraSANs: ["admin.myproject.local"] + ) + let proxy = try DoryTLSProxyServer( + port: 0, + p12Path: p12.path, + password: "test-password", + routes: [ + DomainRoute(hostname: "admin.myproject.local", address: "127.0.0.1", port: backend.port), + ] + ) + try proxy.start() + defer { proxy.stop() } + + let response = try DoryShell.run("/usr/bin/curl", [ + "-kfsS", + "--max-time", "5", + "--noproxy", "*", + "--resolve", "admin.myproject.local:\(proxy.port):127.0.0.1", + "https://admin.myproject.local:\(proxy.port)/", + ], timeout: 10) + + XCTAssertEqual(response, "hello custom tls") + XCTAssertTrue(backend.lastRequest.contains("Host: admin.myproject.local")) + } } private func availableTCPPort() throws -> UInt16 { diff --git a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift index 0cc03325..4e488529 100644 --- a/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift +++ b/dory-core-swift/Tests/DorydKitTests/DorydConfigurationTests.swift @@ -274,6 +274,44 @@ final class DorydConfigurationTests: XCTestCase { XCTAssertFalse(hv.arguments.contains("--amd64"), "amd64 emulation must remain an explicit Settings opt-in") } + func testDockerTierPreparesCompressedHeadlessKernelWhenRawKernelIsNotBundled() throws { + let directory = "/tmp/doryd-config-compressed-kernel-\(getpid())-\(UInt32.random(in: 0.. NoReturn: + raise SystemExit(f"component build error: {message}") + + +def regular_file(path: pathlib.Path, label: str) -> pathlib.Path: + try: + info = path.lstat() + except FileNotFoundError: + fail(f"{label} is missing: {path}") + if not stat.S_ISREG(info.st_mode) or info.st_size <= 0: + fail(f"{label} is not a non-empty regular file: {path}") + return path + + +def directory(path: pathlib.Path, label: str) -> pathlib.Path: + try: + info = path.lstat() + except FileNotFoundError: + fail(f"{label} is missing: {path}") + if not stat.S_ISDIR(info.st_mode): + fail(f"{label} is not a directory: {path}") + return path + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def byte_size(path: pathlib.Path) -> int: + return regular_file(path, "artifact").stat().st_size + + +def tree_size(path: pathlib.Path) -> int: + total = 0 + for root, directories, files in os.walk(path, followlinks=False): + directories[:] = [ + name for name in directories + if not pathlib.Path(root, name).is_symlink() + ] + for name in files: + candidate = pathlib.Path(root, name) + info = candidate.lstat() + if stat.S_ISREG(info.st_mode): + total += info.st_size + if total <= 0: + fail(f"core app contains no regular-file payload: {path}") + return total + + +def run( + command: list[str], + *, + cwd: pathlib.Path | None = None, + env: dict[str, str] | None = None, +) -> str: + completed = subprocess.run( + command, + cwd=cwd, + env=env, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() or f"exit {completed.returncode}" + fail(f"{' '.join(command)} failed: {detail}") + return completed.stdout.strip() + + +def validate_sources(repo: pathlib.Path, source_root: pathlib.Path, kubectl: pathlib.Path) -> None: + expected_default = repo / "guest" / "out" + if source_root.resolve() != expected_default.resolve(): + fail("verified builds must use guest/out; use --skip-source-verification only for tests") + run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo) + run([str(repo / "guest/initfs/verify-build.sh"), "arm64"], cwd=repo) + desktop_env = dict(os.environ) + desktop_env["DORY_KERNEL_PROFILE"] = "desktop" + run([str(repo / "guest/kernel/verify-build.sh"), "arm64"], cwd=repo, env=desktop_env) + for distro in ("debian", "ubuntu", "kali"): + run([str(repo / "guest/desktop/verify-build.sh"), "arm64", distro], cwd=repo) + run(["codesign", "--verify", "--strict", str(kubectl)]) + archs = run(["lipo", "-archs", str(kubectl)]).split() + if "arm64" not in archs: + fail(f"kubectl does not contain arm64 code: {' '.join(archs)}") + + +def generated_at(value: str | None) -> str: + if value: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + elif os.environ.get("SOURCE_DATE_EPOCH"): + parsed = dt.datetime.fromtimestamp( + int(os.environ["SOURCE_DATE_EPOCH"]), tz=dt.timezone.utc + ) + else: + parsed = dt.datetime.now(tz=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def component_specs(source_root: pathlib.Path, kubectl: pathlib.Path) -> list[dict]: + desktop_specs = [] + for distro, display, summary in ( + ( + "debian", + "Debian 13 Desktop", + "A stable Debian 13 Xfce desktop with its own packages and official repositories.", + ), + ( + "ubuntu", + "Ubuntu 24.04 LTS Desktop", + "An Ubuntu 24.04 LTS Xfce desktop with its own packages and official repositories.", + ), + ( + "kali", + "Kali Linux Desktop", + "A Kali rolling Xfce security desktop with its own packages and official repositories.", + ), + ): + desktop_specs.append( + { + "id": f"desktop-{distro}", + "displayName": display, + "summary": summary, + "dependencies": ["docker-core", "linux-desktop"], + "assets": [ + { + "path": f"dory-desktop-{distro}-rootfs-arm64.ext4.lzfse", + "source": source_root / f"dory-desktop-{distro}-rootfs-arm64.ext4", + "delivery": "lzfse-stored", + "executable": False, + }, + { + "path": f"dory-desktop-{distro}-build-arm64.stamp", + "source": source_root / f"dory-desktop-{distro}-build-arm64.stamp", + "delivery": "none", + "executable": False, + }, + { + "path": f"dory-desktop-{distro}-packages-arm64.txt", + "source": source_root / f"dory-desktop-{distro}-packages-arm64.txt", + "delivery": "none", + "executable": False, + }, + ], + } + ) + return [ + { + "id": "kubernetes", + "displayName": "Kubernetes", + "summary": "kubectl and Dory's local k3s workflow. The selected k3s image downloads when you create the cluster.", + "dependencies": ["docker-core"], + "assets": [ + { + "path": "kubectl", + "source": kubectl, + "delivery": "none", + "executable": True, + } + ], + }, + { + "id": "linux-machines", + "displayName": "Linux Machines", + "summary": "Headless VPS-style Linux machines with terminals, services, and persistent disks.", + "dependencies": ["docker-core"], + "assets": [ + { + "path": "dory-hv-kernel-arm64", + "source": source_root / "Image", + "delivery": "lzfse-expanded", + "executable": False, + }, + { + "path": "dory-machine-rootfs-arm64.ext4", + "source": source_root / "initfs-arm64.ext4", + "delivery": "lzfse-expanded", + "executable": False, + }, + ], + }, + { + "id": "linux-desktop", + "displayName": "Linux Desktop Runtime", + "summary": "The graphical VM kernel shared by independently installable desktop distributions.", + "dependencies": ["docker-core"], + "assets": [ + { + "path": "dory-desktop-kernel-arm64.lzfse", + "source": source_root / "Image-desktop", + "delivery": "lzfse-stored", + "executable": False, + }, + { + "path": "kernel-build-arm64-desktop.stamp", + "source": source_root / "kernel-build-arm64-desktop.stamp", + "delivery": "none", + "executable": False, + }, + ], + }, + *desktop_specs, + ] + + +def safe_artifact_name( + version: str, component_id: str, installed_path: str, compressed: bool +) -> str: + suffix = installed_path + if compressed and not suffix.endswith(".lzfse"): + suffix += ".lzfse" + return f"Dory-{version}-component-{component_id}-{ARCHITECTURE}-{suffix}" + + +def materialize_asset( + *, + version: str, + component_id: str, + asset: dict, + output: pathlib.Path, + asset_base_url: str, + compression_tool: pathlib.Path, +) -> dict: + source = regular_file(pathlib.Path(asset["source"]), f"{component_id} source") + delivery = asset["delivery"] + compressed = delivery in {"lzfse-expanded", "lzfse-stored"} + artifact_name = safe_artifact_name(version, component_id, asset["path"], compressed) + destination = output / artifact_name + if compressed: + run( + [ + str(compression_tool), + "-encode", + "-a", + "lzfse", + "-i", + str(source), + "-o", + str(destination), + ] + ) + else: + shutil.copyfile(source, destination) + os.chmod(destination, 0o644) + + download_bytes = byte_size(destination) + download_digest = sha256(destination) + if delivery == "lzfse-expanded": + compression = "lzfse" + installed_bytes = byte_size(source) + installed_digest = sha256(source) + else: + compression = "none" + installed_bytes = download_bytes + installed_digest = download_digest + return { + "path": asset["path"], + "url": f"{asset_base_url.rstrip('/')}/{artifact_name}", + "compression": compression, + "downloadBytes": download_bytes, + "installedBytes": installed_bytes, + "sha256": download_digest, + "installedSHA256": installed_digest, + "executable": bool(asset["executable"]), + } + + +def sign_catalog(catalog_path: pathlib.Path, signer: pathlib.Path) -> str: + regular_file(signer, "Sparkle sign_update") + signature = run([str(signer), "-p", str(catalog_path)]).strip() + try: + decoded = base64.b64decode(signature, validate=True) + except ValueError: + fail("Sparkle sign_update returned a malformed signature") + if len(decoded) != 64: + fail("Sparkle sign_update returned an unexpected Ed25519 signature length") + run([str(signer), "--verify", str(catalog_path), signature]) + return signature + + +def remove_private_build_directory(path: pathlib.Path, parent: pathlib.Path) -> None: + """Delete only a hidden direct child of the declared build-output parent.""" + resolved = path.resolve() + resolved_parent = parent.resolve() + forbidden = { + pathlib.Path("/"), + pathlib.Path.home().resolve(), + pathlib.Path.cwd().resolve(), + } + if ( + resolved in forbidden + or resolved.parent != resolved_parent + or not resolved.name.startswith(".") + or path.is_symlink() + or not path.is_dir() + ): + fail(f"refusing unsafe component build cleanup: {path}") + shutil.rmtree(resolved) + + +def publish(staging: pathlib.Path, output: pathlib.Path) -> None: + backup = output.parent / f".{output.name}.previous-{uuid.uuid4().hex}" + if output.exists() or output.is_symlink(): + if output.is_symlink() or not output.is_dir(): + fail(f"refusing to replace non-directory output: {output}") + output.rename(backup) + try: + staging.rename(output) + except BaseException: + if backup.exists() and not output.exists(): + backup.rename(output) + raise + if backup.exists(): + remove_private_build_directory(backup, output.parent) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--core-artifact", required=True, type=pathlib.Path) + parser.add_argument("--core-app", required=True, type=pathlib.Path) + parser.add_argument("--kubectl", required=True, type=pathlib.Path) + parser.add_argument("--output", required=True, type=pathlib.Path) + parser.add_argument("--asset-base-url") + parser.add_argument("--source-root", type=pathlib.Path) + parser.add_argument("--minimum-app-version") + parser.add_argument("--generated-at") + parser.add_argument("--signer", type=pathlib.Path) + parser.add_argument("--skip-source-verification", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + repo = pathlib.Path(__file__).resolve().parent.parent + source_root = (args.source_root or repo / "guest" / "out").resolve() + core_artifact = regular_file(args.core_artifact.resolve(), "Docker Core artifact") + core_app = directory(args.core_app.resolve(), "Docker Core app") + kubectl = regular_file(args.kubectl.resolve(), "kubectl") + compression_tool = pathlib.Path("/usr/bin/compression_tool") + regular_file(compression_tool, "macOS compression_tool") + if not args.skip_source_verification: + validate_sources(repo, source_root, kubectl) + + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + staging: pathlib.Path | None = pathlib.Path( + tempfile.mkdtemp(prefix=f".{output.name}.partial-", dir=output.parent) + ) + try: + asset_base_url = args.asset_base_url or ( + f"https://github.com/Augani/dory/releases/download/v{args.version}" + ) + releases = [ + { + "id": "docker-core", + "version": args.version, + "displayName": "Docker Core", + "summary": "The signed Dory app, Docker engine, CLI, Buildx, Compose, networking, storage, migration, and health tools.", + "dependencies": [], + "downloadBytes": byte_size(core_artifact), + "installedBytes": tree_size(core_app), + "assets": [], + } + ] + for spec in component_specs(source_root, kubectl): + assets = [ + materialize_asset( + version=args.version, + component_id=spec["id"], + asset=asset, + output=staging, + asset_base_url=asset_base_url, + compression_tool=compression_tool, + ) + for asset in spec["assets"] + ] + releases.append( + { + "id": spec["id"], + "version": args.version, + "displayName": spec["displayName"], + "summary": spec["summary"], + "dependencies": spec["dependencies"], + "downloadBytes": sum(asset["downloadBytes"] for asset in assets), + "installedBytes": sum(asset["installedBytes"] for asset in assets), + "assets": assets, + } + ) + + catalog = { + "kind": CATALOG_KIND, + "schemaVersion": CATALOG_SCHEMA, + "releaseVersion": args.version, + "generatedAt": generated_at(args.generated_at), + "minimumAppVersion": args.minimum_app_version or args.version, + "architecture": ARCHITECTURE, + "components": releases, + } + catalog_path = staging / "catalog.json" + catalog_path.write_text( + json.dumps(catalog, indent=2, sort_keys=True, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.chmod(catalog_path, 0o644) + if args.signer: + signature = sign_catalog(catalog_path, args.signer.resolve()) + (staging / "catalog.json.sig").write_text( + signature + "\n", encoding="ascii" + ) + os.chmod(staging / "catalog.json.sig", 0o644) + (staging / "catalog.json.sha256").write_text( + sha256(catalog_path) + "\n", encoding="ascii" + ) + os.chmod(staging / "catalog.json.sha256", 0o644) + publish(staging, output) + staging = None + finally: + if staging is not None and staging.exists(): + remove_private_build_directory(staging, output.parent) + + +if __name__ == "__main__": + main() diff --git a/scripts/build-gvproxy.sh b/scripts/build-gvproxy.sh index 8d380678..3e0ce25d 100755 --- a/scripts/build-gvproxy.sh +++ b/scripts/build-gvproxy.sh @@ -4,10 +4,10 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" UPSTREAM_VERSION="v0.8.9" -DORY_VERSION="v0.8.9-dory1" +DORY_VERSION="v0.8.9-dory2" SOURCE_SHA256="6cbcb7959a5d90b59253ea6d8bdf0285e2cfbc3b301398704b41e3069293f4fb" PATCH="$ROOT/patches/gvproxy-native-ipv6.patch" -PATCH_SHA256="ca76b2a8a304aa4b3aba835543f325832de83a14163f6b86b37491cc165e2ce3" +PATCH_SHA256="3d6db9d9c2e6ff79b8abd334afe3664c84b84ca11a6308e8cc3d30f8fc05ab96" GO_TOOLCHAIN="go1.26.5" GO_MOD_SHA256="75848c190dca5cc7af27ebe017d5a4d59d4a117c97eaa6b8ac0359e58d868eec" GO_SUM_SHA256="25b1a52ad3181030b6ccf92af5d69a1a4282f8f2342dad5348b5c954c304c4b3" @@ -149,7 +149,7 @@ if [ -n "$PROVENANCE" ]; then echo "arm64_sha256=$arm64_sha" echo "amd64_sha256=$amd64_sha" echo "verified_sha256=$output_sha" - echo "features=native-ipv6-v1,source-preserving-lan-qemu-v1" + echo "features=native-ipv6-v2,host-route-aware-aaaa-v1,source-preserving-lan-qemu-v1" echo "architectures=$actual_arches" } > "$PROVENANCE" fi diff --git a/scripts/bundle-engine.sh b/scripts/bundle-engine.sh index 66c68cfd..6225865b 100755 --- a/scripts/bundle-engine.sh +++ b/scripts/bundle-engine.sh @@ -13,14 +13,17 @@ # reporting, SMP, journaled data disk), signed with # com.apple.security.hypervisor. Preferred where available. # * Contents/Helpers/gvproxy — userspace networking (Apache-2.0) for the dory-hv engine. -# * Contents/Helpers/docker, docker-buildx, docker-compose, kubectl — clean-Mac host CLIs. +# * Contents/Helpers/docker, docker-buildx, docker-compose — Docker Core host CLIs. +# * kubectl is exported as the independently installable Kubernetes component in Core builds. # * Contents/Frameworks/libvirglrenderer.dylib, libMoltenVK.dylib — optional experimental # Venus/Vulkan renderer payload for in-guest GPU acceleration. -# * Contents/Resources/dory-hv-kernel- — raw kernel path used by doryd/dory-hv. -# * Contents/Resources/dory-machine-rootfs-.ext4 — raw per-machine rootfs used by dory-vmm. +# * Contents/Resources/dory-hv-kernel- — legacy raw kernel payload. +# * Contents/Resources/dory-machine-rootfs-.ext4 — legacy raw machine payload. # * Contents/Resources/dory-hv-kernel-.lzfse — LZFSE PVH/Image kernel for dory-hv. -# * Contents/Resources/dory-vm-kernel-.lzfse — LZFSE Linux kernel. -# * Contents/Resources/dory-vm-initfs-.ext4.lzfse — LZFSE VM initfs. +# * Contents/Resources/dory-vm-kernel-.lzfse — compatibility alias to the Docker +# engine kernel in focused Core builds. +# * Contents/Resources/dory-vm-initfs-.ext4.lzfse — compatibility alias to the Docker +# engine rootfs in focused Core builds. # * Contents/Resources/dory-desktop-kernel-arm64.lzfse — optional verified desktop kernel. # * Contents/Resources/dory-desktop--rootfs-arm64.ext4.lzfse — optional desktop images. # * Contents/Resources/dory-agent-linux- — guest relay/agent for host AI bridge @@ -64,6 +67,15 @@ case "$DESKTOP_BUNDLE_MODE" in none|all) ;; *) echo "DORY_DESKTOP_BUNDLE_MODE must be 'none' or 'all'" >&2; exit 64 ;; esac +COMPONENT_BUNDLE_MODE="${DORY_COMPONENT_BUNDLE_MODE:-legacy}" +case "$COMPONENT_BUNDLE_MODE" in + core|legacy) ;; + *) echo "DORY_COMPONENT_BUNDLE_MODE must be 'core' or 'legacy'" >&2; exit 64 ;; +esac +if [ "$COMPONENT_BUNDLE_MODE" = core ] && [ "$DESKTOP_BUNDLE_MODE" != none ]; then + echo "DORY_COMPONENT_BUNDLE_MODE=core cannot embed desktop payloads" >&2 + exit 64 +fi DESKTOP_APPCAST_URL="${DORY_DESKTOP_APPCAST_URL:-https://augani.github.io/dory/appcast-desktop.xml}" find_xcode() { @@ -817,7 +829,8 @@ echo "==> Bundling the host kubectl + docker CLIs (so k8s and the docker CLI nee # Darwin architecture and lipo them into one helper. download_host_cli_for_arch() { - local name="$1" arch="$2" out="$3" karch darch tgz work url expected_sha + local name="$1" arch="$2" out="$3" provenance="${4:-$HOST_CLI_PROVENANCE}" + local karch darch tgz work url expected_sha expected_sha="$(dory_host_cli_expected_sha256 "$name" "$arch")" case "$name" in kubectl) @@ -859,17 +872,19 @@ download_host_cli_for_arch() { esac printf 'name=%s version=%s arch=%s sha256=%s source_url=%s\n' \ "$name" "$(dory_host_cli_version "$name")" "$arch" "$expected_sha" "$url" \ - >> "$HOST_CLI_PROVENANCE" + >> "$provenance" } bundle_universal_host_cli() { - local name="$1" destination="$HELPERS/$1" tmp arch bin arches arch_info + local name="$1" destination="${2:-$HELPERS/$1}" provenance="${3:-$HOST_CLI_PROVENANCE}" + local tmp arch bin arches arch_info local built=() + mkdir -p "$(dirname "$destination")" arches="$(host_cli_arches)" tmp="$(mktemp -d "${TMPDIR:-/tmp}/dory-$name.XXXXXX")" for arch in $arches; do bin="$tmp/$name-$arch" - if ! download_host_cli_for_arch "$name" "$arch" "$bin"; then + if ! download_host_cli_for_arch "$name" "$arch" "$bin" "$provenance"; then rm -rf "$tmp" if [ "${DORY_ALLOW_MISSING_HOST_CLI:-0}" = "1" ]; then echo " WARNING: could not bundle $name for $arch — feature will need a system install on that architecture." @@ -904,7 +919,31 @@ HOST_CLI_PROVENANCE="$RESOURCES/host-cli-provenance.txt" # metadata-for-metadata identical to the SBOM-bound application tree. rm -f "$HOST_CLI_PROVENANCE" install -m 0644 /dev/null "$HOST_CLI_PROVENANCE" -bundle_universal_host_cli kubectl +if [ "$COMPONENT_BUNDLE_MODE" = core ]; then + KUBECTL_COMPONENT_OUTPUT="${DORY_COMPONENT_KUBECTL_OUTPUT:-}" + [ -n "$KUBECTL_COMPONENT_OUTPUT" ] || { + echo " ERROR: Core builds require DORY_COMPONENT_KUBECTL_OUTPUT" >&2 + exit 64 + } + case "$KUBECTL_COMPONENT_OUTPUT" in + /*) ;; + *) echo " ERROR: DORY_COMPONENT_KUBECTL_OUTPUT must be an absolute path" >&2; exit 64 ;; + esac + case "$KUBECTL_COMPONENT_OUTPUT" in + "$APP"/*) echo " ERROR: the Kubernetes component must be exported outside Dory.app" >&2; exit 64 ;; + esac + KUBECTL_COMPONENT_PROVENANCE="${DORY_COMPONENT_KUBECTL_PROVENANCE_OUTPUT:-$KUBECTL_COMPONENT_OUTPUT.provenance.txt}" + mkdir -p "$(dirname "$KUBECTL_COMPONENT_PROVENANCE")" + rm -f "$KUBECTL_COMPONENT_PROVENANCE" + install -m 0644 /dev/null "$KUBECTL_COMPONENT_PROVENANCE" + bundle_universal_host_cli kubectl "$KUBECTL_COMPONENT_OUTPUT" "$KUBECTL_COMPONENT_PROVENANCE" + LC_ALL=C sort -o "$KUBECTL_COMPONENT_PROVENANCE" "$KUBECTL_COMPONENT_PROVENANCE" + chmod 0644 "$KUBECTL_COMPONENT_PROVENANCE" + rm -f "$HELPERS/kubectl" + echo " exported the signed Kubernetes component to $KUBECTL_COMPONENT_OUTPUT" +else + bundle_universal_host_cli kubectl +fi bundle_universal_host_cli docker bundle_universal_host_cli docker-buildx bundle_universal_host_cli docker-compose @@ -1131,8 +1170,12 @@ bundle_hv_kernel_for_arch() { kernel_raw="$RESOURCES/dory-hv-kernel-$arch" kernel_out="$RESOURCES/dory-hv-kernel-$arch.lzfse" if [ -n "$kernel_src" ] && [ -f "$kernel_src" ]; then - install -m0644 "$kernel_src" "$kernel_raw" - echo " bundled Resources/$(basename "$kernel_raw") ($(du -h "$kernel_raw" | awk '{print $1}'))" + if [ "$COMPONENT_BUNDLE_MODE" = legacy ]; then + install -m0644 "$kernel_src" "$kernel_raw" + echo " bundled Resources/$(basename "$kernel_raw") ($(du -h "$kernel_raw" | awk '{print $1}'))" + else + rm -f "$kernel_raw" + fi compress_asset "$kernel_src" "$kernel_out" echo " bundled Resources/$(basename "$kernel_out") ($(du -h "$kernel_out" | awk '{print $1}'), from $(du -h "$kernel_src" | awk '{print $1}'))" else @@ -1203,8 +1246,12 @@ bundle_guest_assets_for_arch() { agent="$(guest_agent_source_for_arch "$arch" || true)" inject_dory_agent_into_initfs "$initfs_src" "$agent" "/tmp/dory-initfs-$arch-agent-$$.ext4" inject_debug_toolbox_into_initfs "$INITFS_TO_BUNDLE" "$arch" - install -m0644 "$INITFS_TO_BUNDLE" "$initfs_raw" - echo " bundled Resources/$(basename "$initfs_raw") ($(du -h "$initfs_raw" | awk '{print $1}'))" + if [ "$COMPONENT_BUNDLE_MODE" = legacy ]; then + install -m0644 "$INITFS_TO_BUNDLE" "$initfs_raw" + echo " bundled Resources/$(basename "$initfs_raw") ($(du -h "$initfs_raw" | awk '{print $1}'))" + else + rm -f "$initfs_raw" + fi compress_asset "$INITFS_TO_BUNDLE" "$initfs_out" echo " bundled Resources/$(basename "$initfs_out") ($(du -h "$initfs_out" | awk '{print $1}'), from $(du -h "$INITFS_TO_BUNDLE" | awk '{print $1}'))" [ "$INITFS_TO_BUNDLE" = "$initfs_src" ] || rm -f "$INITFS_TO_BUNDLE" @@ -1237,6 +1284,27 @@ bundle_engine_rootfs_for_arch() { fi } +link_core_vmm_assets_for_arch() { + local arch="$1" kernel_target rootfs_target kernel_alias rootfs_alias + [ "$COMPONENT_BUNDLE_MODE" = core ] || return 0 + kernel_target="dory-hv-kernel-$arch.lzfse" + rootfs_target="dory-engine-rootfs-$arch.ext4.lzfse" + kernel_alias="$RESOURCES/dory-vm-kernel-$arch.lzfse" + rootfs_alias="$RESOURCES/dory-vm-initfs-$arch.ext4.lzfse" + [ -s "$RESOURCES/$kernel_target" ] || { + echo " ERROR: focused Core build cannot alias missing $kernel_target" >&2 + return 1 + } + [ -s "$RESOURCES/$rootfs_target" ] || { + echo " ERROR: focused Core build cannot alias missing $rootfs_target" >&2 + return 1 + } + ln -sfn "$kernel_target" "$kernel_alias" + ln -sfn "$rootfs_target" "$rootfs_alias" + echo " linked Resources/$(basename "$kernel_alias") -> $kernel_target" + echo " linked Resources/$(basename "$rootfs_alias") -> $rootfs_target" +} + bundle_desktop_assets_for_arch() { local arch="$1" kernel_src kernel_out distro rootfs_src rootfs_out metadata [ "$DESKTOP_BUNDLE_MODE" = all ] || return 0 @@ -1280,8 +1348,19 @@ for asset_arch in ${DORY_BUNDLE_ARCHES:-arm64 amd64}; do bundle_guest_agent_for_arch "$asset_arch" bundle_hv_kernel_for_arch "$asset_arch" bundle_hv_gpu_kernel_for_arch "$asset_arch" - bundle_guest_assets_for_arch "$asset_arch" + if [ "$COMPONENT_BUNDLE_MODE" = legacy ]; then + bundle_guest_assets_for_arch "$asset_arch" + else + # Docker Core already carries the exact kernel and rootfs needed by the macOS 14 VZ fallback. + # Keep its historical resource names as in-bundle aliases instead of shipping a second copy of + # each payload. Linux Machines remains an independently downloaded component with its own raw + # machine rootfs and kernel. + rm -f \ + "$RESOURCES/dory-vm-kernel-$asset_arch.lzfse" \ + "$RESOURCES/dory-vm-initfs-$asset_arch.ext4.lzfse" + fi bundle_engine_rootfs_for_arch "$asset_arch" + link_core_vmm_assets_for_arch "$asset_arch" bundle_desktop_assets_for_arch "$asset_arch" for stamp_kind in kernel initfs; do stamp="$REPO_ROOT/guest/out/${stamp_kind}-build-$asset_arch.stamp" @@ -1326,11 +1405,22 @@ fi write_doryd_launch_agent /usr/libexec/PlistBuddy -c 'Delete :DoryIncludesDesktopLinux' "$APP/Contents/Info.plist" >/dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c 'Delete :DoryBundledComponents' "$APP/Contents/Info.plist" >/dev/null 2>&1 || true +/usr/libexec/PlistBuddy -c 'Add :DoryBundledComponents array' "$APP/Contents/Info.plist" +/usr/libexec/PlistBuddy -c 'Add :DoryBundledComponents:0 string docker-core' "$APP/Contents/Info.plist" if [ "$DESKTOP_BUNDLE_MODE" = all ]; then /usr/libexec/PlistBuddy -c 'Add :DoryIncludesDesktopLinux bool true' "$APP/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :SUFeedURL $DESKTOP_APPCAST_URL" "$APP/Contents/Info.plist" + for component in kubernetes linux-machines linux-desktop desktop-debian desktop-ubuntu desktop-kali; do + /usr/libexec/PlistBuddy -c "Add :DoryBundledComponents: string $component" "$APP/Contents/Info.plist" + done else /usr/libexec/PlistBuddy -c 'Add :DoryIncludesDesktopLinux bool false' "$APP/Contents/Info.plist" + if [ "$COMPONENT_BUNDLE_MODE" = legacy ]; then + for component in kubernetes linux-machines; do + /usr/libexec/PlistBuddy -c "Add :DoryBundledComponents: string $component" "$APP/Contents/Info.plist" + done + fi fi echo "==> Bundling deterministic named-volume transfer helper image…" @@ -1363,5 +1453,6 @@ PAYLOAD_DIGESTS="$RESOURCES/dory-payload-sha256.txt" echo " bundled Resources/dory-payload-sha256.txt" echo "==> Payload injected into $APP" +echo " Component profile: $COMPONENT_BUNDLE_MODE" echo " Engine payload ≈ $(du -ch "$RESOURCES"/dory-hv-*.lzfse "$RESOURCES"/dory-vm-*.lzfse "$RESOURCES"/dory-engine-rootfs-*.ext4.lzfse "$RESOURCES"/dory-desktop-*.lzfse "$HELPERS"/dory-hv "$HELPERS"/docker "$HELPERS"/docker-buildx "$HELPERS"/docker-compose "$HELPERS"/kubectl "$FRAMEWORKS"/*.dylib 2>/dev/null | tail -1 | awk '{print $1}') on disk" echo " Re-sign the app bundle before notarization so the payload is sealed." diff --git a/scripts/dory b/scripts/dory index 13a56e4d..52f98906 100755 --- a/scripts/dory +++ b/scripts/dory @@ -74,9 +74,11 @@ dory — Dory CLI dory Run any docker command against Dory's engine dory install [--json] [--dry-run] - Manual recovery for doryd's per-user terminal integration for docker, compose, kubectl, and dory + Manual recovery for docker, Compose, dory, and optional kubectl terminal integration dory uninstall [--json] [--dry-run] Stop Dory services and remove runtime/shell integration; preserve workload data + dory component list|install|update|verify|remove + Manage signed, removable Kubernetes, Linux Machines, and Desktop packs dory shell Open an interactive shell in a container dory debug [-- cmd...] Reserved for a future agent namespace-debug RPC (currently unavailable) @@ -106,6 +108,10 @@ dory — Dory CLI Dry-run or remove Dory-owned system networking integration dory network --lan-visible on|off Publish ports to the local network, or restrict to localhost + dory network custom-domains + dory network set-custom-domain HOST --published-port N + dory network remove-custom-domain HOST + Manage custom HTTP/HTTPS hostnames routed to published ports dory mount [--json] Validate bind mount read/write and host-edit visibility dory disk [--json] Show host, Docker, Dory state, log, and VM disk usage dory cleanup [--json] [--apply] [--include-volumes] @@ -1196,6 +1202,12 @@ dorydctl_bin() { "$(command -v dorydctl 2>/dev/null || true)" } +installed_component_asset() { + local component="$1" asset="$2" ctl + ctl="$(dorydctl_bin 2>/dev/null)" || return 1 + "$ctl" component path "$component" "$asset" 2>/dev/null +} + network_helper_bin() { first_executable \ "${DORY_NETWORK_HELPER_BIN:-}" \ @@ -1366,6 +1378,12 @@ network_cmd() { authorization-plan|auth-plan) shift || true; network_authorization_plan_cmd "$@" ;; authorize) shift || true; network_authorize_cmd authorize "$@" ;; deauthorize) shift || true; network_authorize_cmd deauthorize "$@" ;; + custom-domains|set-custom-domain|remove-custom-domain) + local subcommand="$1" ctl + shift || true + ctl="$(dorydctl_bin 2>/dev/null)" || { echo "dorydctl not found; start Dory before managing custom domains." >&2; exit 1; } + "$ctl" --timeout 10 network "$subcommand" "$@" + ;; -h|--help) doctor_cmd network --help ;; *) doctor_cmd network "$@" ;; esac @@ -1536,13 +1554,13 @@ doryd_machine_cmd() { if ! dory_machine_has_option --kernel "$@"; then local kernel kernel="$(sandbox_default_kernel || true)" - [ -n "$kernel" ] || { echo "machine kernel not found; set DORYD_MACHINE_KERNEL or install the full Dory app bundle" >&2; exit 1; } + [ -n "$kernel" ] || { echo "Linux Machines is not installed. Run: dory component install linux-machines" >&2; exit 1; } args+=(--kernel "$kernel") fi if ! dory_machine_has_option --rootfs "$@"; then local rootfs rootfs="$(sandbox_default_rootfs || true)" - [ -n "$rootfs" ] || { echo "machine rootfs not found; set DORYD_MACHINE_ROOTFS or install the full Dory app bundle" >&2; exit 1; } + [ -n "$rootfs" ] || { echo "Linux Machines is not installed. Run: dory component install linux-machines" >&2; exit 1; } args+=(--rootfs "$rootfs") fi while IFS= read -r env_row; do @@ -1629,6 +1647,8 @@ sandbox_default_kernel() { "${DORY_SANDBOX_KERNEL:-}" \ "${DORYD_MACHINE_KERNEL:-}" \ "${DORYD_GUEST_KERNEL:-}" \ + "$(installed_component_asset linux-machines "dory-hv-kernel-$arch" || true)" \ + "$(installed_component_asset linux-machines dory-hv-kernel || true)" \ "$DORY_SCRIPT_DIR/../Resources/dory-hv-kernel-$arch" \ "$DORY_SCRIPT_DIR/../Resources/dory-hv-kernel" \ "$repo/guest/out/Image" \ @@ -1643,6 +1663,8 @@ sandbox_default_rootfs() { "${DORY_SANDBOX_ROOTFS:-}" \ "${DORYD_MACHINE_ROOTFS:-}" \ "${DORYD_GUEST_ROOTFS:-}" \ + "$(installed_component_asset linux-machines "dory-machine-rootfs-$arch.ext4" || true)" \ + "$(installed_component_asset linux-machines dory-machine-rootfs.ext4 || true)" \ "$DORY_SCRIPT_DIR/../Resources/dory-machine-rootfs-$arch.ext4" \ "$DORY_SCRIPT_DIR/../Resources/dory-machine-rootfs.ext4" \ "$repo/guest/out/initfs-$arch.ext4" @@ -3409,7 +3431,7 @@ data = { "nonInteractive": True, "promptPolicy": "commands should not prompt unless an operation explicitly needs --apply or user credentials", "destructivePolicy": "run dry-run or read-only commands first; require explicit --apply/--include-volumes/delete verbs for writes", - "hostToolPolicy": "doryd reconciles bundled docker, docker compose, kubectl, dory, and docker context integration automatically while it is running; do not require dory install for first use", + "hostToolPolicy": "doryd reconciles bundled docker, docker compose, dory, and docker context integration automatically while it is running; the Kubernetes component adds kubectl; do not require dory install for first use", }, "schemas": { "agentGuide": "dev.dory.agent.guide v1", @@ -3463,7 +3485,16 @@ data = { "dryRun": True, "manualRecoveryOnly": True, "safeForAutomation": True, - "notes": "Manual recovery only. doryd keeps per-user terminal integration current automatically while it is running, so docker, docker compose, kubectl, and dory resolve from ~/.dory/bin without Docker Desktop or admin access.", + "notes": "Manual recovery only. doryd keeps docker, docker compose, and dory current in ~/.dory/bin without Docker Desktop or admin access. The Kubernetes component adds kubectl.", + }, + { + "id": "component", + "status": "available", + "invoke": "dory component list|install|update|verify|remove [COMPONENT] [--json]", + "json": True, + "safeForAutomation": True, + "components": ["kubernetes", "linux-machines", "linux-desktop", "desktop-debian", "desktop-ubuntu", "desktop-kali"], + "notes": "Manages signed optional payloads on the selected Dory data drive. Desktop distributions add linux-desktop automatically. Removal preserves workload data.", }, { "id": "repair", @@ -3549,7 +3580,7 @@ data = { "invoke": "dory machine ls|create|start|stop|delete|snapshot|restore|exec ...", "json": True, "safeForAutomation": True, - "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection.", + "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Debian, Ubuntu, and Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection.", }, { "id": "ssh", @@ -3704,6 +3735,7 @@ case "${1:-help}" in esac ;; engine) shift; engine_cmd "$@" ;; data) shift; data_cmd "$@" ;; + component) shift; component_ctl="$(dorydctl_bin 2>/dev/null)" || { echo "dorydctl not found; reinstall Dory Docker Core" >&2; exit 1; }; exec "$component_ctl" component "$@" ;; agent) shift; agent_cmd "$@" ;; mcp) shift; mcp_cmd "$@" ;; sandbox) shift; sandbox_cmd "$@" ;; @@ -3744,6 +3776,11 @@ case "${1:-help}" in exec "$DOCKER_BIN" -H "unix://$DORY_SOCK" exec -it -u "$user" -w "$home" "$cid" "$shell" -l fi ;; vm) shift; H="$(vmm_helper || true)"; [ -z "$H" ] && { echo "dory-vmm helper not found; build+sign it (see scripts/bundle-engine.sh)" >&2; exit 1; }; exec "$H" "$@" ;; - k8s) shift; exec "$KUBECTL_BIN" --kubeconfig "$KUBECONFIG_DORY" "$@" ;; + k8s) shift + if ! [ -x "$KUBECTL_BIN" ] && ! command -v "$KUBECTL_BIN" >/dev/null 2>&1; then + echo "Kubernetes is not installed. Run: dory component install kubernetes" >&2 + exit 1 + fi + exec "$KUBECTL_BIN" --kubeconfig "$KUBECONFIG_DORY" "$@" ;; *) exec "$DOCKER_BIN" -H "unix://$DORY_SOCK" "$@" ;; esac diff --git a/scripts/gvproxy-payload.sh b/scripts/gvproxy-payload.sh index 7827b3ec..a25f71ee 100644 --- a/scripts/gvproxy-payload.sh +++ b/scripts/gvproxy-payload.sh @@ -5,8 +5,8 @@ # the pinned Dory dual-stack derivative metadata by default. To test another audited build, set both # DORY_GVPROXY_VERSION and DORY_GVPROXY_SHA256; setting only one is rejected. -DORY_GVPROXY_DEFAULT_VERSION="v0.8.9-dory1" -DORY_GVPROXY_DEFAULT_SHA256="bd9183f5dbe2bd27d7ea57f2f2dd4d5ce26487eeb1fa8c82cd81bad4df50e0c0" +DORY_GVPROXY_DEFAULT_VERSION="v0.8.9-dory2" +DORY_GVPROXY_DEFAULT_SHA256="47c278f1636736ba552de3d2f0e68409cdc968d63bc02149637e449f40274459" dory_gvproxy_validate_overrides() { local version_override="${DORY_GVPROXY_VERSION:-}" diff --git a/scripts/test-build-components.sh b/scripts/test-build-components.sh new file mode 100755 index 00000000..f7e6cb71 --- /dev/null +++ b/scripts/test-build-components.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d "${TMPDIR:-/tmp}/dory-components-test.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT + +SOURCE="$TMP/source" +CORE_APP="$TMP/Dory.app" +OUTPUT="$TMP/components/arm64" +mkdir -p "$SOURCE" "$CORE_APP/Contents/MacOS" + +write_fixture() { + local path="$1" bytes="$2" + dd if=/dev/zero of="$path" bs=1 count=0 seek="$bytes" 2>/dev/null + printf 'dory-fixture-%s\n' "$(basename "$path")" | dd of="$path" conv=notrunc 2>/dev/null +} + +write_fixture "$TMP/Dory-test.dmg" 4096 +write_fixture "$CORE_APP/Contents/MacOS/Dory" 8192 +write_fixture "$TMP/kubectl" 16384 +chmod 0755 "$TMP/kubectl" +write_fixture "$SOURCE/Image" 131072 +write_fixture "$SOURCE/initfs-arm64.ext4" 262144 +write_fixture "$SOURCE/Image-desktop" 196608 + +for distro in debian ubuntu kali; do + write_fixture "$SOURCE/dory-desktop-$distro-rootfs-arm64.ext4" 327680 + printf 'schema=fixture\n' > "$SOURCE/dory-desktop-$distro-build-arm64.stamp" + printf 'xfce4\tfixture\n' > "$SOURCE/dory-desktop-$distro-packages-arm64.txt" +done +printf 'schema=fixture\n' > "$SOURCE/kernel-build-arm64-desktop.stamp" + +build() { + "$ROOT/scripts/build-components.py" \ + --version 9.8.7 \ + --core-artifact "$TMP/Dory-test.dmg" \ + --core-app "$CORE_APP" \ + --kubectl "$TMP/kubectl" \ + --source-root "$SOURCE" \ + --output "$OUTPUT" \ + --asset-base-url https://example.invalid/dory \ + --generated-at 2026-07-16T00:00:00Z \ + --skip-source-verification +} + +build +build + +python3 - "$OUTPUT" "$ROOT" <<'PY' +import hashlib +import importlib.util +import json +import pathlib +import subprocess +import sys +import tempfile + +root = pathlib.Path(sys.argv[1]) +repo = pathlib.Path(sys.argv[2]) +catalog = json.loads((root / "catalog.json").read_text()) +assert catalog["kind"] == "dev.dory.component-catalog" +assert catalog["schemaVersion"] == 1 +assert catalog["architecture"] == "arm64" +assert [item["id"] for item in catalog["components"]] == [ + "docker-core", + "kubernetes", + "linux-machines", + "linux-desktop", + "desktop-debian", + "desktop-ubuntu", + "desktop-kali", +] +assert catalog["components"][0]["assets"] == [] +assert catalog["components"][0]["downloadBytes"] == 4096 +assert catalog["components"][0]["installedBytes"] == 8192 + +for component in catalog["components"][1:]: + assert component["downloadBytes"] == sum( + item["downloadBytes"] for item in component["assets"] + ) + assert component["installedBytes"] == sum( + item["installedBytes"] for item in component["assets"] + ) + for asset in component["assets"]: + artifact = root / asset["url"].rsplit("/", 1)[-1] + assert artifact.is_file() + assert artifact.stat().st_size == asset["downloadBytes"] + assert hashlib.sha256(artifact.read_bytes()).hexdigest() == asset["sha256"] + if asset["compression"] == "none": + assert asset["downloadBytes"] == asset["installedBytes"] + assert asset["sha256"] == asset["installedSHA256"] + else: + with tempfile.NamedTemporaryFile() as decoded: + subprocess.run( + [ + "/usr/bin/compression_tool", + "-decode", + "-a", + "lzfse", + "-i", + str(artifact), + "-o", + decoded.name, + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + data = pathlib.Path(decoded.name).read_bytes() + assert len(data) == asset["installedBytes"] + assert hashlib.sha256(data).hexdigest() == asset["installedSHA256"] + +assert not list(root.parent.glob(".arm64.partial-*")) +assert (root / "catalog.json.sha256").read_text().strip() == hashlib.sha256( + (root / "catalog.json").read_bytes() +).hexdigest() + +spec = importlib.util.spec_from_file_location( + "dory_build_components", repo / "scripts/build-components.py" +) +module = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(module) +try: + module.remove_private_build_directory(repo, repo.parent) +except SystemExit as error: + assert "refusing unsafe component build cleanup" in str(error) +else: + raise AssertionError("cleanup guard accepted the repository root") +assert (repo / ".git").is_dir() +PY + +echo "component packaging test passed" diff --git a/scripts/test.sh b/scripts/test.sh index 6a7cb0a1..a67e5295 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -7,10 +7,11 @@ cd "$ROOT" usage() { cat <<'EOF' -Usage: scripts/test.sh [all|rust|swift|app|ui|build] [-- xcodebuild arguments] +Usage: scripts/test.sh [all|rust|gvproxy|swift|app|ui|build] [-- xcodebuild arguments] all Run every test suite (default) rust Run formatting, lint, and tests for the Rust workspace + gvproxy Rebuild and test Dory's pinned dual-stack network helper swift Run both Swift package test suites app Run the Dory app unit-test scheme ui Run the dedicated Dory UI-test scheme @@ -23,7 +24,7 @@ EOF mode="all" if [ "$#" -gt 0 ]; then case "$1" in - all|rust|swift|app|ui|build) mode="$1"; shift ;; + all|rust|gvproxy|swift|app|ui|build) mode="$1"; shift ;; -h|--help) usage; exit 0 ;; esac fi @@ -96,6 +97,31 @@ run_rust() { ) } +run_gvproxy() { + [ "$(uname -s)" = Darwin ] || { + echo "test: gvproxy requires macOS to produce and inspect its universal binary" >&2 + exit 1 + } + require go + require lipo + ( + tmp="$(mktemp -d "${TMPDIR:-/tmp}/dory-gvproxy-test.XXXXXX")" + trap 'rm -rf "$tmp"' EXIT + scripts/build-gvproxy.sh \ + --output "$tmp/gvproxy" \ + --provenance "$tmp/gvproxy-provenance.txt" + # shellcheck source=gvproxy-payload.sh + source scripts/gvproxy-payload.sh + dory_verify_gvproxy_payload \ + "$tmp/gvproxy" \ + "$(dory_gvproxy_version)" \ + "$(dory_gvproxy_expected_sha256)" + grep -qx \ + 'features=native-ipv6-v2,host-route-aware-aaaa-v1,source-preserving-lan-qemu-v1' \ + "$tmp/gvproxy-provenance.txt" + ) +} + prepare_swift() { select_xcode scripts/build-dory-ffi-xcframework.sh --if-needed @@ -164,6 +190,7 @@ run_build() { case "$mode" in rust) run_rust ;; + gvproxy) run_gvproxy ;; swift) run_swift ;; app) run_app ;; ui) run_ui ;; @@ -171,6 +198,7 @@ case "$mode" in all) run_rust if [ "$(uname -s)" = Darwin ]; then + run_gvproxy run_swift run_app run_ui diff --git a/website/index.html b/website/index.html index 1e9e71b4..c6001c92 100644 --- a/website/index.html +++ b/website/index.html @@ -35,7 +35,7 @@ "name": "Dory", "applicationCategory": "DeveloperApplication", "operatingSystem": "macOS 14 or later on Apple Silicon", - "softwareVersion": "0.3.1", + "softwareVersion": "0.3.2", "description": "A native, open-source local runtime for Docker, Compose, Kubernetes, full graphical Linux desktops, persistent servers, migration, recovery, and coding-agent automation on Mac.", "url": "https://augani.github.io/dory/", "downloadUrl": "https://github.com/Augani/dory/releases/latest", diff --git a/website/public/agent-guide.json b/website/public/agent-guide.json index 404ecb38..ed5f445d 100644 --- a/website/public/agent-guide.json +++ b/website/public/agent-guide.json @@ -2,14 +2,14 @@ "schema": "dev.dory.agent.guide", "version": 1, "summary": "Machine-readable Dory CLI capability map for non-interactive coding agents.", - "release": "0.3.1", + "release": "0.3.2", "defaults": { "nonInteractive": true, "preferJSON": true, "socket": "~/.dory/dory.sock", "promptPolicy": "commands should not prompt unless an operation explicitly needs --apply or user credentials", "destructivePolicy": "run dry-run or read-only commands first; require explicit --apply, --include-volumes, or delete verbs for writes", - "hostToolPolicy": "doryd reconciles bundled docker, docker compose, kubectl, dory, and Docker context integration automatically while it is running; do not require dory install for first use" + "hostToolPolicy": "doryd reconciles bundled docker, docker compose, dory, and Docker context integration automatically while it is running; the Kubernetes component adds kubectl; do not require dory install for first use" }, "exitCodes": { "0": "success or healthy", @@ -73,7 +73,17 @@ "json": true, "dryRun": true, "manualRecoveryOnly": true, - "notes": "Manual recovery only. doryd keeps per-user terminal integration current automatically while it is running, so docker, docker compose, kubectl, and dory resolve from ~/.dory/bin without Docker Desktop or admin access." + "notes": "Manual recovery only. doryd keeps docker, docker compose, and dory current in ~/.dory/bin without Docker Desktop or admin access. The Kubernetes component adds kubectl." + }, + { + "id": "component", + "invoke": "dory component list|install|update|verify|remove [COMPONENT]", + "status": "available", + "safeForAutomation": true, + "json": true, + "dryRun": false, + "components": ["kubernetes", "linux-machines", "linux-desktop", "desktop-debian", "desktop-ubuntu", "desktop-kali"], + "notes": "Manages signed optional payloads on the selected Dory data drive. Desktop distributions add linux-desktop automatically. Removal preserves workload data." }, { "id": "repair", @@ -159,7 +169,7 @@ "status": "available", "safeForAutomation": true, "json": true, - "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. The app creates managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling Xfce desktops from the all-inclusive Desktop edition and Alpine headless servers from either edition. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection." + "notes": "Requires dorydctl and Dory's daemon-managed VM machine runtime; use `dory machine exec NAME --json -- CMD` for schema dev.dory.machine.exec v1. Managed Debian, Ubuntu, and Kali Xfce machines require their matching desktop component; Alpine headless machines require linux-machines. Set DORY_LEGACY_DOCKER_MACHINES=1 only for old container-backed machine inspection." }, { "id": "ssh", diff --git a/website/public/appcast.xml b/website/public/appcast.xml index e1a57e28..d1ccc7e7 100644 --- a/website/public/appcast.xml +++ b/website/public/appcast.xml @@ -5,6 +5,14 @@ https://augani.github.io/dory/appcast.xml Updates for Dory - native Docker and Linux containers for macOS. en + + 0.3.2 + Fri, 17 Jul 2026 04:04:59 +0000 + 43 + 0.3.2 + 14.0 + + 0.3.1 Thu, 16 Jul 2026 16:52:04 +0000 diff --git a/website/public/components/arm64/catalog.json b/website/public/components/arm64/catalog.json new file mode 100644 index 00000000..eea50d3f --- /dev/null +++ b/website/public/components/arm64/catalog.json @@ -0,0 +1,241 @@ +{ + "architecture": "arm64", + "components": [ + { + "assets": [], + "dependencies": [], + "displayName": "Docker Core", + "downloadBytes": 234939462, + "id": "docker-core", + "installedBytes": 429227978, + "summary": "The signed Dory app, Docker engine, CLI, Buildx, Compose, networking, storage, migration, and health tools.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "none", + "downloadBytes": 56944496, + "executable": true, + "installedBytes": 56944496, + "installedSHA256": "8b7f00506b292b29bddbfb79c5dda89a02793426c9a0730803b417f6eca237b4", + "path": "kubectl", + "sha256": "8b7f00506b292b29bddbfb79c5dda89a02793426c9a0730803b417f6eca237b4", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-kubernetes-arm64-kubectl" + } + ], + "dependencies": [ + "docker-core" + ], + "displayName": "Kubernetes", + "downloadBytes": 56944496, + "id": "kubernetes", + "installedBytes": 56944496, + "summary": "kubectl and Dory's local k3s workflow. The selected k3s image downloads when you create the cluster.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "lzfse", + "downloadBytes": 21423733, + "executable": false, + "installedBytes": 62071296, + "installedSHA256": "d98f987c55aca7c7e276e44776a996da3820e3b5d586b5f75f47a16afebca8e1", + "path": "dory-hv-kernel-arm64", + "sha256": "534d3c75d761f81cb65fc2815732ca37c7785d87defb5b3dcf82f269723092ec", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-linux-machines-arm64-dory-hv-kernel-arm64.lzfse" + }, + { + "compression": "lzfse", + "downloadBytes": 90959871, + "executable": false, + "installedBytes": 1073741824, + "installedSHA256": "9f20d1522687f0086cabfd511aa8cfcf2425b8d88f7c0dee21c118c4f5c80510", + "path": "dory-machine-rootfs-arm64.ext4", + "sha256": "cfbaf1bf3b9bb21279fa31540d4fe82dd2d27f58092a6532b8a71a527ef654fb", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-linux-machines-arm64-dory-machine-rootfs-arm64.ext4.lzfse" + } + ], + "dependencies": [ + "docker-core" + ], + "displayName": "Linux Machines", + "downloadBytes": 112383604, + "id": "linux-machines", + "installedBytes": 1135813120, + "summary": "Headless VPS-style Linux machines with terminals, services, and persistent disks.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "none", + "downloadBytes": 23451740, + "executable": false, + "installedBytes": 23451740, + "installedSHA256": "2e5f2b80934b92826ca5c771f94a26009b70c5d166ac48b7872401dd2cd0b7f4", + "path": "dory-desktop-kernel-arm64.lzfse", + "sha256": "2e5f2b80934b92826ca5c771f94a26009b70c5d166ac48b7872401dd2cd0b7f4", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-linux-desktop-arm64-dory-desktop-kernel-arm64.lzfse" + }, + { + "compression": "none", + "downloadBytes": 356, + "executable": false, + "installedBytes": 356, + "installedSHA256": "5baba03081bffdfc55be874e32c1df236b4904375d9dc48c229443baff00bf79", + "path": "kernel-build-arm64-desktop.stamp", + "sha256": "5baba03081bffdfc55be874e32c1df236b4904375d9dc48c229443baff00bf79", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-linux-desktop-arm64-kernel-build-arm64-desktop.stamp" + } + ], + "dependencies": [ + "docker-core" + ], + "displayName": "Linux Desktop Runtime", + "downloadBytes": 23452096, + "id": "linux-desktop", + "installedBytes": 23452096, + "summary": "The graphical VM kernel shared by independently installable desktop distributions.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "none", + "downloadBytes": 418241209, + "executable": false, + "installedBytes": 418241209, + "installedSHA256": "f57521d591c9026cbc9ce638d7b810c2f6b13edddd10358e338e7c1ebc8e7d31", + "path": "dory-desktop-debian-rootfs-arm64.ext4.lzfse", + "sha256": "f57521d591c9026cbc9ce638d7b810c2f6b13edddd10358e338e7c1ebc8e7d31", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-debian-arm64-dory-desktop-debian-rootfs-arm64.ext4.lzfse" + }, + { + "compression": "none", + "downloadBytes": 354, + "executable": false, + "installedBytes": 354, + "installedSHA256": "b4b06d2d63c3a7c0699c1df0df95372bdb94cc9e27fa693adc9f878692dfd11f", + "path": "dory-desktop-debian-build-arm64.stamp", + "sha256": "b4b06d2d63c3a7c0699c1df0df95372bdb94cc9e27fa693adc9f878692dfd11f", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-debian-arm64-dory-desktop-debian-build-arm64.stamp" + }, + { + "compression": "none", + "downloadBytes": 17917, + "executable": false, + "installedBytes": 17917, + "installedSHA256": "e8daf77d4adc2c9ab76d557f67e929c4fcc961b6b5b3ef3b12320e231eda3388", + "path": "dory-desktop-debian-packages-arm64.txt", + "sha256": "e8daf77d4adc2c9ab76d557f67e929c4fcc961b6b5b3ef3b12320e231eda3388", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-debian-arm64-dory-desktop-debian-packages-arm64.txt" + } + ], + "dependencies": [ + "docker-core", + "linux-desktop" + ], + "displayName": "Debian 13 Desktop", + "downloadBytes": 418259480, + "id": "desktop-debian", + "installedBytes": 418259480, + "summary": "A stable Debian 13 Xfce desktop with its own packages and official repositories.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "none", + "downloadBytes": 308900234, + "executable": false, + "installedBytes": 308900234, + "installedSHA256": "2d8af7dafc45d3a9a26024a7e8f809529e5c8bde5cd0bab951a50019f5795332", + "path": "dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse", + "sha256": "2d8af7dafc45d3a9a26024a7e8f809529e5c8bde5cd0bab951a50019f5795332", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-ubuntu-arm64-dory-desktop-ubuntu-rootfs-arm64.ext4.lzfse" + }, + { + "compression": "none", + "downloadBytes": 354, + "executable": false, + "installedBytes": 354, + "installedSHA256": "348875ff99395c10f7addd342903691cf14423bf671ee8ef5714450a73777b38", + "path": "dory-desktop-ubuntu-build-arm64.stamp", + "sha256": "348875ff99395c10f7addd342903691cf14423bf671ee8ef5714450a73777b38", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-ubuntu-arm64-dory-desktop-ubuntu-build-arm64.stamp" + }, + { + "compression": "none", + "downloadBytes": 19972, + "executable": false, + "installedBytes": 19972, + "installedSHA256": "8c558fdb130702c31c741568a2b9adde30160394f8544848927037ae824579ba", + "path": "dory-desktop-ubuntu-packages-arm64.txt", + "sha256": "8c558fdb130702c31c741568a2b9adde30160394f8544848927037ae824579ba", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-ubuntu-arm64-dory-desktop-ubuntu-packages-arm64.txt" + } + ], + "dependencies": [ + "docker-core", + "linux-desktop" + ], + "displayName": "Ubuntu 24.04 LTS Desktop", + "downloadBytes": 308920560, + "id": "desktop-ubuntu", + "installedBytes": 308920560, + "summary": "An Ubuntu 24.04 LTS Xfce desktop with its own packages and official repositories.", + "version": "0.3.2" + }, + { + "assets": [ + { + "compression": "none", + "downloadBytes": 776014596, + "executable": false, + "installedBytes": 776014596, + "installedSHA256": "6ffa2ccc4cc59194d705fffa3a0af68f9f3b12dd5b0e30563cad6f7554c73195", + "path": "dory-desktop-kali-rootfs-arm64.ext4.lzfse", + "sha256": "6ffa2ccc4cc59194d705fffa3a0af68f9f3b12dd5b0e30563cad6f7554c73195", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-kali-arm64-dory-desktop-kali-rootfs-arm64.ext4.lzfse" + }, + { + "compression": "none", + "downloadBytes": 352, + "executable": false, + "installedBytes": 352, + "installedSHA256": "a06e65f3a2070144bda4a989c14cc77d6d7a9b4a04866fc2addfa14990fcb8e8", + "path": "dory-desktop-kali-build-arm64.stamp", + "sha256": "a06e65f3a2070144bda4a989c14cc77d6d7a9b4a04866fc2addfa14990fcb8e8", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-kali-arm64-dory-desktop-kali-build-arm64.stamp" + }, + { + "compression": "none", + "downloadBytes": 24709, + "executable": false, + "installedBytes": 24709, + "installedSHA256": "4fe4f86d595c0ecc2b94f6671545eb99cbc74b3a9d7bbd634883d3c2be6c0497", + "path": "dory-desktop-kali-packages-arm64.txt", + "sha256": "4fe4f86d595c0ecc2b94f6671545eb99cbc74b3a9d7bbd634883d3c2be6c0497", + "url": "https://github.com/Augani/dory/releases/download/v0.3.2/Dory-0.3.2-component-desktop-kali-arm64-dory-desktop-kali-packages-arm64.txt" + } + ], + "dependencies": [ + "docker-core", + "linux-desktop" + ], + "displayName": "Kali Linux Desktop", + "downloadBytes": 776039657, + "id": "desktop-kali", + "installedBytes": 776039657, + "summary": "A Kali rolling Xfce security desktop with its own packages and official repositories.", + "version": "0.3.2" + } + ], + "generatedAt": "2026-07-17T04:04:37Z", + "kind": "dev.dory.component-catalog", + "minimumAppVersion": "0.3.2", + "releaseVersion": "0.3.2", + "schemaVersion": 1 +} diff --git a/website/public/components/arm64/catalog.json.sha256 b/website/public/components/arm64/catalog.json.sha256 new file mode 100644 index 00000000..04cef24a --- /dev/null +++ b/website/public/components/arm64/catalog.json.sha256 @@ -0,0 +1 @@ +c23881f6c3b84113197babba65b85c4c376f9a81de9d9756687065ca87ecab80 diff --git a/website/public/components/arm64/catalog.json.sig b/website/public/components/arm64/catalog.json.sig new file mode 100644 index 00000000..b7c3ee9c --- /dev/null +++ b/website/public/components/arm64/catalog.json.sig @@ -0,0 +1 @@ +wOwwVU86nqa1cQK8iGDcn0sV4YJfV8e39t3uTOYs3vSNTHLwUBlsgv/viea82jM+tE7No3un15Qpz8KHPOrQAQ== diff --git a/website/public/docs/agents.md b/website/public/docs/agents.md index f6796911..cf3f1cb8 100644 --- a/website/public/docs/agents.md +++ b/website/public/docs/agents.md @@ -73,7 +73,7 @@ dory sandbox run --json \ -- /bin/sh -lc 'find /workspace -maxdepth 2 -type f' ``` -Policy facts for Dory 0.3.1: +Policy facts for Dory 0.3.2: - `none` blocks all egress and is enforced. - `full` grants all egress and is enforced. diff --git a/website/public/docs/compatibility.md b/website/public/docs/compatibility.md index 075e18cc..b5b04284 100644 --- a/website/public/docs/compatibility.md +++ b/website/public/docs/compatibility.md @@ -12,7 +12,7 @@ ## Stable product surface - Docker 29 API and bundled CLI -- Buildx, BuildKit, Compose v2, and kubectl +- Buildx, BuildKit, and Compose v2 in Docker Core; kubectl in the optional Kubernetes component - Containers, images, named volumes, bind mounts, bridge networks, custom IPAM, registries, and port publishing - Common linux/amd64 images on Apple Silicon through FEX - k3s v1.34, v1.35, and v1.36 presets @@ -22,7 +22,7 @@ - Localhost ports, optional local domains and HTTPS, built-in low-port forwarding, custom resolver and proxy ports, and opt-in LAN access - Auto-Idle, diagnostics, targeted repair, safe cleanup, support bundles, JSON guide, wait, events, and MCP -The Lean edition includes containers, Kubernetes, and headless Linux servers. The all-inclusive Desktop edition adds all three graphical guest images. Both editions use the same managed data drive, and the Lean edition can manage an existing graphical machine whose disk is already present. +Dory ships one Docker Core app. Kubernetes, Linux Machines, the shared Linux Desktop Runtime, and the managed Debian, Ubuntu, and Kali images are signed optional components stored on the selected data drive. Removing a component reclaims only its installed payload and preserves workload data. ## Developer tools diff --git a/website/public/docs/operations.md b/website/public/docs/operations.md index 92d0d06d..6aec47a7 100644 --- a/website/public/docs/operations.md +++ b/website/public/docs/operations.md @@ -1,6 +1,6 @@ # Operate Dory -This guide covers the stable Dory 0.3.1 operating surface on Apple Silicon Macs running macOS 14 or later. +This guide covers the stable Dory 0.3.2 operating surface on Apple Silicon Macs running macOS 14 or later. ## Install and verify @@ -11,17 +11,30 @@ dory version dory doctor --active ``` -Dory provides `docker`, `docker compose`, `kubectl`, and `dory` through `~/.dory/bin` while doryd runs. A separate Docker Desktop or Docker CLI install is not required. +Dory provides `docker`, `docker compose`, and `dory` through `~/.dory/bin` while doryd runs. The optional Kubernetes component adds `kubectl`. A separate Docker Desktop or Docker CLI install is not required. -The Homebrew cask and standard direct download install the Lean edition with containers, Kubernetes, and headless Linux servers. Install the all-inclusive Desktop edition to create Debian, Ubuntu, or Kali graphical machines. +The Homebrew cask and direct download install Docker Core. Use Settings > Components or `dory component install ID` to add Kubernetes, Linux Machines, or individual desktop distributions. The app shows exact signed download and installed sizes before each installation. -When upgrading from 0.3.0, quit Dory, uninstall the old app, and install one 0.3.1 edition. Normal uninstall preserves the selected `.dorydrive`. Keep only one Dory.app in Applications so macOS registers the correct bundled services. +When upgrading from an older release, quit Dory, uninstall the old app, and install Dory 0.3.2 Docker Core. Normal uninstall preserves the selected `.dorydrive`. Keep only one Dory.app in Applications, then add the optional components you use. + +## Components + +```sh +dory component list +dory component install kubernetes +dory component install linux-machines +dory component install desktop-ubuntu +dory component verify desktop-ubuntu +dory component remove desktop-ubuntu +``` + +Desktop distributions add the shared Linux Desktop Runtime automatically. Removing an optional component deletes only its installed payload. Containers, volumes, cluster state, machine disks, snapshots, and exports stay on the selected data drive. ## Linux desktops and servers The app separates graphical Linux Desktops from lightweight Linux Servers. A new desktop can use Debian 13, Ubuntu 24.04 LTS, or Kali rolling with Xfce, systemd, Bash, and a configurable login user. Its display uses a true 2x guest framebuffer with matching Xfce scaling and follows the Mac window as it resizes. -Desktop creation also controls CPU, memory, development recipe, Mac home sharing, and scoped folders. Each desktop has a thin-provisioned 64 GiB disk in the selected Dory data drive. Headless servers use Alpine with an initial root `/bin/sh` login. +Desktop creation also controls CPU, memory, development recipe, Mac home sharing, and scoped folders. Each desktop has a thin-provisioned 64 GiB disk in the selected Dory data drive. Headless servers use Alpine with an initial root `/bin/sh` login. Install `linux-machines` for headless servers or a matching `desktop-*` component for graphical machines. ## Engine resources diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index df2f8298..c44b56e2 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -1,6 +1,6 @@ # Dory complete agent reference -Version: Dory 0.3.1 +Version: Dory 0.3.2 Host contract: Apple Silicon Mac, macOS 14 or later License: GPL-3.0 Local socket: ~/.dory/dory.sock @@ -23,14 +23,28 @@ Recommended install: brew install --cask Augani/dory/dory -The Homebrew cask and standard download are the Lean edition: containers, Kubernetes, and headless Linux servers. The all-inclusive Desktop download adds the Debian, Ubuntu, and Kali graphical images. Both editions use the same selected `.dorydrive`. +The Homebrew cask and standard download install Docker Core: Dory.app, the Docker engine and CLI, Compose, Buildx, networking, storage, migration, diagnostics, and recovery. A signed catalog provides removable Kubernetes, Linux Machines, Linux Desktop Runtime, Debian, Ubuntu, and Kali components on the selected `.dorydrive`. -Dory 0.3.0 users should quit Dory, uninstall the old app, and install the 0.3.1 edition they want. Normal uninstall preserves workload data. Keep only one Dory.app in Applications so macOS registers the correct bundled services. +Users of an older Dory release should quit Dory, uninstall the old app, and install Dory 0.3.2 Docker Core. Normal uninstall preserves workload data. Keep only one Dory.app in Applications, then install the optional components they use. -Open Dory once. While doryd is running it reconciles bundled `docker`, `docker compose`, `kubectl`, and `dory` tools in ~/.dory/bin and creates the dory Docker context. A separate Docker Desktop or Docker CLI install is not required. +Open Dory once. While doryd is running it reconciles bundled `docker`, `docker compose`, and `dory` tools in ~/.dory/bin and creates the dory Docker context. Installing the Kubernetes component also reconciles `kubectl`. A separate Docker Desktop or Docker CLI install is not required. `dory install` exists as manual recovery. Do not make it the normal first step. +## Focused components + + dory component list + dory component install kubernetes + dory component install linux-machines + dory component install desktop-debian + dory component install desktop-ubuntu + dory component install desktop-kali + dory component update COMPONENT + dory component verify COMPONENT + dory component remove COMPONENT + +Docker Core is required. Desktop distributions add the shared Linux Desktop Runtime automatically. Component catalogs and assets are signature, digest, version, and architecture checked before atomic activation. Removing an optional component deletes only its installed payload. Containers, volumes, cluster state, machine disks, snapshots, and exports remain on the selected data drive. + ## Safe operating policy 1. Inspect before writing. @@ -105,8 +119,11 @@ Published ports are localhost-only by default. Dory never widens explicit loopba dory network authorize --json --apply dory network deauthorize --json --dry-run dory network --lan-visible on + dory network custom-domains + dory network set-custom-domain admin.myproject.local --published-port 80 + dory network remove-custom-domain admin.myproject.local -Optional authorization manages the selected local-domain resolver, local TLS, ports 80 and 443, and published low TCP ports. LAN and Tailscale access are explicit opt-ins. Containers reach Mac services at `host.dory.internal`. +Optional authorization manages the selected local-domain resolver, local TLS, ports 80 and 443, and published low TCP ports. Exact and leftmost-wildcard custom domains can route to a published HTTP port after the hostname resolves to 127.0.0.1. `/etc/hosts` needs each exact name; wildcard resolution requires local DNS. LAN and Tailscale access are explicit opt-ins. Containers reach Mac services at `host.dory.internal`. ## Mounts, disk, and cleanup @@ -159,7 +176,7 @@ Auto-Idle can be blocked by published ports, pinned projects, or Kubernetes acco dory machine snapshot NAME --note NOTE dory machine restore NAME SNAPSHOT -The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13, Ubuntu 24.04 LTS, or Kali rolling, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching Xfce scaling. The Desktop edition is required to create a new graphical machine. +The app has separate Linux Desktops and Linux Servers areas. Desktop creation selects Debian 13, Ubuntu 24.04 LTS, or Kali rolling, a login user, CPU, memory, optional development recipe, Mac home sharing, and scoped mounts. Desktop windows render at a true 2x guest framebuffer, follow window resizing, and configure matching Xfce scaling. A new graphical machine requires its matching `desktop-*` component; a headless machine requires `linux-machines`. The machine command also supports clone, import, export, and snapshot deletion. Structured execution uses `dev.dory.machine.exec v1`. New machines do not inherit arbitrary host secrets or files. The app controls a named environment allow-list and scoped mounts. @@ -168,7 +185,7 @@ The machine command also supports clone, import, export, and snapshot deletion. dory k8s get pods -A kubectl get pods -A -Supported k3s presets are v1.34, v1.35, and v1.36. Switching versions recreates the cluster. k3s has a separate image store from the Docker engine. +Install the Kubernetes component before enabling a cluster. Supported k3s presets are v1.34, v1.35, and v1.36. Switching versions recreates the cluster. The selected k3s image downloads on first cluster creation and remains in Docker storage on the selected data drive. ## Compatibility diff --git a/website/public/llms.txt b/website/public/llms.txt index 971f9f66..b7a1d10d 100644 --- a/website/public/llms.txt +++ b/website/public/llms.txt @@ -2,14 +2,14 @@ > Dory is a native, open-source macOS runtime for Docker, Compose, Kubernetes, full Linux desktops, persistent servers, local networking, migration, diagnostics, and coding-agent automation. -Dory 0.3.1 supports Apple Silicon on macOS 14 or later and bundles its own engine and command-line tools. Its Linux-machine profiles are managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops with configurable users plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release. +Dory 0.3.2 supports Apple Silicon on macOS 14 or later and bundles its own Docker engine and command-line tools. Its optional Linux-machine profiles are managed Debian 13, Ubuntu 24.04 LTS, and Kali rolling Xfce desktops with configurable users plus lightweight Alpine headless VMs. Intel hosts and desktop distributions beyond those three are not part of the current release. -The Lean edition includes containers, Kubernetes, and headless Linux servers. The all-inclusive Desktop edition adds all three verified graphical guest images and their Retina-sharp display stack. Both editions use the same managed Dory data drive. +Dory ships one Docker Core app. A signed catalog adds removable Kubernetes, Linux Machines, Linux Desktop Runtime, Debian, Ubuntu, and Kali components to the selected Dory data drive. The website verifies the catalog and shows the exact total before download. Removing a component reclaims its payload without deleting workload data. ## Canonical documentation - [Complete agent reference](https://augani.github.io/dory/llms-full.txt): Stable commands, safety rules, schemas, recovery, examples, and product boundaries -- [Versioned agent capability map](https://augani.github.io/dory/agent-guide.json): Machine-readable JSON contract for Dory 0.3.1 +- [Versioned agent capability map](https://augani.github.io/dory/agent-guide.json): Machine-readable JSON contract for Dory 0.3.2 - [Agent and MCP guide](https://augani.github.io/dory/docs/agents.md): MCP configuration, tools, machine execution, sandbox policy, and recovery loop - [Operations guide](https://augani.github.io/dory/docs/operations.md): Installation, storage, networking, migration, diagnostics, and repair - [Compatibility contract](https://augani.github.io/dory/docs/compatibility.md): Supported, preview, experimental, and deferred behavior diff --git a/website/src/App.css b/website/src/App.css index 35aea031..4ffd6d6c 100644 --- a/website/src/App.css +++ b/website/src/App.css @@ -239,32 +239,42 @@ .proof-strip span { max-width: 115px; color: var(--muted); font-size: 11px; line-height: 1.35; } .download-section { scroll-margin-top: 24px; padding-top: 95px; background: var(--paper); border-top: 1px solid var(--line); } -.download-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; width: min(1000px, 100%); margin: 0 auto; } -.download-card { display: flex; min-width: 0; flex-direction: column; padding: 34px; border: 1px solid var(--line); border-radius: 22px; background: #fff; box-shadow: 0 18px 48px rgba(19,48,78,.08); } -.download-card-lean { border-color: #9bcef8; box-shadow: 0 24px 60px rgba(20,127,232,.12); } -.download-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.download-card-head > span { color: var(--blue); font-size: 11px; font-weight: 880; letter-spacing: .14em; text-transform: uppercase; } -.download-card-head > b { padding: 5px 8px; border-radius: 999px; background: var(--blue-soft); color: var(--blue-dark); font-size: 9px; font-weight: 800; } -.download-card-desktop .download-card-head > b { background: #edf1f5; color: #657589; } -.download-card h3 { margin: 24px 0 12px; color: var(--ink); font-size: 27px; line-height: 1.12; letter-spacing: -.04em; } -.download-card > p { min-height: 68px; margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.65; } -.download-sizes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 25px 0; } -.download-sizes > div { display: grid; gap: 3px; padding: 14px; border: 1px solid var(--line); border-radius: 11px; background: var(--paper); } -.download-sizes strong { color: var(--ink); font-size: 19px; letter-spacing: -.03em; } -.download-sizes span { color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } -.download-card ul { display: grid; gap: 10px; margin: 0 0 27px; padding: 0; color: #596b80; font-size: 11.5px; list-style: none; } -.download-card li { display: flex; align-items: center; gap: 8px; } -.download-card li svg { width: 16px; flex: 0 0 auto; color: var(--green); } -.download-card > .button { width: 100%; margin-top: auto; } -.download-card > small { margin-top: 12px; color: #8a99aa; font-size: 9.5px; text-align: center; } -.button-ghost-dark { border: 1px solid #203b59; background: var(--navy); color: #fff; box-shadow: 0 12px 28px rgba(7,17,31,.16); } -.button-ghost-dark:hover { background: #11263d; } .download-roadmap { display: grid; grid-template-columns: 28px 1fr; gap: 15px; width: min(1000px, 100%); margin: 18px auto 0; padding: 22px 24px; border: 1px solid #cbdff1; border-radius: 15px; background: #edf7ff; } .download-roadmap > svg { width: 27px; color: var(--blue); } .download-roadmap strong { color: var(--ink); font-size: 13px; } .download-roadmap p { margin: 4px 0 0; color: #60748a; font-size: 11.5px; line-height: 1.6; } .download-release-link { margin: 22px 0 0; color: var(--muted); font-size: 11px; text-align: center; } .download-release-link a { color: var(--blue-dark); font-weight: 760; } +.component-builder { width: min(1060px, 100%); margin: 0 auto; overflow: hidden; border: 1px solid #a9cfee; border-radius: 24px; background: #fff; box-shadow: 0 28px 70px rgba(19,68,109,.12); } +.component-builder-summary { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 36px; align-items: end; padding: 34px; border-bottom: 1px solid var(--line); background: linear-gradient(135deg, #f7fbff 0%, #edf7ff 55%, #f9fbfd 100%); } +.component-builder-summary > div:first-child > span { color: var(--blue); font-size: 10px; font-weight: 850; letter-spacing: .13em; text-transform: uppercase; } +.component-builder-summary h3 { max-width: 640px; margin: 10px 0 12px; color: var(--ink); font-size: 30px; line-height: 1.08; letter-spacing: -.04em; } +.component-builder-summary p { max-width: 690px; margin: 0; color: #60748a; font-size: 12px; line-height: 1.65; } +.component-totals { display: grid; grid-template-columns: repeat(2, minmax(116px,1fr)); gap: 8px; } +.component-totals > div { display: grid; gap: 3px; padding: 14px; border: 1px solid #c8dff2; border-radius: 12px; background: rgba(255,255,255,.78); } +.component-totals strong { color: var(--ink); font-size: 20px; letter-spacing: -.03em; } +.component-totals span { color: var(--muted); font-size: 8.5px; font-weight: 780; letter-spacing: .08em; text-transform: uppercase; } +.component-choice-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; padding: 20px; } +.component-choice { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 11px; min-height: 136px; padding: 17px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; } +.component-choice:hover { transform: translateY(-1px); border-color: #9ec9eb; box-shadow: 0 10px 24px rgba(25,83,130,.08); } +.component-choice.is-selected { border-color: #62ace7; background: #f2f9ff; box-shadow: inset 0 0 0 1px rgba(20,127,232,.08); } +.component-choice.is-required { cursor: default; } +.component-check { display: grid; place-items: center; width: 25px; height: 25px; border: 1px solid #bacbdd; border-radius: 8px; background: #fff; color: #6d8094; font-size: 13px; font-weight: 850; } +.component-choice.is-selected .component-check { border-color: var(--blue); background: var(--blue); color: #fff; } +.component-choice-copy { min-width: 0; } +.component-choice-copy strong { display: block; color: var(--ink); font-size: 13px; } +.component-choice-copy small { display: block; margin-top: 4px; color: var(--blue-dark); font-size: 8.5px; font-weight: 780; letter-spacing: .04em; text-transform: uppercase; } +.component-choice-copy p { margin: 10px 0 0; color: #6b7d90; font-size: 10.5px; line-height: 1.5; } +.component-runtime-note { display: flex; align-items: center; gap: 8px; margin: 0 20px 20px; padding: 11px 14px; border: 1px solid #c7e1d6; border-radius: 10px; background: #f0faf6; color: #3c6957; font-size: 10.5px; font-weight: 720; } +.component-runtime-note svg { width: 16px; color: var(--green); } +.component-builder-footer { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 24px; align-items: center; padding: 22px 24px; border-top: 1px solid var(--line); background: var(--navy); } +.component-builder-footer-copy { min-width: 0; } +.component-builder-footer strong { display: block; overflow: hidden; color: #fff; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.component-builder-footer span { display: block; margin-top: 5px; color: #9fb2c7; font-size: 9.5px; } +.component-builder-actions { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 9px; } +.component-builder-footer .button { min-width: 220px; } +.button-component-open { border: 1px solid rgba(255,255,255,.2); background: rgba(255,255,255,.08); color: #fff; } +.button-component-open:hover { background: rgba(255,255,255,.13); } .intro { background: #fff; } .surface-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } @@ -435,7 +445,9 @@ footer { padding: 65px max(24px, calc((100vw - 1180px) / 2)) 28px; background: v .nav-links-open { display: flex; }.nav-links a { padding: 11px 12px; border-radius: 8px; font-size: 12px; }.nav-links a:hover { background: rgba(255,255,255,.07); }.menu-button { display: block; } .hero { min-height: 900px; padding-top: 160px; }.hero-product { margin-top: 70px; margin-bottom: -170px; }.hero-terminal { display: none; }.float-memory { right: -10px; }.float-data { right: 25px; bottom: 30px; } .proof-strip { grid-template-columns: repeat(2, 1fr); padding-top: 205px; }.proof-strip > div:nth-child(2) { border-right: 0; }.proof-strip > div { border-bottom: 1px solid var(--line); }.proof-strip > div:nth-child(3), .proof-strip > div:nth-child(4) { border-bottom: 0; } - .download-grid { grid-template-columns: 1fr; max-width: 620px; } + .component-builder-summary { grid-template-columns: 1fr; align-items: start; } + .component-totals { width: 100%; } + .component-choice-grid { grid-template-columns: repeat(2, minmax(0,1fr)); } .surface-grid { grid-template-columns: 1fr; }.surface-card { min-height: auto; }.surface-card:nth-child(2) { transform: none; } .cockpit, .linux-desktop, .migration-shell, .agents, .operations-layout, .truth { grid-template-columns: 1fr; }.cockpit-visual, .desktop-product { width: min(760px, 100%); margin: 0 auto; }.migration-shell { padding: 55px 40px; } .foundation-grid { grid-template-columns: 1fr; }.foundation-card { min-height: 380px; } @@ -449,7 +461,17 @@ footer { padding: 65px max(24px, calc((100vw - 1180px) / 2)) 28px; background: v .nav-wrap { padding: 12px; }.nav { min-height: 56px; gap: 8px; padding-left: 12px; }.nav-cta { padding: 9px 10px; font-size: 10.5px; }.menu-button { width: 35px; height: 35px; } .hero { min-height: auto; padding: 125px 17px 72px; }.release-pill { margin-bottom: 27px; }.release-pill > a { display: none; }.hero h1 { font-size: 51px; }.hero-lede { font-size: 16px; }.hero-actions { flex-direction: column; }.button { width: 100%; }.hero-requirements { flex-wrap: wrap; }.hero-product { width: 112%; margin: 55px -6% 0; }.dory-demo { height: min(72vw, 290px); border-radius: 11px; aspect-ratio: auto; }.float-card { display: none; } .proof-strip { padding: 25px 12px 45px; }.proof-strip > div { align-items: flex-start; flex-direction: column; gap: 3px; }.proof-strip strong { font-size: 20px; } - .download-card { padding: 25px 20px; border-radius: 17px; }.download-card-head { align-items: flex-start; flex-direction: column; }.download-card h3 { font-size: 24px; }.download-card > p { min-height: 0; }.download-roadmap { padding: 19px; } + .download-roadmap { padding: 19px; } + .component-builder { border-radius: 17px; } + .component-builder-summary { gap: 22px; padding: 24px 19px; } + .component-builder-summary h3 { font-size: 25px; } + .component-choice-grid { grid-template-columns: 1fr; padding: 14px; } + .component-choice { min-height: 0; } + .component-runtime-note { margin: 0 14px 14px; } + .component-builder-footer, .component-builder-actions { grid-template-columns: 1fr; padding: 19px; } + .component-builder-actions { padding: 0; } + .component-builder-footer strong { white-space: normal; } + .component-builder-footer .button { width: 100%; min-width: 0; } .surface-card { padding: 26px; border-radius: 17px; }.surface-icon { margin-bottom: 28px; }.surface-card h3 { font-size: 26px; } .cockpit-visual .dory-demo { margin-top: 40px; }.visual-note { top: 0; right: 10px; } .desktop-editions, .desktop-capabilities { grid-template-columns: 1fr; } diff --git a/website/src/App.tsx b/website/src/App.tsx index 58f9f652..0df5aef9 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -31,9 +31,233 @@ import './App.css' type Icon = ComponentType> const installCommand = 'brew install --cask Augani/dory/dory' -const releaseUrl = 'https://github.com/Augani/dory/releases/tag/v0.3.1' -const leanDmgUrl = 'https://github.com/Augani/dory/releases/download/v0.3.1/Dory-0.3.1-arm64.dmg' -const desktopDmgUrl = 'https://github.com/Augani/dory/releases/download/v0.3.1/Dory-0.3.1-desktop-arm64.dmg' +const releaseUrl = 'https://github.com/Augani/dory/releases/latest' +const componentPublicKey = 'AFetajNbqZty68rRY7OMWYNt6suUsrokQmYMhDJtnP4=' + +type ComponentId = 'docker-core' | 'kubernetes' | 'linux-machines' | 'linux-desktop' | 'desktop-debian' | 'desktop-ubuntu' | 'desktop-kali' + +type ComponentRelease = { + id: ComponentId + version: string + displayName: string + summary: string + dependencies: ComponentId[] + downloadBytes: number + installedBytes: number +} + +type ComponentCatalog = { + kind: 'dev.dory.component-catalog' + schemaVersion: 1 + releaseVersion: string + architecture: 'arm64' + components: ComponentRelease[] +} + +const componentOrder: ComponentId[] = [ + 'docker-core', + 'kubernetes', + 'linux-machines', + 'linux-desktop', + 'desktop-debian', + 'desktop-ubuntu', + 'desktop-kali', +] + +const componentLabels: Record = { + 'docker-core': 'Docker Core', + kubernetes: 'Kubernetes', + 'linux-machines': 'Linux Machines', + 'linux-desktop': 'Linux Desktop Runtime', + 'desktop-debian': 'Debian 13', + 'desktop-ubuntu': 'Ubuntu 24.04 LTS', + 'desktop-kali': 'Kali Linux', +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KiB', 'MiB', 'GiB'] + const unit = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / 1024 ** unit + return `${unit < 2 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + +function validComponentCatalog(value: unknown): value is ComponentCatalog { + if (!value || typeof value !== 'object') return false + const catalog = value as Partial + if (catalog.kind !== 'dev.dory.component-catalog' || catalog.schemaVersion !== 1 || catalog.architecture !== 'arm64') return false + if (typeof catalog.releaseVersion !== 'string' || !/^[0-9A-Za-z.+_-]{1,64}$/.test(catalog.releaseVersion) || !Array.isArray(catalog.components)) return false + const ids = catalog.components.map((component) => component?.id) + return ids.length === componentOrder.length + && new Set(ids).size === componentOrder.length + && componentOrder.every((id) => ids.includes(id)) + && catalog.components.every((component) => + component + && componentOrder.includes(component.id) + && Number.isSafeInteger(component.downloadBytes) + && component.downloadBytes > 0 + && Number.isSafeInteger(component.installedBytes) + && component.installedBytes > 0 + && Array.isArray(component.dependencies) + && component.dependencies.every((dependency) => componentOrder.includes(dependency)), + ) +} + +function decodeBase64(value: string) { + const decoded = atob(value.trim()) + return Uint8Array.from(decoded, (character) => character.charCodeAt(0)) +} + +async function verifyComponentCatalog(data: ArrayBuffer, signature: string) { + const key = await crypto.subtle.importKey( + 'raw', + decodeBase64(componentPublicKey), + { name: 'Ed25519' }, + false, + ['verify'], + ) + return crypto.subtle.verify( + { name: 'Ed25519' }, + key, + decodeBase64(signature), + data, + ) +} + +function FocusedDownloadSelector() { + const [catalog, setCatalog] = useState(null) + const [catalogUnavailable, setCatalogUnavailable] = useState(false) + const [selected, setSelected] = useState>(new Set(['docker-core'])) + + useEffect(() => { + const controller = new AbortController() + Promise.all([ + fetch('./components/arm64/catalog.json', { cache: 'no-store', signal: controller.signal }), + fetch('./components/arm64/catalog.json.sig', { cache: 'no-store', signal: controller.signal }), + ]) + .then(async ([catalogResponse, signatureResponse]) => { + if (!catalogResponse.ok || !signatureResponse.ok) throw new Error('component catalog is unavailable') + const [data, signature] = await Promise.all([catalogResponse.arrayBuffer(), signatureResponse.text()]) + if (!await verifyComponentCatalog(data, signature)) throw new Error('component catalog signature is invalid') + return JSON.parse(new TextDecoder().decode(data)) as unknown + }) + .then((value) => { + if (!validComponentCatalog(value)) throw new Error('component catalog is invalid') + setCatalog(value) + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') return + setCatalogUnavailable(true) + }) + return () => controller.abort() + }, []) + + if (!catalog) { + return ( +

+
+ ) + } + + const releases = new Map(catalog.components.map((component) => [component.id, component])) + const selectedReleases = componentOrder.flatMap((id) => selected.has(id) && releases.has(id) ? [releases.get(id)!] : []) + const downloadBytes = selectedReleases.reduce((total, component) => total + component.downloadBytes, 0) + const installedBytes = selectedReleases.reduce((total, component) => total + component.installedBytes, 0) + const coreDmgUrl = `https://github.com/Augani/dory/releases/download/v${catalog.releaseVersion}/Dory-${catalog.releaseVersion}-arm64.dmg` + const optionalSelectedIDs = componentOrder.filter((id) => id !== 'docker-core' && selected.has(id)) + const selectionUrl = `dory://components/install?ids=${encodeURIComponent(optionalSelectedIDs.join(','))}` + + const toggle = (id: ComponentId) => { + if (id === 'docker-core' || id === 'linux-desktop') return + setSelected((current) => { + const next = new Set(current) + if (next.has(id)) { + next.delete(id) + if (id.startsWith('desktop-') && !['desktop-debian', 'desktop-ubuntu', 'desktop-kali'].some((distro) => next.has(distro as ComponentId))) { + next.delete('linux-desktop') + } + } else { + next.add(id) + for (const dependency of releases.get(id)?.dependencies ?? []) next.add(dependency) + } + next.add('docker-core') + return next + }) + } + + return ( +
+
+
+ Focused release {catalog.releaseVersion} +

Your app stays small. Your workspace stays complete.

+

Docker Core is the only required download. Dory installs your optional signed components into the selected .dorydrive after the app opens, and can remove their payloads later without deleting workload data.

+
+
+
{formatBytes(downloadBytes)}core + selected downloads
+
{formatBytes(installedBytes)}installed payload
+
+
+ +
+ {componentOrder.filter((id) => id !== 'linux-desktop').map((id) => { + const component = releases.get(id)! + const required = id === 'docker-core' + const active = selected.has(id) + return ( + + ) + })} +
+ + {selected.has('linux-desktop') && ( +

+ Linux Desktop Runtime added automatically · {formatBytes(releases.get('linux-desktop')!.downloadBytes)} +

+ )} + {selected.has('kubernetes') && ( +

+ The selected k3s container image downloads on first cluster creation and is not included in the component total. +

+ )} + +
+
+ {selectedReleases.map((component) => componentLabels[component.id]).join(' + ')} + 1. Download and open the Docker Core app. + {optionalSelectedIDs.length > 0 + ? 2. Open this selection in Dory, review the signed sizes, then confirm installation. + : No optional component downloads are needed for this selection.} +
+
+ Download Docker Core + {optionalSelectedIDs.length > 0 && ( + Open selection in Dory + )} +
+
+
+ ) +} const surfaces: Array<{ icon: Icon @@ -53,10 +277,10 @@ const surfaces: Array<{ }, { icon: Squares2X2Icon, - label: 'Kubernetes, included', + label: 'Kubernetes, when needed', title: 'A local cluster without a second product.', - copy: 'Create k3s in one click, choose a supported version, and work with pods, deployments, services, configuration, secrets, and ingress from the app.', - facts: ['k3s v1.34 to v1.36', 'Bundled kubectl', 'Native resource browser'], + copy: 'Add the signed kubectl component, then create k3s in one click and work with pods, deployments, services, configuration, secrets, and ingress from the app. The selected k3s image downloads on first enable.', + facts: ['k3s v1.34 to v1.36', 'Signed kubectl component', 'Native resource browser'], command: 'dory k8s get pods -A', }, { @@ -83,6 +307,7 @@ const desktopCapabilities = [ ] const cockpitFeatures = [ + ['Components', 'Install, update, verify, and remove focused payloads with exact sizes'], ['Containers', 'Live CPU and memory, logs, shell, ports, Compose groups'], ['Images', 'Pull, run, inspect, copy IDs, delete, and reclaim'], ['Volumes', 'Create, browse files, delete, and prune unused data'], @@ -117,12 +342,13 @@ const agentTools = [ ] const settings = [ + ['Components', 'Signed catalog, exact sizes, updates, verification, dependency-aware removal'], ['General', 'Startup, menu bar, terminal tools, preferred terminal app, browser logins, appearance'], ['Engine & Daemon', 'Backend, CPU, memory, amd64, experimental guest GPU'], ['Resources', 'Data drive, backup, verify, restore, growth, process memory'], ['Machines', 'Host environment allow-list and file-sharing boundaries'], ['Auto-Idle', 'Availability mode, idle delay, blockers, wake notices'], - ['Network', 'Domains, low ports, Docker bridge subnet, resolver, proxies, LAN and Tailscale'], + ['Network', 'Automatic and custom domains, low ports, Docker bridge subnet, resolver, proxies, LAN and Tailscale'], ['USB Devices', 'Scan, attach, detach, and remember USB/IP devices'], ['Local Tools', 'Stable and preview daemon commands with copy actions'], ['Migrate & Compare', 'Source discovery, preflight, import, and comparison'], @@ -132,7 +358,7 @@ const settings = [ const faqs = [ { question: 'Do I need Docker Desktop or a separate Docker CLI?', - answer: 'No. The full Dory app bundles its engine, Docker CLI, Buildx, Compose, and kubectl. While doryd runs, it keeps the tools and the dory context ready in your user account.', + answer: 'No. Docker Core includes the engine, Docker CLI, Buildx, and Compose. Install the optional Kubernetes component when you also want kubectl and Dory\'s local k3s workflow.', }, { question: 'Can I move from OrbStack, Docker Desktop, or Colima?', @@ -148,19 +374,19 @@ const faqs = [ }, { question: 'Can Dory publish ports below 1024?', - answer: 'Yes. Dory has a built-in macOS authorization plan for local domains, trusted HTTPS, ports 80 and 443, and published low TCP ports. The plan is visible and removable in Settings > Network.', + answer: 'Yes. Dory has a built-in macOS authorization plan for trusted HTTPS, ports 80 and 443, and published low TCP ports. Settings > Network also maps exact or wildcard custom domains to a published HTTP port, so nginx-style local domains do not need a second forwarding app.', }, { question: 'Are Linux machines full desktop VMs?', answer: 'Yes on Apple Silicon. Choose managed Debian 13, Ubuntu 24.04 LTS, or Kali rolling with systemd, Xfce, Bash, a configurable user, a Retina-sharp resizable display, and a 64 GiB thin-provisioned disk. Lightweight Alpine headless machines remain available for terminal and service workloads.', }, { - question: 'Which Dory edition should I install?', - answer: 'Choose the lean edition for containers, Kubernetes, and headless Linux servers. Choose the all-inclusive Desktop edition when you also want to create Debian, Ubuntu, or Kali graphical machines. Both editions use the same Dory data drive.', + question: 'Which Dory components should I install?', + answer: 'Start with Docker Core. Add Kubernetes for k3s and kubectl, Linux Machines for headless VPS-style guests, or only the Debian, Ubuntu, and Kali desktop packs you use. Optional payloads live in the selected Dory data drive and can be removed independently.', }, { - question: 'How do I upgrade from Dory 0.3.0?', - answer: 'Quit Dory, uninstall the old app, and install Dory 0.3.1 in either the lean or Desktop edition. Normal uninstall preserves the selected .dorydrive and all workload data. Keep only one Dory.app in Applications so macOS registers the correct services.', + question: 'How do I upgrade from an older Dory release?', + answer: 'Quit Dory, uninstall the old app, and install Dory 0.3.2 Docker Core. Normal uninstall preserves the selected .dorydrive and all workload data. Keep only one Dory.app in Applications, then add the optional components you use.', }, { question: 'Can I choose which terminal Dory opens?', @@ -372,7 +598,7 @@ function DoryDemo({ initialView = 'containers', autoCycle = true }: { initialVie