diff --git a/.gitignore b/.gitignore index 1e0c0f1..fdf3113 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ /My* /my* /server-config.yaml +/desktop-config.yaml +/syspec-config.yaml diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/application/Application.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/application/Application.kt index dac3677..4775142 100644 --- a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/application/Application.kt +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/application/Application.kt @@ -17,7 +17,7 @@ fun main(args: Array) { val checkedArgs = if (args.isEmpty()) arrayOf("-h") else args - val cmd = CliArgumentsParser("java -jar provs-server.jar").parseCommand(checkedArgs) + val cmd = CliArgumentsParser("provs-server.jar").parseCommand(checkedArgs) if (!cmd.isValid()) { println("Arguments are not valid, pls try -h for help.") exitProcess(1) @@ -25,5 +25,6 @@ fun main(args: Array) { val prov = createProvInstance(cmd.target) when(cmd.serverType) { ServerType.K3S -> prov.provisionK3s(cmd as K3sCliCommand) + else -> { throw RuntimeException("Unknown serverType") } } } diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/domain/k3s/K3sService.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/domain/k3s/K3sService.kt index ad822ef..d62913d 100644 --- a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/domain/k3s/K3sService.kt +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/domain/k3s/K3sService.kt @@ -3,7 +3,7 @@ package org.domaindrivenarchitecture.provs.server.domain.k3s import org.domaindrivenarchitecture.provs.framework.core.Prov import org.domaindrivenarchitecture.provs.framework.core.ProvResult import org.domaindrivenarchitecture.provs.server.infrastructure.* -import org.domaindrivenarchitecture.provs.server.infrastructure.k3s.getK3sConfig +import org.domaindrivenarchitecture.provs.server.infrastructure.getK3sConfig /** * Installs a k3s server. diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepository.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepository.kt index 3142360..5c8a5b6 100644 --- a/src/main/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepository.kt +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepository.kt @@ -1,4 +1,4 @@ -package org.domaindrivenarchitecture.provs.server.infrastructure.k3s +package org.domaindrivenarchitecture.provs.server.infrastructure import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName import org.domaindrivenarchitecture.provs.framework.core.readFromFile diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/Application.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/Application.kt new file mode 100644 index 0000000..cef5f18 --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/Application.kt @@ -0,0 +1,19 @@ +package org.domaindrivenarchitecture.provs.syspec.application + +import org.domaindrivenarchitecture.provs.framework.core.cli.createProvInstance +import org.domaindrivenarchitecture.provs.syspec.domain.verifySpec + + +/** + * Performs a system check, either locally or on a remote machine depending on the given arguments. + * + * Get help with option -h + */ +fun main(args: Array) { + + val checkedArgs = if (args.isEmpty()) arrayOf("-h") else args + + val cmd = CliArgumentsParser("provs-syspec.jar").parseCommand(checkedArgs) + + createProvInstance(cmd.target).verifySpec(cmd.configFileName) +} diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/CliArgumentsParser.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/CliArgumentsParser.kt new file mode 100644 index 0000000..2d7f0b3 --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/application/CliArgumentsParser.kt @@ -0,0 +1,31 @@ +package org.domaindrivenarchitecture.provs.syspec.application + +import kotlinx.cli.ArgType +import kotlinx.cli.default +import org.domaindrivenarchitecture.provs.configuration.application.CliTargetParser +import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName +import org.domaindrivenarchitecture.provs.configuration.domain.TargetCliCommand +import org.domaindrivenarchitecture.provs.syspec.domain.SyspecCliCommand + +class CliArgumentsParser(name: String) : CliTargetParser(name) { + + val cliConfigFileName by option( + ArgType.String, + "config-file", + "c", + "the filename containing the yaml config" + ).default("syspec-config.yaml") + + fun parseCommand(args: Array): SyspecCliCommand { + super.parse(args) + + return SyspecCliCommand( + TargetCliCommand( + target, + passwordInteractive + ), + ConfigFileName(cliConfigFileName) + ) + } +} + diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/Spec.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/Spec.kt new file mode 100644 index 0000000..7f2edc2 --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/Spec.kt @@ -0,0 +1,46 @@ +package org.domaindrivenarchitecture.provs.syspec.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class SpecConfig( + val command: List? = null, + val file: List? = null, + val host: List? = null, + val `package`: List? = null, + val netcat: List? = null, + val socket: List? = null, + val certificate: List? = null, +) + + +/** + * Checks that a command executes successfully and + * (if provided) the specified output is contained in the actual output + */ +@Serializable +data class CommandSpec(val command: String, val out: String? = null) + +@Serializable +data class FileSpec(val name: String, val exists: Boolean = true) + +@Serializable +data class HostSpec(val url: String, val expirationDays: Long? = null) + +@Serializable +data class PackageSpec(val name: String, val installed: Boolean = true) + +@Serializable +data class NetcatSpec(val host: String, val port: Int = 80, val reachable: Boolean = true) + +@Serializable +data class SocketSpec( + val processName: String, + val port: Int, + val running: Boolean = true, + val ip: String? = null, + val protocol: String? = null +) + +@Serializable +data class CertificateFileSpec(val name: String, val expirationDays: Long) diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecCliCommand.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecCliCommand.kt new file mode 100644 index 0000000..d176dc0 --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecCliCommand.kt @@ -0,0 +1,9 @@ +package org.domaindrivenarchitecture.provs.syspec.domain + +import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName +import org.domaindrivenarchitecture.provs.configuration.domain.TargetCliCommand + +class SyspecCliCommand ( + val target: TargetCliCommand, + val configFileName: ConfigFileName?, +) \ No newline at end of file diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecService.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecService.kt new file mode 100644 index 0000000..389e3bc --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/domain/SyspecService.kt @@ -0,0 +1,19 @@ +package org.domaindrivenarchitecture.provs.syspec.domain + +import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName +import org.domaindrivenarchitecture.provs.framework.core.Prov +import org.domaindrivenarchitecture.provs.framework.core.ProvResult +import org.domaindrivenarchitecture.provs.syspec.infrastructure.findSpecConfigFromFile +import org.domaindrivenarchitecture.provs.syspec.infrastructure.verifySpecConfig + + +fun Prov.verifySpec(config: ConfigFileName?) = task { + val spec = findSpecConfigFromFile(config) + + if (spec == null) { + ProvResult(false, "Could not read file: ${config?.fileName}") + } else { + verifySpecConfig(spec) + } +} + diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/SyspecConfigRepo.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/SyspecConfigRepo.kt new file mode 100644 index 0000000..b8296b3 --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/SyspecConfigRepo.kt @@ -0,0 +1,47 @@ +package org.domaindrivenarchitecture.provs.syspec.infrastructure + +import com.charleskorn.kaml.Yaml +import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName +import org.domaindrivenarchitecture.provs.framework.core.readFromFile +import org.domaindrivenarchitecture.provs.framework.core.yamlToType +import org.domaindrivenarchitecture.provs.syspec.domain.CommandSpec +import org.domaindrivenarchitecture.provs.syspec.domain.SpecConfig +import java.io.File +import java.io.FileWriter +import java.io.IOException + +private const val DEFAULT_CONFIG_FILE = "syspec-config.yaml" + +internal fun writeSpecConfigToFile( + fileName: String = DEFAULT_CONFIG_FILE, + config: SpecConfig +) { + FileWriter(fileName).use { + it.write( + Yaml.default.encodeToString( + SpecConfig.serializer(), + config + ) + ) + } +} + +internal fun getSpecConfigFromFile(file: ConfigFileName? = null): SpecConfig { + val filename = file?.fileName ?: DEFAULT_CONFIG_FILE + + if ((filename.substringAfterLast("/") == DEFAULT_CONFIG_FILE) && !File(filename).exists()) { + // provide default config + writeSpecConfigToFile(filename, SpecConfig(listOf(CommandSpec("echo just_for_demo", "just_for_demo")))) + } + return readFromFile(filename).yamlToType() +} + +internal fun findSpecConfigFromFile(file: ConfigFileName? = null): SpecConfig? { + return try { + val config = getSpecConfigFromFile(file) + config + } catch (e: IOException) { + println("Error: " + e.message) + null + } +} diff --git a/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/Verification.kt b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/Verification.kt new file mode 100644 index 0000000..fd3edeb --- /dev/null +++ b/src/main/kotlin/org/domaindrivenarchitecture/provs/syspec/infrastructure/Verification.kt @@ -0,0 +1,183 @@ +package org.domaindrivenarchitecture.provs.syspec.infrastructure + +import org.domaindrivenarchitecture.provs.framework.core.Prov +import org.domaindrivenarchitecture.provs.framework.core.ProvResult +import org.domaindrivenarchitecture.provs.framework.ubuntu.filesystem.base.checkFile +import org.domaindrivenarchitecture.provs.framework.ubuntu.install.base.isPackageInstalled +import org.domaindrivenarchitecture.provs.syspec.domain.* +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit + +fun Prov.verifySpecConfig(conf: SpecConfig) = task { + // required to guarantee that at least one result can be provided + val dummySuccess = ProvResult(true) + + conf.command?.let { task("CommandSpecs") { for (spec in conf.command) verify(spec); dummySuccess } } + conf.file?.let { task("FileSpecs") { for (spec in conf.file) verify(spec); dummySuccess } } + conf.host?.let { task("HostSpecs") { for (spec in conf.host) verify(spec); dummySuccess } } + conf.`package`?.let { task("PackageSpecs") { for (spec in conf.`package`) verify(spec); dummySuccess } } + conf.netcat?.let { task("NetcatSpecs") { for (spec in conf.netcat) verify(spec); dummySuccess } } + conf.socket?.let { task("SocketSpecs") { for (spec in conf.socket) verify(spec); dummySuccess } } + conf.certificate?.let { task("CertificateFileSpecs") { for (spec in conf.certificate) verify(spec); dummySuccess } } + + dummySuccess // to be sure that at least one result is provided +} + +fun Prov.verify(cmd: CommandSpec) { + val res = cmdNoEval(cmd.command) + if (!res.success) { + verify(false, "Command [${cmd.command}] is not executable due to error: ${res.err}") + } else { + if (cmd.out == null) { + verify(true, "Command is executable [${cmd.command}]") + } else { + val expected = cmd.out + val actual = res.out?.trimEnd('\n') + val contains = (actual?.contains(expected) ?: false) + verify( + contains, + "Output of command '${cmd.command}' does ${contains.falseToNot()}contain $expected ${if (!contains) "(Actual output: $actual)" else ""}" + ) + } + } +} + +fun Prov.verify(file: FileSpec) { + val actualExists = checkFile(file.name) + verify(actualExists == file.exists, "File [${file.name}] does ${actualExists.falseToNot()}exist.") +} + +fun Prov.verify(hostspec: HostSpec) { + // see https://serverfault.com/questions/661978/displaying-a-remote-ssl-certificate-details-using-cli-tools + val res = + cmdNoEval("echo | openssl s_client -showcerts -servername ${hostspec.url} -connect ${hostspec.url}:443 2>/dev/null | openssl x509 -inform pem -noout -enddate") + + if (!res.success) { + verify(false, "Could not retrieve certificate from ${hostspec.url} due to error: ${res.err}") + } else { + if (hostspec.expirationDays == null) { + verify(true, "Found a certificate on ${hostspec.url}") + } else { + verifyCertExpiration(res.out, hostspec.url, hostspec.expirationDays) + } + } +} + +fun Prov.verify(pkg: PackageSpec) { + val res = isPackageInstalled(pkg.name) + verify(res == pkg.installed, "Package ${pkg.name} is ${res.falseToNot()}installed.") +} + +fun Prov.verify(ncConf: NetcatSpec) { + val timeout = 10 // sec + val res = cmdNoEval("nc ${ncConf.host} ${ncConf.port} -z -w $timeout") + verify( + res.success == ncConf.reachable, + "Host ${ncConf.host} is ${res.success.falseToNot()}reachable at port ${ncConf.port}." + ) +} + +fun Prov.verify(socketConf: SocketSpec): ProvResult { + val res = cmdNoEval("ss -tulpen", sudo = true) + val lines: List = res.out?.trim()?.split("\n") ?: emptyList() + return if (lines.isEmpty()) { + verify(false, "Could not get socketStats due to ${res.err}") + } else { + verifySocketSpec(socketConf, lines) + } +} + + +fun Prov.verify(cert: CertificateFileSpec) { + val res = cmdNoEval("openssl x509 -in ${cert.name} -noout -enddate") + if (!res.success) { + verify(false, "Could not retrieve certificate from ${cert.name} due to error: ${res.err}") + } else { + verifyCertExpiration(res.out, cert.name, cert.expirationDays) + } +} + + +// -------------------------- helper --------------------------------- + +fun Prov.verifySocketSpec(socketConf: SocketSpec, outputLines: List): ProvResult { + val headLine = outputLines[0] + val processRange = "Process +".toRegex().find(headLine)?.range + val ipRange = " +Local Address".toRegex().find(headLine)?.range + val portRange = "Port +".toRegex().find(headLine)?.range + val protocolRange = "Netid +".toRegex().find(headLine)?.range + + if (processRange == null || ipRange == null || portRange == null || protocolRange == null) { + return verify(false, "Could not parse a headline from: $headLine") + } else { + val factLines: List = outputLines.drop(1).filter { it.length == headLine.length } + + var matchingLine: String? = null + for (line in factLines) { + val process = "\"(.+)\"".toRegex().find(line.substring(processRange))?.groups?.get(1)?.value + if (socketConf.processName == process && + socketConf.port.toString() == line.substring(portRange).trim() && + (socketConf.ip == null || socketConf.ip == line.substring(ipRange)) && + (socketConf.protocol == null || socketConf.protocol == line.substring(protocolRange)) + ) { + matchingLine = line + break + } + } + val found = matchingLine != null + return verify(found == socketConf.running, "Did ${found.falseToNot()}find [$socketConf]") + } +} + +private fun Boolean.falseToNot(suffix: String = " ") = if (this) "" else "not$suffix" + +private fun Prov.verify(success: Boolean, message: String): ProvResult { + return verify(success, message, null, null) +} + +private fun Prov.verify(success: Boolean, message: String, expected: T? = null, actual: T? = null): ProvResult { + val expectedText = expected?.let { " | Expected value [$expected]" } ?: "" + val actualText = expected?.let { " | Actual value [$actual]" } ?: "" + val msg = ": $message $expectedText$actualText" + + return task("Verification") { + ProvResult( + success, + cmd = if (success) msg else null, + err = if (!success) msg else null, + ) + } +} + +private data class DiffResult(val diff: Long? = null, val err: String? = null) { + init { + require(diff != null || err != null) + } +} + +private fun diffSslDateToToday(enddate: String?): DiffResult { + val format = SimpleDateFormat("MMM d HH:mm:ss yyyy zzz", Locale.ENGLISH) + return try { + val expirationDate = format.parse(enddate) + val diffInMillisec: Long = expirationDate.time - Date().time + val diffInDays = TimeUnit.MILLISECONDS.toDays(diffInMillisec) + DiffResult(diff = diffInDays) + } catch (e: ParseException) { + DiffResult(err = "Could not parse date '$enddate' with pattern '$format' - Parse error: ${e.message}") + } +} + +private fun Prov.verifyCertExpiration(enddate: String?, certName: String, expirationDays: Long) { + val enddateCleaned = enddate?.replace("notAfter=", "")?.trimEnd('\n') + val (diffInDays, err) = diffSslDateToToday(enddateCleaned) + if (diffInDays == null) { + verify(false, err ?: ("Could not parse date: $enddateCleaned")) + } else { + verify( + diffInDays > expirationDays, + "Certificate of [$certName] expires on [${enddateCleaned}] in $diffInDays days (expected > $expirationDays days)", + ) + } +} diff --git a/src/test/kotlin/org/domaindrivenarchitecture/provs/desktop/infrastructure/K3sConfigRepositoryKtTest.kt b/src/test/kotlin/org/domaindrivenarchitecture/provs/desktop/infrastructure/K3sConfigRepositoryKtTest.kt index aa9962b..4872a2b 100644 --- a/src/test/kotlin/org/domaindrivenarchitecture/provs/desktop/infrastructure/K3sConfigRepositoryKtTest.kt +++ b/src/test/kotlin/org/domaindrivenarchitecture/provs/desktop/infrastructure/K3sConfigRepositoryKtTest.kt @@ -3,7 +3,7 @@ package org.domaindrivenarchitecture.provs.desktop.infrastructure import com.charleskorn.kaml.InvalidPropertyValueException import org.domaindrivenarchitecture.provs.configuration.domain.ConfigFileName import org.domaindrivenarchitecture.provs.framework.ubuntu.secret.SecretSourceType -import org.domaindrivenarchitecture.provs.server.infrastructure.k3s.getK3sConfig +import org.domaindrivenarchitecture.provs.server.infrastructure.getK3sConfig import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows diff --git a/src/test/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepositoryTest.kt b/src/test/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepositoryTest.kt index 2aad8ca..f586ee9 100644 --- a/src/test/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepositoryTest.kt +++ b/src/test/kotlin/org/domaindrivenarchitecture/provs/server/infrastructure/K3sConfigRepositoryTest.kt @@ -7,7 +7,6 @@ import org.domaindrivenarchitecture.provs.server.domain.k3s.Certmanager import org.domaindrivenarchitecture.provs.server.domain.k3s.K3sConfig import org.domaindrivenarchitecture.provs.server.domain.k3s.Loopback import org.domaindrivenarchitecture.provs.server.domain.k3s.Node -import org.domaindrivenarchitecture.provs.server.infrastructure.k3s.getK3sConfig import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows