Skip to content

Commit

Permalink
feat: ssh support for build command
Browse files Browse the repository at this point in the history
Fixes #705: Add support for ssh property in the build command

Signed-off-by: Domenico Salvatore <[email protected]>
  • Loading branch information
banditopazzo committed Oct 12, 2024
1 parent 7090de3 commit de03e45
Show file tree
Hide file tree
Showing 6 changed files with 271 additions and 0 deletions.
1 change: 1 addition & 0 deletions newsfragments/build-ssh.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for "ssh" property in the build command.
2 changes: 2 additions & 0 deletions podman_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions tests/integration/build_ssh/context/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions tests/integration/build_ssh/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions tests/integration/build_ssh/id_ed25519_dummy
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h
EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg
AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa
pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw==
-----END OPENSSH PRIVATE KEY-----
226 changes: 226 additions & 0 deletions tests/integration/build_ssh/test_build_ssh.py
Original file line number Diff line number Diff line change
@@ -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.")

0 comments on commit de03e45

Please sign in to comment.