Compare commits

..

1 Commits

Author SHA1 Message Date
9de393c6ae added Docker image build to Gitea
Some checks failed
CI / build (push) Failing after 2m25s
2025-01-11 15:09:55 +08:00
40 changed files with 406 additions and 1552 deletions

View File

@@ -5,6 +5,8 @@ on:
- '*'
jobs:
build:
env:
RUNNER_TOOL_CACHE: /toolcache
runs-on: hostinger
steps:
- name: Checkout sources

View File

@@ -1,21 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration>
<configuration>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/>
<import class="ch.qos.logback.core.ConsoleAppender"/>
<appender name="console" class="ConsoleAppender">
<target>System.err</target>
<encoder class="PatternLayoutEncoder">
<pattern>%d [%highlight(%-5level)] \(%thread\) %logger{36} -%kvp- %msg %n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="console"/>
</root>
<logger name="io.netty" level="debug"/>
<logger name="com.google.code.yanf4j" level="warn"/>
<logger name="net.rubyeye.xmemcached" level="warn"/>
</configuration>

View File

@@ -7,7 +7,7 @@ WORKDIR /home/luser
FROM base-release AS release
ADD gbcs-cli-envelope-*.jar gbcs.jar
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar", "server"]
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar"]
FROM base-release AS release-memcached
ADD --chown=luser:luser gbcs-cli-envelope-*.jar gbcs.jar
@@ -15,7 +15,7 @@ RUN mkdir plugins
WORKDIR /home/luser/plugins
RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/gbcs-memcached*.tar
WORKDIR /home/luser
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar", "server"]
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar"]
FROM release-memcached as compose
COPY --chown=luser:luser conf/gbcs-memcached.xml /home/luser/.config/gbcs/gbcs.xml

View File

@@ -146,29 +146,29 @@ class Xml(val doc: Document, val element: Element) {
dbf.isExpandEntityReferences = true
dbf.isIgnoringComments = true
dbf.isNamespaceAware = true
dbf.isValidating = schemaResourceURL == null
dbf.setFeature("http://apache.org/xml/features/validation/schema", true)
dbf.isValidating = false
dbf.setFeature("http://apache.org/xml/features/validation/schema", true);
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 newDocumentBuilder(resource: URL, schemaResourceURL: URL?): DocumentBuilder {
val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder()
db.setErrorHandler(ErrorHandler(resource))
return db
}
fun parseXmlResource(resource: URL, schemaResourceURL: URL?): Document {
val db = newDocumentBuilder(resource, schemaResourceURL)
return resource.openStream().use(db::parse)
}
fun parseXmlResource(resource: URL, schemaResourceURL: URL?): Document {
val db = newDocumentBuilder(resource, schemaResourceURL)
return resource.openStream().use(db::parse)
}
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 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 write(doc: Document, output: OutputStream) {
val transformerFactory = TransformerFactory.newInstance()
@@ -183,12 +183,7 @@ class Xml(val doc: Document, val element: Element) {
transformer.transform(source, result)
}
fun of(
namespaceURI: String,
qualifiedName: String,
schemaResourceURL: URL? = null,
cb: Xml.(el: Element) -> Unit
): Document {
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()
@@ -212,7 +207,7 @@ class Xml(val doc: Document, val element: Element) {
fun node(
name: String,
namespaceURI: String? = null,
namespaceURI : String? = null,
attrs: Map<String, String> = emptyMap(),
cb: Xml.(el: Element) -> Unit = {}
): Element {
@@ -227,7 +222,7 @@ class Xml(val doc: Document, val element: Element) {
}
}
fun attr(key: String, value: String, namespaceURI: String? = null) {
fun attr(key: String, value: String, namespaceURI : String? = null) {
element.setAttributeNS(namespaceURI, key, value)
}

View File

@@ -7,11 +7,12 @@ plugins {
id 'maven-publish'
}
import net.woggioni.gradle.envelope.EnvelopeJarTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import net.woggioni.gradle.graalvm.NativeImagePlugin
import net.woggioni.gradle.graalvm.NativeImageTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
Property<String> mainClassName = objects.property(String.class)
mainClassName.set('net.woggioni.gbcs.cli.GradleBuildCacheServerCli')
@@ -42,7 +43,6 @@ dependencies {
implementation catalog.netty.codec.http
implementation catalog.picocli
implementation project(":gbcs-client")
implementation rootProject
// runtimeOnly catalog.slf4j.jdk14

View File

@@ -3,7 +3,6 @@ module net.woggioni.gbcs.cli {
requires net.woggioni.gbcs;
requires info.picocli;
requires net.woggioni.gbcs.base;
requires net.woggioni.gbcs.client;
requires kotlin.stdlib;
requires net.woggioni.jwo;

View File

@@ -1,22 +1,28 @@
package net.woggioni.gbcs.cli
import net.woggioni.gbcs.GradleBuildCacheServer
import net.woggioni.gbcs.GradleBuildCacheServer.Companion.DEFAULT_CONFIGURATION_URL
import net.woggioni.gbcs.base.GbcsUrlStreamHandlerFactory
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.debug
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.cli.impl.AbstractVersionProvider
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.cli.impl.commands.BenchmarkCommand
import net.woggioni.gbcs.cli.impl.commands.ClientCommand
import net.woggioni.gbcs.cli.impl.commands.PasswordHashCommand
import net.woggioni.gbcs.cli.impl.commands.ServerCommand
import net.woggioni.jwo.Application
import net.woggioni.jwo.JWO
import org.slf4j.Logger
import picocli.CommandLine
import picocli.CommandLine.Model.CommandSpec
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.nio.file.Path
@CommandLine.Command(
name = "gbcs", versionProvider = GradleBuildCacheServerCli.VersionProvider::class
)
class GradleBuildCacheServerCli : GbcsCommand() {
class GradleBuildCacheServerCli(application : Application, private val log : Logger) : GbcsCommand() {
class VersionProvider : AbstractVersionProvider()
companion object {
@@ -29,22 +35,24 @@ class GradleBuildCacheServerCli : GbcsCommand() {
.configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR")
.configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir")
.build()
val gbcsCli = GradleBuildCacheServerCli()
val gbcsCli = GradleBuildCacheServerCli(app, log)
val commandLine = CommandLine(gbcsCli)
commandLine.setExecutionExceptionHandler { ex, cl, parseResult ->
log.error(ex.message, ex)
CommandLine.ExitCode.SOFTWARE
}
commandLine.addSubcommand(ServerCommand(app))
commandLine.addSubcommand(PasswordHashCommand())
commandLine.addSubcommand(
CommandLine(ClientCommand(app)).apply {
addSubcommand(BenchmarkCommand())
})
System.exit(commandLine.execute(*args))
}
}
@CommandLine.Option(
names = ["-c", "--config-file"],
description = ["Read the application configuration from this file"],
paramLabel = "CONFIG_FILE"
)
private var configurationFile: Path = findConfigurationFile(application)
@CommandLine.Option(names = ["-V", "--version"], versionHelp = true)
var versionHelp = false
private set
@@ -52,8 +60,40 @@ class GradleBuildCacheServerCli : GbcsCommand() {
@CommandLine.Spec
private lateinit var spec: CommandSpec
private fun findConfigurationFile(app : Application): Path {
val confDir = app.computeConfigurationDirectory()
val configurationFile = confDir.resolve("gbcs.xml")
return configurationFile
}
private fun createDefaultConfigurationFile(configurationFile : Path) {
log.info {
"Creating default configuration file at '$configurationFile'"
}
val defaultConfigurationFileResource = DEFAULT_CONFIGURATION_URL
Files.newOutputStream(configurationFile).use { outputStream ->
defaultConfigurationFileResource.openStream().use { inputStream ->
JWO.copy(inputStream, outputStream)
}
}
}
override fun run() {
spec.commandLine().usage(System.out);
if (!Files.exists(configurationFile)) {
Files.createDirectories(configurationFile.parent)
createDefaultConfigurationFile(configurationFile)
}
val configuration = GradleBuildCacheServer.loadConfiguration(configurationFile)
log.debug {
ByteArrayOutputStream().also {
GradleBuildCacheServer.dumpConfiguration(configuration, it)
}.let {
"Server configuration:\n${String(it.toByteArray())}"
}
}
val server = GradleBuildCacheServer(configuration)
server.run().use {
}
}
}

View File

@@ -1,8 +1,6 @@
package net.woggioni.gbcs.cli.impl
import net.woggioni.jwo.Application
import picocli.CommandLine
import java.nio.file.Path
abstract class GbcsCommand : Runnable {
@@ -10,10 +8,4 @@ abstract class GbcsCommand : Runnable {
@CommandLine.Option(names = ["-h", "--help"], usageHelp = true)
var usageHelp = false
private set
protected fun findConfigurationFile(app: Application, fileName : String): Path {
val confDir = app.computeConfigurationDirectory()
val configurationFile = confDir.resolve(fileName)
return configurationFile
}
}

View File

@@ -1,119 +0,0 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.error
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.client.GbcsClient
import picocli.CommandLine
import java.security.SecureRandom
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import kotlin.random.Random
@CommandLine.Command(
name = "benchmark",
description = ["Run a load test against the server"],
showDefaultValues = true
)
class BenchmarkCommand : GbcsCommand() {
private val log = contextLogger()
@CommandLine.Spec
private lateinit var spec: CommandLine.Model.CommandSpec
@CommandLine.Option(
names = ["-e", "--entries"],
description = ["Total number of elements to be added to the cache"],
paramLabel = "NUMBER_OF_ENTRIES"
)
private var numberOfEntries = 1000
override fun run() {
val clientCommand = spec.parent().userObject() as ClientCommand
val profile = clientCommand.profileName.let { profileName ->
clientCommand.configuration.profiles[profileName]
?: throw IllegalArgumentException("Profile $profileName does not exist in configuration")
}
val client = GbcsClient(profile)
val entryGenerator = sequence {
val random = Random(SecureRandom.getInstance("NativePRNGNonBlocking").nextLong())
while (true) {
val key = Base64.getUrlEncoder().encode(random.nextBytes(16)).toString(Charsets.UTF_8)
val value = random.nextBytes(0x1000)
yield(key to value)
}
}
val entries = let {
val completionQueue = LinkedBlockingQueue<Future<Pair<String, ByteArray>>>(numberOfEntries)
val start = Instant.now()
entryGenerator.take(numberOfEntries).forEach { entry ->
val future = client.put(entry.first, entry.second).thenApply { entry }
future.whenComplete { _, _ ->
completionQueue.put(future)
}
}
val inserted = sequence<Pair<String, ByteArray>> {
var completionCounter = 0
while (completionCounter < numberOfEntries) {
val future = completionQueue.take()
try {
yield(future.get())
} catch (ee: ExecutionException) {
val cause = ee.cause ?: ee
log.error(cause.message, cause)
}
completionCounter += 1
}
}.toList()
val end = Instant.now()
log.info {
val elapsed = Duration.between(start, end).toMillis()
"Insertion rate: ${numberOfEntries.toDouble() / elapsed * 1000} ops/s"
}
inserted
}
log.info {
"Inserted ${entries.size} entries"
}
if (entries.isNotEmpty()) {
val completionQueue = LinkedBlockingQueue<Future<Unit>>(entries.size)
val start = Instant.now()
entries.forEach { entry ->
val future = client.get(entry.first).thenApply {
if (it == null) {
log.error {
"Missing entry for key '${entry.first}'"
}
} else if (!entry.second.contentEquals(it)) {
log.error {
"Retrieved a value different from what was inserted for key '${entry.first}'"
}
}
}
future.whenComplete { _, _ ->
completionQueue.put(future)
}
}
var completionCounter = 0
while (completionCounter < entries.size) {
completionQueue.take()
completionCounter += 1
}
val end = Instant.now()
log.info {
val elapsed = Duration.between(start, end).toMillis()
"Retrieval rate: ${entries.size.toDouble() / elapsed * 1000} ops/s"
}
} else {
log.error("Skipping retrieval benchmark as it was not possible to insert any entry in the cache")
}
}
}

View File

@@ -1,42 +0,0 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.client.GbcsClient
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.jwo.Application
import picocli.CommandLine
import java.nio.file.Path
@CommandLine.Command(
name = "client",
description = ["GBCS client"],
showDefaultValues = true
)
class ClientCommand(app : Application) : GbcsCommand() {
@CommandLine.Option(
names = ["-c", "--configuration"],
description = ["Path to the client configuration file"],
paramLabel = "CONFIGURATION_FILE"
)
private var configurationFile : Path = findConfigurationFile(app, "gbcs-client.xml")
@CommandLine.Option(
names = ["-p", "--profile"],
description = ["Name of the client profile to be used"],
paramLabel = "PROFILE",
required = true
)
var profileName : String? = null
val configuration : GbcsClient.Configuration by lazy {
GbcsClient.Configuration.parse(configurationFile)
}
override fun run() {
println("Available profiles:")
configuration.profiles.forEach { (profileName, _) ->
println(profileName)
}
}
}

View File

@@ -5,9 +5,9 @@ import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.cli.impl.converters.OutputStreamConverter
import net.woggioni.jwo.UncloseableOutputStream
import picocli.CommandLine
import java.io.BufferedWriter
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.io.PrintWriter
@CommandLine.Command(
@@ -20,7 +20,7 @@ class PasswordHashCommand : GbcsCommand() {
names = ["-o", "--output-file"],
description = ["Write the output to a file instead of stdout"],
converter = [OutputStreamConverter::class],
showDefaultValue = CommandLine.Help.Visibility.NEVER,
defaultValue = "stdout",
paramLabel = "OUTPUT_FILE"
)
private var outputStream: OutputStream = UncloseableOutputStream(System.out)
@@ -30,8 +30,9 @@ class PasswordHashCommand : GbcsCommand() {
val password2 = String(System.console().readPassword("Type your password again for confirmation:"))
if(password1 != password2) throw IllegalArgumentException("Passwords do not match")
PrintWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)).use {
it.println(hashPassword(password1))
BufferedWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)).use {
it.write(hashPassword(password1))
it.newLine()
}
}
}

View File

@@ -1,67 +0,0 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.GradleBuildCacheServer
import net.woggioni.gbcs.GradleBuildCacheServer.Companion.DEFAULT_CONFIGURATION_URL
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.debug
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.client.GbcsClient
import net.woggioni.jwo.Application
import net.woggioni.jwo.JWO
import picocli.CommandLine
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.nio.file.Path
@CommandLine.Command(
name = "server",
description = ["GBCS server"],
showDefaultValues = true
)
class ServerCommand(app : Application) : GbcsCommand() {
private val log = contextLogger()
private fun createDefaultConfigurationFile(configurationFile: Path) {
log.info {
"Creating default configuration file at '$configurationFile'"
}
val defaultConfigurationFileResource = DEFAULT_CONFIGURATION_URL
Files.newOutputStream(configurationFile).use { outputStream ->
defaultConfigurationFileResource.openStream().use { inputStream ->
JWO.copy(inputStream, outputStream)
}
}
}
@CommandLine.Option(
names = ["-c", "--config-file"],
description = ["Read the application configuration from this file"],
paramLabel = "CONFIG_FILE"
)
private var configurationFile: Path = findConfigurationFile(app, "gbcs-server.xml")
val configuration : GbcsClient.Configuration by lazy {
GbcsClient.Configuration.parse(configurationFile)
}
override fun run() {
if (!Files.exists(configurationFile)) {
Files.createDirectories(configurationFile.parent)
createDefaultConfigurationFile(configurationFile)
}
val configuration = GradleBuildCacheServer.loadConfiguration(configurationFile)
log.debug {
ByteArrayOutputStream().also {
GradleBuildCacheServer.dumpConfiguration(configuration, it)
}.let {
"Server configuration:\n${String(it.toByteArray())}"
}
}
val server = GradleBuildCacheServer(configuration)
server.run().use {
}
}
}

View File

@@ -12,11 +12,10 @@
</encoder>
</appender>
<root level="info">
<root level="debug">
<appender-ref ref="console"/>
</root>
<logger name="io.netty" level="debug"/>
<logger name="io.netty.handler.ssl.BouncyCastlePemReader" level="info"/>
<logger name="com.google.code.yanf4j" level="warn"/>
<logger name="net.rubyeye.xmemcached" level="warn"/>
</configuration>

View File

@@ -1,13 +0,0 @@
plugins {
id 'java-library'
alias catalog.plugins.kotlin.jvm
}
dependencies {
implementation project(':gbcs-base')
implementation catalog.picocli
implementation catalog.netty.buffer
implementation catalog.netty.codec.http
}

View File

@@ -1,262 +0,0 @@
package net.woggioni.gbcs.benchmark;
import lombok.Getter;
import lombok.SneakyThrows;
import net.woggioni.jwo.Fun;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
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.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.function.Predicate;
public class Main {
@SneakyThrows
private static Properties loadProperties() {
Properties properties = new Properties();
try (final var is = Main.class.getResourceAsStream("/benchmark.properties")) {
properties.load(is);
}
return properties;
}
private static final Properties properties = loadProperties();
@State(Scope.Thread)
public static class ExecutionPlan {
private final Random random = new Random(101325);
@Getter
private final HttpClient client = createHttpClient();
private final Map<String, byte[]> entries = new HashMap<>();
private HttpClient createHttpClient() {
final var clientBuilder = HttpClient.newBuilder();
getSslContext().ifPresent(clientBuilder::sslContext);
return clientBuilder.build();
}
public final Map<String, byte[]> getEntries() {
return Collections.unmodifiableMap(entries);
}
public Map.Entry<String, byte[]> newEntry() {
final var keyBuffer = new byte[0x20];
random.nextBytes(keyBuffer);
final var key = Base64.getUrlEncoder().encodeToString(keyBuffer);
final var value = new byte[0x1000];
random.nextBytes(value);
return Map.entry(key, value);
}
@SneakyThrows
public HttpRequest.Builder newRequestBuilder(String key) {
final var requestBuilder = HttpRequest.newBuilder()
.uri(getServerURI().resolve(key));
String user = getUser();
if (user != null) {
requestBuilder.header("Authorization", buildAuthorizationHeader(user, getPassword()));
}
return requestBuilder;
}
@SneakyThrows
public URI getServerURI() {
return new URI(properties.getProperty("gbcs.server.url"));
}
@SneakyThrows
public Optional<String> getClientTrustStorePassword() {
return Optional.ofNullable(properties.getProperty("gbcs.client.ssl.truststore.password"))
.filter(Predicate.not(String::isEmpty));
}
@SneakyThrows
public Optional<KeyStore> getClientTrustStore() {
return Optional.ofNullable(properties.getProperty("gbcs.client.ssl.truststore.file"))
.filter(Predicate.not(String::isEmpty))
.map(Path::of)
.map((Fun<Path, KeyStore>) keyStoreFile -> {
final var keyStore = KeyStore.getInstance("PKCS12");
try (final var is = Files.newInputStream(keyStoreFile)) {
keyStore.load(is, getClientTrustStorePassword().map(String::toCharArray).orElse(null));
}
return keyStore;
});
}
@SneakyThrows
public Optional<KeyStore> getClientKeyStore() {
return Optional.ofNullable(properties.getProperty("gbcs.client.ssl.keystore.file"))
.filter(Predicate.not(String::isEmpty))
.map(Path::of)
.map((Fun<Path, KeyStore>) keyStoreFile -> {
final var keyStore = KeyStore.getInstance("PKCS12");
try (final var is = Files.newInputStream(keyStoreFile)) {
keyStore.load(is, getClientKeyStorePassword().map(String::toCharArray).orElse(null));
}
return keyStore;
});
}
@SneakyThrows
public Optional<String> getClientKeyStorePassword() {
return Optional.ofNullable(properties.getProperty("gbcs.client.ssl.keystore.password"))
.filter(Predicate.not(String::isEmpty));
}
@SneakyThrows
public Optional<String> getClientKeyPassword() {
return Optional.ofNullable(properties.getProperty("gbcs.client.ssl.key.password"))
.filter(Predicate.not(String::isEmpty));
}
@SneakyThrows
public String getUser() {
return Optional.ofNullable(properties.getProperty("gbcs.server.username"))
.filter(Predicate.not(String::isEmpty))
.orElse(null);
}
@SneakyThrows
public String getPassword() {
return Optional.ofNullable(properties.getProperty("gbcs.server.password"))
.filter(Predicate.not(String::isEmpty))
.orElse(null);
}
private String buildAuthorizationHeader(String user, String password) {
final var b64 = Base64.getEncoder().encode(String.format("%s:%s", user, password).getBytes(StandardCharsets.UTF_8));
return "Basic " + new String(b64);
}
@SneakyThrows
private Optional<SSLContext> getSslContext() {
return getClientKeyStore().map((Fun<KeyStore, SSLContext>) clientKeyStore -> {
final var kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(clientKeyStore, getClientKeyStorePassword().map(String::toCharArray).orElse(null));
// Set up trust manager factory with the truststore
final var trustManagers = getClientTrustStore().map((Fun<KeyStore, TrustManager[]>) ts -> {
final var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ts);
return tmf.getTrustManagers();
}).orElse(new TrustManager[0]);
// Create SSL context with the key and trust managers
final var sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), trustManagers, null);
return sslContext;
});
}
@SneakyThrows
@Setup(Level.Trial)
public void setUp() {
final var client = getClient();
for (int i = 0; i < 1000; i++) {
final var pair = newEntry();
final var requestBuilder = newRequestBuilder(pair.getKey())
.header("Content-Type", "application/octet-stream")
.PUT(HttpRequest.BodyPublishers.ofByteArray(pair.getValue()));
final var response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString());
if (201 != response.statusCode()) {
throw new IllegalStateException(Integer.toString(response.statusCode()));
} else {
entries.put(pair.getKey(), pair.getValue());
}
}
}
@TearDown
public void tearDown() {
client.close();
}
private Iterator<Map.Entry<String, byte[]>> it = null;
private Map.Entry<String, byte[]> nextEntry() {
if (it == null || !it.hasNext()) {
it = getEntries().entrySet().iterator();
}
return it.next();
}
}
@SneakyThrows
@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public void get(ExecutionPlan plan) {
final var client = plan.getClient();
final var entry = plan.nextEntry();
final var requestBuilder = plan.newRequestBuilder(entry.getKey())
.header("Accept", "application/octet-stream")
.GET();
final var response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
if (200 != response.statusCode()) {
throw new IllegalStateException(Integer.toString(response.statusCode()));
} else {
if (!Arrays.equals(entry.getValue(), response.body())) {
throw new IllegalStateException("Retrieved unexpected value");
}
}
}
@SneakyThrows
@Benchmark
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
public void put(Main.ExecutionPlan plan) {
final var client = plan.getClient();
final var entry = plan.nextEntry();
final var requestBuilder = plan.newRequestBuilder(entry.getKey())
.header("Content-Type", "application/octet-stream")
.PUT(HttpRequest.BodyPublishers.ofByteArray(entry.getValue()));
final var response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray());
if (201 != response.statusCode()) {
throw new IllegalStateException(Integer.toString(response.statusCode()));
}
}
}

View File

@@ -1,6 +0,0 @@
gbcs.server.url= https://gbcs.woggioni.net:443
gbcs.client.ssl.keystore.file=conf/woggioni@c962475fa38.p12
gbcs.client.ssl.keystore.password=password
gbcs.client.ssl.key.password=password
gbcs.client.ssl.truststore.file=conf/truststore.pfx
gbcs.client.ssl.truststore.password=password

View File

@@ -1,15 +0,0 @@
module net.woggioni.gbcs.client {
requires io.netty.handler;
requires io.netty.codec.http;
requires io.netty.transport;
requires kotlin.stdlib;
requires io.netty.common;
requires io.netty.buffer;
requires java.xml;
requires net.woggioni.gbcs.base;
requires io.netty.codec;
exports net.woggioni.gbcs.client;
opens net.woggioni.gbcs.client.schema;
}

View File

@@ -1,209 +0,0 @@
package net.woggioni.gbcs.client
import io.netty.bootstrap.Bootstrap
import io.netty.buffer.ByteBuf
import io.netty.buffer.Unpooled
import io.netty.channel.Channel
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInitializer
import io.netty.channel.ChannelPipeline
import io.netty.channel.SimpleChannelInboundHandler
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioSocketChannel
import io.netty.handler.codec.DecoderException
import io.netty.handler.codec.http.DefaultFullHttpRequest
import io.netty.handler.codec.http.FullHttpRequest
import io.netty.handler.codec.http.FullHttpResponse
import io.netty.handler.codec.http.HttpClientCodec
import io.netty.handler.codec.http.HttpContentDecompressor
import io.netty.handler.codec.http.HttpHeaderNames
import io.netty.handler.codec.http.HttpHeaderValues
import io.netty.handler.codec.http.HttpMethod
import io.netty.handler.codec.http.HttpObjectAggregator
import io.netty.handler.codec.http.HttpResponseStatus
import io.netty.handler.codec.http.HttpVersion
import io.netty.handler.ssl.SslContext
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.stream.ChunkedWriteHandler
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.client.impl.Parser
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.Base64
import java.util.concurrent.CompletableFuture
import io.netty.util.concurrent.Future as NettyFuture
class GbcsClient(private val profile: Configuration.Profile) : AutoCloseable {
private val group: NioEventLoopGroup
private var sslContext: SslContext
data class Configuration(
val profiles : Map<String, Profile>
) {
sealed class Authentication {
data class TlsClientAuthenticationCredentials(val key: PrivateKey, val certificateChain: Array<X509Certificate>) : Authentication()
data class BasicAuthenticationCredentials(val username: String, val password: String) : Authentication()
}
data class Profile(
val serverURI: URI,
val authentication : Authentication?
)
companion object {
fun parse(path : Path) : Configuration {
return Files.newInputStream(path).use {
Xml.parseXml(path.toUri().toURL(), it)
}.let(Parser::parse)
}
}
}
init {
group = NioEventLoopGroup()
this.sslContext = SslContextBuilder.forClient().also { builder ->
(profile.authentication as? Configuration.Authentication.TlsClientAuthenticationCredentials)?.let { tlsClientAuthenticationCredentials ->
builder.keyManager(
tlsClientAuthenticationCredentials.key,
*tlsClientAuthenticationCredentials.certificateChain
)
}
}.build()
}
fun get(key: String): CompletableFuture<ByteArray?> {
return sendRequest(profile.serverURI.resolve(key), HttpMethod.GET, null)
.thenApply {
val status = it.status()
if (it.status() == HttpResponseStatus.NOT_FOUND) {
null
} else if (it.status() != HttpResponseStatus.OK) {
throw HttpException(status)
} else {
it.content()
}
}.thenApply { maybeByteBuf ->
maybeByteBuf?.let {
val result = ByteArray(it.readableBytes())
it.getBytes(0, result)
result
}
}
}
fun put(key: String, content: ByteArray): CompletableFuture<Unit> {
return sendRequest(profile.serverURI.resolve(key), HttpMethod.PUT, content).thenApply {
val status = it.status()
if (it.status() != HttpResponseStatus.CREATED) {
throw HttpException(status)
}
}
}
private fun sendRequest(uri: URI, method: HttpMethod, body: ByteArray?): CompletableFuture<FullHttpResponse> {
val responseFuture = CompletableFuture<FullHttpResponse>()
try {
val scheme = if (uri.scheme == null) "http" else uri.scheme
val host = uri.host
var port = uri.port
if (port == -1) {
port = if ("https".equals(scheme, ignoreCase = true)) 443 else 80
}
val bootstrap = Bootstrap()
bootstrap.group(group)
.channel(NioSocketChannel::class.java)
.handler(object : ChannelInitializer<SocketChannel>() {
override fun initChannel(ch: SocketChannel) {
val pipeline: ChannelPipeline = ch.pipeline()
// Add SSL handler if needed
if ("https".equals(scheme, ignoreCase = true)) {
pipeline.addLast("ssl", sslContext.newHandler(ch.alloc(), host, port))
}
// HTTP handlers
pipeline.addLast("codec", HttpClientCodec())
pipeline.addLast("decompressor", HttpContentDecompressor())
pipeline.addLast("aggregator", HttpObjectAggregator(1048576))
pipeline.addLast("chunked", ChunkedWriteHandler())
// Custom handler for processing responses
pipeline.addLast("handler", object : SimpleChannelInboundHandler<FullHttpResponse>() {
override fun channelRead0(
ctx: ChannelHandlerContext,
response: FullHttpResponse
) {
responseFuture.complete(response)
ctx.close()
}
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
val ex = when (cause) {
is DecoderException -> cause.cause
else -> cause
}
responseFuture.completeExceptionally(ex)
ctx.close()
}
})
}
})
// Connect to host
val channel: Channel = bootstrap.connect(host, port).sync().channel()
// Prepare the HTTP request
val request: FullHttpRequest = let {
val content: ByteBuf? = body?.takeIf(ByteArray::isNotEmpty)?.let(Unpooled::wrappedBuffer)
DefaultFullHttpRequest(HttpVersion.HTTP_1_1, method, uri.rawPath, content ?: Unpooled.buffer(0)).apply {
headers().apply {
if (content != null) {
set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.APPLICATION_OCTET_STREAM)
set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes())
}
set(HttpHeaderNames.HOST, host)
set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
set(
HttpHeaderNames.ACCEPT_ENCODING,
HttpHeaderValues.GZIP.toString() + "," + HttpHeaderValues.DEFLATE.toString()
)
// Add basic auth if configured
(profile.authentication as? Configuration.Authentication.BasicAuthenticationCredentials)?.let { credentials ->
val auth = "${credentials.username}:${credentials.password}"
val encodedAuth = Base64.getEncoder().encodeToString(auth.toByteArray())
set(HttpHeaderNames.AUTHORIZATION, "Basic $encodedAuth")
}
}
}
}
// Set headers
// Send the request
channel.writeAndFlush(request)
} catch (e: Exception) {
responseFuture.completeExceptionally(e)
}
return responseFuture
}
fun shutDown(): NettyFuture<*> {
return group.shutdownGracefully()
}
override fun close() {
shutDown().sync()
}
}

View File

@@ -1,9 +0,0 @@
package net.woggioni.gbcs.client
import io.netty.handler.codec.http.HttpResponseStatus
class HttpException(private val status : HttpResponseStatus) : RuntimeException(status.reasonPhrase()) {
override val message: String
get() = "Http status ${status.code()}: ${status.reasonPhrase()}"
}

View File

@@ -1,38 +0,0 @@
package net.woggioni.gbcs.client
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
import kotlin.random.Random
//object Main {
// @JvmStatic
// fun main(vararg args : String) {
// val pwd = "PO%!*bW9p'Zp#=uu\$fl{Ij`Ad.8}x#ho".toCharArray()
// val keystore = KeyStore.getInstance("PKCS12").apply{
// Files.newInputStream(Path.of("/home/woggioni/ssl/woggioni@c962475fa38.pfx")).use {
// load(it, pwd)
// }
// }
// val key = keystore.getKey("woggioni@c962475fa38", pwd) as PrivateKey
// val certChain = keystore.getCertificateChain("woggioni@c962475fa38").asSequence()
// .map { it as X509Certificate }
// .toList()
// .toTypedArray()
// GbcsClient.Configuration(
// serverURI = URI("https://gbcs.woggioni.net/"),
// GbcsClient.TlsClientAuthenticationCredentials(
// key, certChain
// )
// ).let(::GbcsClient).use { client ->
// val random = Random(101325)
// val entry = "something" to ByteArray(0x1000).also(random::nextBytes)
// client.put(entry.first, entry.second)
// val retrieved = client.get(entry.first).get()
// println(retrieved.contentEquals(entry.second))
// }
// }
//}

View File

@@ -1,64 +0,0 @@
package net.woggioni.gbcs.client.impl
import net.woggioni.gbcs.base.Xml.Companion.asIterable
import net.woggioni.gbcs.client.GbcsClient
import org.w3c.dom.Document
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.X509Certificate
object Parser {
fun parse(document: Document): GbcsClient.Configuration {
val root = document.documentElement
val profiles = mutableMapOf<String, GbcsClient.Configuration.Profile>()
for (child in root.asIterable()) {
val tagName = child.localName
when (tagName) {
"profile" -> {
val name = child.getAttribute("name")
val uri = child.getAttribute("base-url").let(::URI)
var authentication: GbcsClient.Configuration.Authentication? = null
for (gchild in child.asIterable()) {
when (gchild.localName) {
"tls-client-auth" -> {
val keyStoreFile = gchild.getAttribute("key-store-file")
val keyStorePassword =
gchild.getAttribute("key-store-password").takeIf(String::isNotEmpty)
val keyAlias = gchild.getAttribute("key-alias")
val keyPassword = gchild.getAttribute("key-password").takeIf(String::isNotEmpty)
val keystore = KeyStore.getInstance("PKCS12").apply {
Files.newInputStream(Path.of(keyStoreFile)).use {
load(it, keyStorePassword?.toCharArray())
}
}
val key = keystore.getKey(keyAlias, keyPassword?.toCharArray()) as PrivateKey
val certChain = keystore.getCertificateChain(keyAlias).asSequence()
.map { it as X509Certificate }
.toList()
.toTypedArray()
authentication =
GbcsClient.Configuration.Authentication.TlsClientAuthenticationCredentials(key, certChain)
}
"basic-auth" -> {
val username = gchild.getAttribute("user")
val password = gchild.getAttribute("password")
authentication =
GbcsClient.Configuration.Authentication.BasicAuthenticationCredentials(username, password)
}
}
}
profiles[name] = GbcsClient.Configuration.Profile(uri, authentication)
}
}
}
return GbcsClient.Configuration(profiles)
}
}

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<xs:schema targetNamespace="urn:net.woggioni.gbcs.client"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
xmlns:gbcs-client="urn:net.woggioni.gbcs.client"
elementFormDefault="unqualified"
>
<xs:element name="profiles" type="gbcs-client:profilesType"/>
<xs:complexType name="profilesType">
<xs:sequence minOccurs="0">
<xs:element name="profile" type="gbcs-client:profileType" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="profileType">
<xs:choice>
<xs:element name="basic-auth" type="gbcs-client:basicAuthType"/>
<xs:element name="tls-client-auth" type="gbcs-client:tlsClientAuthType"/>
</xs:choice>
<xs:attribute name="name" type="xs:token" use="required"/>
<xs:attribute name="base-url" type="xs:anyURI" use="required"/>
</xs:complexType>
<xs:complexType name="basicAuthType">
<xs:attribute name="user" type="xs:string" use="required"/>
<xs:attribute name="password" type="xs:string" use="required"/>
</xs:complexType>
<xs:complexType name="tlsClientAuthType">
<xs:attribute name="key-store-file" type="xs:anyURI" use="required"/>
<xs:attribute name="key-store-password" type="xs:string" use="required"/>
<xs:attribute name="key-alias" type="xs:string" use="required"/>
<xs:attribute name="key-password" type="xs:string" use="optional"/>
</xs:complexType>
</xs:schema>

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gbcs-client:profiles xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs-client="urn:net.woggioni.gbcs.client"
xs:schemaLocation="urn:net.woggioni.gbcs.client jms://net.woggioni.gbcs.client/net/woggioni/gbcs/client/schema/gbcs-client.xsd"
>
<profile name="profile1" base-url="https://gbcs1.example.com/">
<tls-client-auth
key-store-file="keystore.pfx"
key-store-password="password"
key-alias="woggioni@c962475fa38"
key-password="key-password"/>
</profile>
<profile name="profile2" base-url="https://gbcs2.example.com/">
<basic-auth user="user" password="password"/>
</profile>
</gbcs-client:profiles>

View File

@@ -9,16 +9,14 @@ import net.woggioni.gbcs.base.Xml.Companion.asIterable
import org.w3c.dom.Document
import org.w3c.dom.Element
import java.time.Duration
import java.util.zip.Deflater
class MemcachedCacheProvider : CacheProvider<MemcachedCacheConfiguration> {
override fun getXmlSchemaLocation() = "classpath:net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd"
override fun getXmlType() = "memcachedCacheType"
override fun getXmlNamespace() = "urn:net.woggioni.gbcs-memcached"
val xmlNamespacePrefix : String
get() = "gbcs-memcached"
override fun getXmlNamespace()= "urn:net.woggioni.gbcs-memcached"
override fun deserialize(el: Element): MemcachedCacheConfiguration {
val servers = mutableListOf<HostAndPort>()
@@ -37,7 +35,7 @@ class MemcachedCacheProvider : CacheProvider<MemcachedCacheConfiguration> {
val compressionMode = el.getAttribute("compression-mode")
.takeIf(String::isNotEmpty)
?.let {
when (it) {
when(it) {
"gzip" -> CompressionMode.GZIP
"zip" -> CompressionMode.ZIP
else -> CompressionMode.ZIP
@@ -62,14 +60,12 @@ class MemcachedCacheProvider : CacheProvider<MemcachedCacheConfiguration> {
)
}
override fun serialize(doc: Document, cache: MemcachedCacheConfiguration) = cache.run {
val result = doc.createElement("cache")
override fun serialize(doc: Document, cache : MemcachedCacheConfiguration) = cache.run {
val result = doc.createElementNS(xmlNamespace,"cache")
Xml.of(doc, result) {
attr("xmlns:${xmlNamespacePrefix}", xmlNamespace, namespaceURI = "http://www.w3.org/2000/xmlns/")
attr("xs:type", "${xmlNamespacePrefix}:$xmlType", GBCS.XML_SCHEMA_NAMESPACE_URI)
attr("xs:type", xmlType, GBCS.XML_SCHEMA_NAMESPACE_URI)
for (server in servers) {
node("server") {
node("server", xmlNamespace) {
attr("host", server.host)
attr("port", server.port.toString())
}
@@ -79,12 +75,10 @@ class MemcachedCacheProvider : CacheProvider<MemcachedCacheConfiguration> {
digestAlgorithm?.let { digestAlgorithm ->
attr("digest", digestAlgorithm)
}
attr(
"compression-mode", when (compressionMode) {
CompressionMode.GZIP -> "gzip"
CompressionMode.ZIP -> "zip"
}
)
attr("compression-mode", when(compressionMode) {
CompressionMode.GZIP -> "gzip"
CompressionMode.ZIP -> "zip"
})
}
result
}

View File

@@ -20,14 +20,14 @@
<xs:attribute name="max-age" type="xs:duration" default="P1D"/>
<xs:attribute name="max-size" type="xs:unsignedInt" default="1048576"/>
<xs:attribute name="digest" type="xs:token" />
<xs:attribute name="compression-mode" type="gbcs-memcached:compressionType" default="zip"/>
<xs:attribute name="compression-type" type="gbcs-memcached:compressionType" default="deflate"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<xs:simpleType name="compressionType">
<xs:restriction base="xs:token">
<xs:enumeration value="zip"/>
<xs:enumeration value="deflate"/>
<xs:enumeration value="gzip"/>
</xs:restriction>
</xs:simpleType>

View File

@@ -31,5 +31,5 @@ include 'gbcs-base'
include 'gbcs-memcached'
include 'gbcs-cli'
include 'docker'
include 'gbcs-client'

View File

@@ -102,7 +102,6 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
private class ClientCertificateAuthenticator(
authorizer: Authorizer,
private val sslEngine: SSLEngine,
private val anonymousUserRoles: Set<Role>?,
private val userExtractor: Configuration.UserExtractor?,
private val groupExtractor: Configuration.GroupExtractor?,
) : AbstractNettyHttpAuthenticator(authorizer) {
@@ -113,16 +112,16 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set<Role>? {
return try {
sslEngine.session.peerCertificates.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())
} ?: anonymousUserRoles
sslEngine.session.peerCertificates
} catch (es: SSLPeerUnverifiedException) {
anonymousUserRoles
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())
}
}
}
@@ -140,21 +139,21 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
log.debug(ctx) {
"Missing Authorization header"
}
return users[""]?.roles
return null
}
val cursor = authorizationHeader.indexOf(' ')
if (cursor < 0) {
log.debug(ctx) {
"Invalid Authorization header: '$authorizationHeader'"
}
return users[""]?.roles
return null
}
val authenticationType = authorizationHeader.substring(0, cursor)
if ("Basic" != authenticationType) {
log.debug(ctx) {
"Invalid authentication type header: '$authenticationType'"
}
return users[""]?.roles
return null
}
val (username, password) = Base64.getDecoder().decode(authorizationHeader.substring(cursor + 1))
.let(::String)
@@ -191,7 +190,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} else {
val javaKeyStore = loadKeystore(keyStore.file, keyStore.password)
val serverKey = javaKeyStore.getKey(
keyStore.keyAlias, (keyStore.keyPassword ?: "").let(String::toCharArray)
keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray)
) as PrivateKey
val serverCert: Array<X509Certificate> =
Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias))
@@ -200,7 +199,8 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
SslContextBuilder.forServer(serverKey, *serverCert).apply {
if (tls.isVerifyClients) {
clientAuth(ClientAuth.OPTIONAL)
tls.trustStore?.let { trustStore ->
val trustStore = tls.trustStore
if (trustStore != null) {
val ts = loadKeystore(trustStore.file, trustStore.password)
trustManager(
ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus)
@@ -268,17 +268,18 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
val auth = cfg.authentication
var authenticator: AbstractNettyHttpAuthenticator? = null
if (auth is Configuration.BasicAuthentication) {
authenticator = (NettyHttpBasicAuthenticator(cfg.users, RoleAuthorizer()))
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(),
roleAuthorizer,
sslHandler.engine(),
cfg.users[""]?.roles,
userExtractor(auth),
groupExtractor(auth)
)
@@ -445,7 +446,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
class ServerHandle(
httpChannelFuture: ChannelFuture,
private val executorGroups: Iterable<EventExecutorGroup>
private val executorGroups : Iterable<EventExecutorGroup>
) : AutoCloseable {
private val httpChannel: Channel = httpChannelFuture.channel()
@@ -475,7 +476,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
val serverSocketChannel = NioServerSocketChannel::class.java
val workerGroup = bossGroup
val eventExecutorGroup = run {
val threadFactory = if (cfg.isUseVirtualThread) {
val threadFactory = if(cfg.isUseVirtualThread) {
Thread.ofVirtual().factory()
} else {
null

View File

@@ -1,18 +1,16 @@
package net.woggioni.gbcs.auth
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.handler.ssl.SslHandler
import io.netty.handler.ssl.SslHandshakeCompletionEvent
import java.security.KeyStore
import java.security.cert.CertPathValidator
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.PKIXParameters
import java.security.cert.PKIXRevocationChecker
import java.security.cert.X509Certificate
import java.util.EnumSet
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.handler.ssl.SslHandler
import io.netty.handler.ssl.SslHandshakeCompletionEvent
import javax.net.ssl.SSLSession
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
@@ -50,11 +48,7 @@ class ClientCertificateValidator private constructor(
object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
val clientCertificateChain = certificateFactory.generateCertPath(chain.toList())
try {
validator.validate(clientCertificateChain, params)
} catch (ex : CertPathValidatorException) {
throw CertificateException(ex)
}
validator.validate(clientCertificateChain, params)
}
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {

View File

@@ -23,11 +23,10 @@ object Parser {
fun parse(document: Document): Configuration {
val root = document.documentElement
val anonymousUser = User("", null, emptySet())
var cache: Cache? = null
var host = "127.0.0.1"
var port = 11080
var users : Map<String, User> = mapOf(anonymousUser.name to anonymousUser)
var users = emptyMap<String, User>()
var groups = emptyMap<String, Group>()
var tls: Tls? = null
val serverPath = root.getAttribute("path")
@@ -36,17 +35,16 @@ object Parser {
?.let(String::toBoolean) ?: true
var authentication: Authentication? = null
for (child in root.asIterable()) {
val tagName = child.localName
when (tagName) {
when (child.localName) {
"authorization" -> {
var knownUsers = sequenceOf(anonymousUser)
for (gchild in child.asIterable()) {
when (gchild.localName) {
when (child.localName) {
"users" -> {
knownUsers += parseUsers(gchild)
users = parseUsers(child)
}
"groups" -> {
val pair = parseGroups(gchild, knownUsers)
val pair = parseGroups(child, users)
users = pair.first
groups = pair.second
}
@@ -78,17 +76,17 @@ object Parser {
"client-certificate" -> {
var tlsExtractorUser: TlsCertificateExtractor? = null
var tlsExtractorGroup: TlsCertificateExtractor? = null
for (ggchild in gchild.asIterable()) {
when (ggchild.localName) {
for (gchild in child.asIterable()) {
when (gchild.localName) {
"group-extractor" -> {
val attrName = ggchild.getAttribute("attribute-name")
val pattern = ggchild.getAttribute("pattern")
val attrName = gchild.getAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern")
tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern)
}
"user-extractor" -> {
val attrName = ggchild.getAttribute("attribute-name")
val pattern = ggchild.getAttribute("pattern")
val attrName = gchild.getAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern")
tlsExtractorUser = TlsCertificateExtractor(attrName, pattern)
}
}
@@ -153,22 +151,23 @@ object Parser {
}
}.toSet()
private fun parseUserRefs(root: Element) = root.asIterable().asSequence().map {
private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter {
it.localName == "user"
}.map {
it.getAttribute("ref")
}.toSet()
private fun parseUsers(root: Element): Sequence<User> {
private fun parseUsers(root: Element): Map<String, User> {
return root.asIterable().asSequence().filter {
it.localName == "user"
}.map { el ->
val username = el.getAttribute("name")
val password = el.getAttribute("password").takeIf(String::isNotEmpty)
User(username, password, emptySet())
}
username to User(username, password, emptySet())
}.toMap()
}
private fun parseGroups(root: Element, knownUsers: Sequence<User>): Pair<Map<String, User>, Map<String, Group>> {
val knownUsersMap = knownUsers.associateBy(User::getName)
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.localName == "group"
@@ -178,7 +177,7 @@ object Parser {
for (child in el.asIterable()) {
when (child.localName) {
"users" -> {
parseUserRefs(child).mapNotNull(knownUsersMap::get).forEach { user ->
parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user ->
userGroups.computeIfAbsent(user.name) {
mutableSetOf()
}.add(groupName)
@@ -192,7 +191,7 @@ object Parser {
}
groupName to Group(groupName, roles)
}.toMap()
val users = knownUsersMap.map { (name, user) ->
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

@@ -17,7 +17,7 @@ object Serializer {
attr("useVirtualThreads", conf.isUseVirtualThread.toString())
// attr("xmlns:xs", GradleBuildCacheServer.XML_SCHEMA_NAMESPACE_URI)
val value = schemaLocations.asSequence().map { (k, v) -> "$k $v" }.joinToString(" ")
attr("xs:schemaLocation", value , namespaceURI = GBCS.XML_SCHEMA_NAMESPACE_URI)
attr("xs:schemaLocation",value , namespaceURI = GBCS.XML_SCHEMA_NAMESPACE_URI)
conf.serverPath
?.takeIf(String::isNotEmpty)
@@ -35,12 +35,10 @@ object Serializer {
node("authorization") {
node("users") {
for(user in conf.users.values) {
if(user.name.isNotEmpty()) {
node("user") {
attr("name", user.name)
user.password?.let { password ->
attr("password", password)
}
node("user") {
attr("name", user.name)
user.password?.let { password ->
attr("password", password)
}
}
}
@@ -57,19 +55,11 @@ object Serializer {
attr("name", group.name)
if(users.isNotEmpty()) {
node("users") {
var anonymousUser : Configuration.User? = null
for(user in users) {
if(user.name.isNotEmpty()) {
node("user") {
attr("ref", user.name)
}
} else {
anonymousUser = user
node("user") {
attr("ref", user.name)
}
}
if(anonymousUser != null) {
node("anonymous")
}
}
}
if(group.roles.isNotEmpty()) {
@@ -92,12 +82,6 @@ object Serializer {
}
is Configuration.ClientCertificateAuthentication -> {
node("client-certificate") {
authentication.groupExtractor?.let { extractor ->
node("group-extractor") {
attr("attribute-name", extractor.rdnType)
attr("pattern", extractor.pattern)
}
}
authentication.userExtractor?.let { extractor ->
node("user-extractor") {
attr("attribute-name", extractor.rdnType)

View File

@@ -21,6 +21,7 @@
</xs:keyref>
</xs:element>
<xs:element name="authentication" type="gbcs:authenticationType" minOccurs="0" 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" use="optional"/>
@@ -126,8 +127,7 @@
<xs:complexType name="userRefsType">
<xs:sequence>
<xs:element name="user" type="gbcs:userRefType" maxOccurs="unbounded" minOccurs="0"/>
<xs:element name="anonymous" minOccurs="0" maxOccurs="1"/>
<xs:element name="user" type="gbcs:userRefType" maxOccurs="unbounded" minOccurs="0"/>
</xs:sequence>
</xs:complexType>

View File

@@ -1,76 +0,0 @@
package net.woggioni.gbcs.test
import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.cache.FileSystemCacheConfiguration
import net.woggioni.gbcs.configuration.Serializer
import net.woggioni.gbcs.utils.NetworkUtils
import java.net.URI
import java.net.http.HttpRequest
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import java.time.Duration
import java.util.Base64
import java.util.zip.Deflater
import kotlin.random.Random
abstract class AbstractBasicAuthServerTest : AbstractServerTest() {
private lateinit var cacheDir : Path
protected val random = Random(101325)
protected val keyValuePair = newEntry(random)
protected val serverPath = "gbcs"
protected val readersGroup = Configuration.Group("readers", setOf(Role.Reader))
protected val writersGroup = Configuration.Group("writers", setOf(Role.Writer))
abstract protected val users : List<Configuration.User>
override fun setUp() {
this.cacheDir = testDir.resolve("cache")
cfg = Configuration(
"127.0.0.1",
NetworkUtils.getFreePort(),
serverPath,
users.asSequence().map { it.name to it}.toMap(),
sequenceOf(writersGroup, readersGroup).map { it.name to it}.toMap(),
FileSystemCacheConfiguration(this.cacheDir,
maxAge = Duration.ofSeconds(3600 * 24),
digestAlgorithm = "MD5",
compressionLevel = Deflater.DEFAULT_COMPRESSION,
compressionEnabled = false
),
Configuration.BasicAuthentication(),
null,
true,
)
Xml.write(Serializer.serialize(cfg), System.out)
}
override fun tearDown() {
}
protected 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"
}
protected fun newRequestBuilder(key : String) = HttpRequest.newBuilder()
.uri(URI.create("http://${cfg.host}:${cfg.port}/$serverPath/$key"))
protected 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
}
}

View File

@@ -1,192 +0,0 @@
package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.cache.FileSystemCacheConfiguration
import net.woggioni.gbcs.configuration.Serializer
import net.woggioni.gbcs.utils.CertificateUtils
import net.woggioni.gbcs.utils.CertificateUtils.X509Credentials
import net.woggioni.gbcs.utils.NetworkUtils
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.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 java.util.zip.Deflater
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import kotlin.random.Random
abstract class AbstractTlsServerTest : 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
protected lateinit var ca: X509Credentials
protected val readersGroup = Configuration.Group("readers", setOf(Role.Reader))
protected val writersGroup = Configuration.Group("writers", setOf(Role.Writer))
protected val random = Random(101325)
protected val keyValuePair = newEntry(random)
private val serverPath : String? = null
protected abstract val users : List<Configuration.User>
protected 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)
}
}
protected 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())
)
}
protected 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(
"127.0.0.1",
NetworkUtils.getFreePort(),
serverPath,
users.asSequence().map { it.name to it }.toMap(),
sequenceOf(writersGroup, readersGroup).map { it.name to it }.toMap(),
FileSystemCacheConfiguration(this.cacheDir,
maxAge = Duration.ofSeconds(3600 * 24),
compressionEnabled = true,
compressionLevel = Deflater.DEFAULT_COMPRESSION,
digestAlgorithm = "MD5"
),
Configuration.ClientCertificateAuthentication(
Configuration.TlsCertificateExtractor("CN", "(.*)"),
null
),
Configuration.Tls(
Configuration.KeyStore(this.serverKeyStoreFile, null, SERVER_CERTIFICATE_ENTRY, PASSWORD),
Configuration.TrustStore(this.trustStoreFile, null, false),
true
),
false,
)
Xml.write(Serializer.serialize(cfg), System.out)
}
override fun tearDown() {
}
protected fun newRequestBuilder(key: String) = HttpRequest.newBuilder()
.uri(URI.create("https://${cfg.host}:${cfg.port}/${serverPath ?: ""}/$key"))
private 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"
}
protected 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
}
}

View File

@@ -4,26 +4,90 @@ import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.PasswordSecurity.hashPassword
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.cache.FileSystemCacheConfiguration
import net.woggioni.gbcs.configuration.Serializer
import net.woggioni.gbcs.utils.NetworkUtils
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import java.io.IOException
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 java.util.zip.Deflater
import kotlin.random.Random
class BasicAuthServerTest : AbstractBasicAuthServerTest() {
class BasicAuthServerTest : AbstractServerTest() {
companion object {
private const val PASSWORD = "password"
}
override val users = listOf(
Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)),
Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)),
Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)),
Configuration.User("", null, setOf(readersGroup))
)
private lateinit var cacheDir : Path
private val random = Random(101325)
private val keyValuePair = newEntry(random)
private val serverPath = "gbcs"
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(
"127.0.0.1",
NetworkUtils.getFreePort(),
serverPath,
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(),
sequenceOf(writersGroup, readersGroup).map { it.name to it}.toMap(),
FileSystemCacheConfiguration(this.cacheDir,
maxAge = Duration.ofSeconds(3600 * 24),
digestAlgorithm = "MD5",
compressionLevel = Deflater.DEFAULT_COMPRESSION,
compressionEnabled = false
),
Configuration.BasicAuthentication(),
null,
true,
)
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}/$serverPath/$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)
@@ -36,7 +100,7 @@ class BasicAuthServerTest : AbstractBasicAuthServerTest() {
.PUT(HttpRequest.BodyPublishers.ofByteArray(value))
val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode())
Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode())
}
@Test
@@ -115,20 +179,6 @@ class BasicAuthServerTest : AbstractBasicAuthServerTest() {
@Test
@Order(6)
fun getAsAnonymousUser() {
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(7)
fun getMissingKeyAsAReaderUser() {
val client: HttpClient = HttpClient.newHttpClient()

View File

@@ -1,7 +1,7 @@
package net.woggioni.gbcs.test
import net.woggioni.gbcs.base.GBCS.toUrl
import net.woggioni.gbcs.base.GbcsUrlStreamHandlerFactory
import net.woggioni.gbcs.base.GBCS.toUrl
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.configuration.Parser
import net.woggioni.gbcs.configuration.Serializer
@@ -18,7 +18,6 @@ class ConfigurationTest {
strings = [
"classpath:net/woggioni/gbcs/test/gbcs-default.xml",
"classpath:net/woggioni/gbcs/test/gbcs-memcached.xml",
"classpath:net/woggioni/gbcs/test/gbcs-tls.xml",
]
)
@ParameterizedTest

View File

@@ -1,53 +0,0 @@
package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.PasswordSecurity.hashPassword
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
class NoAnonymousUserBasicAuthServerTest : AbstractBasicAuthServerTest() {
companion object {
private const val PASSWORD = "anotherPassword"
}
override val users = listOf(
Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)),
Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)),
Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)),
)
@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 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.UNAUTHORIZED.code(), response.statusCode())
}
}

View File

@@ -1,47 +0,0 @@
package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
class NoAnonymousUserTlsServerTest : AbstractTlsServerTest() {
override val users = listOf(
Configuration.User("user1", null, setOf(readersGroup)),
Configuration.User("user2", null, setOf(writersGroup)),
Configuration.User("user3", null, setOf(readersGroup, writersGroup)),
)
@Test
@Order(1)
fun getAsAnonymousUser() {
val (key, _) = keyValuePair
val client: HttpClient = getHttpClient(null)
val requestBuilder = newRequestBuilder(key)
.header("Content-Type", "application/octet-stream")
.GET()
val response: HttpResponse<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode())
}
@Test
@Order(2)
fun putAsAnonymousUser() {
val (key, value) = keyValuePair
val client: HttpClient = getHttpClient(null)
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())
}
}

View File

@@ -32,17 +32,185 @@ import javax.net.ssl.TrustManagerFactory
import kotlin.random.Random
class TlsServerTest : AbstractTlsServerTest() {
class TlsServerTest : AbstractServerTest() {
override val users = listOf(
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 serverPath : String? = null
private val users = listOf(
Configuration.User("user1", null, setOf(readersGroup)),
Configuration.User("user2", null, setOf(writersGroup)),
Configuration.User("user3", null, setOf(readersGroup, writersGroup)),
Configuration.User("", null, setOf(readersGroup))
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(
"127.0.0.1",
NetworkUtils.getFreePort(),
serverPath,
users.asSequence().map { it.name to it }.toMap(),
sequenceOf(writersGroup, readersGroup).map { it.name to it }.toMap(),
FileSystemCacheConfiguration(this.cacheDir,
maxAge = Duration.ofSeconds(3600 * 24),
compressionEnabled = true,
compressionLevel = Deflater.DEFAULT_COMPRESSION,
digestAlgorithm = "MD5"
),
Configuration.ClientCertificateAuthentication(
Configuration.TlsCertificateExtractor("CN", "(.*)"),
null
),
Configuration.Tls(
Configuration.KeyStore(this.serverKeyStoreFile, null, SERVER_CERTIFICATE_ENTRY, PASSWORD),
Configuration.TrustStore(this.trustStoreFile, null, false),
true
),
false,
)
Xml.write(Serializer.serialize(cfg), System.out)
}
override fun tearDown() {
}
fun newRequestBuilder(key: String) = HttpRequest.newBuilder()
.uri(URI.create("https://${cfg.host}:${cfg.port}/${serverPath ?: ""}/$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 {
@@ -58,7 +226,7 @@ class TlsServerTest : AbstractTlsServerTest() {
}
@Test
@Order(2)
@Order(3)
fun getAsAWriterUser() {
val (key, _) = keyValuePair
val user = cfg.users.values.find {
@@ -67,6 +235,7 @@ class TlsServerTest : AbstractTlsServerTest() {
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())
@@ -74,7 +243,7 @@ class TlsServerTest : AbstractTlsServerTest() {
}
@Test
@Order(3)
@Order(4)
fun putAsAWriterUser() {
val (key, value) = keyValuePair
val user = cfg.users.values.find {
@@ -84,6 +253,7 @@ class TlsServerTest : AbstractTlsServerTest() {
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())
@@ -91,7 +261,7 @@ class TlsServerTest : AbstractTlsServerTest() {
}
@Test
@Order(4)
@Order(5)
fun getAsAReaderUser() {
val (key, value) = keyValuePair
val user = cfg.users.values.find {
@@ -100,6 +270,7 @@ class TlsServerTest : AbstractTlsServerTest() {
val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}")))
val requestBuilder = newRequestBuilder(key)
.header("Authorization", buildAuthorizationHeader(user, PASSWORD))
.GET()
val response: HttpResponse<ByteArray> =
@@ -109,7 +280,7 @@ class TlsServerTest : AbstractTlsServerTest() {
}
@Test
@Order(5)
@Order(6)
fun getMissingKeyAsAReaderUser() {
val (key, _) = newEntry(random)
val user = cfg.users.values.find {
@@ -118,39 +289,11 @@ class TlsServerTest : AbstractTlsServerTest() {
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())
}
@Test
@Order(6)
fun getAsAnonymousUser() {
val (key, value) = keyValuePair
val client: HttpClient = getHttpClient(null)
val requestBuilder = newRequestBuilder(key)
.header("Content-Type", "application/octet-stream")
.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(7)
fun putAsAnonymousUser() {
val (key, value) = keyValuePair
val client: HttpClient = getHttpClient(null)
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())
}
}

View File

@@ -4,7 +4,7 @@
xmlns:gbcs-memcached="urn:net.woggioni.gbcs-memcached"
xs:schemaLocation="urn:net.woggioni.gbcs-memcached jpms://net.woggioni.gbcs.memcached/net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd urn:net.woggioni.gbcs jpms://net.woggioni.gbcs/net/woggioni/gbcs/schema/gbcs.xsd">
<bind host="127.0.0.1" port="11443" />
<cache xs:type="gbcs-memcached:memcachedCacheType" max-age="P7D" max-size="101325" digest="SHA-256">
<cache xs:type="gbcs-memcached:memcachedCacheType" max-age="P7D" max-size="101325" compression-mode="gzip" digest="SHA-256">
<server host="127.0.0.1" port="11211"/>
</cache>
<authentication>

View File

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gbcs:server useVirtualThreads="false" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs="urn:net.woggioni.gbcs"
xs:schemaLocation="urn:net.woggioni.gbcs jpms://net.woggioni.gbcs/net/woggioni/gbcs/schema/gbcs.xsd">
<bind host="127.0.0.1" port="11443"/>
<cache xs:type="gbcs:fileSystemCacheType" path="/tmp/gbcs" max-age="P7D"/>
<authorization>
<users>
<user name="user1" password="password1"/>
<user name="user2" password="password2"/>
<user name="user3" password="password3"/>
</users>
<groups>
<group name="readers">
<users>
<user ref="user1"/>
<!-- <user ref="user5"/>-->
<anonymous/>
</users>
<roles>
<reader/>
</roles>
</group>
<group name="writers">
<users>
<user ref="user2"/>
</users>
<roles>
<writer/>
</roles>
</group>
<group name="readers-writers">
<users>
<user ref="user3"/>
</users>
<roles>
<reader/>
<writer/>
</roles>
</group>
</groups>
</authorization>
<authentication>
<client-certificate>
<group-extractor pattern="group-pattern" attribute-name="O"/>
<user-extractor pattern="user-pattern" attribute-name="CN"/>
</client-certificate>
</authentication>
<tls>
<keystore file="keystore.pfx" key-alias="key1" password="password" key-password="key-password"/>
<truststore file="truststore.pfx" password="password" check-certificate-status="true" />
</tls>
</gbcs:server>