version 1.0
All checks were successful
CI / build (push) Successful in 50s

This commit is contained in:
2024-12-23 16:00:14 +08:00
parent 13f7ecc88a
commit 688a196a52
29 changed files with 1951 additions and 433 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -1 +1 @@
Args=-H:Optimize=3 -H:+TraceClassInitialization
Args=-H:Optimize=3 --gc=serial --libc=musl --static -H:TraceClassInitialization=io.netty.handler.ssl.BouncyCastleAlpnSslUtils

View File

@@ -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;

View File

@@ -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, URLConnection>) URL::openConnection)
.orElseThrow(IOException::new);
}
}

View File

@@ -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<String, String> {
// 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<ByteArray, ByteArray> {
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<Role>?
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 {

View File

@@ -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<Role>, request: HttpRequest) : Boolean
}

View File

@@ -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<String, Set<Role>>,
val groups : Map<String, Set<Role>>,
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<String, Set<Role>>()
val groups = emptyMap<String, Set<Role>>()
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)
}
}
}

View File

@@ -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<String, String>, 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<Role>? {
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<String, Configuration.User>, authorizer: Authorizer
) : AbstractNettyHttpAuthenticator(authorizer) {
companion object {
private val log = contextLogger()
}
override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set<Role>? {
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<Channel>() {
private class ServerInitializer(private val cfg: Configuration) : ChannelInitializer<Channel>() {
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<X509Certificate> = Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias))
.map { it as X509Certificate }
.toArray {size -> Array<X509Certificate?>(size) { null } }
keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray)
) as PrivateKey
val serverCert: Array<X509Certificate> =
Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias))
.map { it as X509Certificate }
.toArray { size -> Array<X509Certificate?>(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<String, String>::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<FullHttpRequest>() {
private class ServerHandler(private val cache: Cache, private val serverPrefix: String?) :
SimpleChannelInboundHandler<FullHttpRequest>() {
companion object {
private val log = contextLogger()
private fun splitPath(req: HttpRequest): Map.Entry<String, String> {
private fun splitPath(req: HttpRequest): Pair<String?, String> {
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<String>) : Configuration {
registerUrlProtocolHandler()
fun loadConfiguration(args: Array<String>): 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<String>) {
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<String>) {
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()
}
}
}

View File

@@ -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<String, Set<Role>>) : Authorizer {
class RoleAuthorizer : Authorizer {
companion object {
private val METHOD_MAP = mapOf(
@@ -12,11 +12,11 @@ class UserAuthorizer(private val users: Map<String, Set<Role>>) : Authorizer {
)
}
override fun authorize(user: String, request: HttpRequest) = users[user]?.let { roles ->
override fun authorize(roles: Set<Role>, request: HttpRequest) : Boolean {
val allowedMethods = roles.asSequence()
.mapNotNull(METHOD_MAP::get)
.flatten()
.toSet()
request.method() in allowedMethods
} ?: false
return request.method() in allowedMethods
}
}

View File

@@ -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<Node> {
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<Element>
}
}
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<String, String> = 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<String, String>) {
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))
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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<String, User>,
val groups: Map<String, Group>,
val cache: Cache,
val authentication : Authentication?,
val tls: Tls?,
val useVirtualThread: Boolean
) {
data class Group(val name: String, val roles: Set<Role>) {
override fun hashCode(): Int {
return name.hashCode()
}
}
data class User(val name: String, val password: String?, val groups: Set<Group>) {
override fun hashCode(): Int {
return name.hashCode()
}
val roles : Set<Role>
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<String, User>,
groups: Map<String, Group>,
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<String, User>()
var groups = emptyMap<String, Group>()
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<String, User> {
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<String, User>): Pair<Map<String, User>, Map<String, Group>> {
val userGroups = mutableMapOf<String, MutableSet<String>>()
val groups = root.asIterable().asSequence().filter {
it.nodeName == "group"
}.map { el ->
val groupName = el.getAttribute("name")
var roles = emptySet<Role>()
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
}
}
}

View File

@@ -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<Configuration.Group, Configuration.User>::first, Pair<Configuration.Group, Configuration.User>::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())
}
}
}
}
}
}
}

View File

@@ -15,4 +15,5 @@
<root level="debug">
<appender-ref ref="console"/>
</root>
<logger name="io.netty" level="info"/>
</configuration>

View File

@@ -1,48 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gbcs:server xmlns:gbcs="urn:net.woggioni.gbcs" path="/cache" useVirtualThreads="false">
<gbcs:server xmlns:gbcs="urn:net.woggioni.gbcs" useVirtualThreads="false">
<bind host="127.0.0.1" port="11443"/>
<cache path="/tmp/gbcs"/>
<authorization>
<users>
<user name="user1" password="password"/>
<user name="user2" password="password"/>
<user name="user3" password="password"/>
<user name="user4" password="password"/>
<user name="user5" password="password">
<roles>
<reader/>
<writer/>
</roles>
</user>
</users>
<groups>
<group name="group1">
<users>
<user ref="user1"/>
</users>
<roles>
<reader/>
<writer/>
</roles>
</group>
<group name="group2">
<users>
<user ref="user1"/>
<user ref="user2"/>
</users>
<roles>
<reader/>
<writer/>
</roles>
</group>
</groups>
</authorization>
<tls-certificate-authorization>
<group-extractor attribute-name="CN" pattern="(.*)"/>
<user-extractor attribute-name="CN" pattern="(.*)"/>
</tls-certificate-authorization>
<!-- <tls verify-clients="false">-->
<!-- <keystore file="" key-alias=""/>-->
<!-- <truststore file=""/>-->
<!-- </tls>-->
<cache>
<file-system-cache path="/tmp/gbcs" max-age="P7D"/>
</cache>
<authentication>
<none/>
</authentication>
</gbcs:server>

View File

@@ -7,8 +7,11 @@
<xs:complexType name="serverType">
<xs:sequence minOccurs="0">
<xs:element name="bind" type="gbcs:bindType" maxOccurs="1"/>
<xs:element name="cache" type="gbcs:cacheDirType" maxOccurs="1"/>
<xs:element name="authorization" type="gbcs:authorizationType" maxOccurs="1">
<xs:element name="cache" type="gbcs:cacheType" maxOccurs="1"/>
<!-- <xs:choice>-->
<!-- <xs:element name="fileSystemCache" type="fileSystemCacheType"/>-->
<!-- </xs:choice>-->
<xs:element name="authorization" type="gbcs:authorizationType" minOccurs="0">
<xs:key name="userId">
<xs:selector xpath="users/user"/>
<xs:field xpath="@name"/>
@@ -18,10 +21,11 @@
<xs:field xpath="@ref"/>
</xs:keyref>
</xs:element>
<xs:element name="authentication" type="gbcs:authenticationType" maxOccurs="1"/>
<xs:element name="tls-certificate-authorization" type="gbcs:tlsCertificateAuthorizationType" minOccurs="0" maxOccurs="1"/>
<xs:element name="tls" type="gbcs:tlsType" minOccurs="0" maxOccurs="1"/>
</xs:sequence>
<xs:attribute name="path" type="xs:string"/>
<xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="useVirtualThreads" type="xs:boolean" use="optional"/>
</xs:complexType>
@@ -30,8 +34,15 @@
<xs:attribute name="port" type="xs:unsignedShort" use="required"/>
</xs:complexType>
<xs:complexType name="cacheDirType">
<xs:complexType name="cacheType">
<xs:choice>
<xs:element name="file-system-cache" type="gbcs:fileSystemCacheType"/>
</xs:choice>
</xs:complexType>
<xs:complexType name="fileSystemCacheType">
<xs:attribute name="path" type="xs:string" use="required"/>
<xs:attribute name="max-age" type="xs:string" default="P1D"/>
</xs:complexType>
<xs:complexType name="tlsCertificateAuthorizationType">
@@ -58,6 +69,14 @@
</xs:all>
</xs:complexType>
<xs:complexType name="authenticationType">
<xs:choice>
<xs:element name="basic"/>
<xs:element name="client-certificate" type="gbcs:tlsCertificateAuthorizationType"/>
<xs:element name="none"/>
</xs:choice>
</xs:complexType>
<xs:complexType name="usersType">
<xs:sequence>
<xs:element name="user" type="gbcs:userType" minOccurs="0" maxOccurs="unbounded"/>
@@ -65,9 +84,6 @@
</xs:complexType>
<xs:complexType name="userType">
<xs:all minOccurs="0" maxOccurs="1">
<xs:element name="roles" type="gbcs:rolesType"/>
</xs:all>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="password" type="xs:string" use="optional"/>
</xs:complexType>

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<String, ByteArray> {
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<String> = 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<String> = 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<String> = 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<String> = 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<ByteArray> = 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<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode())
}
}

View File

@@ -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)
}
}

View File

@@ -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<String, ByteArray> {
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<String> = 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<ByteArray> = 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<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode())
}
}

View File

@@ -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<String, ByteArray> {
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<String> = 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<String> = 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<String> = 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<String> = 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<ByteArray> =
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<ByteArray> =
client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode())
}
}

View File

@@ -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)
}
}