Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,11 @@ let package = Package(
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
<<<<<<< sshFlagAuthSocketFix
.product(name: "ContainerizationOCI", package: "containerization"),
=======
"ContainerAPIService",
>>>>>>> main
"ContainerResource",
]
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public struct ContainerConfiguration: Sendable, Codable {
public var virtualization: Bool = false
/// Enable SSH agent socket forwarding from host to container.
public var ssh: Bool = false
/// Optiional preferred SSH agent socket path captured from client-side environment
public var sshAuthSocketPath: String? = nil
/// Whether to mount the rootfs as read-only.
public var readOnly: Bool = false

Expand All @@ -69,6 +71,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case runtimeHandler
case virtualization
case ssh
case sshAuthSocketPath
case readOnly
}

Expand Down Expand Up @@ -99,6 +102,7 @@ public struct ContainerConfiguration: Sendable, Codable {
runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux"
virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false
ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false
sshAuthSocketPath = try container.decodeIfPresent(String.self, forKey: .sshAuthSocketPath)
readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ public struct Utility {
config.publishedSockets = try Parser.publishSockets(management.publishSockets)

config.ssh = management.ssh
if management.ssh {
config.sshAuthSocketPath = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]
}
config.readOnly = management.readOnly

if let runtime = management.runtime {
Expand Down
108 changes: 105 additions & 3 deletions Sources/Services/ContainerSandboxService/Server/SandboxService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,82 @@ public actor SandboxService {
private static let sshAuthSocketGuestPath = "/run/host-services/ssh-auth.sock"
private static let sshAuthSocketEnvVar = "SSH_AUTH_SOCK"

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 {
case config = "config"
case runtimeEnv = "runtimeEnv"
case launchctl = "launchctl"
}

private static func isUnixSocket(path: String) -> Bool {
(try? File.info(path).isSocket) ?? false
}

private static func launchctlSSHAuthSock() -> String? {
let proc = Foundation.Process()
proc.executableURL = URL(fileURLWithPath: "/bin/launchctl")
proc.arguments = ["getenv", Self.sshAuthSocketEnvVar]

let out = Pipe()
proc.standardOutput = out
proc.standardError = Pipe()

do {
try proc.run()
proc.waitUntilExit()
guard proc.terminationStatus == 0 else {
return nil
}
let data = out.fileHandleForReading.readDataToEndOfFile()
guard var value = String(data: data, encoding: .utf8) else {
return nil
}
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
} catch {
return nil
}
}

private static func resolveSSHAuthSocketHostPath(config: ContainerConfiguration) -> (path: String, source: SSHAuthSocketSource)? {
guard config.ssh else {
return nil
}

if let configuredPath = config.sshAuthSocketPath,
Self.isUnixSocket(path: configuredPath)
{
return (configuredPath, .config)
}

if let envPath = Foundation.ProcessInfo.processInfo.environment[Self.sshAuthSocketEnvVar],
Self.isUnixSocket(path: envPath)
{
return (envPath, .runtimeEnv)
}

if let launchctlPath = Self.launchctlSSHAuthSock(),
Self.isUnixSocket(path: launchctlPath)
{
return (launchctlPath, .launchctl)
}

return nil
}

private static func sshAuthSocketHostUrl(config: ContainerConfiguration) -> URL? {
guard let resolved = Self.resolveSSHAuthSocketHostPath(config: config) else {
return nil
}
return URL(fileURLWithPath: resolved.path)
}

/// 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,
interfaceStrategy: InterfaceStrategy,
Expand Down Expand Up @@ -120,6 +189,39 @@ public actor SandboxService {

var config = try bundle.configuration

if config.ssh {
let runtimeSshAuthSock = Foundation.ProcessInfo.processInfo.environment[Self.sshAuthSocketEnvVar]
let runtimeSocketIsValid = runtimeSshAuthSock.map { Self.isUnixSocket(path: $0) } ?? false
let configuredSshAuthSock = config.sshAuthSocketPath
let configuredSocketIsValid = configuredSshAuthSock.map { Self.isUnixSocket(path: $0) } ?? false
if let resolved = Self.resolveSSHAuthSocketHostPath(config: config) {
self.log.info(
"ssh agent forwarding requested",
metadata: [
"hostSocketPath": "\(resolved.path)",
"hostSocketSource": "\(resolved.source.rawValue)",
"hostSocketIsSocket": "true",
"guestSocketPath": "\(Self.sshAuthSocketGuestPath)",
"configuredSocketPath": "\(configuredSshAuthSock ?? "")",
"configuredSocketIsValid": "\(configuredSocketIsValid)",
"runtimeEnvSocketPath": "\(runtimeSshAuthSock ?? "")",
"runtimeEnvSocketIsValid": "\(runtimeSocketIsValid)",
]
)
} else {
self.log.warning(
"ssh agent forwarding requested but no valid SSH_AUTH_SOCK source found",
metadata: [
"envVar": "\(Self.sshAuthSocketEnvVar)",
"configuredSocketPath": "\(configuredSshAuthSock ?? "")",
"configuredSocketIsValid": "\(configuredSocketIsValid)",
"runtimeEnvSocketPath": "\(runtimeSshAuthSock ?? "")",
"runtimeEnvSocketIsValid": "\(runtimeSocketIsValid)",
]
)
}
}

var kernel = try bundle.kernel
kernel.commandLine.kernelArgs.append("oops=panic")
kernel.commandLine.kernelArgs.append("lsm=lockdown,capability,landlock,yama,apparmor")
Expand Down
98 changes: 98 additions & 0 deletions Tests/ContainerResourceTests/SSHConfigTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerizationOCI
import Foundation
import Testing

@testable import ContainerResource

/// Unit tests for SSH agent forwarding configuration (`ssh`, `sshAuthSocketPath`).
/// Ensures the config model correctly persists and restores the caller's socket path
/// so runtime resolution (config → runtime env → launchctl) is not regressed.
struct SSHConfigTests {

@Test("SSH config round-trip: ssh and sshAuthSocketPath are preserved after encode/decode")
func sshConfigRoundTripPreservesSocketPath() throws {
var config = makeMinimalConfig()
config.ssh = true
config.sshAuthSocketPath = "/path/to/agent.sock"

let encoded = try JSONEncoder().encode(config)
let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: encoded)

#expect(decoded.ssh == true)
#expect(decoded.sshAuthSocketPath == "/path/to/agent.sock")
}

@Test("SSH config round-trip: ssh false and nil path are preserved")
func sshConfigRoundTripPreservesFalseAndNil() throws {
let config = makeMinimalConfig()
#expect(config.ssh == false)
#expect(config.sshAuthSocketPath == nil)

let encoded = try JSONEncoder().encode(config)
let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: encoded)

#expect(decoded.ssh == false)
#expect(decoded.sshAuthSocketPath == nil)
}

@Test("SSH config decode: missing ssh and sshAuthSocketPath default to false and nil")
func sshConfigDecodeDefaults() throws {
let minimalJSON = """
{
"id": "test",
"image": {"reference": "alpine", "descriptor": {"digest": "sha256:test", "mediaType": "application/vnd.oci.image.manifest.v1+json", "size": 0}},
"initProcess": {
"executable": "/bin/sh",
"arguments": [],
"environment": [],
"workingDirectory": "/",
"terminal": false,
"user": {"id": {"uid": 0, "gid": 0}},
"supplementalGroups": [],
"rlimits": []
}
}
"""
let data = minimalJSON.data(using: .utf8)!
let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: data)

#expect(decoded.ssh == false)
#expect(decoded.sshAuthSocketPath == nil)
}

private func makeMinimalConfig() -> ContainerConfiguration {
let descriptor = Descriptor(
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: "sha256:test",
size: 0
)
let image = ImageDescription(reference: "alpine", descriptor: descriptor)
let process = ProcessConfiguration(
executable: "/bin/sh",
arguments: [],
environment: [],
workingDirectory: "/",
terminal: false,
user: .id(uid: 0, gid: 0),
supplementalGroups: [],
rlimits: []
)
return ContainerConfiguration(id: "test", image: image, process: process)
}
}