-
Notifications
You must be signed in to change notification settings - Fork 695
fix: --ssh flag for running containers (issue #1189) #1214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,7 +55,13 @@ public struct ContainersHarness: Sendable { | |
| ) | ||
| } | ||
| let stdio = message.stdio() | ||
| try await service.bootstrap(id: id, stdio: stdio) | ||
| let dynamicEnv: [String: String] = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here the same? |
||
| if let data = message.dataNoCopy(key: .dynamicEnv) { | ||
| try JSONDecoder().decode([String: String].self, from: data) | ||
| } else { | ||
| [:] | ||
| } | ||
| try await service.bootstrap(id: id, stdio: stdio, dynamicEnv: dynamicEnv) | ||
| return message.reply() | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -398,7 +398,8 @@ public actor ContainersService { | |
| } | ||
|
|
||
| /// Bootstrap the init process of the container. | ||
| public func bootstrap(id: String, stdio: [FileHandle?]) async throws { | ||
| /// - Parameter dynamicEnv: Optional start-time environment overrides passed from the client. | ||
| public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String] = [:]) async throws { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't need default value for |
||
| log.debug( | ||
| "ContainersService: enter", | ||
| metadata: [ | ||
|
|
@@ -473,7 +474,7 @@ public actor ContainersService { | |
| id: id, | ||
| runtime: runtime | ||
| ) | ||
| try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments) | ||
| try await sandboxClient.bootstrap(stdio: stdio, allocatedAttachments: allocatedAttachments, dynamicEnv: dynamicEnv) | ||
|
|
||
| try await self.exitMonitor.registerProcess( | ||
| id: id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,7 +77,8 @@ public struct SandboxClient: Sendable { | |
|
|
||
| // Runtime Methods | ||
| extension SandboxClient { | ||
| public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment]) async throws { | ||
| /// - Parameter dynamicEnv: Optional start-time environment overrides passed from the API service. | ||
| public func bootstrap(stdio: [FileHandle?], allocatedAttachments: [AllocatedAttachment], dynamicEnv: [String: String] = [:]) async throws { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't need default value. |
||
| let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) | ||
|
|
||
| for (i, h) in stdio.enumerated() { | ||
|
|
@@ -96,6 +97,11 @@ extension SandboxClient { | |
| } | ||
| } | ||
|
|
||
| if !dynamicEnv.isEmpty { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be better to keep it simple? |
||
| let encodedDynamicEnv = try JSONEncoder().encode(dynamicEnv) | ||
| request.set(key: SandboxKeys.dynamicEnv.rawValue, value: encodedDynamicEnv) | ||
| } | ||
|
|
||
| do { | ||
| try request.setAllocatedAttachments(allocatedAttachments) | ||
| try await self.client.send(request) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -75,13 +75,78 @@ public actor SandboxService { | |
| } | ||
| } | ||
|
|
||
| private static func sshAuthSocketHostUrl(config: ContainerConfiguration) -> URL? { | ||
| if config.ssh, let sshSocket = Foundation.ProcessInfo.processInfo.environment[Self.sshAuthSocketEnvVar] { | ||
| return URL(fileURLWithPath: sshSocket) | ||
| private enum SSHAuthSocketSource: String { | ||
| /// Path supplied by client at bootstrap time (current shell's SSH_AUTH_SOCK). | ||
| case bootstrap = "bootstrap" | ||
| } | ||
|
|
||
| private static func isUnixSocket(path: String) -> Bool { | ||
| (try? File.info(path).isSocket) ?? false | ||
| } | ||
|
|
||
| /// Resolves the host path for SSH agent socket forwarding. Uses only the path passed by the | ||
| /// client at bootstrap time; the sandbox does not read SSH_AUTH_SOCK from its own environment | ||
| /// or launchctl (the client is responsible for passing the current value). | ||
| private static func resolveSSHAuthSocketHostPath(config: ContainerConfiguration, bootstrapOverridePath: String? = nil) -> (path: String, source: SSHAuthSocketSource)? { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to return
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| guard config.ssh else { | ||
| return nil | ||
| } | ||
|
|
||
| if let bootstrapOverridePath, | ||
| Self.isUnixSocket(path: bootstrapOverridePath) | ||
| { | ||
| return (bootstrapOverridePath, .bootstrap) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| private static func sshAuthSocketHostUrl(config: ContainerConfiguration, bootstrapOverridePath: String? = nil) -> URL? { | ||
| guard let resolved = Self.resolveSSHAuthSocketHostPath(config: config, bootstrapOverridePath: bootstrapOverridePath) else { | ||
| return nil | ||
| } | ||
| return URL(fileURLWithPath: resolved.path) | ||
| } | ||
|
|
||
| /// Merges start-time environment overrides into a base environment list. | ||
| /// Existing keys are replaced in-place and new keys are appended. | ||
| /// Made static for unit testability. | ||
| public static func mergedEnvironmentVariables(base: [String], overrides: [String: String]) -> [String] { | ||
| guard !overrides.isEmpty else { | ||
| return base | ||
| } | ||
|
|
||
| var env = base | ||
| var pending = overrides | ||
|
|
||
| for (index, entry) in env.enumerated() { | ||
| guard let separator = entry.firstIndex(of: "=") else { | ||
| continue | ||
| } | ||
|
|
||
| let key = String(entry[..<separator]) | ||
| if let overrideValue = pending.removeValue(forKey: key) { | ||
| env[index] = "\(key)=\(overrideValue)" | ||
| } | ||
| } | ||
|
|
||
| for key in pending.keys.sorted() { | ||
| guard let value = pending[key] else { | ||
| continue | ||
| } | ||
| env.append("\(key)=\(value)") | ||
| } | ||
|
|
||
| return env | ||
| } | ||
|
|
||
| /// Create an instance with a bundle that describes the container. | ||
| /// | ||
| /// - Parameters: | ||
| /// - root: The file URL for the bundle root. | ||
| /// - interfaceStrategy: The strategy for producing network interface | ||
| /// objects for each network to which the container attaches. | ||
| /// - log: The destination for log messages. | ||
| public init( | ||
| root: URL, | ||
| interfaceStrategies: [NetworkPluginInfo: InterfaceStrategy], | ||
|
|
@@ -144,6 +209,36 @@ public actor SandboxService { | |
| try bundle.createLogFile() | ||
|
|
||
| var config = try bundle.configuration | ||
| // Extract dynamic env overrides from the XPC request; when SSH forwarding is enabled, | ||
| // SSH_AUTH_SOCK from this map provides the host socket path to mount. | ||
| let dynamicEnv: [String: String] = | ||
| if let data = message.dataNoCopy(key: SandboxKeys.dynamicEnv.rawValue) { | ||
| try JSONDecoder().decode([String: String].self, from: data) | ||
| } else { | ||
| [:] | ||
| } | ||
| let bootstrapSshAuthPath = dynamicEnv[Self.sshAuthSocketEnvVar] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's a bit complicated both |
||
|
|
||
| if config.ssh { | ||
| if let resolved = Self.resolveSSHAuthSocketHostPath(config: config, bootstrapOverridePath: bootstrapSshAuthPath) { | ||
| self.log.info( | ||
| "ssh agent forwarding requested", | ||
| metadata: [ | ||
| "hostSocketPath": "\(resolved.path)", | ||
| "hostSocketSource": "\(resolved.source.rawValue)", | ||
| "guestSocketPath": "\(Self.sshAuthSocketGuestPath)", | ||
| ] | ||
| ) | ||
| } else { | ||
| self.log.warning( | ||
| "ssh agent forwarding requested but no valid SSH_AUTH_SOCK source found", | ||
| metadata: [ | ||
| "envVar": "\(Self.sshAuthSocketEnvVar)", | ||
| "bootstrapPath": "\(bootstrapSshAuthPath ?? "")", | ||
| ] | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| var kernel = try bundle.kernel | ||
| kernel.commandLine.kernelArgs.append("oops=panic") | ||
|
|
@@ -215,7 +310,12 @@ public actor SandboxService { | |
| let id = config.id | ||
| let rootfs = try bundle.containerRootfs.asMount | ||
| let container = try LinuxContainer(id, rootfs: rootfs, vmm: vmm, logger: self.log) { czConfig in | ||
| try Self.configureContainer(czConfig: &czConfig, config: config) | ||
| try Self.configureContainer( | ||
| czConfig: &czConfig, | ||
| config: config, | ||
| bootstrapOverridePath: bootstrapSshAuthPath, | ||
| dynamicEnv: dynamicEnv | ||
| ) | ||
| czConfig.interfaces = interfaces | ||
| czConfig.process.stdout = stdout | ||
| czConfig.process.stderr = stderr | ||
|
|
@@ -840,7 +940,9 @@ public actor SandboxService { | |
|
|
||
| private static func configureContainer( | ||
| czConfig: inout LinuxContainer.Configuration, | ||
| config: ContainerConfiguration | ||
| config: ContainerConfiguration, | ||
| bootstrapOverridePath: String? = nil, | ||
| dynamicEnv: [String: String] = [:] | ||
| ) throws { | ||
| czConfig.cpus = config.resources.cpus | ||
| czConfig.memoryInBytes = config.resources.memoryInBytes | ||
|
|
@@ -873,7 +975,7 @@ public actor SandboxService { | |
| czConfig.sockets.append(socketConfig) | ||
| } | ||
|
|
||
| if let socketUrl = Self.sshAuthSocketHostUrl(config: config) { | ||
| if let socketUrl = Self.sshAuthSocketHostUrl(config: config, bootstrapOverridePath: bootstrapOverridePath) { | ||
| let socketPath = socketUrl.path(percentEncoded: false) | ||
| let attrs = try? FileManager.default.attributesOfItem(atPath: socketPath) | ||
| let permissions = (attrs?[.posixPermissions] as? NSNumber) | ||
|
|
@@ -899,7 +1001,12 @@ public actor SandboxService { | |
| searchDomains: dns.searchDomains, options: dns.options) | ||
| } | ||
|
|
||
| try Self.configureInitialProcess(czConfig: &czConfig, config: config) | ||
| try Self.configureInitialProcess( | ||
| czConfig: &czConfig, | ||
| config: config, | ||
| bootstrapOverridePath: bootstrapOverridePath, | ||
| dynamicEnv: dynamicEnv | ||
| ) | ||
| } | ||
|
|
||
| private func getDefaultNameservers(allocatedAttachments: [AllocatedAttachment]) async throws -> [String] { | ||
|
|
@@ -916,17 +1023,21 @@ public actor SandboxService { | |
|
|
||
| private static func configureInitialProcess( | ||
| czConfig: inout LinuxContainer.Configuration, | ||
| config: ContainerConfiguration | ||
| config: ContainerConfiguration, | ||
| bootstrapOverridePath: String? = nil, | ||
| dynamicEnv: [String: String] = [:] | ||
| ) throws { | ||
| let process = config.initProcess | ||
|
|
||
| czConfig.process.arguments = [process.executable] + process.arguments | ||
| czConfig.process.environmentVariables = process.environment | ||
| czConfig.process.environmentVariables = Self.mergedEnvironmentVariables( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit comment, |
||
| base: process.environment, | ||
| overrides: dynamicEnv | ||
| ) | ||
|
|
||
| if Self.sshAuthSocketHostUrl(config: config) != nil { | ||
| if !czConfig.process.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { | ||
| czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") | ||
| } | ||
| if Self.sshAuthSocketHostUrl(config: config, bootstrapOverridePath: bootstrapOverridePath) != nil { | ||
| czConfig.process.environmentVariables.removeAll(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) | ||
| czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") | ||
| } | ||
|
|
||
| czConfig.process.terminal = process.terminal | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be better just keep it simple: encode
dynamicEnvalways (assuming it's not that performance heavy operation).