refactor-git-trust #4

Merged
zwa merged 11 commits from refactor-git-trust into master 2023-08-15 19:40:53 +00:00
13 changed files with 275 additions and 77 deletions

View file

@ -0,0 +1,38 @@
# ADR: We implement domain services static
Domain services can be implemented either as object (and composed like done in spring / example1 ) or with extension
function and composed static (see example2).
## example1
```kotlin
class DesktopServie(val aptApi: AptApi, val prov: Prov) {
fun provisionIdeDesktop(onlyModules: List<String>? = null) {
prov.task {
if (onlyModules == null) {
aptApi.aptInstall(OPEN_VPM)
}
}
}
}
```
## example2
```kotlin
fun Prov.provisionIdeDesktop(onlyModules: List<String>? = null) {
if (onlyModules == null) {
aptInstall(OPEN_VPM)
}
}
```
## Decission
We use extension function and composed static.
## Reason
1. Similar to composed objects we can easily mock `aptInstall` in tests. Both solutions are equivalent.
2. Inheritance in case of composed objects we can solve by static composition.
3. Object composition we can solve by static composition.
There is no reason left to change the current implementd pattern.

77
doc/dev/architecture.md Normal file
View file

@ -0,0 +1,77 @@
## Initialization
```mermaid
sequenceDiagram
actor user
participant app as Application
participant ds as DesktopService
participant gtr as KnownHost
participant pa as CliArgumentsParser
participant cr as DesktopConfigRepository
participant ut as CliUtils
participant su as ProvsWithSudo
user ->> app: main
activate app
app ->> pa: parseCommands
app ->> cr: getConfig(configFileName)
app ->> ut: createProvInstance(cmd.target)
app ->> su: ensureSudoWithoutPassword(cmd.target.remoteTarget()?.password)
app ->> ds: provisionDesktopCommand(cmd, config)
activate ds
ds ->> gtr: values()
gtr -->> ds: List(KnownHost)
deactivate ds
deactivate app
```
## Domain
```mermaid
classDiagram
namespace configuration {
class TargetCliCommand {
val target: String,
val passwordInteractive: Boolean = false
}
class ConfigFileName {
fileName: String
}
}
namespace desktop {
class DesktopCliCommand {
}
class DesktopConfig {
val ssh: SshKeyPairSource? = null,
val gpg: KeyPairSource? = null,
val gitUserName: String? = null,
val gitEmail: String? = null,
}
class DesktopType {
val name: String
}
class DesktopOnlyModule {
<<enum>>
FIREFOX, VERIFY
}
class KnownHost {
hostName: String,
hostKeys: List<HostKey>
}
}
DesktopCliCommand "1" *-- "1" DesktopType: type
DesktopCliCommand "1" *-- "1" TargetCliCommand: target
DesktopCliCommand "1" *-- "1" ConfigFileName: configFile
DesktopCliCommand "1" *-- "..n" DesktopOnlyModule: onlyModules
```

View file

@ -12,6 +12,7 @@ import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.provisionKeys
import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.currentUserCanSudoWithoutPassword
import org.domaindrivenarchitecture.provs.framework.ubuntu.user.base.whoami
internal fun Prov.provisionDesktopCommand(cmd: DesktopCliCommand, conf: DesktopConfig) = task {
provisionDesktop(
cmd.type,

View file

@ -0,0 +1,35 @@
package org.domaindrivenarchitecture.provs.desktop.domain
/**
* A HostKey should contain space-separated: keytype, key and (optionally) a comment
*
* See: https://man7.org/linux/man-pages/man8/sshd.8.html#SSH_KNOWN_HOSTS_FILE_FORMAT
*/
typealias HostKey = String
open class KnownHost protected constructor(val hostName: String, val hostKeys: List<HostKey>) {
companion object {
val GITHUB = KnownHost(
"github.com", listOf(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=",
)
)
val GITLAB = KnownHost(
"gitlab.com", listOf(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf",
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9",
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=",
)
)
@JvmStatic
protected val values = listOf(GITHUB, GITLAB)
fun values(): List<KnownHost> {
return values
}
}
}

View file

@ -0,0 +1,13 @@
package org.domaindrivenarchitecture.provs.desktop.domain
import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.addKnownHost
fun Prov.addKnownHosts(knownHosts: List<KnownHost> = KnownHost.values()) = task {
for (knownHost in knownHosts) {
with(knownHost) {
addKnownHost(hostName, hostKeys, verifyKeys = true)
}
}
}

View file

@ -15,7 +15,9 @@ fun Prov.installDevOps() = task {
installTerraform()
installKubectlAndTools()
installYq()
// TODO: the can be removed
installAwsCredentials()
// TODO: the can be removed
installDevOpsFolder()
}

View file

@ -3,9 +3,6 @@ package org.domaindrivenarchitecture.provs.framework.ubuntu.git.base
import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.core.ProvResult
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.*
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.KNOWN_HOSTS_FILE
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.trustHost
import java.io.File
/**
@ -41,25 +38,3 @@ fun Prov.gitClone(
}
}
fun Prov.trustGithub() = task {
// current fingerprints from https://docs.github.com/en/github/authenticating-to-github/githubs-ssh-key-fingerprints
val fingerprints = setOf(
"SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s github.com", // (RSA)
"SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM github.com", // (ECDSA)
"SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU github.com" // (Ed25519)
)
trustHost("github.com", fingerprints)
}
fun Prov.trustGitlab() = task {
// entries for known_hosts from https://docs.gitlab.com/ee/user/gitlab_com/
val gitlabFingerprints = """
gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf
gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9
gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=
""".trimIndent()
addTextToFile("\n" + gitlabFingerprints + "\n", File(KNOWN_HOSTS_FILE))
}

View file

@ -2,12 +2,9 @@ package org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base
import org.domaindrivenarchitecture.provs.framework.core.Prov
import org.domaindrivenarchitecture.provs.framework.core.ProvResult
import org.domaindrivenarchitecture.provs.framework.core.echoCommandForText
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createDir
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.createSecretFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.*
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.SshKeyPair
import java.io.File
const val KNOWN_HOSTS_FILE = "~/.ssh/known_hosts"
@ -34,61 +31,39 @@ fun Prov.isHostKnown(hostOrIp: String) : Boolean {
/**
* Adds ssh keys for specified host (which also can be an ip-address) to ssh-file "known_hosts"
* Either add the specified rsaFingerprints or - if null - add automatically retrieved keys.
* Either add the specified keys or - if null - add keys automatically retrieved.
* Note: adding keys automatically is vulnerable to a man-in-the-middle attack, thus considered insecure and not recommended.
*/
fun Prov.trustHost(host: String, fingerprintsOfKeysToBeAdded: Set<String>?) = taskWithResult {
if (isHostKnown(host)) {
return@taskWithResult ProvResult(true, out = "Host already known")
}
fun Prov.addKnownHost(host: String, keysToBeAdded: List<String>?, verifyKeys: Boolean = false) = task {
if (!checkFile(KNOWN_HOSTS_FILE)) {
createDir(".ssh")
createFile(KNOWN_HOSTS_FILE, null)
}
if (fingerprintsOfKeysToBeAdded == null) {
if (keysToBeAdded == null) {
// auto add keys
cmd("ssh-keyscan $host >> $KNOWN_HOSTS_FILE")
} else {
// logic based on https://serverfault.com/questions/447028/non-interactive-git-clone-ssh-fingerprint-prompt
val actualKeys = findSshKeys(host)
if (actualKeys == null || actualKeys.size == 0) {
return@taskWithResult ProvResult(false, out = "No valid keys found for host: $host")
}
val actualFingerprints = getFingerprintsForKeys(actualKeys)
for (fingerprintToBeAdded in fingerprintsOfKeysToBeAdded) {
var indexOfKeyFound = -1
// search for fingerprint in actual fingerprints
for ((i, actualFingerprint) in actualFingerprints.withIndex()) {
if (actualFingerprint.contains(fingerprintToBeAdded)) {
indexOfKeyFound = i
break
for (key in keysToBeAdded) {
if (!verifyKeys) {
addTextToFile("\n$host $key\n", File(KNOWN_HOSTS_FILE))
} else {
val validKeys = getSshKeys(host)
if (validKeys?.contains(key) == true) {
addTextToFile("\n$host $key\n", File(KNOWN_HOSTS_FILE))
} else {
addResultToEval(ProvResult(false, err = "The following key of host [$host] could not be verified successfully: " + key))
}
}
if (indexOfKeyFound == -1) {
return@taskWithResult ProvResult(
false,
err = "Fingerprint ($fingerprintToBeAdded) could not be found in actual fingerprints: $actualFingerprints"
)
}
cmd(echoCommandForText(actualKeys.get(indexOfKeyFound) + "\n") + " >> $KNOWN_HOSTS_FILE")
}
ProvResult(true)
}
}
/**
* Returns a list of valid ssh keys for the given host (host can also be an ip address)
* Returns a list of valid ssh keys for the given host (host can also be an ip address), keys are returned as keytype and key BUT WITHOUT the host name
*/
private fun Prov.findSshKeys(host: String): List<String>? {
return cmd("ssh-keyscan $host 2>/dev/null").out?.split("\n")?.filter { x -> "" != x }
}
/**
* Returns a list of fingerprints of the given sshKeys; the returning list has same size and order as the specified list of sshKeys
*/
private fun Prov.getFingerprintsForKeys(sshKeys: List<String>): List<String> {
return sshKeys.map { x -> cmd("echo \"$x\" | ssh-keygen -lf -").out ?: "" }
private fun Prov.getSshKeys(host: String, keytype: String? = null): List<String>? {
val keytypeOption = keytype?.let { " -t $keytype " } ?: ""
val output = cmd("ssh-keyscan $keytypeOption $host 2>/dev/null").out?.trim()
return output?.split("\n")?.filter { x -> "" != x }?.map { x -> x.substringAfter(" ") }
}

View file

@ -13,7 +13,7 @@ import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
internal class CliTargetCommandKtTest {
internal class TargetCliCommandKtTest {
companion object {
@BeforeAll

View file

@ -0,0 +1,54 @@
package org.domaindrivenarchitecture.provs.desktop.domain
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.deleteFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.KNOWN_HOSTS_FILE
import org.domaindrivenarchitecture.provs.test.defaultTestContainer
import org.domaindrivenarchitecture.provs.test.tags.ContainerTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class KnownHostTest {
@ContainerTest
fun defaultKnownHosts() {
// given
val prov = defaultTestContainer()
prov.task {
aptInstall("ssh")
deleteFile(KNOWN_HOSTS_FILE)
}
// when
val res = prov.addKnownHosts()
// then
assertTrue(res.success)
}
// Subclass of KnownHost for test knownHostSubclass_includes_additional_host
class KnownHostsSubclass(hostName: String, hostKeys: List<HostKey>): KnownHost(hostName, hostKeys) {
companion object {
val ANOTHER_HOST = KnownHostsSubclass("anotherhost.com", listOf("key1"))
fun values(): List<KnownHost> {
return values + ANOTHER_HOST
}
}
}
@Test
fun knownHostSubclass_includes_additional_host() {
// when
val hosts = KnownHostsSubclass.values()
// then
assertTrue(hosts.size > 1)
assertEquals("key1", hosts.last().hostKeys[0])
}
}

View file

@ -1,5 +1,6 @@
package org.domaindrivenarchitecture.provs.framework.ubuntu.git.base
import org.domaindrivenarchitecture.provs.desktop.domain.addKnownHosts
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkDir
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base.isHostKnown
@ -18,12 +19,10 @@ internal class GitKtTest {
a.aptInstall("openssh-client")
// when
val res = a.trustGithub()
val res2 = a.trustGitlab()
val res = a.addKnownHosts()
// then
assertTrue(res.success)
assertTrue(res2.success)
assertTrue(a.isHostKnown("github.com"), "github.com does not seem to be a known host")
assertTrue(a.isHostKnown("gitlab.com"), "gitlab.com does not seem to be a known host")
@ -37,7 +36,7 @@ internal class GitKtTest {
prov.aptInstall("git")
// when
prov.trustGithub()
prov.addKnownHosts()
val res1 = prov.gitClone("https://gitlab.com/domaindrivenarchitecture/not a valid basename.git", "~/")
val res2 = prov.gitClone(repo)
val res3 = prov.gitClone(repo, pullIfExisting = false)

View file

@ -1,15 +1,16 @@
package org.domaindrivenarchitecture.provs.framework.ubuntu.keys.base
import org.domaindrivenarchitecture.provs.framework.core.Secret
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.deleteFile
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.fileContainsText
import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.fileContent
import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.aptInstall
import org.domaindrivenarchitecture.provs.framework.ubuntu.keys.*
import org.domaindrivenarchitecture.provs.test.defaultTestContainer
import org.domaindrivenarchitecture.provs.test.tags.ContainerTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.*
internal class SshKtTest {
@ContainerTest
fun configureSshKeys_for_ssh_type_rsa() {
// given
@ -45,4 +46,32 @@ internal class SshKtTest {
val privateSshKeyFileContent = prov.fileContent("~/.ssh/id_ed25519")
assertEquals(privateED25519SnakeOilKey() + "\n", privateSshKeyFileContent)
}
@ContainerTest
fun addKnownHost() {
// given
val prov = defaultTestContainer()
prov.task {
aptInstall("ssh")
deleteFile(KNOWN_HOSTS_FILE)
}
// when
val res = prov.addKnownHost("github.com", listOf("dummyProtocol dummyKey", "dummyProtocol2 dummyKey2", ))
val res2 = prov.addKnownHost("github.com", listOf("dummyProtocol dummyKey", "dummyProtocol2 dummyKey2", ))
val res3 = prov.addKnownHost("github.com", listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", ), verifyKeys = true)
val res4 = prov.addKnownHost("github.com", listOf("ssh-ed25519 AAAAC3Nzalwrongkey!!!", ), verifyKeys = true)
// then
assertTrue(res.success)
assertTrue(prov.fileContainsText(KNOWN_HOSTS_FILE, "github.com dummyProtocol dummyKey"))
assertTrue(prov.fileContainsText(KNOWN_HOSTS_FILE, "github.com dummyProtocol2 dummyKey2"))
assertTrue(res2.success)
val keyCount = prov.cmd("grep -o -i dummyKey2 $KNOWN_HOSTS_FILE | wc -l").out?.trim()
assertEquals("1", keyCount)
assertTrue(res3.success)
assertFalse(res4.success)
}
}