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
23 changes: 20 additions & 3 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ extension APIServer {
let pluginLoader = try initializePluginLoader(log: log)

try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes, debug: debug)
// Shared by the containers and volumes services so a volume's secret is written,
// read, and removed through the same keychain domain.
let credentialStore = KeychainVolumeCredentialStore()
let containersService = try initializeContainersService(
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
credentialStore: credentialStore,
log: log,
routes: &routes
)
Expand All @@ -80,7 +84,12 @@ extension APIServer {
await containersService.setNetworksService(networkService)
initializeHealthCheckService(log: log, routes: &routes)
try initializeKernelService(log: log, routes: &routes)
let volumesService = try await initializeVolumeService(containersService: containersService, log: log, routes: &routes)
let volumesService = try await initializeVolumeService(
containersService: containersService,
credentialStore: credentialStore,
log: log,
routes: &routes
)
try initializeDiskUsageService(
containersService: containersService,
volumesService: volumesService,
Expand Down Expand Up @@ -274,6 +283,7 @@ extension APIServer {
private func initializeContainersService(
pluginLoader: PluginLoader,
containerSystemConfig: ContainerSystemConfig,
credentialStore: VolumeCredentialStore,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) throws -> ContainersService {
Expand All @@ -286,7 +296,8 @@ extension APIServer {
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
debugHelpers: debug
debugHelpers: debug,
credentialStore: credentialStore
)
let harness = ContainersHarness(service: service, log: log)

Expand Down Expand Up @@ -360,13 +371,19 @@ extension APIServer {

private func initializeVolumeService(
containersService: ContainersService,
credentialStore: VolumeCredentialStore,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) async throws -> VolumesService {
log.info("initializing volume service")

let resourceRoot = appRoot.appending(FilePath.Component("volumes"))
let service = try await VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let service = try await VolumesService(
resourceRoot: resourceRoot,
containersService: containersService,
log: log,
credentialStore: credentialStore
)
let harness = VolumesHarness(service: service, log: log)

routes[XPCRoute.volumeCreate] = XPCServer.route(harness.create)
Expand Down
15 changes: 14 additions & 1 deletion Sources/ContainerCommands/Volume/VolumeCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Foundation

extension Application.VolumeCommand {
Expand All @@ -25,6 +26,9 @@ extension Application.VolumeCommand {
abstract: "Create a new volume"
)

@Option(name: .customLong("driver"), help: "Volume driver: local, smb, or nfs (default: local; smb and nfs are experimental)")
var driver: String = "local"

@Option(name: .customLong("label"), help: "Set metadata for a volume")
var labels: [String] = []

Expand All @@ -51,9 +55,18 @@ extension Application.VolumeCommand {
parsedDriverOpts["size"] = size
}

// The server validates the options too, but checking here fails fast with a CLI error.
if driver == "smb" || driver == "nfs" {
do {
try MountOptionValidator.validate(parsedDriverOpts)
} catch let violation as MountOptionValidator.Violation {
throw ValidationError(violation.description)
}
}

let volume = try await ClientVolume.create(
name: name,
driver: "local",
driver: driver,
Comment thread
tonycoco marked this conversation as resolved.
driverOpts: parsedDriverOpts,
labels: parsedLabels
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ extension VolumeResource: ListDisplayable {
name,
isAnonymous ? "anonymous" : "named",
configuration.driver,
configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
// The SMB password is kept in the keychain and never stored in the options, so this
// redaction is defence in depth for a volume written by something other than the API server.
configuration.options.isEmpty
? ""
: configuration.options.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\($0.key == "password" ? "***" : $0.value)" }
.joined(separator: ","),
]
}

Expand Down
110 changes: 106 additions & 4 deletions Sources/ContainerResource/Container/Filesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public struct Filesystem: Sendable, Codable {

case block(format: String, cache: CacheMode, sync: SyncMode)
case volume(name: String, format: String, cache: CacheMode, sync: SyncMode)
case smb(name: String? = nil, share: String, mountOptions: [String: String])
case nfs(name: String? = nil, share: String, mountOptions: [String: String])
case virtiofs
case tmpfs
}
Expand Down Expand Up @@ -115,6 +117,30 @@ public struct Filesystem: Sendable, Codable {
)
}

/// An SMB share mounted directly inside the guest via CIFS.
public static func smb(
name: String? = nil, share: String, mountOptions: [String: String], destination: String, options: MountOptions
) -> Filesystem {
.init(
type: .smb(name: name, share: share, mountOptions: mountOptions),
source: share,
destination: destination,
options: options
)
}

/// An NFS share mounted directly inside the guest.
public static func nfs(
name: String? = nil, share: String, mountOptions: [String: String], destination: String, options: MountOptions
) -> Filesystem {
.init(
type: .nfs(name: name, share: share, mountOptions: mountOptions),
source: share,
destination: destination,
options: options
)
}

/// A vritiofs backed filesystem providing a directory.
public static func virtiofs(source: String, destination: String, options: MountOptions) -> Filesystem {
.init(
Expand All @@ -134,6 +160,22 @@ public struct Filesystem: Sendable, Codable {
)
}

/// Returns true if the Filesystem is an SMB volume.
public var isSMB: Bool {
switch type {
case .smb(_, _, _): true
default: false
}
}

/// Returns true if the Filesystem is an NFS volume.
public var isNFS: Bool {
switch type {
case .nfs(_, _, _): true
default: false
}
}

/// Returns true if the Filesystem is backed by a block device.
public var isBlock: Bool {
switch type {
Expand All @@ -145,20 +187,80 @@ public struct Filesystem: Sendable, Codable {

/// Returns true if the Filesystem is a named volume.
public var isVolume: Bool {
switch type {
case .volume(_, _, _, _): true
default: false
}
volumeName != nil
}

/// Returns the volume name if this is a volume filesystem, nil otherwise.
public var volumeName: String? {
switch type {
case .volume(let name, _, _, _): name
case .smb(let name, _, _): name
case .nfs(let name, _, _): name
default: nil
}
}

/// Returns the combined mount options for this filesystem, merging any
/// driver-specific mount options (excluding `share`) in deterministic key order
/// with the general mount options.
package var resolvedMountOptions: MountOptions {
switch type {
case .smb(_, _, let mountOptions), .nfs(_, _, let mountOptions):
return mountOptions.filter { $0.key != MountOptionValidator.shareKey }
.sorted { $0.key < $1.key }
.map { $0.value.isEmpty ? $0.key : "\($0.key)=\($0.value)" } + self.options
default:
return self.options
}
}

/// Checks that the share and driver mount options of an SMB or NFS filesystem are syntactically valid.
///
/// The share is checked as a mount source and the remaining options as the values that are joined
/// into the mount options string, matching how `resolvedMountOptions` treats them. Other filesystem
/// types carry no driver mount options and always pass.
///
/// - Throws: `MountOptionValidator.Violation` for the first value that breaks a rule.
package func validateMountOptions() throws {
switch type {
case .smb(_, let share, let mountOptions), .nfs(_, let share, let mountOptions):
try MountOptionValidator.validateShare(share)
try MountOptionValidator.validate(mountOptions)
default:
break
}
}

/// Checks that an SMB or NFS filesystem carries no secret in its mount options.
///
/// Stored configurations must never hold a password; for SMB it lives in the credential store and
/// is added to the mount options in memory at bootstrap, and NFS has no password option at all.
/// Other filesystem types always pass.
///
/// - Throws: `MountOptionValidator.Violation` naming the offending key.
package func validateNoSecretOptions() throws {
switch type {
case .smb(_, _, let mountOptions), .nfs(_, _, let mountOptions):
try MountOptionValidator.validateNoSecrets(mountOptions)
default:
break
}
}

/// Returns a copy of an SMB filesystem with the password added to its mount options.
///
/// This is used at bootstrap to combine the stored, secret-free configuration with the password
/// read from the credential store. Other filesystem types are returned unchanged.
package func addingSMBPassword(_ password: String) -> Filesystem {
guard case .smb(let name, let share, var mountOptions) = type else {
return self
}
mountOptions[MountOptionValidator.passwordKey] = password
var copy = self
copy.type = .smb(name: name, share: share, mountOptions: mountOptions)
return copy
}

/// Returns true if the Filesystem is backed by a in-memory mount type.
public var isTmpfs: Bool {
switch type {
Expand Down
127 changes: 127 additions & 0 deletions Sources/ContainerResource/Volume/MountOptionValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//

/// Checks that the share and driver options of a network-backed volume are syntactically valid.
///
/// The options are joined into a comma-separated `key=value` string before they reach the guest
/// kernel's mount, so a value that contains a comma, whitespace, or a control character could smuggle
/// in extra options. The share is different: it is the mount source, which is passed to `mount(2)` as
/// its own argument and never joined into the options string, so it only needs to be a well-formed
/// string. The API server checks both when a volume is created and again when a container is created,
/// and the runtime checks them once more before mounting, so none of them takes values read from a
/// client or from storage on faith.
package enum MountOptionValidator {
/// The key under which the share is carried in the driver options.
package static let shareKey = "share"

/// The most characters a key may have.
package static let keyLengthMax = 64

/// The most characters a value may have.
package static let valueLengthMax = 1024

/// The most characters a share may have.
package static let shareLengthMax = 4096

/// The option key under which an SMB password is given to `container volume create`.
package static let passwordKey = "password"

/// The SMB mount option keys that carry or point at a secret.
///
/// `pass` is an alias of `password`, and `credentials`/`cred` name a file of credentials. None of
/// them may appear in a stored volume or container configuration; the password is kept in the
/// credential store instead and added to the mount options only at bootstrap.
package static let secretKeys: Set<String> = [passwordKey, "pass", "credentials", "cred"]

/// A rule that an option breaks. The description says which option and why.
///
/// The description never includes the value, since a value may be a credential.
package struct Violation: Error, CustomStringConvertible, Equatable {
package let description: String

init(_ description: String) {
self.description = description
}
}

/// Checks that the share of a network mount is well formed.
///
/// A share may contain spaces and commas, which are common in SMB share names, since it is never
/// joined into the options string. It must not be empty or contain control characters, which would
/// truncate or corrupt the C string handed to `mount(2)`.
///
/// - Parameter share: The share, such as `//server/share` or `server:/export/path`.
/// - Throws: `Violation` if the share breaks a rule.
package static func validateShare(_ share: String) throws {
guard !share.isEmpty else {
throw Violation("share must not be empty")
}
let sharePattern = #/^[^\p{Cc}]{1,4096}$/#
guard share.wholeMatch(of: sharePattern) != nil else {
throw Violation("invalid share: must not contain control characters, with at most \(Self.shareLengthMax) characters")
}
}

/// Checks that `options` carries no secret.
///
/// - Parameter options: The mount options of an SMB or NFS filesystem.
/// - Throws: `Violation` naming the first secret key found, in key order.
package static func validateNoSecrets(_ options: [String: String]) throws {
if let key = options.keys.sorted().first(where: Self.secretKeys.contains) {
throw Violation(
"mount option \"\(key)\" is not allowed here; give the password to `container volume create --driver smb` so it is kept in the keychain, or delete and recreate the volume if it was created by an earlier build"
)
}
}

/// Checks that every key and value in `options` follows the rules.
///
/// - A key starts with a letter and contains only letters, digits, `_`, and `-`, with at most
/// `keyLengthMax` characters.
/// - A value contains no commas, whitespace, or control characters, with at most `valueLengthMax`
/// characters. An empty value denotes a bare flag such as `nolock`.
///
/// The share, if present under `shareKey`, is checked with `validateShare` instead, since it is
/// not joined into the options string. Options are checked in key order, so the same input always
/// produces the same error.
///
/// - Parameter options: The driver options, as parsed from `--opt key=value`.
/// - Throws: `Violation` for the first option that breaks a rule.
package static func validate(_ options: [String: String]) throws {
let keyPattern = #/^[A-Za-z][A-Za-z0-9_-]{0,63}$/#
let valuePattern = #/^[^,\s\p{Cc}]{0,1024}$/#

for key in options.keys.sorted() {
guard let value = options[key] else {
continue
}
if key == Self.shareKey {
try Self.validateShare(value)
continue
}
guard key.wholeMatch(of: keyPattern) != nil else {
throw Violation(
"invalid mount option key \"\(key)\": must start with a letter and contain only letters, digits, '_', or '-', with at most \(Self.keyLengthMax) characters"
)
}
guard value.wholeMatch(of: valuePattern) != nil else {
throw Violation(
"invalid value for mount option \"\(key)\": must not contain commas, whitespace, or control characters, with at most \(Self.valueLengthMax) characters"
)
}
}
}
}
Loading