diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..9522b22 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,17 @@ +name: CI +on: + push: + tags: + - '*' +jobs: + build: + runs-on: hostinger + steps: + - name: Checkout sources + uses: actions/checkout@v4 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Execute Gradle build + env: + PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} + run: ./gradlew build publish \ No newline at end of file diff --git a/build.gradle b/build.gradle index 38506ba..bf20066 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,8 @@ plugins { - id 'application' + id 'java-library' alias catalog.plugins.kotlin.jvm - alias catalog.plugins.graalvm.native.image - alias catalog.plugins.graalvm.jlink alias catalog.plugins.envelope + alias catalog.plugins.sambal id 'maven-publish' } @@ -13,16 +12,11 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget group = 'net.woggioni' -version = getProperty('gbcs.version') +version = project.currentTag ?: "${getProperty('gbcs.version')}.${project.gitRevision[0..10]}" -application { +envelopeJar { mainModule = 'net.woggioni.gbcs' mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer' -// mainClass = 'net.woggioni.gbcs.NettyPingServer' -} - -configureNativeImage { - mainClass = 'net.woggioni.gbcs.GraalNativeImageConfiguration' } repositories { @@ -43,10 +37,9 @@ dependencies { // runtimeOnly catalog.slf4j.jdk14 runtimeOnly catalog.logback.classic -// // https://mvnrepository.com/artifact/org.fusesource.jansi/jansi -// runtimeOnly group: 'org.fusesource.jansi', name: 'jansi', version: '2.4.1' - + testImplementation catalog.bcprov.jdk18on + testImplementation catalog.bcpkix.jdk18on testImplementation catalog.junit.jupiter.api testImplementation catalog.junit.jupiter.params testRuntimeOnly catalog.junit.jupiter.engine @@ -57,7 +50,6 @@ java { modularity.inferModulePath = true toolchain { languageVersion = JavaLanguageVersion.of(21) -// vendor = JvmVendorSpec.GRAAL_VM } } @@ -67,6 +59,8 @@ test { tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs=' + project.sourceSets.main.output.asPath + options.javaModuleVersion = provider { version } + options.javaModuleMainClass = envelopeJar.mainClass } tasks.withType(JavaCompile) { @@ -94,7 +88,17 @@ def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get( publishing { repositories { maven { - url = 'https://mvn.woggioni.net/' + name = "Gitea" + url = uri(getProperty('gitea.maven.url')) + + credentials(HttpHeaderCredentials) { + name = "Authorization" + value = "token ${System.getenv()["PUBLISHER_TOKEN"]}" + } + + authentication { + header(HttpHeaderAuthentication) + } } } publications { diff --git a/gradle.properties b/gradle.properties index 6d7ce5e..fe919ae 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ -gbcs.version = 2024.12.13 +gbcs.version = 1.0.0 -lys.version = 2024.12.07 +lys.version = 2024.12.21 gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven diff --git a/native-image/native-image.properties b/native-image/native-image.properties index 3a2e1e7..1382f92 100644 --- a/native-image/native-image.properties +++ b/native-image/native-image.properties @@ -1 +1 @@ -Args=-H:Optimize=3 -H:+TraceClassInitialization \ No newline at end of file +Args=-H:Optimize=3 --gc=serial --libc=musl --static -H:TraceClassInitialization=io.netty.handler.ssl.BouncyCastleAlpnSslUtils \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index f083c6a..43edb3e 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,9 +1,10 @@ import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider; -module net.woggioni.gbcs { +open module net.woggioni.gbcs { requires java.sql; requires java.xml; requires java.logging; + requires java.naming; requires kotlin.stdlib; requires io.netty.buffer; requires io.netty.transport; diff --git a/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java b/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java index 5263a27..d27d62f 100644 --- a/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java +++ b/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java @@ -1,10 +1,13 @@ package net.woggioni.gbcs.url; +import net.woggioni.jwo.Fun; + import java.io.IOException; import java.net.URL; import java.net.URLConnection; import java.net.URLStreamHandler; import java.net.URLStreamHandlerFactory; +import java.util.Optional; public class ClasspathUrlStreamHandlerFactoryProvider implements URLStreamHandlerFactory { @@ -22,7 +25,9 @@ public class ClasspathUrlStreamHandlerFactoryProvider implements URLStreamHandle @Override protected URLConnection openConnection(URL u) throws IOException { final URL resourceUrl = classLoader.getResource(u.getPath()); - return resourceUrl.openConnection(); + return Optional.ofNullable(resourceUrl) + .map((Fun) URL::openConnection) + .orElseThrow(IOException::new); } } diff --git a/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt b/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt index bab3495..58c0359 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt @@ -11,11 +11,18 @@ import io.netty.handler.codec.http.HttpRequest import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpVersion import io.netty.util.ReferenceCountUtil +import java.security.SecureRandom +import java.security.spec.KeySpec +import java.util.Base64 +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec + abstract class AbstractNettyHttpAuthenticator(private val authorizer : Authorizer) : ChannelInboundHandlerAdapter() { - private companion object { + companion object { + private const val KEY_LENGTH = 256 private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER).apply { headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" @@ -25,13 +32,59 @@ abstract class AbstractNettyHttpAuthenticator(private val authorizer : Authorize HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, Unpooled.EMPTY_BUFFER).apply { headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" } + + private fun concat(arr1: ByteArray, arr2: ByteArray): ByteArray { + val result = ByteArray(arr1.size + arr2.size) + var j = 0 + for(element in arr1) { + result[j] = element + j += 1 + } + for(element in arr2) { + result[j] = element + j += 1 + } + return result + } + + + fun hashPassword(password : String, salt : String? = null) : String { + val actualSalt = salt?.let(Base64.getDecoder()::decode) ?: SecureRandom().run { + val result = ByteArray(16) + nextBytes(result) + result + } + val spec: KeySpec = PBEKeySpec(password.toCharArray(), actualSalt, 10, KEY_LENGTH) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") + val hash = factory.generateSecret(spec).encoded + return String(Base64.getEncoder().encode(concat(hash, actualSalt))) + } + +// fun decodePasswordHash(passwordHash : String) : Pair { +// return passwordHash.indexOf(':') +// .takeIf { it > 0 } +// ?.let { sep -> +// passwordHash.substring(0, sep) to passwordHash.substring(sep) +// } ?: throw IllegalArgumentException("Failed to decode password hash") +// } + + fun decodePasswordHash(passwordHash : String) : Pair { + val decoded = Base64.getDecoder().decode(passwordHash) + val hash = ByteArray(KEY_LENGTH / 8) + val salt = ByteArray(decoded.size - KEY_LENGTH / 8) + System.arraycopy(decoded, 0, hash, 0, hash.size) + System.arraycopy(decoded, hash.size, salt, 0, salt.size) + return hash to salt + } } - abstract fun authenticate(ctx : ChannelHandlerContext, req : HttpRequest) : String? + + + abstract fun authenticate(ctx : ChannelHandlerContext, req : HttpRequest) : Set? override fun channelRead(ctx: ChannelHandlerContext, msg: Any) { if(msg is HttpRequest) { - val user = authenticate(ctx, msg) ?: return authenticationFailure(ctx, msg) - val authorized = authorizer.authorize(user, msg) + val roles = authenticate(ctx, msg) ?: return authenticationFailure(ctx, msg) + val authorized = authorizer.authorize(roles, msg) if(authorized) { super.channelRead(ctx, msg) } else { diff --git a/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt b/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt index 8b873fb..68ee054 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt @@ -3,5 +3,5 @@ package net.woggioni.gbcs import io.netty.handler.codec.http.HttpRequest fun interface Authorizer { - fun authorize(user : String, request: HttpRequest) : Boolean + fun authorize(roles : Set, request: HttpRequest) : Boolean } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Configuration.kt b/src/main/kotlin/net/woggioni/gbcs/Configuration.kt deleted file mode 100644 index 174da34..0000000 --- a/src/main/kotlin/net/woggioni/gbcs/Configuration.kt +++ /dev/null @@ -1,109 +0,0 @@ -package net.woggioni.gbcs - -import java.nio.file.Path -import java.nio.file.Paths -import org.w3c.dom.Document -import net.woggioni.gbcs.Xml.asIterable -import org.w3c.dom.Element - -data class HostAndPort(val host: String, val port : Integer) { - override fun toString() = "$host:$port" -} - -data class TlsConfiguration(val keyStore: KeyStore?, val trustStore: TrustStore?, val verifyClients : Boolean) -data class KeyStore( - val file : Path, - val password : String?, - val keyAlias: String, - val keyPassword : String? -) - -data class TrustStore( - val file : Path, - val password : String?, - val checkCertificateStatus : Boolean -) - -data class Configuration( - val cacheFolder : Path, - val host : String, - val port : Int, - val users : Map>, - val groups : Map>, - val tlsConfiguration: TlsConfiguration?, - val serverPath : String, - val useVirtualThread : Boolean -) { - companion object { - fun parse(document : Element) : Configuration { - - var cacheFolder = Paths.get(System.getProperty("user.home")).resolve(".gbcs") - var host = "127.0.0.1" - var port = 11080 - val users = emptyMap>() - val groups = emptyMap>() - var tlsConfiguration : TlsConfiguration? = null - val serverPath = document.getAttribute("path") - .takeIf(String::isNotEmpty) - ?: "/" - val useVirtualThread = document.getAttribute("useVirtualThreads") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) ?: false - - for(child in document.asIterable()) { - when(child.nodeName) { - "bind" -> { - host = child.getAttribute("host") - port = Integer.parseInt(child.getAttribute("port")) - } - "cache" -> { - cacheFolder = Paths.get(child.textContent) - } - "tls" -> { - val verifyClients = child.getAttribute("verify-clients") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) ?: false - var keyStore : KeyStore? = null - var trustStore : TrustStore? = null - for(granChild in child.asIterable()) { - when(granChild.nodeName) { - "keystore" -> { - val trustStoreFile = Paths.get(granChild.getAttribute("file")) - val trustStorePassword = granChild.getAttribute("password") - .takeIf(String::isNotEmpty) - val keyAlias = granChild.getAttribute("key-alias") - val keyPasswordPassword = granChild.getAttribute("password") - .takeIf(String::isNotEmpty) - keyStore = KeyStore( - trustStoreFile, - trustStorePassword, - keyAlias, - keyPasswordPassword - ) - } - "truststore" -> { - val trustStoreFile = Paths.get(granChild.getAttribute("file")) - val trustStorePassword = granChild.getAttribute("password") - .takeIf(String::isNotEmpty) - val checkCertificateStatus = granChild.getAttribute("check-certificate-status") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) - ?: false - trustStore = TrustStore( - trustStoreFile, - trustStorePassword, - checkCertificateStatus - ) - } - } - } - tlsConfiguration = TlsConfiguration(keyStore, trustStore, verifyClients) - } - } - - } - - return Configuration(cacheFolder, host, port, users, groups, tlsConfiguration, serverPath, useVirtualThread) - } - } -} diff --git a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt index 81701e6..dfc3660 100644 --- a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt @@ -1,21 +1,5 @@ package net.woggioni.gbcs -import java.net.InetSocketAddress -import java.net.URL -import java.net.URLStreamHandlerFactory -import java.nio.channels.FileChannel -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.security.KeyStore -import java.security.MessageDigest -import java.security.PrivateKey -import java.security.cert.X509Certificate -import java.util.AbstractMap.SimpleEntry -import java.util.Arrays -import java.util.Base64 -import java.util.concurrent.Executors import io.netty.bootstrap.ServerBootstrap import io.netty.buffer.ByteBuf import io.netty.buffer.Unpooled @@ -54,23 +38,44 @@ import io.netty.handler.ssl.ClientAuth import io.netty.handler.ssl.SslContext import io.netty.handler.ssl.SslContextBuilder import io.netty.handler.stream.ChunkedNioFile +import io.netty.handler.stream.ChunkedNioStream import io.netty.handler.stream.ChunkedWriteHandler import io.netty.util.concurrent.DefaultEventExecutorGroup -import io.netty.util.concurrent.DefaultThreadFactory import io.netty.util.concurrent.EventExecutorGroup +import net.woggioni.gbcs.cache.Cache +import net.woggioni.gbcs.cache.FileSystemCache +import net.woggioni.gbcs.configuration.Configuration import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider -import javax.net.ssl.SSLPeerUnverifiedException import net.woggioni.jwo.Application import net.woggioni.jwo.JWO import net.woggioni.jwo.Tuple2 +import java.net.InetSocketAddress import java.net.URI -import java.util.concurrent.ForkJoinPool +import java.net.URL +import java.net.URLStreamHandlerFactory +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.Arrays +import java.util.Base64 +import java.util.concurrent.Executors +import java.util.regex.Matcher +import java.util.regex.Pattern +import javax.naming.ldap.LdapName +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLPeerUnverifiedException -class GradleBuildCacheServer(private val cfg : Configuration) { +class GradleBuildCacheServer(private val cfg: Configuration) { - internal class HttpChunkContentCompressor(threshold : Int, vararg compressionOptions: CompressionOptions = emptyArray()) - : HttpContentCompressor(threshold, *compressionOptions) { + private class HttpChunkContentCompressor( + threshold: Int, + vararg compressionOptions: CompressionOptions = emptyArray() + ) : HttpContentCompressor(threshold, *compressionOptions) { override fun write(ctx: ChannelHandlerContext, msg: Any, promise: ChannelPromise) { var message: Any? = msg if (message is ByteBuf) { @@ -88,14 +93,42 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } } - private class NettyHttpBasicAuthenticator( - private val credentials: Map, authorizer: Authorizer) : AbstractNettyHttpAuthenticator(authorizer) { + private class ClientCertificateAuthenticator( + authorizer: Authorizer, + private val sslEngine: SSLEngine, + private val userExtractor: Configuration.UserExtractor?, + private val groupExtractor: Configuration.GroupExtractor?, + ) : AbstractNettyHttpAuthenticator(authorizer) { companion object { private val log = contextLogger() } - override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): String? { + override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set? { + return try { + sslEngine.session.peerCertificates + } catch (es : SSLPeerUnverifiedException) { + null + }?.takeIf { + it.isNotEmpty() + }?.let { peerCertificates -> + val clientCertificate = peerCertificates.first() as X509Certificate + val user = userExtractor?.extract(clientCertificate) + val group = groupExtractor?.extract(clientCertificate) + (group?.roles ?: emptySet()) + (user?.roles ?: emptySet()) + } + } + } + + private class NettyHttpBasicAuthenticator( + private val users: Map, authorizer: Authorizer + ) : AbstractNettyHttpAuthenticator(authorizer) { + + companion object { + private val log = contextLogger() + } + + override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set? { val authorizationHeader = req.headers()[HttpHeaderNames.AUTHORIZATION] ?: let { log.debug(ctx) { "Missing Authorization header" @@ -116,67 +149,77 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } return null } - val (user, password) = Base64.getDecoder().decode(authorizationHeader.substring(cursor + 1)) - .let(::String) - .let { - val colon = it.indexOf(':') - if(colon < 0) { - log.debug(ctx) { - "Missing colon from authentication" - } - return null + val (username, password) = Base64.getDecoder().decode(authorizationHeader.substring(cursor + 1)) + .let(::String) + .let { + val colon = it.indexOf(':') + if (colon < 0) { + log.debug(ctx) { + "Missing colon from authentication" } - it.substring(0, colon) to it.substring(colon + 1) + return null } - return user.takeIf { - credentials[user] == password - } + it.substring(0, colon) to it.substring(colon + 1) + } + + return username.let(users::get)?.takeIf { user -> + user.password?.let { passwordAndSalt -> + val (_, salt) = decodePasswordHash(passwordAndSalt) + hashPassword(password, Base64.getEncoder().encodeToString(salt)) == passwordAndSalt + } ?: false + }?.roles } } - private class ServerInitializer(private val cfg : Configuration) : ChannelInitializer() { + private class ServerInitializer(private val cfg: Configuration) : ChannelInitializer() { - private fun createSslCtx(tlsConfiguration : TlsConfiguration) : SslContext { - val keyStore = tlsConfiguration.keyStore - return if(keyStore == null) { + private fun createSslCtx(tls: Configuration.Tls): SslContext { + val keyStore = tls.keyStore + return if (keyStore == null) { throw IllegalArgumentException("No keystore configured") } else { val javaKeyStore = loadKeystore(keyStore.file, keyStore.password) val serverKey = javaKeyStore.getKey( - keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray)) as PrivateKey - val serverCert : Array = Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias)) - .map { it as X509Certificate } - .toArray {size -> Array(size) { null } } + keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray) + ) as PrivateKey + val serverCert: Array = + Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias)) + .map { it as X509Certificate } + .toArray { size -> Array(size) { null } } SslContextBuilder.forServer(serverKey, *serverCert).apply { - if(tlsConfiguration.verifyClients) { - clientAuth(ClientAuth.REQUIRE) - val trustStore = tlsConfiguration.trustStore + if (tls.verifyClients) { + clientAuth(ClientAuth.OPTIONAL) + val trustStore = tls.trustStore if (trustStore != null) { val ts = loadKeystore(trustStore.file, trustStore.password) trustManager( - ClientCertificateValidator.getTrustManager(ts, trustStore.checkCertificateStatus)) + ClientCertificateValidator.getTrustManager(ts, trustStore.checkCertificateStatus) + ) } } }.build() } } - private val sslContext : SslContext? = cfg.tlsConfiguration?.let(this::createSslCtx) + private val sslContext: SslContext? = cfg.tls?.let(this::createSslCtx) + private val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()) companion object { - val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()) - fun loadKeystore(file : Path, password : String?) : KeyStore { + + fun loadKeystore(file: Path, password: String?): KeyStore { val ext = JWO.splitExtension(file) .map(Tuple2::get_2) .orElseThrow { IllegalArgumentException( - "Keystore file '${file}' must have .jks, .p12, .pfx extension") + "Keystore file '${file}' must have .jks, .p12, .pfx extension" + ) } - val keystore = when(ext.substring(1).lowercase()) { + val keystore = when (ext.substring(1).lowercase()) { "jks" -> KeyStore.getInstance("JKS") "p12", "pfx" -> KeyStore.getInstance("PKCS12") else -> throw IllegalArgumentException( - "Keystore file '${file}' must have .jks, .p12, .pfx extension") + "Keystore file '${file}' must have .jks, .p12, .pfx extension" + ) } Files.newInputStream(file).use { keystore.load(it, password?.let(String::toCharArray)) @@ -185,21 +228,71 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } } + private fun userExtractor(authentication: Configuration.ClientCertificateAuthentication) = + authentication.userExtractor?.let { extractor -> + val pattern = Pattern.compile(extractor.pattern) + val rdnType = extractor.rdnType + Configuration.UserExtractor { cert: X509Certificate -> + val userName = LdapName(cert.subjectX500Principal.name).rdns.find { + it.type == rdnType + }?.let { + pattern.matcher(it.value.toString()) + }?.takeIf(Matcher::matches)?.group(1) + cfg.users[userName] ?: throw java.lang.RuntimeException("Failed to extract user") + } + } + + private fun groupExtractor(authentication: Configuration.ClientCertificateAuthentication) = + authentication.groupExtractor?.let { extractor -> + val pattern = Pattern.compile(extractor.pattern) + val rdnType = extractor.rdnType + Configuration.GroupExtractor { cert: X509Certificate -> + val groupName = LdapName(cert.subjectX500Principal.name).rdns.find { + it.type == rdnType + }?.let { + pattern.matcher(it.value.toString()) + }?.takeIf(Matcher::matches)?.group(1) + cfg.groups[groupName] ?: throw java.lang.RuntimeException("Failed to extract group") + } + } + override fun initChannel(ch: Channel) { - val userAuthorizer = UserAuthorizer(cfg.users) val pipeline = ch.pipeline() - if(sslContext != null) { + val auth = cfg.authentication + var authenticator : AbstractNettyHttpAuthenticator? = null + if (auth is Configuration.BasicAuthentication) { + val roleAuthorizer = RoleAuthorizer() + authenticator = (NettyHttpBasicAuthenticator(cfg.users, roleAuthorizer)) + } + if (sslContext != null) { val sslHandler = sslContext.newHandler(ch.alloc()) pipeline.addLast(sslHandler) + + if(auth is Configuration.ClientCertificateAuthentication) { + val roleAuthorizer = RoleAuthorizer() + authenticator = ClientCertificateAuthenticator( + roleAuthorizer, + sslHandler.engine(), + userExtractor(auth), + groupExtractor(auth) + ) + } } pipeline.addLast(HttpServerCodec()) pipeline.addLast(HttpChunkContentCompressor(1024)) pipeline.addLast(ChunkedWriteHandler()) pipeline.addLast(HttpObjectAggregator(Int.MAX_VALUE)) -// pipeline.addLast(NettyHttpBasicAuthenticator(mapOf("user" to "password")), userAuthorizer) - pipeline.addLast(group, ServerHandler(cfg.cacheFolder, cfg.serverPath)) + authenticator?.let{ + pipeline.addLast(it) + } + val cacheImplementation = when(val cache = cfg.cache) { + is Configuration.FileSystemCache -> { + FileSystemCache(cache.root, cache.maxAge) + } + else -> throw NotImplementedError() + } + pipeline.addLast(group, ServerHandler(cacheImplementation, cfg.serverPath)) pipeline.addLast(ExceptionHandler()) - Files.createDirectories(cfg.cacheFolder) } } @@ -207,36 +300,42 @@ class GradleBuildCacheServer(private val cfg : Configuration) { private val log = contextLogger() private val NOT_AUTHORIZED: FullHttpResponse = DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, Unpooled.EMPTY_BUFFER).apply { + HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, Unpooled.EMPTY_BUFFER + ).apply { headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" } + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { - when(cause) { + when (cause) { is DecoderException -> { log.error(cause.message, cause) ctx.close() } + is SSLPeerUnverifiedException -> { - ctx.writeAndFlush(NOT_AUTHORIZED.retainedDuplicate()).addListener(ChannelFutureListener.CLOSE_ON_FAILURE) + ctx.writeAndFlush(NOT_AUTHORIZED.retainedDuplicate()) + .addListener(ChannelFutureListener.CLOSE_ON_FAILURE) } + else -> { - log.error(cause.message, cause) - ctx.close() - } + log.error(cause.message, cause) + ctx.close() + } } } } - private class ServerHandler(private val cacheDir: Path, private val serverPrefix: String) : SimpleChannelInboundHandler() { + private class ServerHandler(private val cache: Cache, private val serverPrefix: String?) : + SimpleChannelInboundHandler() { companion object { private val log = contextLogger() - private fun splitPath(req: HttpRequest): Map.Entry { + private fun splitPath(req: HttpRequest): Pair { val uri = req.uri() val i = uri.lastIndexOf('/') if (i < 0) throw RuntimeException(String.format("Malformed request URI: '%s'", uri)) - return SimpleEntry(uri.substring(0, i), uri.substring(i + 1)) + return uri.substring(0, i).takeIf(String::isNotEmpty) to uri.substring(i + 1) } } @@ -246,14 +345,13 @@ class GradleBuildCacheServer(private val cfg : Configuration) { if (method === HttpMethod.GET) { val (prefix, key) = splitPath(msg) if (serverPrefix == prefix) { - val file = cacheDir.resolve(digestString(key.toByteArray())) - if (Files.exists(file)) { + cache.get(digestString(key.toByteArray()))?.let { channel -> log.debug(ctx) { "Cache hit for key '$key'" } val response = DefaultHttpResponse(msg.protocolVersion(), HttpResponseStatus.OK) response.headers()[HttpHeaderNames.CONTENT_TYPE] = HttpHeaderValues.APPLICATION_OCTET_STREAM - if(!keepAlive) { + if (!keepAlive) { response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE) response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.IDENTITY) } else { @@ -261,14 +359,22 @@ class GradleBuildCacheServer(private val cfg : Configuration) { response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED) } ctx.write(response) - val channel = FileChannel.open(file, StandardOpenOption.READ) - if(keepAlive) { - ctx.write(ChunkedNioFile(channel)) - ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) - } else { - ctx.writeAndFlush(DefaultFileRegion(channel, 0, Files.size(file))).addListener(ChannelFutureListener.CLOSE) + when (channel) { + is FileChannel -> { + if (keepAlive) { + ctx.write(ChunkedNioFile(channel)) + ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) + } else { + ctx.writeAndFlush(DefaultFileRegion(channel, 0, channel.size())) + .addListener(ChannelFutureListener.CLOSE) + } + } + else -> { + ctx.write(ChunkedNioStream(channel)) + ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) + } } - } else { + } ?: let { log.debug(ctx) { "Cache miss for key '$key'" } @@ -291,19 +397,11 @@ class GradleBuildCacheServer(private val cfg : Configuration) { "Added value for key '$key' to build cache" } val content = msg.content() - val file = cacheDir.resolve(digestString(key.toByteArray())) - val tmpFile = Files.createTempFile(cacheDir, null, ".tmp") - try { - Files.newOutputStream(tmpFile).use { - content.readBytes(it, content.readableBytes()) - } - Files.move(tmpFile, file, StandardCopyOption.ATOMIC_MOVE) - } catch (t : Throwable) { - Files.delete(tmpFile) - throw t - } - val response = DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.CREATED, - Unpooled.copiedBuffer(key.toByteArray())) + cache.put(digestString(key.toByteArray()), content) + val response = DefaultFullHttpResponse( + msg.protocolVersion(), HttpResponseStatus.CREATED, + Unpooled.copiedBuffer(key.toByteArray()) + ) response.headers()[HttpHeaderNames.CONTENT_LENGTH] = response.content().readableBytes() ctx.writeAndFlush(response) } else { @@ -326,13 +424,14 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } class ServerHandle( - private val httpChannel : ChannelFuture, + private val httpChannel: ChannelFuture, private val bossGroup: EventLoopGroup, - private val workerGroup: EventLoopGroup) : AutoCloseable { + private val workerGroup: EventLoopGroup + ) : AutoCloseable { - private val closeFuture : ChannelFuture = httpChannel.channel().closeFuture() + private val closeFuture: ChannelFuture = httpChannel.channel().closeFuture() - fun shutdown() : ChannelFuture { + fun shutdown(): ChannelFuture { return httpChannel.channel().close() } @@ -341,7 +440,7 @@ class GradleBuildCacheServer(private val cfg : Configuration) { closeFuture.sync() } finally { val fut1 = workerGroup.shutdownGracefully() - val fut2 = if(bossGroup !== workerGroup) { + val fut2 = if (bossGroup !== workerGroup) { bossGroup.shutdownGracefully() } else null fut1.sync() @@ -350,11 +449,11 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } } - fun run() : ServerHandle { + fun run(): ServerHandle { // Create the multithreaded event loops for the server val bossGroup = NioEventLoopGroup() val serverSocketChannel = NioServerSocketChannel::class.java - val workerGroup = if(cfg.useVirtualThread) { + val workerGroup = if (cfg.useVirtualThread) { NioEventLoopGroup(0, Executors.newVirtualThreadPerTaskExecutor()) } else { NioEventLoopGroup(0, Executors.newWorkStealingPool()) @@ -378,12 +477,20 @@ class GradleBuildCacheServer(private val cfg : Configuration) { companion object { + private fun String.toUrl() : URL = URL.of(URI(this), null) + private val log by lazy { contextLogger() } + private const val PROTOCOL_HANDLER = "java.protocol.handler.pkgs" private const val HANDLERS_PACKAGE = "net.woggioni.gbcs.url" + val CONFIGURATION_SCHEMA_URL by lazy { + "classpath:net/woggioni/gbcs/gbcs.xsd".toUrl() + } + val DEFAULT_CONFIGURATION_URL by lazy { "classpath:net/woggioni/gbcs/gbcs-default.xml".toUrl() } + /** * Reset any cached handlers just in case a jar protocol has already been used. We * reset the handler by trying to set a null [URLStreamHandlerFactory] which @@ -396,6 +503,7 @@ class GradleBuildCacheServer(private val cfg : Configuration) { // Ignore } } + fun registerUrlProtocolHandler() { val handlers = System.getProperty(PROTOCOL_HANDLER, "") System.setProperty( @@ -405,21 +513,19 @@ class GradleBuildCacheServer(private val cfg : Configuration) { resetCachedUrlHandlers() } - fun loadConfiguration(args: Array) : Configuration { - registerUrlProtocolHandler() + fun loadConfiguration(args: Array): Configuration { +// registerUrlProtocolHandler() URL.setURLStreamHandlerFactory(ClasspathUrlStreamHandlerFactoryProvider()) - Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader +// Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader val app = Application.builder("gbcs") .configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR") .configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir") .build() val confDir = app.computeConfigurationDirectory() val configurationFile = confDir.resolve("gbcs.xml") - log.info { "here" } - if(!Files.exists(configurationFile)) { + if (!Files.exists(configurationFile)) { Files.createDirectories(confDir) - val defaultConfigurationFileResourcePath = "classpath:net/woggioni/gbcs/gbcs-default.xml" - val defaultConfigurationFileResource = URI(defaultConfigurationFileResourcePath).toURL() + val defaultConfigurationFileResource = DEFAULT_CONFIGURATION_URL Files.newOutputStream(configurationFile).use { outputStream -> defaultConfigurationFileResource.openStream().use { inputStream -> JWO.copy(inputStream, outputStream) @@ -427,37 +533,36 @@ class GradleBuildCacheServer(private val cfg : Configuration) { } } // val schemaUrl = javaClass.getResource("/net/woggioni/gbcs/gbcs.xsd") - val schemaUrl = URL.of(URI("classpath:net/woggioni/gbcs/gbcs.xsd"), null) - val dbf = Xml.newDocumentBuilderFactory() + val schemaUrl = CONFIGURATION_SCHEMA_URL + val dbf = Xml.newDocumentBuilderFactory(schemaUrl) // dbf.schema = Xml.getSchema(this::class.java.module.getResourceAsStream("/net/woggioni/gbcs/gbcs.xsd")) dbf.schema = Xml.getSchema(schemaUrl) val db = dbf.newDocumentBuilder().apply { setErrorHandler(Xml.ErrorHandler(schemaUrl)) } val doc = Files.newInputStream(configurationFile).use(db::parse) - return Configuration.parse(doc.documentElement) + return Configuration.parse(doc) } @JvmStatic fun main(args: Array) { val configuration = loadConfiguration(args) -// Runtime.getRuntime().addShutdownHook(Thread { -// Thread.sleep(5000) -// javaClass.classLoader.loadClass("net.woggioni.jwo.exception.ChildProcessException") -// } -// ) GradleBuildCacheServer(configuration).run().use { } } - fun digest(data : ByteArray, - md : MessageDigest = MessageDigest.getInstance("MD5")) : ByteArray { + fun digest( + data: ByteArray, + md: MessageDigest = MessageDigest.getInstance("MD5") + ): ByteArray { md.update(data) return md.digest() } - fun digestString(data : ByteArray, - md : MessageDigest = MessageDigest.getInstance("MD5")) : String { + fun digestString( + data: ByteArray, + md: MessageDigest = MessageDigest.getInstance("MD5") + ): String { return JWO.bytesToHex(digest(data, md)) } } @@ -467,8 +572,9 @@ object GraalNativeImageConfiguration { @JvmStatic fun main(args: Array) { val conf = GradleBuildCacheServer.loadConfiguration(args) - val handle = GradleBuildCacheServer(conf).run() - Thread.sleep(10_000) - handle.shutdown() + GradleBuildCacheServer(conf).run().use { + Thread.sleep(3000) + it.shutdown() + } } } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt b/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt index 51e4121..3823155 100644 --- a/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt @@ -3,7 +3,7 @@ package net.woggioni.gbcs import io.netty.handler.codec.http.HttpMethod import io.netty.handler.codec.http.HttpRequest -class UserAuthorizer(private val users: Map>) : Authorizer { +class RoleAuthorizer : Authorizer { companion object { private val METHOD_MAP = mapOf( @@ -12,11 +12,11 @@ class UserAuthorizer(private val users: Map>) : Authorizer { ) } - override fun authorize(user: String, request: HttpRequest) = users[user]?.let { roles -> + override fun authorize(roles: Set, request: HttpRequest) : Boolean { val allowedMethods = roles.asSequence() .mapNotNull(METHOD_MAP::get) .flatten() .toSet() - request.method() in allowedMethods - } ?: false + return request.method() in allowedMethods + } } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Xml.kt b/src/main/kotlin/net/woggioni/gbcs/Xml.kt index cbf00e3..3f5cddd 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Xml.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Xml.kt @@ -1,7 +1,15 @@ package net.woggioni.gbcs +import org.slf4j.LoggerFactory +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.xml.sax.SAXNotRecognizedException +import org.xml.sax.SAXNotSupportedException +import org.xml.sax.SAXParseException import java.io.InputStream -import java.io.InputStreamReader +import java.io.OutputStream import java.net.URL import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA @@ -9,23 +17,18 @@ import javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING import javax.xml.XMLConstants.W3C_XML_SCHEMA_NS_URI import javax.xml.parsers.DocumentBuilder import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.Source +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult import javax.xml.transform.stream.StreamSource import javax.xml.validation.Schema import javax.xml.validation.SchemaFactory -import net.woggioni.jwo.xml.Xml -import org.slf4j.LoggerFactory -import org.w3c.dom.Document -import org.w3c.dom.Element -import org.w3c.dom.Node -import org.w3c.dom.NodeList import org.xml.sax.ErrorHandler as ErrHandler -import org.xml.sax.SAXNotRecognizedException -import org.xml.sax.SAXNotSupportedException -import org.xml.sax.SAXParseException + class NodeListIterator(private val nodeList: NodeList) : Iterator { - private var cursor : Int = 0 + private var cursor: Int = 0 override fun hasNext(): Boolean { return cursor < nodeList.length } @@ -69,7 +72,7 @@ class ElementIterator(parent: Element, name: String? = null) : Iterator } } -object Xml { +class Xml(private val doc: Document, val element: Element) { class ErrorHandler(private val fileURL: URL) : ErrHandler { @@ -79,84 +82,97 @@ object Xml { override fun warning(ex: SAXParseException) { log.warn( - "Problem at {}:{}:{} parsing deployment configuration: {}", - fileURL, ex.lineNumber, ex.columnNumber, ex.message + "Problem at {}:{}:{} parsing deployment configuration: {}", + fileURL, ex.lineNumber, ex.columnNumber, ex.message ) } override fun error(ex: SAXParseException) { log.error( - "Problem at {}:{}:{} parsing deployment configuration: {}", - fileURL, ex.lineNumber, ex.columnNumber, ex.message + "Problem at {}:{}:{} parsing deployment configuration: {}", + fileURL, ex.lineNumber, ex.columnNumber, ex.message ) throw ex } override fun fatalError(ex: SAXParseException) { log.error( - "Problem at {}:{}:{} parsing deployment configuration: {}", - fileURL, ex.lineNumber, ex.columnNumber, ex.message + "Problem at {}:{}:{} parsing deployment configuration: {}", + fileURL, ex.lineNumber, ex.columnNumber, ex.message ) throw ex } } - private fun disableProperty(dbf: DocumentBuilderFactory, propertyName: String) { - try { - dbf.setAttribute(propertyName, "") - } catch (iae: IllegalArgumentException) { - // Property not supported. - } - } + companion object { + fun Element.asIterable() = Iterable { ElementIterator(this, null) } + fun NodeList.asIterable() = Iterable { NodeListIterator(this) } - private fun disableProperty(sf: SchemaFactory, propertyName: String) { - try { - sf.setProperty(propertyName, "") - } catch (ex: SAXNotRecognizedException) { - // Property not supported. - } catch (ex: SAXNotSupportedException) { + private fun disableProperty(dbf: DocumentBuilderFactory, propertyName: String) { + try { + dbf.setAttribute(propertyName, "") + } catch (iae: IllegalArgumentException) { + // Property not supported. + } } - } - fun getSchema(schema: URL): Schema { - val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) - sf.setFeature(FEATURE_SECURE_PROCESSING, true) + private fun disableProperty(sf: SchemaFactory, propertyName: String) { + try { + sf.setProperty(propertyName, "") + } catch (ex: SAXNotRecognizedException) { + // Property not supported. + } catch (ex: SAXNotSupportedException) { + } + } + + fun getSchema(schema: URL): Schema { + val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) + sf.setFeature(FEATURE_SECURE_PROCESSING, true) // disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) // disableProperty(sf, ACCESS_EXTERNAL_DTD) - sf.errorHandler = ErrorHandler(schema) - return sf.newSchema(schema) - } + sf.errorHandler = ErrorHandler(schema) + return sf.newSchema(schema) + } - fun getSchema(inputStream: InputStream): Schema { - val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) - sf.setFeature(FEATURE_SECURE_PROCESSING, true) + fun getSchema(inputStream: InputStream): Schema { + val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) + sf.setFeature(FEATURE_SECURE_PROCESSING, true) // disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) // disableProperty(sf, ACCESS_EXTERNAL_DTD) - return sf.newSchema(StreamSource(inputStream)) + return sf.newSchema(StreamSource(inputStream)) + } + + fun newDocumentBuilderFactory(schemaResourceURL: URL?): DocumentBuilderFactory { + val dbf = DocumentBuilderFactory.newInstance() + dbf.setFeature(FEATURE_SECURE_PROCESSING, true) + disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA) + disableProperty(dbf, ACCESS_EXTERNAL_DTD) + dbf.isExpandEntityReferences = false + dbf.isIgnoringComments = true + dbf.isNamespaceAware = true + dbf.isValidating = false + schemaResourceURL?.let { + dbf.schema = getSchema(it) + } + return dbf + } + + fun newDocumentBuilder(resource: URL, schemaResourceURL: URL?): DocumentBuilder { + val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder() + db.setErrorHandler(ErrorHandler(resource)) + return db } - fun newDocumentBuilderFactory(): DocumentBuilderFactory { - val dbf = DocumentBuilderFactory.newInstance() - dbf.setFeature(FEATURE_SECURE_PROCESSING, true) - disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA) - disableProperty(dbf, ACCESS_EXTERNAL_DTD) - dbf.isExpandEntityReferences = false - dbf.isIgnoringComments = true - dbf.isNamespaceAware = true - dbf.isValidating = false - return dbf + fun parseXmlResource(resource: URL, schemaResourceURL: URL?): Document { + val db = newDocumentBuilder(resource, schemaResourceURL) + return resource.openStream().use(db::parse) } -// fun newDocumentBuilder(resource: URL, schemaResourceURL: String?): DocumentBuilder { -// val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder() -// db.setErrorHandler(XmlErrorHandler(resource)) -// return db -// } + fun parseXml(sourceURL : URL, sourceStream: InputStream? = null, schemaResourceURL: URL? = null): Document { + val db = newDocumentBuilder(sourceURL, schemaResourceURL) + return sourceStream?.let(db::parse) ?: sourceURL.openStream().use(db::parse) + } -// fun parseXmlResource(resource: URL, schemaResourceURL: String?): Document { -// val db = newDocumentBuilder(resource, schemaResourceURL) -// return resource.openStream().use(db::parse) -// } // // fun newDocumentBuilder(resource: URL): DocumentBuilder { // val db = newDocumentBuilderFactory(null).newDocumentBuilder() @@ -169,6 +185,78 @@ object Xml { // return resource.openStream().use(db::parse) // } - fun Element.asIterable() = Iterable { ElementIterator(this, null) } - fun NodeList.asIterable() = Iterable { NodeListIterator(this) } + fun write(doc: Document, output: OutputStream) { + val transformerFactory = TransformerFactory.newInstance() + val transformer = transformerFactory.newTransformer() + transformer.setOutputProperty(OutputKeys.INDENT, "yes") +// val domImpl = doc.getImplementation() +// val docType = domImpl.createDocumentType( +// "plist", +// "-//Apple//DTD PLIST 1.0//EN", +// "http://www.apple.com/DTDs/PropertyList-1.0.dtd" +// ) +// transformer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, docType.getPublicId()) +// transformer.setOutputProperty(OutputKeys.DOCTYPE_SYSTEM, docType.getSystemId()) +// val transformerFactory = TransformerFactory.newInstance() +// val transformer: Transformer = transformerFactory.newTransformer() + transformer.setOutputProperty(OutputKeys.INDENT, "yes") + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4") + transformer.setOutputProperty(OutputKeys.STANDALONE, "yes") + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8") + val source = DOMSource(doc) + val result = StreamResult(output) + transformer.transform(source, result) + } + + fun of(namespaceURI: String, qualifiedName: String, schemaResourceURL: URL? = null, cb: Xml.(el: Element) -> Unit): Document { + val dbf = newDocumentBuilderFactory(schemaResourceURL) + val db = dbf.newDocumentBuilder() + val doc = db.newDocument() + val root = doc.createElementNS(namespaceURI, qualifiedName) + .also(doc::appendChild) + Xml(doc, root).cb(root) + return doc + } + + fun of(doc: Document, el: Element, cb: Xml.(el: Element) -> Unit): Element { + Xml(doc, el).cb(el) + return el + } + + fun Element.removeChildren() { + while (true) { + removeChild(firstChild ?: break) + } + } + + } + + fun node( + name: String, + attrs: Map = emptyMap(), + cb: Xml.(el: Element) -> Unit = {} + ): Element { + val child = doc.createElement(name) + for ((key, value) in attrs) { + child.setAttribute(key, value) + } + return child + .also { + element.appendChild(it) + Xml(doc, it).cb(it) + } + } + + + fun attrs(vararg attributes: Pair) { + for (attr in attributes) element.setAttribute(attr.first, attr.second) + } + + fun attr(key: String, value: String) { + element.setAttribute(key, value) + } + + fun text(txt: String) { + element.appendChild(doc.createTextNode(txt)) + } } diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt b/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt new file mode 100644 index 0000000..26761a1 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt @@ -0,0 +1,10 @@ +package net.woggioni.gbcs.cache + +import io.netty.buffer.ByteBuf +import java.nio.channels.ByteChannel + +interface Cache { + fun get(key : String) : ByteChannel? + + fun put(key : String, content : ByteBuf) : Unit +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt new file mode 100644 index 0000000..3e882e5 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt @@ -0,0 +1,90 @@ +package net.woggioni.gbcs.cache + +import io.netty.buffer.ByteBuf +import net.woggioni.gbcs.GradleBuildCacheServer.Companion.digestString +import net.woggioni.jwo.LockFile +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.time.Duration +import java.time.Instant +import java.util.concurrent.atomic.AtomicReference + + +class FileSystemCache(val root: Path, val maxAge: Duration) : Cache { + + private fun lockFilePath(key: String): Path = root.resolve("$key.lock") + + init { + Files.createDirectories(root) + } + + override fun equals(other: Any?): Boolean { + return when (other) { + is FileSystemCache -> { + other.root == root && other.maxAge == maxAge + } + + else -> false + } + } + + override fun hashCode(): Int { + return root.hashCode() xor maxAge.hashCode() + } + + private var nextGc = AtomicReference(Instant.now().plus(maxAge)) + + override fun get(key: String) = LockFile.acquire(lockFilePath(key), true).use { + root.resolve(key).takeIf(Files::exists)?.let { FileChannel.open(it, StandardOpenOption.READ) } + }.also { + gc() + } + + override fun put(key: String, content: ByteBuf) { + LockFile.acquire(lockFilePath(key), false).use { + val file = root.resolve(key) + val tmpFile = Files.createTempFile(root, null, ".tmp") + try { + Files.newOutputStream(tmpFile).use { + content.readBytes(it, content.readableBytes()) + } + Files.move(tmpFile, file, StandardCopyOption.ATOMIC_MOVE) + } catch (t: Throwable) { + Files.delete(tmpFile) + throw t + } + }.also { + gc() + } + } + + private fun gc() { + val now = Instant.now() + val oldValue = nextGc.getAndSet(now.plus(maxAge)) + if (oldValue < now) { + actualGc(now) + } + } + + @Synchronized + private fun actualGc(now: Instant) { + Files.list(root).filter { + !it.fileName.toString().endsWith(".lock") + }.filter { + val creationTimeStamp = Files.readAttributes(it, BasicFileAttributes::class.java) + .creationTime() + .toInstant() + now > creationTimeStamp.plus(maxAge) + }.forEach { file -> + val lockFile = lockFilePath(file.fileName.toString()) + LockFile.acquire(lockFile, false).use { + Files.delete(file) + } + Files.delete(lockFile) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt new file mode 100644 index 0000000..0a55a83 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt @@ -0,0 +1,294 @@ +package net.woggioni.gbcs.configuration + +import net.woggioni.gbcs.Role +import net.woggioni.gbcs.Xml.Companion.asIterable +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.nio.file.Path +import java.nio.file.Paths +import java.security.cert.X509Certificate +import java.time.Duration + +data class Configuration private constructor( + val host: String, + val port: Int, + val serverPath: String?, + val users: Map, + val groups: Map, + val cache: Cache, + val authentication : Authentication?, + val tls: Tls?, + val useVirtualThread: Boolean +) { + + data class Group(val name: String, val roles: Set) { + override fun hashCode(): Int { + return name.hashCode() + } + } + + data class User(val name: String, val password: String?, val groups: Set) { + override fun hashCode(): Int { + return name.hashCode() + } + + val roles : Set + get() = groups.asSequence().flatMap { it.roles }.toSet() + } + + data class HostAndPort(val host: String, val port: Int) { + override fun toString() = "$host:$port" + } + + fun interface UserExtractor { + fun extract(cert :X509Certificate) : User + } + + fun interface GroupExtractor { + fun extract(cert :X509Certificate) : Group + } + + data class Tls( + val keyStore: KeyStore?, + val trustStore: TrustStore?, + val verifyClients: Boolean, + ) + + data class KeyStore( + val file: Path, + val password: String?, + val keyAlias: String, + val keyPassword: String? + ) + + data class TrustStore( + val file: Path, + val password: String?, + val checkCertificateStatus: Boolean + ) + + + data class TlsCertificateExtractor(val rdnType : String, val pattern : String) + + interface Authentication + + class BasicAuthentication : Authentication + + data class ClientCertificateAuthentication( + val userExtractor: TlsCertificateExtractor?, + val groupExtractor: TlsCertificateExtractor?) : Authentication + + + interface Cache + + data class FileSystemCache(val root: Path, val maxAge: Duration) : Cache + + companion object { + + fun of( + host: String, + port: Int, + serverPath: String?, + users: Map, + groups: Map, + cache: Cache, + authentication : Authentication?, + tls: Tls?, + useVirtualThread: Boolean + ) = Configuration( + host, + port, + serverPath?.takeIf { it.isNotEmpty() && it != "/" }, + users, + groups, + cache, + authentication, + tls, + useVirtualThread + ) + + fun parse(document: Document): Configuration { + val root = document.documentElement + var cache: Cache? = null + var host = "127.0.0.1" + var port = 11080 + var users = emptyMap() + var groups = emptyMap() + var tls: Tls? = null + val serverPath = root.getAttribute("path") + val useVirtualThread = root.getAttribute("useVirtualThreads") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) ?: false + var authentication : Authentication? = null + for (child in root.asIterable()) { + when (child.nodeName) { + "authorization" -> { + for (gchild in child.asIterable()) { + when (child.nodeName) { + "users" -> { + users = parseUsers(child) + } + + "groups" -> { + val pair = parseGroups(child, users) + users = pair.first + groups = pair.second + } + } + } + } + + "bind" -> { + host = child.getAttribute("host") + port = Integer.parseInt(child.getAttribute("port")) + } + + "cache" -> { + for (gchild in child.asIterable()) { + when (gchild.nodeName) { + "file-system-cache" -> { + val cacheFolder = gchild.getAttribute("path") + .takeIf(String::isNotEmpty) + ?.let(Paths::get) + ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") + val maxAge = gchild.getAttribute("max-age") + .takeIf(String::isNotEmpty) + ?.let(Duration::parse) + ?: Duration.ofDays(1) + cache = FileSystemCache(cacheFolder, maxAge) + } + } + } + } + + "authentication" -> { + for (gchild in child.asIterable()) { + when (gchild.nodeName) { + "basic" -> { + authentication = BasicAuthentication() + } + + "client-certificate" -> { + var tlsExtractorUser : TlsCertificateExtractor? = null + var tlsExtractorGroup : TlsCertificateExtractor? = null + for (gchild in child.asIterable()) { + when (gchild.nodeName) { + "group-extractor" -> { + val attrName = gchild.getAttribute("attribute-name") + val pattern = gchild.getAttribute("pattern") + tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) + } + + "user-extractor" -> { + val attrName = gchild.getAttribute("attribute-name") + val pattern = gchild.getAttribute("pattern") + tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) + } + } + } + authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) + } + } + } + } + + "tls" -> { + val verifyClients = child.getAttribute("verify-clients") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) ?: false + var keyStore: KeyStore? = null + var trustStore: TrustStore? = null + for (granChild in child.asIterable()) { + when (granChild.nodeName) { + "keystore" -> { + val keyStoreFile = Paths.get(granChild.getAttribute("file")) + val keyStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + val keyAlias = granChild.getAttribute("key-alias") + val keyPassword = granChild.getAttribute("key-password") + .takeIf(String::isNotEmpty) + keyStore = KeyStore( + keyStoreFile, + keyStorePassword, + keyAlias, + keyPassword + ) + } + + "truststore" -> { + val trustStoreFile = Paths.get(granChild.getAttribute("file")) + val trustStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + val checkCertificateStatus = granChild.getAttribute("check-certificate-status") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) + ?: false + trustStore = TrustStore( + trustStoreFile, + trustStorePassword, + checkCertificateStatus + ) + } + } + } + tls = Tls(keyStore, trustStore, verifyClients) + } + } + } + return of(host, port, serverPath, users, groups, cache!!, authentication, tls, useVirtualThread) + } + + private fun parseRoles(root: Element) = root.asIterable().asSequence().map { + when (it.nodeName) { + "reader" -> Role.Reader + "writer" -> Role.Writer + else -> throw UnsupportedOperationException("Illegal node '${it.nodeName}'") + } + }.toSet() + + private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter { + it.nodeName == "user" + }.map { + it.getAttribute("ref") + }.toSet() + + private fun parseUsers(root: Element): Map { + return root.asIterable().asSequence().filter { + it.nodeName == "user" + }.map { el -> + val username = el.getAttribute("name") + val password = el.getAttribute("password").takeIf(String::isNotEmpty) + username to User(username, password, emptySet()) + }.toMap() + } + + private fun parseGroups(root: Element, knownUsers : Map): Pair, Map> { + val userGroups = mutableMapOf>() + val groups = root.asIterable().asSequence().filter { + it.nodeName == "group" + }.map { el -> + val groupName = el.getAttribute("name") + var roles = emptySet() + for (child in el.asIterable()) { + when (child.nodeName) { + "users" -> { + parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user -> + userGroups.computeIfAbsent(user.name) { + mutableSetOf() + }.add(groupName) + } + } + "roles" -> { + roles = parseRoles(child) + } + } + } + groupName to Group(groupName, roles) + }.toMap() + val users = knownUsers.map { (name, user) -> + name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet()) + }.toMap() + return users to groups + } + } +} diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt new file mode 100644 index 0000000..88485f9 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt @@ -0,0 +1,122 @@ +package net.woggioni.gbcs.configuration + +import net.woggioni.gbcs.Xml +import org.w3c.dom.Document + +object Serializer { + + private const val GBCS_NAMESPACE: String = "urn:net.woggioni.gbcs" + private const val GBCS_PREFIX: String = "gbcs" + + fun serialize(conf : Configuration) : Document { + return Xml.of(GBCS_NAMESPACE, GBCS_PREFIX + ":server") { + attr("userVirtualThreads", conf.useVirtualThread.toString()) + conf.serverPath?.let { serverPath -> + attr("path", serverPath) + } + node("bind") { + attr("host", conf.host) + attr("port", conf.port.toString()) + } + node("cache") { + when(val cache = conf.cache) { + is Configuration.FileSystemCache -> { + node("file-system-cache") { + attr("path", cache.root.toString()) + attr("max-age", cache.maxAge.toString()) + } + } + else -> throw NotImplementedError() + } + } + node("authorization") { + node("users") { + for(user in conf.users.values) { + node("user") { + attr("name", user.name) + user.password?.let { password -> + attr("password", password) + } + } + } + } + node("groups") { + val groups = conf.users.values.asSequence() + .flatMap { + user -> user.groups.map { it to user } + }.groupBy(Pair::first, Pair::second) + for(pair in groups) { + val group = pair.key + val users = pair.value + node("group") { + attr("name", group.name) + if(users.isNotEmpty()) { + node("users") { + for(user in users) { + node("user") { + attr("ref", user.name) + } + } + } + } + if(group.roles.isNotEmpty()) { + node("roles") { + for(role in group.roles) { + node(role.toString().lowercase()) + } + } + } + } + } + } + } + + conf.authentication?.let { authentication -> + node("authentication") { + when(authentication) { + is Configuration.BasicAuthentication -> { + node("basic") + } + is Configuration.ClientCertificateAuthentication -> { + node("client-certificate") { + authentication.userExtractor?.let { extractor -> + node("user-extractor") { + attr("attribute-name", extractor.rdnType) + attr("pattern", extractor.pattern) + } + } + } + } + } + } + } + + conf.tls?.let { tlsConfiguration -> + node("tls") { + tlsConfiguration.keyStore?.let { keyStore -> + node("keystore") { + attr("file", keyStore.file.toString()) + keyStore.password?.let { keyStorePassword -> + attr("password", keyStorePassword) + } + attr("key-alias", keyStore.keyAlias) + keyStore.keyPassword?.let { keyPassword -> + attr("key-password", keyPassword) + } + } + } + + tlsConfiguration.trustStore?.let { trustStore -> + node("truststore") { + attr("file", trustStore.file.toString()) + trustStore.password?.let { password -> + attr("password", password) + } + attr("check-certificate-status", trustStore.checkCertificateStatus.toString()) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index b562cd5..2861972 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -15,4 +15,5 @@ + \ No newline at end of file diff --git a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml index f5878bf..29746aa 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml +++ b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml @@ -1,48 +1,10 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + \ No newline at end of file diff --git a/src/main/resources/net/woggioni/gbcs/gbcs.xsd b/src/main/resources/net/woggioni/gbcs/gbcs.xsd index ce3adfe..1e7078a 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs.xsd +++ b/src/main/resources/net/woggioni/gbcs/gbcs.xsd @@ -7,8 +7,11 @@ - - + + + + + @@ -18,10 +21,11 @@ + - + @@ -30,8 +34,15 @@ - + + + + + + + + @@ -58,6 +69,14 @@ + + + + + + + + @@ -65,9 +84,6 @@ - - - diff --git a/src/test/java/module-info.java.backup b/src/test/java/module-info.java.backup new file mode 100644 index 0000000..4b1307c --- /dev/null +++ b/src/test/java/module-info.java.backup @@ -0,0 +1,7 @@ +module net.woggioni.gbcs.test { + requires org.junit.jupiter.api; + requires net.woggioni.gbcs; + requires kotlin.stdlib; + requires java.xml; + requires java.naming; +} \ No newline at end of file diff --git a/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java b/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java new file mode 100644 index 0000000..e89d315 --- /dev/null +++ b/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java @@ -0,0 +1,227 @@ +package net.woggioni.gbcs.utils; + +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectAltPublicKeyInfo; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.io.FileOutputStream; +import java.math.BigInteger; +import java.net.InetAddress; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +public class CertificateUtils { + + public record X509Credentials( + KeyPair keyPair, + X509Certificate certificate + ){ } + public static class CertificateAuthority { + private final PrivateKey privateKey; + private final X509Certificate certificate; + + public CertificateAuthority(PrivateKey privateKey, X509Certificate certificate) { + this.privateKey = privateKey; + this.certificate = certificate; + } + + public PrivateKey getPrivateKey() { return privateKey; } + public X509Certificate getCertificate() { return certificate; } + } + + /** + * Creates a new Certificate Authority (CA) + * @param commonName The CA's common name + * @param validityDays How long the CA should be valid for + * @return The generated CA containing both private key and certificate + */ + public static X509Credentials createCertificateAuthority(String commonName, int validityDays) + throws Exception { + // Generate key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(4096); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name("CN=" + commonName); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + issuerName, + keyPair.getPublic() + ); + + // Add CA extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(true) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign) + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(keyPair.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + return new X509Credentials(keyPair, cert); + } + + /** + * Creates a server certificate signed by the CA + * @param ca The Certificate Authority to sign with + * @param subjectName The server's common name + * @param validityDays How long the certificate should be valid for + * @return KeyPair containing the server's private key and certificate + */ + public static X509Credentials createServerCertificate(X509Credentials ca, X500Name subjectName, int validityDays) + throws Exception { + // Generate server key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair serverKeyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name(ca.certificate().getSubjectX500Principal().getName()); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + subjectName, + serverKeyPair.getPublic() + ); + + // Add server certificate extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(false) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment) + ); + certBuilder.addExtension( + Extension.extendedKeyUsage, + true, + new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth}) + ); + GeneralNames subjectAltNames = GeneralNames.getInstance( + new DERSequence( + new GeneralName[] { + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + } + ) + ); + certBuilder.addExtension( + Extension.subjectAlternativeName, + true, + subjectAltNames + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(ca.keyPair().getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + + return new X509Credentials(serverKeyPair, cert); + } + + /** + * Creates a client certificate signed by the CA + * @param ca The Certificate Authority to sign with + * @param subjectName The client's common name + * @param validityDays How long the certificate should be valid for + * @return KeyPair containing the client's private key and certificate + */ + public static X509Credentials createClientCertificate(X509Credentials ca, X500Name subjectName, int validityDays) + throws Exception { + // Generate client key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair clientKeyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name(ca.certificate().getSubjectX500Principal().getName()); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + subjectName, + clientKeyPair.getPublic() + ); + + // Add client certificate extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(false) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature) + ); + certBuilder.addExtension( + Extension.extendedKeyUsage, + true, + new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth}) + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(ca.keyPair().getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + return new X509Credentials(clientKeyPair, cert); + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/CertificateValidationTest.kt b/src/test/kotlin/net/woggioni/gbcs/CertificateValidationTest.kt deleted file mode 100644 index d4a9c74..0000000 --- a/src/test/kotlin/net/woggioni/gbcs/CertificateValidationTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package net.woggioni.gbcs - -import java.nio.file.Files -import java.nio.file.Path -import java.security.KeyStore -import java.security.cert.CertPathValidator -import java.security.cert.CertificateFactory -import java.security.cert.PKIXParameters -import org.junit.jupiter.api.Test - -class CertificateValidationTest { - - @Test - fun test() { - val keystore = KeyStore.getInstance("PKCS12") - val keystorePath = Path.of("/home/woggioni/ssl/woggioni@f6aa5663ef26.pfx") - Files.newInputStream(keystorePath).use { - keystore.load(it, System.getenv("KEYPASS").toCharArray()) - } - val pkix = CertPathValidator.getInstance("PKIX") - val trustStore = KeyStore.getInstance("PKCS12") - val trustStorePath = Path.of("/home/woggioni/ssl/truststore.pfx") - - Files.newInputStream(trustStorePath).use { - trustStore.load(it, "123456".toCharArray()) - } - - val certificateFactory = CertificateFactory.getInstance("X.509") - val cert = keystore.getCertificateChain("woggioni@f6aa5663ef26").toList() - .let(certificateFactory::generateCertPath) - val params = PKIXParameters(trustStore) - params.isRevocationEnabled = false - pkix.validate(cert, params) - } -} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/ConfigurationTest.kt b/src/test/kotlin/net/woggioni/gbcs/ConfigurationTest.kt deleted file mode 100644 index f78c1f1..0000000 --- a/src/test/kotlin/net/woggioni/gbcs/ConfigurationTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.woggioni.gbcs - -import java.net.URI -import java.net.URL -import org.junit.jupiter.api.Test - -class ConfigurationTest { - - @Test - fun test() { - GradleBuildCacheServer.registerUrlProtocolHandler() - val schemaUrl = this::class.java.getResource("/net/woggioni/gbcs/gbcs.xsd") - val dbf = Xml.newDocumentBuilderFactory() - dbf.schema = Xml.getSchema(schemaUrl) - val db = dbf.newDocumentBuilder().apply { - setErrorHandler(Xml.ErrorHandler(schemaUrl)) - } - val configurationUrl = this::class.java.getResource("/net/woggioni/gbcs/gbcs-default.xml") - val doc = configurationUrl.openStream().use(db::parse) - Configuration.parse(doc.documentElement) - } -} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt new file mode 100644 index 0000000..d2829c9 --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt @@ -0,0 +1,51 @@ +package net.woggioni.gbcs.test + +import net.woggioni.gbcs.GradleBuildCacheServer +import net.woggioni.gbcs.configuration.Configuration +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.ClassOrderer +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +abstract class AbstractServerTest { + + protected lateinit var cfg : Configuration + + protected lateinit var testDir : Path + + private var serverHandle : GradleBuildCacheServer.ServerHandle? = null + + @BeforeAll + fun setUp0(@TempDir tmpDir : Path) { + this.testDir = tmpDir + setUp() + startServer(cfg) + } + + @AfterAll + fun tearDown0() { + tearDown() + stopServer() + } + + abstract fun setUp() + + abstract fun tearDown() + + private fun startServer(cfg : Configuration) { + this.serverHandle = GradleBuildCacheServer(cfg).run() + } + + private fun stopServer() { + this.serverHandle?.use { + it.shutdown() + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt new file mode 100644 index 0000000..8b85353 --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt @@ -0,0 +1,189 @@ +package net.woggioni.gbcs.test + +import io.netty.handler.codec.Headers +import io.netty.handler.codec.http.HttpResponseStatus +import net.woggioni.gbcs.AbstractNettyHttpAuthenticator.Companion.hashPassword +import net.woggioni.gbcs.Authorizer +import net.woggioni.gbcs.Role +import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.configuration.Serializer +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import java.time.Duration +import java.util.Base64 +import kotlin.random.Random + + +class BasicAuthServerTest : AbstractServerTest() { + + companion object { + private const val PASSWORD = "password" + } + + private lateinit var cacheDir : Path + + private val random = Random(101325) + private val keyValuePair = newEntry(random) + + override fun setUp() { + this.cacheDir = testDir.resolve("cache") + val readersGroup = Configuration.Group("readers", setOf(Role.Reader)) + val writersGroup = Configuration.Group("writers", setOf(Role.Writer)) + cfg = Configuration.of( + cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), + host = "127.0.0.1", + port = ServerSocket(0).localPort + 1, + users = listOf( + Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)), + Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)), + Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)) + ).asSequence().map { it.name to it}.toMap(), + groups = sequenceOf(writersGroup, readersGroup).map { it.name to it}.toMap(), + authentication = Configuration.BasicAuthentication(), + useVirtualThread = true, + tls = null, + serverPath = "/" + ) + Xml.write(Serializer.serialize(cfg), System.out) + } + + override fun tearDown() { + } + + fun buildAuthorizationHeader(user : Configuration.User, password : String) : String { + val b64 = Base64.getEncoder().encode("${user.name}:${password}".toByteArray(Charsets.UTF_8)).let{ + String(it, StandardCharsets.UTF_8) + } + return "Basic $b64" + } + + fun newRequestBuilder(key : String) = HttpRequest.newBuilder() + .uri(URI.create("http://${cfg.host}:${cfg.port}/$key")) + + + fun newEntry(random : Random) : Pair { + val key = ByteArray(0x10).let { + random.nextBytes(it) + Base64.getUrlEncoder().encodeToString(it) + } + val value = ByteArray(0x1000).also { + random.nextBytes(it) + } + return key to value + } + + @Test + @Order(1) + fun putWithNoAuthorizationHeader() { + val client: HttpClient = HttpClient.newHttpClient() + val (key, value) = keyValuePair + + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode()) + } + + @Test + @Order(2) + fun putAsAReaderUser() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Reader in it.roles && Role.Writer !in it.roles + } ?: throw RuntimeException("Reader user not found") + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()) + } + + @Test + @Order(3) + fun getAsAWriterUser() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, _) = keyValuePair + val user = cfg.users.values.find { + Role.Writer in it.roles + } ?: throw RuntimeException("Reader user not found") + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()) + } + + @Test + @Order(4) + fun putAsAWriterUser() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Writer in it.roles + } ?: throw RuntimeException("Reader user not found") + + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()) + } + + @Test + @Order(5) + fun getAsAReaderUser() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Reader in it.roles + } ?: throw RuntimeException("Reader user not found") + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()) + Assertions.assertArrayEquals(value, response.body()) + } + + @Test + @Order(6) + fun getMissingKeyAsAReaderUser() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, _) = newEntry(random) + val user = cfg.users.values.find { + Role.Reader in it.roles + } ?: throw RuntimeException("Reader user not found") + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt new file mode 100644 index 0000000..049bb3e --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt @@ -0,0 +1,32 @@ +package net.woggioni.gbcs.test + +import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.GradleBuildCacheServer +import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.configuration.Serializer +import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path + +class ConfigurationTest { + + @Test + fun test(@TempDir testDir : Path) { + URL.setURLStreamHandlerFactory(ClasspathUrlStreamHandlerFactoryProvider()) + val dbf = Xml.newDocumentBuilderFactory(GradleBuildCacheServer.CONFIGURATION_SCHEMA_URL) + val db = dbf.newDocumentBuilder() + val configurationUrl = GradleBuildCacheServer.DEFAULT_CONFIGURATION_URL + val doc = configurationUrl.openStream().use(db::parse) + val cfg = Configuration.parse(doc) + val configFile = testDir.resolve("gbcs.xml") + Files.newOutputStream(configFile).use { + Xml.write(Serializer.serialize(cfg), it) + } + val parsed = Configuration.parse(Xml.parseXml(configFile.toUri().toURL())) + Assertions.assertEquals(cfg, parsed) + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt new file mode 100644 index 0000000..53ab69d --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt @@ -0,0 +1,98 @@ +package net.woggioni.gbcs.test + +import io.netty.handler.codec.http.HttpResponseStatus +import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.configuration.Serializer +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Path +import java.time.Duration +import java.util.Base64 +import kotlin.random.Random + + +class NoAuthServerTest : AbstractServerTest() { + + private lateinit var cacheDir : Path + + private val random = Random(101325) + private val keyValuePair = newEntry(random) + + override fun setUp() { + this.cacheDir = testDir.resolve("cache") + cfg = Configuration.of( + cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), + host = "127.0.0.1", + port = ServerSocket(0).localPort + 1, + users = emptyMap(), + groups = emptyMap(), + authentication = null, + useVirtualThread = true, + tls = null, + serverPath = "/" + ) + Xml.write(Serializer.serialize(cfg), System.out) + } + + override fun tearDown() { + } + + fun newRequestBuilder(key : String) = HttpRequest.newBuilder() + .uri(URI.create("http://${cfg.host}:${cfg.port}/$key")) + + fun newEntry(random : Random) : Pair { + val key = ByteArray(0x10).let { + random.nextBytes(it) + Base64.getUrlEncoder().encodeToString(it) + } + val value = ByteArray(0x1000).also { + random.nextBytes(it) + } + return key to value + } + + @Test + @Order(1) + fun putWithNoAuthorizationHeader() { + val client: HttpClient = HttpClient.newHttpClient() + val (key, value) = keyValuePair + + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()) + } + + @Test + @Order(2) + fun getWithNoAuthorizationHeader() { + val client: HttpClient = HttpClient.newHttpClient() + val (key, value ) = keyValuePair + val requestBuilder = newRequestBuilder(key) + .GET() + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()) + Assertions.assertArrayEquals(value, response.body()) + } + + @Test + @Order(3) + fun getMissingKey() { + val client: HttpClient = HttpClient.newHttpClient() + + val (key, _) = newEntry(random) + val requestBuilder = newRequestBuilder(key).GET() + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt new file mode 100644 index 0000000..5579813 --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt @@ -0,0 +1,292 @@ +package net.woggioni.gbcs.test + +import io.netty.handler.codec.http.HttpResponseStatus +import net.woggioni.gbcs.Role +import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.configuration.Serializer +import net.woggioni.gbcs.utils.CertificateUtils +import net.woggioni.gbcs.utils.CertificateUtils.X509Credentials +import org.bouncycastle.asn1.x500.X500Name +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyStore +import java.security.KeyStore.PasswordProtection +import java.time.Duration +import java.util.Base64 +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import kotlin.random.Random + + +class TlsServerTest : AbstractServerTest() { + + companion object { + private const val CA_CERTIFICATE_ENTRY = "gbcs-ca" + private const val CLIENT_CERTIFICATE_ENTRY = "gbcs-client" + private const val SERVER_CERTIFICATE_ENTRY = "gbcs-server" + private const val PASSWORD = "password" + } + + private lateinit var cacheDir: Path + private lateinit var serverKeyStoreFile: Path + private lateinit var clientKeyStoreFile: Path + private lateinit var trustStoreFile: Path + private lateinit var serverKeyStore: KeyStore + private lateinit var clientKeyStore: KeyStore + private lateinit var trustStore: KeyStore + private lateinit var ca: X509Credentials + + private val readersGroup = Configuration.Group("readers", setOf(Role.Reader)) + private val writersGroup = Configuration.Group("writers", setOf(Role.Writer)) + private val random = Random(101325) + private val keyValuePair = newEntry(random) + + private val users = listOf( + Configuration.User("user1", null, setOf(readersGroup)), + Configuration.User("user2", null, setOf(writersGroup)), + Configuration.User("user3", null, setOf(readersGroup, writersGroup)) + ) + + fun createKeyStoreAndTrustStore() { + ca = CertificateUtils.createCertificateAuthority(CA_CERTIFICATE_ENTRY, 30) + val serverCert = CertificateUtils.createServerCertificate(ca, X500Name("CN=$SERVER_CERTIFICATE_ENTRY"), 30) + val clientCert = CertificateUtils.createClientCertificate(ca, X500Name("CN=$CLIENT_CERTIFICATE_ENTRY"), 30) + + serverKeyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setEntry(CA_CERTIFICATE_ENTRY, KeyStore.TrustedCertificateEntry(ca.certificate), PasswordProtection(null)) + setEntry( + SERVER_CERTIFICATE_ENTRY, + KeyStore.PrivateKeyEntry( + serverCert.keyPair().private, + arrayOf(serverCert.certificate(), ca.certificate) + ), + PasswordProtection(PASSWORD.toCharArray()) + ) + } + Files.newOutputStream(this.serverKeyStoreFile).use { + serverKeyStore.store(it, null) + } + + clientKeyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setEntry(CA_CERTIFICATE_ENTRY, KeyStore.TrustedCertificateEntry(ca.certificate), PasswordProtection(null)) + setEntry( + CLIENT_CERTIFICATE_ENTRY, + KeyStore.PrivateKeyEntry( + clientCert.keyPair().private, + arrayOf(clientCert.certificate(), ca.certificate) + ), + PasswordProtection(PASSWORD.toCharArray()) + ) + } + Files.newOutputStream(this.clientKeyStoreFile).use { + clientKeyStore.store(it, null) + } + + trustStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setEntry(CA_CERTIFICATE_ENTRY, KeyStore.TrustedCertificateEntry(ca.certificate), PasswordProtection(null)) + } + Files.newOutputStream(this.trustStoreFile).use { + trustStore.store(it, null) + } + } + + fun getClientKeyStore(ca : X509Credentials, subject: X500Name) = KeyStore.getInstance("PKCS12").apply { + val clientCert = CertificateUtils.createClientCertificate(ca, subject, 30) + + load(null, null) + setEntry(CA_CERTIFICATE_ENTRY, KeyStore.TrustedCertificateEntry(ca.certificate), PasswordProtection(null)) + setEntry( + CLIENT_CERTIFICATE_ENTRY, + KeyStore.PrivateKeyEntry(clientCert.keyPair().private, arrayOf(clientCert.certificate(), ca.certificate)), + PasswordProtection(PASSWORD.toCharArray()) + ) + } + + fun getHttpClient(clientKeyStore : KeyStore?): HttpClient { + val kmf = clientKeyStore?.let { + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { + init(it, PASSWORD.toCharArray()) + } + } + + + // Set up trust manager factory with the truststore + val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + tmf.init(trustStore) + + // Create SSL context with the key and trust managers + val sslContext = SSLContext.getInstance("TLS").apply { + init(kmf?.keyManagers ?: emptyArray(), tmf.trustManagers, null) + } + return HttpClient.newBuilder().sslContext(sslContext).build() + } + + override fun setUp() { + this.clientKeyStoreFile = testDir.resolve("client-keystore.p12") + this.serverKeyStoreFile = testDir.resolve("server-keystore.p12") + this.trustStoreFile = testDir.resolve("truststore.p12") + this.cacheDir = testDir.resolve("cache") + createKeyStoreAndTrustStore() + cfg = Configuration.of( + cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), + host = "127.0.0.1", + port = ServerSocket(0).localPort + 1, + users = users.asSequence().map { it.name to it }.toMap(), + groups = sequenceOf(writersGroup, readersGroup).map { it.name to it }.toMap(), + authentication = Configuration.ClientCertificateAuthentication( + userExtractor = Configuration.TlsCertificateExtractor("CN", "(.*)"), + groupExtractor = null + ), + useVirtualThread = true, + tls = Configuration.Tls( + Configuration.KeyStore(this.serverKeyStoreFile, null, SERVER_CERTIFICATE_ENTRY, PASSWORD), + Configuration.TrustStore(this.trustStoreFile, null, false), + true + ), + serverPath = "/" + ) + Xml.write(Serializer.serialize(cfg), System.out) + } + + override fun tearDown() { + } + + fun newRequestBuilder(key: String) = HttpRequest.newBuilder() + .uri(URI.create("https://${cfg.host}:${cfg.port}/$key")) + + fun buildAuthorizationHeader(user: Configuration.User, password: String): String { + val b64 = Base64.getEncoder().encode("${user.name}:${password}".toByteArray(Charsets.UTF_8)).let { + String(it, StandardCharsets.UTF_8) + } + return "Basic $b64" + } + + fun newEntry(random: Random): Pair { + val key = ByteArray(0x10).let { + random.nextBytes(it) + Base64.getUrlEncoder().encodeToString(it) + } + val value = ByteArray(0x1000).also { + random.nextBytes(it) + } + return key to value + } + + @Test + @Order(1) + fun putWithNoClientCertificate() { + val client: HttpClient = getHttpClient(null) + val (key, value) = keyValuePair + + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode()) + } + + @Test + @Order(2) + fun putAsAReaderUser() { + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Reader in it.roles && Role.Writer !in it.roles + } ?: throw RuntimeException("Reader user not found") + val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()) + } + + @Test + @Order(3) + fun getAsAWriterUser() { + + val (key, _) = keyValuePair + val user = cfg.users.values.find { + Role.Writer in it.roles + } ?: throw RuntimeException("Reader user not found") + val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()) + } + + @Test + @Order(4) + fun putAsAWriterUser() { + + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Writer in it.roles + } ?: throw RuntimeException("Reader user not found") + val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) + + val requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)) + + val response: HttpResponse = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()) + } + + @Test + @Order(5) + fun getAsAReaderUser() { + val (key, value) = keyValuePair + val user = cfg.users.values.find { + Role.Reader in it.roles + } ?: throw RuntimeException("Reader user not found") + val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = + client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()) + Assertions.assertArrayEquals(value, response.body()) + } + + @Test + @Order(6) + fun getMissingKeyAsAReaderUser() { + val (key, _) = newEntry(random) + val user = cfg.users.values.find { + Role.Reader in it.roles + } ?: throw RuntimeException("Reader user not found") + val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) + + val requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET() + + val response: HttpResponse = + client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt new file mode 100644 index 0000000..fa847a7 --- /dev/null +++ b/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt @@ -0,0 +1,19 @@ +package net.woggioni.gbcs.test + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import javax.naming.ldap.LdapName + +class X500NameTest { + + @Test + fun test() { + val name = + "C=SG, L=Bugis, CN=woggioni@f6aa5663ef26, emailAddress=oggioni.walter@gmail.com, street=1 Fraser Street\\, Duo Residences #23-05, postalCode=189350, GN=Walter, SN=Oggioni, pseudonym=woggioni" + val ldapName = LdapName(name) + val value = ldapName.rdns.asSequence().find { + it.type == "CN" + }!!.value + Assertions.assertEquals("woggioni@f6aa5663ef26", value) + } +} \ No newline at end of file