diff --git a/newsfragments/build-ssh.feature b/newsfragments/build-ssh.feature new file mode 100644 index 00000000..d74711f9 --- /dev/null +++ b/newsfragments/build-ssh.feature @@ -0,0 +1 @@ +Added support for "ssh" property in the build command. diff --git a/podman_compose.py b/podman_compose.py index 6b974f54..59fa4b58 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -2401,6 +2401,8 @@ async def build_one(compose, args, cnt): build_args.extend([f"--build-context={additional_ctx}"]) if "target" in build_desc: build_args.extend(["--target", build_desc["target"]]) + for agent_or_key in norm_as_list(build_desc.get("ssh", {})): + build_args.extend(["--ssh", agent_or_key]) container_to_ulimit_build_args(cnt, build_args) if getattr(args, "no_cache", None): build_args.append("--no-cache") diff --git a/tests/integration/build_ssh/context/Dockerfile b/tests/integration/build_ssh/context/Dockerfile new file mode 100644 index 00000000..f8439b05 --- /dev/null +++ b/tests/integration/build_ssh/context/Dockerfile @@ -0,0 +1,13 @@ +# Base image +FROM alpine:latest + +# Install OpenSSH client +RUN apk add openssh + +# Test the SSH agents during the build + +RUN --mount=type=ssh ssh-add -L + +RUN --mount=type=ssh,id=id1 ssh-add -L + +RUN --mount=type=ssh,id=id2 ssh-add -L diff --git a/tests/integration/build_ssh/docker-compose.yml b/tests/integration/build_ssh/docker-compose.yml new file mode 100644 index 00000000..261688a4 --- /dev/null +++ b/tests/integration/build_ssh/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3" +services: + test_build_ssh_map: + build: + context: ./context + dockerfile: Dockerfile + ssh: + default: + id1: "./id_ed25519_dummy" + id2: "./agent_dummy.sock" + image: my-alpine-build-ssh-map + command: env + test_build_ssh_array: + build: + context: ./context + dockerfile: Dockerfile + ssh: + - default + - "id1=./id_ed25519_dummy" + - "id2=./agent_dummy.sock" + image: my-alpine-build-ssh-array + command: env diff --git a/tests/integration/build_ssh/id_ed25519_dummy b/tests/integration/build_ssh/id_ed25519_dummy new file mode 100644 index 00000000..ea326ae7 --- /dev/null +++ b/tests/integration/build_ssh/id_ed25519_dummy @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h +EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg +AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa +pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw== +-----END OPENSSH PRIVATE KEY----- diff --git a/tests/integration/build_ssh/test_build_ssh.py b/tests/integration/build_ssh/test_build_ssh.py new file mode 100644 index 00000000..f26b10f3 --- /dev/null +++ b/tests/integration/build_ssh/test_build_ssh.py @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: GPL-2.0 + +import os +import socket +import struct +import threading +import unittest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from tests.integration.test_podman_compose import podman_compose_path +from tests.integration.test_podman_compose import test_path +from tests.integration.test_utils import RunSubprocessMixin + + +class TestBuildSsh(unittest.TestCase, RunSubprocessMixin): + def test_build_ssh(self): + """The build context can contain the ssh authentications that the image builder should + use during image build. They can be either an array or a map. + """ + + compose_path = os.path.join(test_path(), "build_ssh/docker-compose.yml") + sock_path = os.path.join(test_path(), "build_ssh/agent_dummy.sock") + private_key_file = os.path.join(test_path(), "build_ssh/id_ed25519_dummy") + + agent = MockSSHAgent(private_key_file) + + try: + # Set SSH_AUTH_SOCK because `default` expects it + os.environ['SSH_AUTH_SOCK'] = sock_path + + # Start a mock SSH agent server + agent.start_agent(sock_path) + + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_path, + "build", + "test_build_ssh_map", + "test_build_ssh_array", + ]) + + finally: + # Now we send the stop command to gracefully shut down the server + agent.stop_agent() + # agent_thread.join() + if os.path.exists(sock_path): + os.remove(sock_path) + + self.run_subprocess_assert_returncode([ + "podman", + "rmi", + "my-alpine-build-ssh-map", + "my-alpine-build-ssh-array", + ]) + + +# SSH agent message types +SSH_AGENTC_REQUEST_IDENTITIES = 11 +SSH_AGENT_IDENTITIES_ANSWER = 12 +SSH_AGENT_FAILURE = 5 +STOP_REQUEST = 0xFF + + +class MockSSHAgent: + def __init__(self, private_key_path): + self.sock_path = None + self.server_sock = None + self.running = threading.Event() + self.keys = [self._load_ed25519_private_key(private_key_path)] + self.agent_thread = None # Thread to run the agent + + def _load_ed25519_private_key(self, private_key_path): + """Load ED25519 private key from an OpenSSH private key file.""" + with open(private_key_path, 'rb') as key_file: + private_key = serialization.load_ssh_private_key(key_file.read(), password=None) + + # Ensure it's an Ed25519 key + if not isinstance(private_key, Ed25519PrivateKey): + raise ValueError("Invalid key type, expected ED25519 private key.") + + # Get the public key corresponding to the private key + public_key = private_key.public_key() + + # Serialize the public key to the OpenSSH format + public_key_blob = public_key.public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + + # SSH key type "ssh-ed25519" + key_type = b"ssh-ed25519" + + # Build the key blob (public key part for the agent) + key_blob_full = ( + struct.pack(">I", len(key_type)) + + key_type # Key type length + type + + struct.pack(">I", len(public_key_blob)) + + public_key_blob # Public key length + key blob + ) + + # Comment (typically username@hostname) + comment = "user@host" + + return ("ssh-ed25519", key_blob_full, comment) + + def start_agent(self, sock_path): + """Start the mock SSH agent and create a Unix domain socket.""" + self.sock_path = sock_path + if os.path.exists(self.sock_path): + os.remove(self.sock_path) + + self.server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server_sock.bind(self.sock_path) + self.server_sock.listen(5) + + # print(f"Mock SSH agent running with socket: {self.sock_path}") + os.environ['SSH_AUTH_SOCK'] = self.sock_path + + self.running.set() # Set the running event + + # Start a thread to accept client connections + self.agent_thread = threading.Thread(target=self._accept_connections, daemon=True) + self.agent_thread.start() + + def _accept_connections(self): + """Accept and handle incoming connections.""" + while self.running.is_set(): + try: + client_sock, _ = self.server_sock.accept() + # print("Connection received, handling client request.") + self._handle_client(client_sock) + except Exception as e: + print(f"Error accepting connection: {e}") + + def _handle_client(self, client_sock): + """Handle a single client request (like ssh-add).""" + try: + # Read the message length (first 4 bytes) + length_message = client_sock.recv(4) + if not length_message: + raise "no length message received" + + msg_len = struct.unpack(">I", length_message)[0] + + request_message = client_sock.recv(msg_len) + + # Check for STOP_REQUEST + if request_message[0] == STOP_REQUEST: + # print("Received stop request.") + client_sock.close() + self.running.clear() # Stop accepting connections + return + + # Check for SSH_AGENTC_REQUEST_IDENTITIES + if request_message[0] == SSH_AGENTC_REQUEST_IDENTITIES: + response = self._mock_list_keys_response() + client_sock.sendall(response) + else: + print("Message not recognized") + # Send failure if the message type is not recognized + response = struct.pack(">I", 1) + struct.pack(">B", SSH_AGENT_FAILURE) + client_sock.sendall(response) + + except socket.error: + print("Client socket error.") + pass # You can handle specific errors here if needed + finally: + client_sock.close() # Ensure the client socket is closed + + def _mock_list_keys_response(self): + """Create a mock response for ssh-add -l, listing keys.""" + + # print("Mocking response") + + # Start building the response + response = struct.pack(">B", SSH_AGENT_IDENTITIES_ANSWER) # Message type + + # Number of keys + response += struct.pack(">I", len(self.keys)) + + # For each key, append key blob and comment + for key_type, key_blob, comment in self.keys: + # Key blob length and content + response += struct.pack(">I", len(key_blob)) + key_blob + + # Comment length and content + comment_encoded = comment.encode() + response += struct.pack(">I", len(comment_encoded)) + comment_encoded + + # Prefix the entire response with the total message length + response = struct.pack(">I", len(response)) + response + + return response + + def stop_agent(self): + """Stop the mock SSH agent.""" + if self.running.is_set(): # First check if the agent is running + # Create a temporary connection to send the stop command + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_sock: + client_sock.connect(self.sock_path) # Connect to the server + + stop_command = struct.pack( + ">B", STOP_REQUEST + ) # Pack the stop command as a single byte + + # Send the message length first + message_length = struct.pack(">I", len(stop_command)) + client_sock.sendall(message_length) # Send the length first + + client_sock.sendall(stop_command) # Send the stop command + + self.running.clear() # Stop accepting new connections + + # Wait for the agent thread to finish + if self.agent_thread: + self.agent_thread.join() # Wait for the thread to finish + self.agent_thread = None # Reset thread reference + + # Remove the socket file only after the server socket is closed + if self.server_sock: # Check if the server socket exists + self.server_sock.close() # Close the server socket + os.remove(self.sock_path) + + # print("Mock SSH agent stopped.")