From 7962dae643beaa7fed53cfbfe5dabab6ecfe0a40 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Thu, 11 Dec 2025 12:07:17 -0800 Subject: [PATCH] Add capabilities support (#444) Closes https://github.com/apple/containerization/issues/442 This adds capabilities support to LinuxContainer via a new surface in ContainerizationOS + some C wrappers. --- Sources/CShim/capability.c | 32 + Sources/CShim/include/capability.h | 28 + Sources/CShim/include/prctl.h | 33 + Sources/CShim/prctl.c | 47 ++ .../LinuxProcessConfiguration.swift | 115 ++++ Sources/ContainerizationOCI/Spec.swift | 47 +- .../Linux/Capabilities.swift | 651 ++++++++++++++++++ Sources/Integration/ContainerTests.swift | 199 ++++++ Sources/Integration/Suite.swift | 5 + Sources/cctl/RunCommand.swift | 1 + vminitd/Sources/vmexec/ExecCommand.swift | 7 + vminitd/Sources/vmexec/RunCommand.swift | 7 + vminitd/Sources/vmexec/vmexec.swift | 45 ++ 13 files changed, 1207 insertions(+), 10 deletions(-) create mode 100644 Sources/CShim/capability.c create mode 100644 Sources/CShim/include/capability.h create mode 100644 Sources/CShim/include/prctl.h create mode 100644 Sources/CShim/prctl.c create mode 100644 Sources/ContainerizationOS/Linux/Capabilities.swift diff --git a/Sources/CShim/capability.c b/Sources/CShim/capability.c new file mode 100644 index 00000000..0c0a6edc --- /dev/null +++ b/Sources/CShim/capability.c @@ -0,0 +1,32 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization 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. + */ + +#if defined(__linux__) + +#include +#include +#include "capability.h" + +// Capability syscall wrappers +int CZ_capget(void *header, void *data) { + return syscall(SYS_capget, header, data); +} + +int CZ_capset(void *header, void *data) { + return syscall(SYS_capset, header, data); +} + +#endif diff --git a/Sources/CShim/include/capability.h b/Sources/CShim/include/capability.h new file mode 100644 index 00000000..4cab5679 --- /dev/null +++ b/Sources/CShim/include/capability.h @@ -0,0 +1,28 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization 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. + */ + +#ifndef __CAPABILITY_H +#define __CAPABILITY_H + +#if defined(__linux__) + +// Capability syscall wrappers +int CZ_capget(void *header, void *data); +int CZ_capset(void *header, void *data); + +#endif + +#endif diff --git a/Sources/CShim/include/prctl.h b/Sources/CShim/include/prctl.h new file mode 100644 index 00000000..a5e91abf --- /dev/null +++ b/Sources/CShim/include/prctl.h @@ -0,0 +1,33 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization 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. + */ + +#ifndef __PRCTL_H +#define __PRCTL_H + +#if defined(__linux__) + +#include + +// Capability management prctl wrappers +int CZ_prctl_set_keepcaps(); +int CZ_prctl_clear_keepcaps(); +int CZ_prctl_capbset_drop(unsigned int capability); +int CZ_prctl_cap_ambient_clear_all(); +int CZ_prctl_cap_ambient_raise(unsigned int capability); + +#endif + +#endif diff --git a/Sources/CShim/prctl.c b/Sources/CShim/prctl.c new file mode 100644 index 00000000..ec6c38aa --- /dev/null +++ b/Sources/CShim/prctl.c @@ -0,0 +1,47 @@ +/* + * Copyright © 2025 Apple Inc. and the Containerization 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. + */ + +#if defined(__linux__) + +#include +#include "prctl.h" + +// Set keep caps to preserve capabilities across setuid() +int CZ_prctl_set_keepcaps() { + return prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0); +} + +// Clear keep caps after user change +int CZ_prctl_clear_keepcaps() { + return prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0); +} + +// Drop capability from bounding set +int CZ_prctl_capbset_drop(unsigned int capability) { + return prctl(PR_CAPBSET_DROP, capability, 0, 0, 0); +} + +// Clear all ambient capabilities +int CZ_prctl_cap_ambient_clear_all() { + return prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0); +} + +// Raise ambient capability +int CZ_prctl_cap_ambient_raise(unsigned int capability) { + return prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, capability, 0, 0); +} + +#endif diff --git a/Sources/Containerization/LinuxProcessConfiguration.swift b/Sources/Containerization/LinuxProcessConfiguration.swift index 8c749a06..78c7a2a3 100644 --- a/Sources/Containerization/LinuxProcessConfiguration.swift +++ b/Sources/Containerization/LinuxProcessConfiguration.swift @@ -17,6 +17,116 @@ import ContainerizationOCI import ContainerizationOS +/// User-friendly Linux capabilities configuration +public struct LinuxCapabilities: Sendable { + /// Capabilities that define the maximum set of capabilities a process can have + public var bounding: [CapabilityName] = [] + /// Capabilities that are actually in effect for the current process + public var effective: [CapabilityName] = [] + /// Capabilities that can be inherited by child processes + public var inheritable: [CapabilityName] = [] + /// Capabilities that are currently permitted for the process + public var permitted: [CapabilityName] = [] + /// Capabilities that are preserved across execve() calls + public var ambient: [CapabilityName] = [] + + /// Grant all capabilities + public static let allCapabilities = LinuxCapabilities( + bounding: CapabilityName.allCases, + effective: CapabilityName.allCases, + inheritable: CapabilityName.allCases, + permitted: CapabilityName.allCases, + ambient: CapabilityName.allCases + ) + + /// Default configuration + public static let defaultOCICapabilities = LinuxCapabilities( + bounding: [ + .chown, + .dacOverride, + .fsetid, + .fowner, + .mknod, + .netRaw, + .setgid, + .setuid, + .setfcap, + .setpcap, + .netBindService, + .sysChroot, + .kill, + .auditWrite, + ], + effective: [ + .chown, + .dacOverride, + .fsetid, + .fowner, + .mknod, + .netRaw, + .setgid, + .setuid, + .setfcap, + .setpcap, + .netBindService, + .sysChroot, + .kill, + .auditWrite, + ], + permitted: [ + .chown, + .dacOverride, + .fsetid, + .fowner, + .mknod, + .netRaw, + .setgid, + .setuid, + .setfcap, + .setpcap, + .netBindService, + .sysChroot, + .kill, + .auditWrite, + ], + ) + + public init( + bounding: [CapabilityName] = [], + effective: [CapabilityName] = [], + inheritable: [CapabilityName] = [], + permitted: [CapabilityName] = [], + ambient: [CapabilityName] = [] + ) { + self.bounding = bounding + self.effective = effective + self.inheritable = inheritable + self.permitted = permitted + self.ambient = ambient + } + + /// Convenience initializer that sets the same capabilities to effective, permitted, and bounding sets + /// This matches the typical pattern used by containerd/runc + public init(capabilities: [CapabilityName]) { + self.bounding = capabilities + self.effective = capabilities + self.inheritable = [] + self.permitted = capabilities + self.ambient = [] + } + + /// Convert to OCI format for transport + public func toOCI() -> ContainerizationOCI.LinuxCapabilities { + ContainerizationOCI.LinuxCapabilities( + bounding: bounding.isEmpty ? nil : bounding.map { $0.description }, + effective: effective.isEmpty ? nil : effective.map { $0.description }, + inheritable: inheritable.isEmpty ? nil : inheritable.map { $0.description }, + permitted: permitted.isEmpty ? nil : permitted.map { $0.description }, + ambient: ambient.isEmpty ? nil : ambient.map { $0.description } + ) + } +} + public struct LinuxProcessConfiguration: Sendable { /// The default PATH value for a process. public static let defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -31,6 +141,8 @@ public struct LinuxProcessConfiguration: Sendable { public var user: ContainerizationOCI.User = .init() /// The rlimits for the container process. public var rlimits: [POSIXRlimit] = [] + /// The Linux capabilities for the container process. + public var capabilities: LinuxCapabilities = .allCapabilities /// Whether to allocate a pseudo terminal for the process. If you'd like interactive /// behavior and are planning to use a terminal for stdin/out/err on the client side, /// this should likely be set to true. @@ -50,6 +162,7 @@ public struct LinuxProcessConfiguration: Sendable { workingDirectory: String = "/", user: ContainerizationOCI.User = .init(), rlimits: [POSIXRlimit] = [], + capabilities: LinuxCapabilities = .allCapabilities, terminal: Bool = false, stdin: ReaderStream? = nil, stdout: Writer? = nil, @@ -60,6 +173,7 @@ public struct LinuxProcessConfiguration: Sendable { self.workingDirectory = workingDirectory self.user = user self.rlimits = rlimits + self.capabilities = capabilities self.terminal = terminal self.stdin = stdin self.stdout = stdout @@ -92,6 +206,7 @@ public struct LinuxProcessConfiguration: Sendable { args: self.arguments, cwd: self.workingDirectory, env: self.environmentVariables, + capabilities: self.capabilities.toOCI(), user: self.user, rlimits: self.rlimits, terminal: self.terminal diff --git a/Sources/ContainerizationOCI/Spec.swift b/Sources/ContainerizationOCI/Spec.swift index 1fa496fb..5171925c 100644 --- a/Sources/ContainerizationOCI/Spec.swift +++ b/Sources/ContainerizationOCI/Spec.swift @@ -157,6 +157,7 @@ public struct Process: Codable, Sendable { }() self.init(args: args, cwd: cwd, env: env, user: user) } + public init(from decoder: Decoder) throws { self.init() @@ -194,18 +195,26 @@ public struct Process: Codable, Sendable { } public struct LinuxCapabilities: Codable, Sendable { - public var bounding: [String] - public var effective: [String] - public var inheritable: [String] - public var permitted: [String] - public var ambient: [String] + public var bounding: [String]? + public var effective: [String]? + public var inheritable: [String]? + public var permitted: [String]? + public var ambient: [String]? + + enum CodingKeys: String, CodingKey { + case bounding + case effective + case inheritable + case permitted + case ambient + } public init( - bounding: [String], - effective: [String], - inheritable: [String], - permitted: [String], - ambient: [String] + bounding: [String]? = nil, + effective: [String]? = nil, + inheritable: [String]? = nil, + permitted: [String]? = nil, + ambient: [String]? = nil ) { self.bounding = bounding self.effective = effective @@ -213,6 +222,24 @@ public struct LinuxCapabilities: Codable, Sendable { self.permitted = permitted self.ambient = ambient } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bounding = try container.decodeIfPresent([String].self, forKey: .bounding) + self.effective = try container.decodeIfPresent([String].self, forKey: .effective) + self.inheritable = try container.decodeIfPresent([String].self, forKey: .inheritable) + self.permitted = try container.decodeIfPresent([String].self, forKey: .permitted) + self.ambient = try container.decodeIfPresent([String].self, forKey: .ambient) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(bounding, forKey: .bounding) + try container.encodeIfPresent(effective, forKey: .effective) + try container.encodeIfPresent(inheritable, forKey: .inheritable) + try container.encodeIfPresent(permitted, forKey: .permitted) + try container.encodeIfPresent(ambient, forKey: .ambient) + } } public struct Box: Codable, Sendable { diff --git a/Sources/ContainerizationOS/Linux/Capabilities.swift b/Sources/ContainerizationOS/Linux/Capabilities.swift new file mode 100644 index 00000000..7879513e --- /dev/null +++ b/Sources/ContainerizationOS/Linux/Capabilities.swift @@ -0,0 +1,651 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the Containerization 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 CShim +import Foundation + +// MARK: - Configuration Types + +public struct CapabilitySet: Sendable, Hashable { + private enum Value: Hashable, Sendable, CaseIterable { + case bounding + case effective + case inheritable + case permitted + case ambient + } + + private var value: Value + private init(_ value: Value) { + self.value = value + } + + public init(rawValue: String) { + let values = Value.allCases.reduce(into: [String: Value]()) { + $0[String(describing: $1)] = $1 + } + + let match = values[rawValue] + guard let match else { + fatalError("invalid CapabilitySet Value \(rawValue)") + } + self.value = match + } + + public static var bounding: Self { Self(.bounding) } + public static var effective: Self { Self(.effective) } + public static var inheritable: Self { Self(.inheritable) } + public static var permitted: Self { Self(.permitted) } + public static var ambient: Self { Self(.ambient) } +} + +extension CapabilitySet: CustomStringConvertible { + public var description: String { + String(describing: self.value) + } +} + +public struct CapabilityName: Sendable, Hashable { + private enum Value: Hashable, Sendable, CaseIterable { + case chown + case dacOverride + case dacReadSearch + case fowner + case fsetid + case kill + case setgid + case setuid + case setpcap + case linuxImmutable + case netBindService + case netBroadcast + case netAdmin + case netRaw + case ipcLock + case ipcOwner + case sysModule + case sysRawio + case sysChroot + case sysPtrace + case sysPacct + case sysAdmin + case sysBoot + case sysNice + case sysResource + case sysTime + case sysTtyConfig + case mknod + case lease + case auditWrite + case auditControl + case setfcap + case macOverride + case macAdmin + case syslog + case wakeAlarm + case blockSuspend + case auditRead + case perfmon + case bpf + case checkpointRestore + } + + private var value: Value + private init(_ value: Value) { + self.value = value + } + + public init(rawValue: String) { + let normalized = rawValue.hasPrefix("CAP_") ? rawValue : "CAP_\(rawValue)" + + let capNameMap: [String: Value] = [ + "CAP_CHOWN": .chown, + "CAP_DAC_OVERRIDE": .dacOverride, + "CAP_DAC_READ_SEARCH": .dacReadSearch, + "CAP_FOWNER": .fowner, + "CAP_FSETID": .fsetid, + "CAP_KILL": .kill, + "CAP_SETGID": .setgid, + "CAP_SETUID": .setuid, + "CAP_SETPCAP": .setpcap, + "CAP_LINUX_IMMUTABLE": .linuxImmutable, + "CAP_NET_BIND_SERVICE": .netBindService, + "CAP_NET_BROADCAST": .netBroadcast, + "CAP_NET_ADMIN": .netAdmin, + "CAP_NET_RAW": .netRaw, + "CAP_IPC_LOCK": .ipcLock, + "CAP_IPC_OWNER": .ipcOwner, + "CAP_SYS_MODULE": .sysModule, + "CAP_SYS_RAWIO": .sysRawio, + "CAP_SYS_CHROOT": .sysChroot, + "CAP_SYS_PTRACE": .sysPtrace, + "CAP_SYS_PACCT": .sysPacct, + "CAP_SYS_ADMIN": .sysAdmin, + "CAP_SYS_BOOT": .sysBoot, + "CAP_SYS_NICE": .sysNice, + "CAP_SYS_RESOURCE": .sysResource, + "CAP_SYS_TIME": .sysTime, + "CAP_SYS_TTY_CONFIG": .sysTtyConfig, + "CAP_MKNOD": .mknod, + "CAP_LEASE": .lease, + "CAP_AUDIT_WRITE": .auditWrite, + "CAP_AUDIT_CONTROL": .auditControl, + "CAP_SETFCAP": .setfcap, + "CAP_MAC_OVERRIDE": .macOverride, + "CAP_MAC_ADMIN": .macAdmin, + "CAP_SYSLOG": .syslog, + "CAP_WAKE_ALARM": .wakeAlarm, + "CAP_BLOCK_SUSPEND": .blockSuspend, + "CAP_AUDIT_READ": .auditRead, + "CAP_PERFMON": .perfmon, + "CAP_BPF": .bpf, + "CAP_CHECKPOINT_RESTORE": .checkpointRestore, + ] + + guard let match = capNameMap[normalized] else { + fatalError("invalid CapabilityName \(normalized)") + } + self.value = match + } + + public var capValue: UInt32 { + switch self.value { + case .chown: return 0 + case .dacOverride: return 1 + case .dacReadSearch: return 2 + case .fowner: return 3 + case .fsetid: return 4 + case .kill: return 5 + case .setgid: return 6 + case .setuid: return 7 + case .setpcap: return 8 + case .linuxImmutable: return 9 + case .netBindService: return 10 + case .netBroadcast: return 11 + case .netAdmin: return 12 + case .netRaw: return 13 + case .ipcLock: return 14 + case .ipcOwner: return 15 + case .sysModule: return 16 + case .sysRawio: return 17 + case .sysChroot: return 18 + case .sysPtrace: return 19 + case .sysPacct: return 20 + case .sysAdmin: return 21 + case .sysBoot: return 22 + case .sysNice: return 23 + case .sysResource: return 24 + case .sysTime: return 25 + case .sysTtyConfig: return 26 + case .mknod: return 27 + case .lease: return 28 + case .auditWrite: return 29 + case .auditControl: return 30 + case .setfcap: return 31 + case .macOverride: return 32 + case .macAdmin: return 33 + case .syslog: return 34 + case .wakeAlarm: return 35 + case .blockSuspend: return 36 + case .auditRead: return 37 + case .perfmon: return 38 + case .bpf: return 39 + case .checkpointRestore: return 40 + } + } + + public static var chown: Self { Self(.chown) } + public static var dacOverride: Self { Self(.dacOverride) } + public static var dacReadSearch: Self { Self(.dacReadSearch) } + public static var fowner: Self { Self(.fowner) } + public static var fsetid: Self { Self(.fsetid) } + public static var kill: Self { Self(.kill) } + public static var setgid: Self { Self(.setgid) } + public static var setuid: Self { Self(.setuid) } + public static var setpcap: Self { Self(.setpcap) } + public static var linuxImmutable: Self { Self(.linuxImmutable) } + public static var netBindService: Self { Self(.netBindService) } + public static var netBroadcast: Self { Self(.netBroadcast) } + public static var netAdmin: Self { Self(.netAdmin) } + public static var netRaw: Self { Self(.netRaw) } + public static var ipcLock: Self { Self(.ipcLock) } + public static var ipcOwner: Self { Self(.ipcOwner) } + public static var sysModule: Self { Self(.sysModule) } + public static var sysRawio: Self { Self(.sysRawio) } + public static var sysChroot: Self { Self(.sysChroot) } + public static var sysPtrace: Self { Self(.sysPtrace) } + public static var sysPacct: Self { Self(.sysPacct) } + public static var sysAdmin: Self { Self(.sysAdmin) } + public static var sysBoot: Self { Self(.sysBoot) } + public static var sysNice: Self { Self(.sysNice) } + public static var sysResource: Self { Self(.sysResource) } + public static var sysTime: Self { Self(.sysTime) } + public static var sysTtyConfig: Self { Self(.sysTtyConfig) } + public static var mknod: Self { Self(.mknod) } + public static var lease: Self { Self(.lease) } + public static var auditWrite: Self { Self(.auditWrite) } + public static var auditControl: Self { Self(.auditControl) } + public static var setfcap: Self { Self(.setfcap) } + public static var macOverride: Self { Self(.macOverride) } + public static var macAdmin: Self { Self(.macAdmin) } + public static var syslog: Self { Self(.syslog) } + public static var wakeAlarm: Self { Self(.wakeAlarm) } + public static var blockSuspend: Self { Self(.blockSuspend) } + public static var auditRead: Self { Self(.auditRead) } + public static var perfmon: Self { Self(.perfmon) } + public static var bpf: Self { Self(.bpf) } + public static var checkpointRestore: Self { Self(.checkpointRestore) } + + public static var allCases: [CapabilityName] { + Value.allCases.map { CapabilityName($0) } + } +} + +extension CapabilityName: CustomStringConvertible { + public var description: String { + switch self.value { + case .chown: return "CAP_CHOWN" + case .dacOverride: return "CAP_DAC_OVERRIDE" + case .dacReadSearch: return "CAP_DAC_READ_SEARCH" + case .fowner: return "CAP_FOWNER" + case .fsetid: return "CAP_FSETID" + case .kill: return "CAP_KILL" + case .setgid: return "CAP_SETGID" + case .setuid: return "CAP_SETUID" + case .setpcap: return "CAP_SETPCAP" + case .linuxImmutable: return "CAP_LINUX_IMMUTABLE" + case .netBindService: return "CAP_NET_BIND_SERVICE" + case .netBroadcast: return "CAP_NET_BROADCAST" + case .netAdmin: return "CAP_NET_ADMIN" + case .netRaw: return "CAP_NET_RAW" + case .ipcLock: return "CAP_IPC_LOCK" + case .ipcOwner: return "CAP_IPC_OWNER" + case .sysModule: return "CAP_SYS_MODULE" + case .sysRawio: return "CAP_SYS_RAWIO" + case .sysChroot: return "CAP_SYS_CHROOT" + case .sysPtrace: return "CAP_SYS_PTRACE" + case .sysPacct: return "CAP_SYS_PACCT" + case .sysAdmin: return "CAP_SYS_ADMIN" + case .sysBoot: return "CAP_SYS_BOOT" + case .sysNice: return "CAP_SYS_NICE" + case .sysResource: return "CAP_SYS_RESOURCE" + case .sysTime: return "CAP_SYS_TIME" + case .sysTtyConfig: return "CAP_SYS_TTY_CONFIG" + case .mknod: return "CAP_MKNOD" + case .lease: return "CAP_LEASE" + case .auditWrite: return "CAP_AUDIT_WRITE" + case .auditControl: return "CAP_AUDIT_CONTROL" + case .setfcap: return "CAP_SETFCAP" + case .macOverride: return "CAP_MAC_OVERRIDE" + case .macAdmin: return "CAP_MAC_ADMIN" + case .syslog: return "CAP_SYSLOG" + case .wakeAlarm: return "CAP_WAKE_ALARM" + case .blockSuspend: return "CAP_BLOCK_SUSPEND" + case .auditRead: return "CAP_AUDIT_READ" + case .perfmon: return "CAP_PERFMON" + case .bpf: return "CAP_BPF" + case .checkpointRestore: return "CAP_CHECKPOINT_RESTORE" + } + } +} + +// MARK: - Linux Implementation + +#if os(Linux) + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +import CShim + +/// Capability type flags +public struct CapType: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + // Individual capability sets (for Get/Set/Unset/etc) + public static let effective = CapType(rawValue: 1 << 0) + public static let permitted = CapType(rawValue: 1 << 1) + public static let inheritable = CapType(rawValue: 1 << 2) + public static let bounding = CapType(rawValue: 1 << 3) + public static let ambient = CapType(rawValue: 1 << 4) + + // Bulk operation flags (for Apply/Fill/Clear) + public static let caps = CapType(rawValue: 1 << 8) // CAPS - effective, permitted, inheritable + public static let bounds = CapType(rawValue: 1 << 9) // BOUNDS - bounding set + public static let ambs = CapType(rawValue: 1 << 10) // AMBS - ambient capabilities +} + +private struct CapabilityHeader { + var version: UInt32 + var pid: Int32 + + init(pid: Int32 = 0) { + self.version = 0x2008_0522 + self.pid = pid + } +} + +private struct CapabilityData { + var effective1: UInt32 + var permitted1: UInt32 + var inheritable1: UInt32 + var effective2: UInt32 + var permitted2: UInt32 + var inheritable2: UInt32 + + init( + effective1: UInt32 = 0, + permitted1: UInt32 = 0, + inheritable1: UInt32 = 0, + effective2: UInt32 = 0, + permitted2: UInt32 = 0, + inheritable2: UInt32 = 0 + ) { + self.effective1 = effective1 + self.permitted1 = permitted1 + self.inheritable1 = inheritable1 + self.effective2 = effective2 + self.permitted2 = permitted2 + self.inheritable2 = inheritable2 + } +} + +/// Interface with Linux capabilities +/// https://linux.die.net/man/7/capabilities +public struct LinuxCapabilities: Sendable { + private var effectiveSet: UInt64 = 0 + private var permittedSet: UInt64 = 0 + private var inheritableSet: UInt64 = 0 + private var boundingSet: UInt64 = 0 + private var ambientSet: UInt64 = 0 + + public init() {} + + /// Get the highest supported capability from the kernel + public static func getLastSupported() throws -> CapabilityName { + guard let data = try? String(contentsOfFile: "/proc/sys/kernel/cap_last_cap", encoding: .ascii), + let lastCap = UInt32(data.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + throw LinuxCapabilities.Error.invalidCapabilitySet("failed to read /proc/sys/kernel/cap_last_cap") + } + + guard let capability = CapabilityName.allCases.first(where: { $0.capValue == lastCap }) else { + throw LinuxCapabilities.Error.invalidCapabilitySet("no capability found for kernel max cap \(lastCap)") + } + + return capability + } + + /// Set keep caps + public static func setKeepCaps() throws { + let result = CZ_prctl_set_keepcaps() + if result != 0 { + throw LinuxCapabilities.Error.prctlFailed(errno: errno, operation: "PR_SET_KEEPCAPS") + } + } + + /// Clear keep caps + public static func clearKeepCaps() throws { + let result = CZ_prctl_clear_keepcaps() + if result != 0 { + throw LinuxCapabilities.Error.prctlFailed(errno: errno, operation: "PR_CLEAR_KEEPCAPS") + } + } + + /// Load current process capabilities from kernel + public mutating func load() throws { + let data = try getCurrentCapabilities() + self.effectiveSet = UInt64(data.effective1) + self.permittedSet = UInt64(data.permitted1) + self.inheritableSet = UInt64(data.inheritable1) + } + + /// Check if capability is present in the given set + public func get(which: CapType, what: CapabilityName) -> Bool { + let bit = UInt64(1) << what.capValue + + if which.contains(.effective) { + return (effectiveSet & bit) != 0 + } else if which.contains(.permitted) { + return (permittedSet & bit) != 0 + } else if which.contains(.inheritable) { + return (inheritableSet & bit) != 0 + } else if which.contains(.bounding) { + return (boundingSet & bit) != 0 + } else if which.contains(.ambient) { + return (ambientSet & bit) != 0 + } + return false + } + + /// Set capabilities in the given sets + public mutating func set(which: CapType, caps: [CapabilityName]) { + let mask = caps.reduce(UInt64(0)) { result, cap in + result | (UInt64(1) << cap.capValue) + } + + if which.contains(.effective) { + effectiveSet |= mask + } + if which.contains(.permitted) { + permittedSet |= mask + } + if which.contains(.inheritable) { + inheritableSet |= mask + } + if which.contains(.bounding) { + boundingSet |= mask + } + if which.contains(.ambient) { + ambientSet |= mask + } + } + + /// Unset capabilities from the given sets + public mutating func unset(which: CapType, caps: [CapabilityName]) { + let mask = caps.reduce(UInt64(0)) { result, cap in + result | (UInt64(1) << cap.capValue) + } + + if which.contains(.effective) { + effectiveSet &= ~mask + } + if which.contains(.permitted) { + permittedSet &= ~mask + } + if which.contains(.inheritable) { + inheritableSet &= ~mask + } + if which.contains(.bounding) { + boundingSet &= ~mask + } + if which.contains(.ambient) { + ambientSet &= ~mask + } + } + + /// Fill all bits of given capability types + public mutating func fill(kind: CapType) { + if kind.contains(.caps) { + effectiveSet = 0xFFFF_FFFF_FFFF_FFFF + permittedSet = 0xFFFF_FFFF_FFFF_FFFF + inheritableSet = 0 + } + if kind.contains(.bounds) { + boundingSet = 0xFFFF_FFFF_FFFF_FFFF + } + if kind.contains(.ambs) { + ambientSet = 0xFFFF_FFFF_FFFF_FFFF + } + } + + /// Clear all bits of given capability types + public mutating func clear(kind: CapType) { + if kind.contains(.caps) { + effectiveSet = 0 + permittedSet = 0 + inheritableSet = 0 + } + if kind.contains(.bounds) { + boundingSet = 0 + } + if kind.contains(.ambs) { + ambientSet = 0 + } + } + + /// Apply capabilities to current process + public func apply(kind: CapType) throws { + // Apply bounding set (requires CAP_SETPCAP) + if kind.contains(.bounds) { + try applyBoundingSet() + } + + // Apply main capabilities (effective, permitted, inheritable) + if kind.contains(.caps) { + try applyMainCapabilities() + } + + // Apply ambient capabilities + if kind.contains(.ambs) { + try applyAmbientCapabilities() + } + } + + private func applyBoundingSet() throws { + let currentData = try getCurrentCapabilities() + let hasSetPCap = (currentData.effective1 & (1 << CapabilityName.setpcap.capValue)) != 0 + + if hasSetPCap { + // Get the last supported capability to avoid trying to drop unsupported ones + let lastSupported = try Self.getLastSupported() + + for cap in CapabilityName.allCases { + // Skip capabilities higher than what the kernel supports + guard cap.capValue <= lastSupported.capValue else { continue } + + let capBit = UInt64(1) << cap.capValue + if (boundingSet & capBit) == 0 { + let result = CZ_prctl_capbset_drop(cap.capValue) + if result != 0 && errno != EINVAL { + throw Error.prctlFailed(errno: errno, operation: "PR_CAPBSET_DROP") + } + } + } + } + } + + private func applyMainCapabilities() throws { + let data = CapabilityData( + effective1: UInt32(effectiveSet & 0xFFFF_FFFF), + permitted1: UInt32(permittedSet & 0xFFFF_FFFF), + inheritable1: UInt32(inheritableSet & 0xFFFF_FFFF) + ) + + try setCapabilities(data: data) + } + + private func applyAmbientCapabilities() throws { + // Clear all ambient capabilities first + let clearResult = CZ_prctl_cap_ambient_clear_all() + if clearResult != 0 && errno != EINVAL { + throw Error.prctlFailed(errno: errno, operation: "PR_CAP_AMBIENT_CLEAR_ALL") + } + + // Get the last supported capability to avoid trying to set unsupported ones + let lastSupported = try Self.getLastSupported() + + // Set each ambient capability + for cap in CapabilityName.allCases { + // Skip capabilities higher than what the kernel supports + guard cap.capValue <= lastSupported.capValue else { continue } + + let capBit = UInt64(1) << cap.capValue + if (ambientSet & capBit) != 0 { + let result = CZ_prctl_cap_ambient_raise(cap.capValue) + if result != 0 && errno != EINVAL { + throw Error.prctlFailed(errno: errno, operation: "PR_CAP_AMBIENT_RAISE") + } + } + } + } + + private func getCurrentCapabilities() throws -> CapabilityData { + var header = CapabilityHeader() + var data = CapabilityData() + + let result = withUnsafeMutablePointer(to: &header) { headerPtr in + withUnsafeMutablePointer(to: &data) { dataPtr in + CZ_capget(headerPtr, dataPtr) + } + } + + if result != 0 { + throw Error.capgetFailed(errno: errno) + } + + return data + } + + private func setCapabilities(data: CapabilityData) throws { + var header = CapabilityHeader() + var mutableData = data + + let result = withUnsafeMutablePointer(to: &header) { headerPtr in + withUnsafeMutablePointer(to: &mutableData) { dataPtr in + CZ_capset(headerPtr, dataPtr) + } + } + + if result != 0 { + throw Error.capsetFailed(errno: errno) + } + } +} + +extension LinuxCapabilities { + public enum Error: Swift.Error, CustomStringConvertible { + case unsupportedCapability(name: String) + case capsetFailed(errno: Int32) + case capgetFailed(errno: Int32) + case prctlFailed(errno: Int32, operation: String) + case invalidCapabilitySet(String) + + public var description: String { + switch self { + case .unsupportedCapability(let name): + return "unsupported capability: \(name)" + case .capsetFailed(let errno): + return "capset failed with errno \(errno): \(String(cString: strerror(errno)))" + case .capgetFailed(let errno): + return "capget failed with errno \(errno): \(String(cString: strerror(errno)))" + case .prctlFailed(let errno, let operation): + return "prctl(\(operation)) failed with errno \(errno): \(String(cString: strerror(errno)))" + case .invalidCapabilitySet(let message): + return "invalid capability set configuration: \(message)" + } + } + } +} + +#endif diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 966fd3d7..e5b0cad0 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -1237,4 +1237,203 @@ extension IntegrationSuite { try await container.stop() throw IntegrationError.assert(msg: "container start should have failed") } + + // MARK: - Capability Tests + + func testCapabilitiesSysAdmin() async throws { + let id = "test-capabilities-sysadmin" + + let bs = try await bootstrap(id) + + // First test: without CAP_SYS_ADMIN (should be denied) + let bufferDenied = BufferWriter() + let containerWithoutSysAdmin = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities() + config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp || echo 'mount failed as expected'"] + config.process.stdout = bufferDenied + config.bootLog = bs.bootLog + } + + try await containerWithoutSysAdmin.create() + try await containerWithoutSysAdmin.start() + + var status = try await containerWithoutSysAdmin.wait() + try await containerWithoutSysAdmin.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container should have run successfully, got exit code \(status.exitCode)") + } + + guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard outputDenied.contains("mount failed as expected") else { + throw IntegrationError.assert(msg: "expected mount failure message, got: \(outputDenied)") + } + + // Second test: with CAP_SYS_ADMIN (should succeed) + let containerWithSysAdmin = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities(capabilities: [.sysAdmin]) + config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp"] + config.bootLog = bs.bootLog + } + + try await containerWithSysAdmin.create() + try await containerWithSysAdmin.start() + + status = try await containerWithSysAdmin.wait() + try await containerWithSysAdmin.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container with CAP_SYS_ADMIN should mount successfully, got exit code \(status.exitCode)") + } + } + + func testCapabilitiesNetAdmin() async throws { + let id = "test-capabilities-netadmin" + + let bs = try await bootstrap(id) + + // First test: without CAP_NET_ADMIN (should be denied) + let bufferDenied = BufferWriter() + let containerWithoutNetAdmin = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities() + config.process.arguments = ["/bin/sh", "-c", "ip link set lo down 2>/dev/null || echo 'network operation denied as expected'"] + config.process.stdout = bufferDenied + config.bootLog = bs.bootLog + } + + try await containerWithoutNetAdmin.create() + try await containerWithoutNetAdmin.start() + + var status = try await containerWithoutNetAdmin.wait() + try await containerWithoutNetAdmin.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container should handle network denial gracefully, got exit code \(status.exitCode)") + } + + guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard outputDenied.contains("network operation denied as expected") else { + throw IntegrationError.assert(msg: "expected network denial message, got: \(outputDenied)") + } + + // Second test: with CAP_NET_ADMIN (should succeed) + let bufferAllowed = BufferWriter() + let containerWithNetAdmin = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities(capabilities: [.netAdmin]) + config.process.arguments = ["/bin/sh", "-c", "ip link set lo down && ip link set lo up"] + config.process.stdout = bufferAllowed + config.bootLog = bs.bootLog + } + + try await containerWithNetAdmin.create() + try await containerWithNetAdmin.start() + + status = try await containerWithNetAdmin.wait() + try await containerWithNetAdmin.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container with CAP_NET_ADMIN should perform network operations, got exit code \(status.exitCode)") + } + } + + func testCapabilitiesOCIDefault() async throws { + let id = "test-capabilities-OCI-default" + + let bs = try await bootstrap(id) + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // Use default capability set + config.process.capabilities = .defaultOCICapabilities + config.process.arguments = ["/bin/sh", "-c", "echo 'Running with OCI default capabilities'"] + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container with OCI default capabilities should run, got exit code \(status.exitCode)") + } + } + + func testCapabilitiesAllCapabilities() async throws { + let id = "test-capabilities-all" + + let bs = try await bootstrap(id) + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = .allCapabilities + config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp && ip link set lo down"] + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container with all capabilities should perform all operations, got exit code \(status.exitCode)") + } + } + + func testCapabilitiesFileOwnership() async throws { + let id = "test-capabilities-chown" + + let bs = try await bootstrap(id) + + // First test: without CAP_CHOWN + let bufferDenied = BufferWriter() + let containerWithoutChown = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities() + config.process.arguments = ["/bin/sh", "-c", "touch /tmp/testfile && chown 1000:1000 /tmp/testfile 2>/dev/null || echo 'chown denied as expected'"] + config.process.stdout = bufferDenied + config.bootLog = bs.bootLog + } + + try await containerWithoutChown.create() + try await containerWithoutChown.start() + + var status = try await containerWithoutChown.wait() + try await containerWithoutChown.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container should handle chown denial gracefully, got exit code \(status.exitCode)") + } + + guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard outputDenied.contains("chown denied as expected") else { + throw IntegrationError.assert(msg: "expected chown denial message, got: \(outputDenied)") + } + + // Second test: with CAP_CHOWN + let bufferAllowed = BufferWriter() + let containerWithChown = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.capabilities = LinuxCapabilities(capabilities: [.chown]) + config.process.arguments = ["/bin/sh", "-c", "touch /tmp/testfile && chown 1000:1000 /tmp/testfile"] + config.process.stdout = bufferAllowed + config.bootLog = bs.bootLog + } + + try await containerWithChown.create() + try await containerWithChown.start() + + status = try await containerWithChown.wait() + try await containerWithChown.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container with CAP_CHOWN should succeed, got exit code \(status.exitCode)") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 0d191fe7..3d2747b3 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -300,6 +300,11 @@ struct IntegrationSuite: AsyncParsableCommand { Test("process delete idempotency", testProcessDeleteIdempotency), Test("multiple execs without delete", testMultipleExecsWithoutDelete), Test("container bootlog using filehandle", testBootLogFileHandle), + Test("container capabilities sys admin", testCapabilitiesSysAdmin), + Test("container capabilities net admin", testCapabilitiesNetAdmin), + Test("container capabilities OCI default", testCapabilitiesOCIDefault), + Test("container capabilities all capabilities", testCapabilitiesAllCapabilities), + Test("container capabilities file ownership", testCapabilitiesFileOwnership), // Pods Test("pod single container", testPodSingleContainer), diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index 1670e866..a704d417 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -100,6 +100,7 @@ extension Application { config.process.setTerminalIO(terminal: current) config.process.arguments = arguments config.process.workingDirectory = cwd + config.process.capabilities = .allCapabilities for mount in self.mounts { let paths = mount.split(separator: ":") diff --git a/vminitd/Sources/vmexec/ExecCommand.swift b/vminitd/Sources/vmexec/ExecCommand.swift index e7a63c8d..af80f8ca 100644 --- a/vminitd/Sources/vmexec/ExecCommand.swift +++ b/vminitd/Sources/vmexec/ExecCommand.swift @@ -16,6 +16,7 @@ import ArgumentParser import ContainerizationOCI +import ContainerizationOS import Foundation import LCShim import Logging @@ -133,12 +134,18 @@ struct ExecCommand: ParsableCommand { try App.applyCloseExecOnFDs() try App.setRLimits(rlimits: process.rlimits) + // Prepare capabilities (before user change) + let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities()) + // Change stdio to be owned by the requested user. try App.fixStdioPerms(user: process.user) // Set uid, gid, and supplementary groups try App.setPermissions(user: process.user) + // Finish capabilities (after user change) + try App.finishCapabilities(preparedCaps) + try App.exec(process: process) } else { // parent process // Send our child's pid to our parent before we exit. diff --git a/vminitd/Sources/vmexec/RunCommand.swift b/vminitd/Sources/vmexec/RunCommand.swift index 47ac234f..779f1c5b 100644 --- a/vminitd/Sources/vmexec/RunCommand.swift +++ b/vminitd/Sources/vmexec/RunCommand.swift @@ -17,6 +17,7 @@ import ArgumentParser import Cgroup import ContainerizationOCI +import ContainerizationOS import Foundation import LCShim import Logging @@ -141,12 +142,18 @@ struct RunCommand: ParsableCommand { try App.setRLimits(rlimits: process.rlimits) + // Prepare capabilities (before user change) + let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities()) + // Change stdio to be owned by the requested user. try App.fixStdioPerms(user: process.user) // Set uid, gid, and supplementary groups. try App.setPermissions(user: process.user) + // Finish capabilities (after user change) + try App.finishCapabilities(preparedCaps) + // Finally execve the container process. try App.exec(process: process, currentEnv: process.env) } diff --git a/vminitd/Sources/vmexec/vmexec.swift b/vminitd/Sources/vmexec/vmexec.swift index a2ec7b7c..0f70242c 100644 --- a/vminitd/Sources/vmexec/vmexec.swift +++ b/vminitd/Sources/vmexec/vmexec.swift @@ -171,6 +171,51 @@ extension App { } } + static func prepareCapabilities(capabilities: ContainerizationOCI.LinuxCapabilities) throws -> ContainerizationOS.LinuxCapabilities? { + // Create capabilities instance from OCI config + var caps = ContainerizationOS.LinuxCapabilities() + + caps.set(which: [.effective], caps: (capabilities.effective ?? []).compactMap { CapabilityName(rawValue: $0) }) + caps.set(which: [.permitted], caps: (capabilities.permitted ?? []).compactMap { CapabilityName(rawValue: $0) }) + caps.set(which: [.inheritable], caps: (capabilities.inheritable ?? []).compactMap { CapabilityName(rawValue: $0) }) + caps.set(which: [.bounding], caps: (capabilities.bounding ?? []).compactMap { CapabilityName(rawValue: $0) }) + caps.set(which: [.ambient], caps: (capabilities.ambient ?? []).compactMap { CapabilityName(rawValue: $0) }) + + // Apply bounding set BEFORE user change (drop capabilities early) + do { + try caps.apply(kind: .bounds) + } catch { + throw App.Failure(message: "failed to apply bounding set capabilities: \(error)") + } + + // Set keep caps to preserve capabilities across setuid() + do { + try LinuxCapabilities.setKeepCaps() + } catch { + throw App.Failure(message: "failed to set keep caps: \(error)") + } + + return caps + } + + static func finishCapabilities(_ caps: ContainerizationOS.LinuxCapabilities?) throws { + guard let caps = caps else { return } + + do { + try LinuxCapabilities.clearKeepCaps() + } catch { + throw App.Failure(message: "failed to clear keep caps: \(error)") + } + + do { + try caps.apply(kind: [.caps]) + } catch { + throw App.Failure(message: "failed to apply final capabilities: \(error)") + } + + try? caps.apply(kind: [.ambs]) + } + static func Errno(stage: String, info: String = "") -> ContainerizationError { let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage]) return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))")