diff --git a/src/main/java/de/tum/in/www1/artemis/config/icl/ssh/SshConstants.java b/src/main/java/de/tum/in/www1/artemis/config/icl/ssh/SshConstants.java index 5bc0d4e686c1..1c133aa4c1bc 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/icl/ssh/SshConstants.java +++ b/src/main/java/de/tum/in/www1/artemis/config/icl/ssh/SshConstants.java @@ -10,5 +10,7 @@ @Profile(PROFILE_LOCALVC) public class SshConstants { + public static final AttributeRepository.AttributeKey IS_BUILD_AGENT_KEY = new AttributeRepository.AttributeKey<>(); + public static final AttributeRepository.AttributeKey USER_KEY = new AttributeRepository.AttributeKey<>(); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java index 7fbfdf9e5e42..e3563ed8d80d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/SharedQueueManagementService.java @@ -139,7 +139,7 @@ public List getBuildAgentInformation() { public List getBuildAgentInformationWithoutRecentBuildJobs() { return buildAgentInformation.values().stream().map(agent -> new BuildAgentInformation(agent.name(), agent.maxNumberOfConcurrentBuildJobs(), - agent.numberOfCurrentBuildJobs(), agent.runningBuildJobs(), agent.status(), null)).toList(); + agent.numberOfCurrentBuildJobs(), agent.runningBuildJobs(), agent.status(), null, null)).toList(); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildAgentSshKeyService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildAgentSshKeyService.java new file mode 100644 index 000000000000..393e250e6b00 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/BuildAgentSshKeyService.java @@ -0,0 +1,105 @@ +package de.tum.in.www1.artemis.service.connectors.localci.buildagent; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_BUILDAGENT; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Optional; + +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyEncryptionContext; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Profile; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +@Service +@Profile(PROFILE_BUILDAGENT) +public class BuildAgentSshKeyService { + + private static final Logger log = LoggerFactory.getLogger(BuildAgentSshKeyService.class); + + private KeyPair keyPair; + + @Value("${artemis.version-control.ssh-private-key-folder-path:#{null}}") + private Optional gitSshPrivateKeyPath; + + @Value("${artemis.version-control.build-agent-use-ssh:false}") + private boolean useSshForBuildAgent; + + @Value("${info.contact}") + private String sshKeyComment; + + /** + * Generates the SSH key pair and writes the private key when the application is started and the build agents should use SSH for their git operations. + */ + @EventListener(ApplicationReadyEvent.class) + public void applicationReady() { + if (!useSshForBuildAgent) { + return; + } + + log.info("Using SSH for build agent authentication."); + + if (gitSshPrivateKeyPath.isEmpty()) { + throw new RuntimeException("No SSH private key folder was set but should use SSH for build agent authentication."); + } + + try { + generateKeyPair(); + writePrivateKey(); + } + catch (IOException | GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + private void generateKeyPair() throws NoSuchAlgorithmException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(4096); + keyPair = keyGen.generateKeyPair(); + } + + private void writePrivateKey() throws IOException, GeneralSecurityException { + Path privateKeyPath = Path.of(gitSshPrivateKeyPath.orElseThrow(), "id_rsa"); + OpenSSHKeyPairResourceWriter writer = new OpenSSHKeyPairResourceWriter(); + + try (OutputStream outputStream = Files.newOutputStream(privateKeyPath)) { + writer.writePrivateKey(keyPair, sshKeyComment, new OpenSSHKeyEncryptionContext(), outputStream); + } + + Files.setPosixFilePermissions(privateKeyPath, PosixFilePermissions.fromString("rw-------")); + } + + /** + * Returns the formated SSH public key. + * If SSH is not used for the build agent, it returns {@code null}. + * + * @return the public key + */ + public String getPublicKeyAsString() { + if (!useSshForBuildAgent) { + return null; + } + + OpenSSHKeyPairResourceWriter writer = new OpenSSHKeyPairResourceWriter(); + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + writer.writePublicKey(keyPair, sshKeyComment, outputStream); + return outputStream.toString(); + } + catch (IOException | GeneralSecurityException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java index 755db417bed8..853b89493827 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/buildagent/SharedQueueProcessingService.java @@ -63,6 +63,8 @@ public class SharedQueueProcessingService { private final AtomicInteger localProcessingJobs = new AtomicInteger(0); + private final BuildAgentSshKeyService buildAgentSSHKeyService; + /** * Lock to prevent multiple nodes from processing the same build job. */ @@ -87,11 +89,12 @@ public class SharedQueueProcessingService { private UUID listenerId; public SharedQueueProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ExecutorService localCIBuildExecutorService, - BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap) { + BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap, BuildAgentSshKeyService buildAgentSSHKeyService) { this.hazelcastInstance = hazelcastInstance; this.localCIBuildExecutorService = (ThreadPoolExecutor) localCIBuildExecutorService; this.buildJobManagementService = buildJobManagementService; this.buildLogsMap = buildLogsMap; + this.buildAgentSSHKeyService = buildAgentSSHKeyService; } /** @@ -104,7 +107,7 @@ public void init() { this.sharedLock = this.hazelcastInstance.getCPSubsystem().getLock("buildJobQueueLock"); this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); - this.listenerId = this.queue.addItemListener(new SharedQueueProcessingService.QueuedBuildJobItemListener(), true); + this.listenerId = this.queue.addItemListener(new QueuedBuildJobItemListener(), true); } @PreDestroy @@ -268,7 +271,9 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue recentBuildJobs.add(recentBuildJob); } - return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, active, recentBuildJobs); + String publicSshKey = buildAgentSSHKeyService.getPublicKeyAsString(); + + return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, active, recentBuildJobs, publicSshKey); } private List getProcessingJobsOfNode(String memberAddress) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/dto/BuildAgentInformation.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/dto/BuildAgentInformation.java index 9dcce26f6479..e69880c94650 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/dto/BuildAgentInformation.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/dto/BuildAgentInformation.java @@ -10,7 +10,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, boolean status, - List recentBuildJobs) implements Serializable { + List recentBuildJobs, String publicSshKey) implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -23,6 +23,6 @@ public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJ */ public BuildAgentInformation(BuildAgentInformation agentInformation, List recentBuildJobs) { this(agentInformation.name(), agentInformation.maxNumberOfConcurrentBuildJobs(), agentInformation.numberOfCurrentBuildJobs(), agentInformation.runningBuildJobs, - agentInformation.status(), recentBuildJobs); + agentInformation.status(), recentBuildJobs, agentInformation.publicSshKey()); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/icl/GitPublickeyAuthenticatorService.java b/src/main/java/de/tum/in/www1/artemis/service/icl/GitPublickeyAuthenticatorService.java index 51ccf391de0e..2d2c14155dc7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/icl/GitPublickeyAuthenticatorService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/icl/GitPublickeyAuthenticatorService.java @@ -2,8 +2,11 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_LOCALVC; +import java.io.IOException; +import java.security.GeneralSecurityException; import java.security.PublicKey; import java.util.Objects; +import java.util.Optional; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; @@ -16,6 +19,8 @@ import de.tum.in.www1.artemis.config.icl.ssh.HashUtils; import de.tum.in.www1.artemis.config.icl.ssh.SshConstants; import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.service.connectors.localci.SharedQueueManagementService; +import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildAgentInformation; @Profile(PROFILE_LOCALVC) @Service @@ -25,8 +30,11 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator private final UserRepository userRepository; - public GitPublickeyAuthenticatorService(UserRepository userRepository) { + private final Optional localCIBuildJobQueueService; + + public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService) { this.userRepository = userRepository; + this.localCIBuildJobQueueService = localCIBuildJobQueueService; } @Override @@ -46,6 +54,7 @@ public boolean authenticate(String username, PublicKey publicKey, ServerSession if (Objects.equals(storedPublicKey, publicKey)) { log.debug("Found user {} for public key authentication", user.get().getLogin()); session.setAttribute(SshConstants.USER_KEY, user.get()); + session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); return true; } else { @@ -56,6 +65,29 @@ public boolean authenticate(String username, PublicKey publicKey, ServerSession log.error("Failed to convert stored public key string to PublicKey object", e); } } + else if (localCIBuildJobQueueService.isPresent() + && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, publicKey))) { + log.info("Authenticating as build agent"); + session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, true); + return true; + } return false; } + + private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation agent, PublicKey publicKey) { + if (agent.publicSshKey() == null) { + return false; + } + + AuthorizedKeyEntry agentKeyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(agent.publicSshKey()); + PublicKey agentPublicKey; + try { + agentPublicKey = agentKeyEntry.resolvePublicKey(null, null, null); + } + catch (IOException | GeneralSecurityException e) { + return false; + } + + return agentPublicKey.equals(publicKey); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/icl/SshGitLocationResolverService.java b/src/main/java/de/tum/in/www1/artemis/service/icl/SshGitLocationResolverService.java index 5ed008b77e25..886c509792f5 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/icl/SshGitLocationResolverService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/icl/SshGitLocationResolverService.java @@ -71,13 +71,19 @@ public Path resolveRootDirectory(String command, String[] args, ServerSession se // git-upload-pack means fetch (read operation), git-receive-pack means push (write operation) final var repositoryAction = gitCommand.equals("git-upload-pack") ? RepositoryActionType.READ : gitCommand.equals("git-receive-pack") ? RepositoryActionType.WRITE : null; - final var user = session.getAttribute(SshConstants.USER_KEY); - try { - localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, localVCRepositoryUri.isPracticeRepository()); + + if (session.getAttribute(SshConstants.IS_BUILD_AGENT_KEY) && repositoryAction == RepositoryActionType.READ) { + // We already checked for build agent authenticity } - catch (LocalVCForbiddenException e) { - log.error("User {} does not have access to the repository {}", user.getLogin(), repositoryPath); - throw new AccessDeniedException("User does not have access to this repository", e); + else { + final var user = session.getAttribute(SshConstants.USER_KEY); + try { + localVCServletService.authorizeUser(repositoryTypeOrUserName, user, exercise, repositoryAction, localVCRepositoryUri.isPracticeRepository()); + } + catch (LocalVCForbiddenException e) { + log.error("User {} does not have access to the repository {}", user.getLogin(), repositoryPath); + throw new AccessDeniedException("User does not have access to this repository", e); + } } // we cannot trust unvalidated user input diff --git a/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationLocalCILocalVCTest.java b/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationLocalCILocalVCTest.java index e6a814bf82ec..33ff2450f7c4 100644 --- a/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationLocalCILocalVCTest.java +++ b/src/test/java/de/tum/in/www1/artemis/AbstractSpringIntegrationLocalCILocalVCTest.java @@ -63,7 +63,8 @@ "artemis.continuous-integration.specify-concurrent-builds=true", "artemis.continuous-integration.concurrent-build-size=1", "artemis.continuous-integration.asynchronous=false", "artemis.continuous-integration.build.images.java.default=dummy-docker-image", "artemis.continuous-integration.image-cleanup.enabled=true", "artemis.continuous-integration.image-cleanup.disk-space-threshold-mb=1000000000", - "spring.liquibase.enabled=true", "artemis.iris.health-ttl=500" }) + "spring.liquibase.enabled=true", "artemis.iris.health-ttl=500", "artemis.version-control.ssh-private-key-folder-path=${java.io.tmpdir}", + "artemis.version-control.build-agent-use-ssh=true", "info.contact=test@localhost", "artemis.version-control.ssh-template-clone-url=ssh://git@localhost:7921/" }) @ContextConfiguration(classes = TestBuildAgentConfiguration.class) public abstract class AbstractSpringIntegrationLocalCILocalVCTest extends AbstractArtemisIntegrationTest { diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/BuildAgentSshAuthenticationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/BuildAgentSshAuthenticationIntegrationTest.java new file mode 100644 index 000000000000..74b39dd2801b --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/BuildAgentSshAuthenticationIntegrationTest.java @@ -0,0 +1,49 @@ +package de.tum.in.www1.artemis.localvcci; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; + +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationLocalCILocalVCTest; +import de.tum.in.www1.artemis.service.connectors.localci.buildagent.BuildAgentSshKeyService; +import de.tum.in.www1.artemis.service.connectors.localci.buildagent.SharedQueueProcessingService; +import de.tum.in.www1.artemis.service.connectors.localci.dto.BuildAgentInformation; + +class BuildAgentSshAuthenticationIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { + + @Autowired + @Qualifier("hazelcastInstance") + private HazelcastInstance hazelcastInstance; + + @Autowired + private BuildAgentSshKeyService buildAgentSSHKeyService; + + @Autowired + private SharedQueueProcessingService sharedQueueProcessingService; + + @Value("${artemis.version-control.ssh-private-key-folder-path}") + protected String gitSshPrivateKeyPath; + + @Test + void testWriteSSHKey() { + boolean sshPrivateKeyExists = Files.exists(Path.of(System.getProperty("java.io.tmpdir"), "id_rsa")); + assertThat(sshPrivateKeyExists).as("SSH private key written to tmp dir.").isTrue(); + } + + @Test + void testSSHInHazelcast() { + sharedQueueProcessingService.updateBuildAgentInformation(); + IMap buildAgentInformation = hazelcastInstance.getMap("buildAgentInformation"); + assertThat(buildAgentInformation.values()).as("SSH public key available in hazelcast.") + .anyMatch(agent -> agent.publicSshKey().equals(buildAgentSSHKeyService.getPublicKeyAsString())); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java index d3afbf88ffb1..00da4ceef09c 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIResourceIntegrationTest.java @@ -90,7 +90,7 @@ void createJobs() { job1 = new BuildJobQueueItem("1", "job1", "address1", 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); job2 = new BuildJobQueueItem("2", "job2", "address1", 2, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); String memberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - agent1 = new BuildAgentInformation(memberAddress, 1, 0, new ArrayList<>(List.of(job1)), false, new ArrayList<>(List.of(job2))); + agent1 = new BuildAgentInformation(memberAddress, 1, 0, new ArrayList<>(List.of(job1)), false, new ArrayList<>(List.of(job2)), null); BuildJobQueueItem finishedJobQueueItem1 = new BuildJobQueueItem("3", "job3", "address1", 3, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); BuildJobQueueItem finishedJobQueueItem2 = new BuildJobQueueItem("4", "job4", "address1", 4, course.getId() + 1, 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo2,