Skip to content

Commit a3fb4b3

Browse files
authored
Merge pull request #1058 from banditopazzo/705-ssh-key-support-in-build
feat: add support for ssh property in the build command
2 parents 4660feb + ab33954 commit a3fb4b3

File tree

6 files changed

+298
-0
lines changed

6 files changed

+298
-0
lines changed

newsfragments/build-ssh.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added support for "ssh" property in the build command.

podman_compose.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2403,6 +2403,8 @@ async def build_one(compose, args, cnt):
24032403
build_args.extend([f"--build-context={additional_ctx}"])
24042404
if "target" in build_desc:
24052405
build_args.extend(["--target", build_desc["target"]])
2406+
for agent_or_key in norm_as_list(build_desc.get("ssh", {})):
2407+
build_args.extend(["--ssh", agent_or_key])
24062408
container_to_ulimit_build_args(cnt, build_args)
24072409
if getattr(args, "no_cache", None):
24082410
build_args.append("--no-cache")
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Base image
2+
FROM alpine:latest
3+
4+
# Install OpenSSH client
5+
RUN apk add openssh
6+
7+
# Test the SSH agents during the build
8+
9+
RUN echo -n "default: " >> /result.log
10+
RUN --mount=type=ssh ssh-add -L >> /result.log
11+
12+
RUN echo -n "id1: " >> /result.log
13+
RUN --mount=type=ssh,id=id1 ssh-add -L >> /result.log
14+
15+
RUN echo -n "id2: " >> /result.log
16+
RUN --mount=type=ssh,id=id2 ssh-add -L >> /result.log
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
version: "3"
2+
services:
3+
test_build_ssh_map:
4+
build:
5+
context: ./context
6+
dockerfile: Dockerfile
7+
ssh:
8+
default:
9+
id1: "./id_ed25519_dummy"
10+
id2: "./agent_dummy.sock"
11+
image: my-alpine-build-ssh-map
12+
command:
13+
- cat
14+
- /result.log
15+
test_build_ssh_array:
16+
build:
17+
context: ./context
18+
dockerfile: Dockerfile
19+
ssh:
20+
- default
21+
- "id1=./id_ed25519_dummy"
22+
- "id2=./agent_dummy.sock"
23+
image: my-alpine-build-ssh-array
24+
command:
25+
- cat
26+
- /result.log
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3+
QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h
4+
EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg
5+
AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa
6+
pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw==
7+
-----END OPENSSH PRIVATE KEY-----
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# SPDX-License-Identifier: GPL-2.0
2+
3+
import os
4+
import socket
5+
import struct
6+
import threading
7+
import unittest
8+
9+
from cryptography.hazmat.primitives import serialization
10+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
11+
12+
from tests.integration.test_podman_compose import podman_compose_path
13+
from tests.integration.test_podman_compose import test_path
14+
from tests.integration.test_utils import RunSubprocessMixin
15+
16+
expected_lines = [
17+
"default: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
18+
"id1: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
19+
"id2: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum",
20+
]
21+
22+
23+
class TestBuildSsh(unittest.TestCase, RunSubprocessMixin):
24+
def test_build_ssh(self):
25+
"""The build context can contain the ssh authentications that the image builder should
26+
use during image build. They can be either an array or a map.
27+
"""
28+
29+
compose_path = os.path.join(test_path(), "build_ssh/docker-compose.yml")
30+
sock_path = os.path.join(test_path(), "build_ssh/agent_dummy.sock")
31+
private_key_file = os.path.join(test_path(), "build_ssh/id_ed25519_dummy")
32+
33+
agent = MockSSHAgent(private_key_file)
34+
35+
try:
36+
# Set SSH_AUTH_SOCK because `default` expects it
37+
os.environ['SSH_AUTH_SOCK'] = sock_path
38+
39+
# Start a mock SSH agent server
40+
agent.start_agent(sock_path)
41+
42+
self.run_subprocess_assert_returncode([
43+
podman_compose_path(),
44+
"-f",
45+
compose_path,
46+
"build",
47+
"test_build_ssh_map",
48+
"test_build_ssh_array",
49+
])
50+
51+
for test_image in [
52+
"test_build_ssh_map",
53+
"test_build_ssh_array",
54+
]:
55+
out, _ = self.run_subprocess_assert_returncode([
56+
podman_compose_path(),
57+
"-f",
58+
compose_path,
59+
"run",
60+
"--rm",
61+
test_image,
62+
])
63+
64+
out = out.decode('utf-8')
65+
66+
# Check if all lines are contained in the output
67+
self.assertTrue(
68+
all(line in out for line in expected_lines),
69+
f"Incorrect output for image {test_image}",
70+
)
71+
72+
finally:
73+
# Now we send the stop command to gracefully shut down the server
74+
agent.stop_agent()
75+
76+
if os.path.exists(sock_path):
77+
os.remove(sock_path)
78+
79+
self.run_subprocess_assert_returncode([
80+
"podman",
81+
"rmi",
82+
"my-alpine-build-ssh-map",
83+
"my-alpine-build-ssh-array",
84+
])
85+
86+
87+
# SSH agent message types
88+
SSH_AGENTC_REQUEST_IDENTITIES = 11
89+
SSH_AGENT_IDENTITIES_ANSWER = 12
90+
SSH_AGENT_FAILURE = 5
91+
STOP_REQUEST = 0xFF
92+
93+
94+
class MockSSHAgent:
95+
def __init__(self, private_key_path):
96+
self.sock_path = None
97+
self.server_sock = None
98+
self.running = threading.Event()
99+
self.keys = [self._load_ed25519_private_key(private_key_path)]
100+
self.agent_thread = None # Thread to run the agent
101+
102+
def _load_ed25519_private_key(self, private_key_path):
103+
"""Load ED25519 private key from an OpenSSH private key file."""
104+
with open(private_key_path, 'rb') as key_file:
105+
private_key = serialization.load_ssh_private_key(key_file.read(), password=None)
106+
107+
# Ensure it's an Ed25519 key
108+
if not isinstance(private_key, Ed25519PrivateKey):
109+
raise ValueError("Invalid key type, expected ED25519 private key.")
110+
111+
# Get the public key corresponding to the private key
112+
public_key = private_key.public_key()
113+
114+
# Serialize the public key to the OpenSSH format
115+
public_key_blob = public_key.public_bytes(
116+
encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw
117+
)
118+
119+
# SSH key type "ssh-ed25519"
120+
key_type = b"ssh-ed25519"
121+
122+
# Build the key blob (public key part for the agent)
123+
key_blob_full = (
124+
struct.pack(">I", len(key_type))
125+
+ key_type # Key type length + type
126+
+ struct.pack(">I", len(public_key_blob))
127+
+ public_key_blob # Public key length + key blob
128+
)
129+
130+
# Comment (empty)
131+
comment = ""
132+
133+
return ("ssh-ed25519", key_blob_full, comment)
134+
135+
def start_agent(self, sock_path):
136+
"""Start the mock SSH agent and create a Unix domain socket."""
137+
self.sock_path = sock_path
138+
if os.path.exists(self.sock_path):
139+
os.remove(self.sock_path)
140+
141+
self.server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
142+
self.server_sock.bind(self.sock_path)
143+
self.server_sock.listen(5)
144+
145+
os.environ['SSH_AUTH_SOCK'] = self.sock_path
146+
147+
self.running.set() # Set the running event
148+
149+
# Start a thread to accept client connections
150+
self.agent_thread = threading.Thread(target=self._accept_connections, daemon=True)
151+
self.agent_thread.start()
152+
153+
def _accept_connections(self):
154+
"""Accept and handle incoming connections."""
155+
while self.running.is_set():
156+
try:
157+
client_sock, _ = self.server_sock.accept()
158+
self._handle_client(client_sock)
159+
except Exception as e:
160+
print(f"Error accepting connection: {e}")
161+
162+
def _handle_client(self, client_sock):
163+
"""Handle a single client request (like ssh-add)."""
164+
try:
165+
# Read the message length (first 4 bytes)
166+
length_message = client_sock.recv(4)
167+
if not length_message:
168+
raise "no length message received"
169+
170+
msg_len = struct.unpack(">I", length_message)[0]
171+
172+
request_message = client_sock.recv(msg_len)
173+
174+
# Check for STOP_REQUEST
175+
if request_message[0] == STOP_REQUEST:
176+
client_sock.close()
177+
self.running.clear() # Stop accepting connections
178+
return
179+
180+
# Check for SSH_AGENTC_REQUEST_IDENTITIES
181+
if request_message[0] == SSH_AGENTC_REQUEST_IDENTITIES:
182+
response = self._mock_list_keys_response()
183+
client_sock.sendall(response)
184+
else:
185+
print("Message not recognized")
186+
# Send failure if the message type is not recognized
187+
response = struct.pack(">I", 1) + struct.pack(">B", SSH_AGENT_FAILURE)
188+
client_sock.sendall(response)
189+
190+
except socket.error:
191+
print("Client socket error.")
192+
pass # You can handle specific errors here if needed
193+
finally:
194+
client_sock.close() # Ensure the client socket is closed
195+
196+
def _mock_list_keys_response(self):
197+
"""Create a mock response for ssh-add -l, listing keys."""
198+
199+
# Start building the response
200+
response = struct.pack(">B", SSH_AGENT_IDENTITIES_ANSWER) # Message type
201+
202+
# Number of keys
203+
response += struct.pack(">I", len(self.keys))
204+
205+
# For each key, append key blob and comment
206+
for key_type, key_blob, comment in self.keys:
207+
# Key blob length and content
208+
response += struct.pack(">I", len(key_blob)) + key_blob
209+
210+
# Comment length and content
211+
comment_encoded = comment.encode()
212+
response += struct.pack(">I", len(comment_encoded)) + comment_encoded
213+
214+
# Prefix the entire response with the total message length
215+
response = struct.pack(">I", len(response)) + response
216+
217+
return response
218+
219+
def stop_agent(self):
220+
"""Stop the mock SSH agent."""
221+
if self.running.is_set(): # First check if the agent is running
222+
# Create a temporary connection to send the stop command
223+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_sock:
224+
client_sock.connect(self.sock_path) # Connect to the server
225+
226+
stop_command = struct.pack(
227+
">B", STOP_REQUEST
228+
) # Pack the stop command as a single byte
229+
230+
# Send the message length first
231+
message_length = struct.pack(">I", len(stop_command))
232+
client_sock.sendall(message_length) # Send the length first
233+
234+
client_sock.sendall(stop_command) # Send the stop command
235+
236+
self.running.clear() # Stop accepting new connections
237+
238+
# Wait for the agent thread to finish
239+
if self.agent_thread:
240+
self.agent_thread.join() # Wait for the thread to finish
241+
self.agent_thread = None # Reset thread reference
242+
243+
# Remove the socket file only after the server socket is closed
244+
if self.server_sock: # Check if the server socket exists
245+
self.server_sock.close() # Close the server socket
246+
os.remove(self.sock_path)

0 commit comments

Comments
 (0)