Compare commits

...

8 Commits
0.0.1 ... 0.0.5

Author SHA1 Message Date
05a265e4b4 added connection pooling to gbcs-client
All checks were successful
CI / build (push) Successful in 3m55s
2025-01-16 13:37:14 +08:00
5af99330f8 server is now a subcommand 2025-01-16 11:35:05 +08:00
747168cda3 added client command 2025-01-16 11:16:01 +08:00
225f156864 added anonymous user
All checks were successful
CI / build (push) Successful in 3m51s
2025-01-15 00:22:29 +08:00
696cb74740 added JMH benchmark
All checks were successful
CI / build (push) Successful in 3m44s
2025-01-13 09:51:14 +08:00
59f267426c added Docker image build to Gitea 2025-01-11 15:30:51 +08:00
608a9d18de fixed logging
Some checks failed
CI / build (push) Successful in 1m38s
CI / Build Docker images (push) Failing after 21s
2025-01-10 22:13:22 +08:00
d2c00402df updated Netty version 2025-01-10 22:02:11 +08:00
50 changed files with 1816 additions and 559 deletions

View File

@@ -5,8 +5,6 @@ on:
- '*' - '*'
jobs: jobs:
build: build:
env:
RUNNER_TOOL_CACHE: /toolcache
runs-on: hostinger runs-on: hostinger
steps: steps:
- name: Checkout sources - name: Checkout sources
@@ -19,49 +17,53 @@ jobs:
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
- name: Execute Gradle build - name: Execute Gradle build
env:
PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }}
run: ./gradlew build run: ./gradlew build
- name: Publish artifacts - name: Prepare Docker image build
env: run: ./gradlew prepareDockerBuild
PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} - name: Get project version
run: ./gradlew publish id: retrieve-version
build-docker: run: ./gradlew -q version >> "$GITHUB_OUTPUT"
name: "Build Docker images" - name: Set up QEMU
runs-on: hostinger uses: docker/setup-qemu-action@v3
steps: - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.4.0
with: with:
driver: docker-container driver: docker-container
- - name: Login to Gitea container registry
name: Login to Gitea container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: gitea.woggioni.net registry: gitea.woggioni.net
username: woggioni username: woggioni
password: ${{ secrets.PUBLISHER_TOKEN }} password: ${{ secrets.PUBLISHER_TOKEN }}
- -
name: Build and push gbcs images name: Build gbcs Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5.3.0
with: with:
context: "docker/build/docker"
platforms: linux/amd64,linux/arm64
push: true push: true
pull: true pull: true
tags: | tags: |
"gitea.woggioni.net/woggioni/gbcs:slim" gitea.woggioni.net/woggioni/gbcs:latest
cache-from: type=registry,ref=gitea.woggioni.net/woggioni/gbcs:buildx gitea.woggioni.net/woggioni/gbcs:${{ steps.retrieve-version.outputs.VERSION }}
cache-to: type=registry,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true,ref=gitea.woggioni.net/woggioni/gbcs:buildx
target: release target: release
cache-from: type=registry,ref=gitea.woggioni.net/woggioni/gbcs:buildx
- -
name: Build and push gbcs memcached image name: Build gbcs memcached Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v5.3.0
with: with:
context: "docker/build/docker"
platforms: linux/amd64,linux/arm64
push: true push: true
pull: true pull: true
tags: | tags: |
"gitea.woggioni.net/woggioni/gbcs:latest" gitea.woggioni.net/woggioni/gbcs:memcached
"gitea.woggioni.net/woggioni/gbcs:memcached" gitea.woggioni.net/woggioni/gbcs:memcached-${{ steps.retrieve-version.outputs.VERSION }}
target: release-memcached
cache-from: type=registry,ref=gitea.woggioni.net/woggioni/gbcs:buildx cache-from: type=registry,ref=gitea.woggioni.net/woggioni/gbcs:buildx
cache-to: type=registry,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true,ref=gitea.woggioni.net/woggioni/gbcs:buildx cache-to: type=registry,mode=max,compression=zstd,image-manifest=true,oci-mediatypes=true,ref=gitea.woggioni.net/woggioni/gbcs:buildx
target: release-memcached - name: Publish artifacts
env:
PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }}
run: ./gradlew publish

View File

@@ -1,46 +1,2 @@
FROM container-registry.oracle.com/graalvm/native-image:21 AS oracle FROM gitea.woggioni.net/woggioni/gbcs:memcached
COPY --chown=luser:luser conf/gbcs-memcached.xml /home/luser/.config/gbcs/gbcs.xml
FROM ubuntu:24.04 AS build
COPY --from=oracle /usr/lib64/graalvm/ /usr/lib64/graalvm/
ENV JAVA_HOME=/usr/lib64/graalvm/graalvm-java21
USER ubuntu
WORKDIR /home/ubuntu
RUN mkdir gbcs
WORKDIR /home/ubuntu/gbcs
COPY --chown=ubuntu:users .git .git
COPY --chown=ubuntu:users gbcs-base gbcs-base
COPY --chown=ubuntu:users gbcs-api gbcs-api
COPY --chown=ubuntu:users gbcs-memcached gbcs-memcached
COPY --chown=ubuntu:users gbcs-cli gbcs-cli
COPY --chown=ubuntu:users src src
COPY --chown=ubuntu:users settings.gradle settings.gradle
COPY --chown=ubuntu:users build.gradle build.gradle
COPY --chown=ubuntu:users gradle.properties gradle.properties
COPY --chown=ubuntu:users gradle gradle
COPY --chown=ubuntu:users gradlew gradlew
RUN --mount=type=cache,target=/home/ubuntu/.gradle,uid=1000,gid=1000 ./gradlew --no-daemon assemble
FROM alpine:latest AS base-release
RUN --mount=type=cache,target=/var/cache/apk apk update
RUN --mount=type=cache,target=/var/cache/apk apk add openjdk21-jre
RUN adduser -D luser
USER luser
WORKDIR /home/luser
FROM base-release AS release
RUN --mount=type=bind,from=build,source=/home/ubuntu/gbcs/gbcs-cli/build,target=/home/luser/build cp build/libs/gbcs-cli-envelope-*.jar gbcs.jar
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar"]
FROM base-release AS release-memcached
RUN --mount=type=bind,from=build,source=/home/ubuntu/gbcs/gbcs-cli/build,target=/home/luser/build cp build/libs/gbcs-cli-envelope-*.jar gbcs.jar
RUN mkdir plugins
WORKDIR /home/luser/plugins
RUN --mount=type=bind,from=build,source=/home/ubuntu/gbcs/gbcs-memcached/build/distributions,target=/build/distributions tar -xf /build/distributions/gbcs-memcached*.tar
WORKDIR /home/luser
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

@@ -6,9 +6,9 @@ plugins {
id 'maven-publish' id 'maven-publish'
} }
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
allprojects { subproject -> allprojects { subproject ->
group = 'net.woggioni' group = 'net.woggioni'
@@ -130,3 +130,9 @@ publishing {
} }
} }
tasks.register('version') {
doLast {
println("VERSION=$version")
}
}

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<gbcs:server useVirtualThreads="false" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" <gbcs:server useVirtualThreads="true" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs="urn:net.woggioni.gbcs" xmlns:gbcs="urn:net.woggioni.gbcs"
xmlns:gbcs-memcached="urn:net.woggioni.gbcs-memcached" 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"> 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">

21
conf/logback.xml Normal file
View File

@@ -0,0 +1,21 @@
<?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

@@ -11,7 +11,6 @@ services:
gbcs: gbcs:
build: build:
context: . context: .
target: compose
container_name: gbcs container_name: gbcs
restart: unless-stopped restart: unless-stopped
ports: ports:

21
docker/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM alpine:latest AS base-release
RUN --mount=type=cache,target=/var/cache/apk apk update
RUN --mount=type=cache,target=/var/cache/apk apk add openjdk21-jre
RUN adduser -D luser
USER luser
WORKDIR /home/luser
FROM base-release AS release
ADD gbcs-cli-envelope-*.jar gbcs.jar
ENTRYPOINT ["java", "-jar", "/home/luser/gbcs.jar", "server"]
FROM base-release AS release-memcached
ADD --chown=luser:luser gbcs-cli-envelope-*.jar gbcs.jar
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"]
FROM release-memcached as compose
COPY --chown=luser:luser conf/gbcs-memcached.xml /home/luser/.config/gbcs/gbcs.xml

67
docker/build.gradle Normal file
View File

@@ -0,0 +1,67 @@
plugins {
id 'base'
alias(catalog.plugins.gradle.docker)
}
import com.bmuschko.gradle.docker.tasks.image.DockerBuildImage
import com.bmuschko.gradle.docker.tasks.image.DockerPushImage
import com.bmuschko.gradle.docker.tasks.image.DockerTagImage
configurations {
docker {
canBeResolved = true
transitive = false
visible = false
canBeConsumed = false
}
}
dependencies {
docker project(path: ':gbcs-cli', configuration: 'release')
docker project(path: ':gbcs-memcached', configuration: 'release')
}
Provider<Task> cleanTaskProvider = tasks.named(BasePlugin.CLEAN_TASK_NAME) {}
Provider<Copy> prepareDockerBuild = tasks.register('prepareDockerBuild', Copy) {
dependsOn cleanTaskProvider
group = 'docker'
into project.layout.buildDirectory.file('docker')
from(configurations.docker)
from(file('Dockerfile'))
}
Provider<DockerBuildImage> dockerBuild = tasks.register('dockerBuildImage', DockerBuildImage) {
group = 'docker'
dependsOn prepareDockerBuild
images.add('gitea.woggioni.net/woggioni/gbcs:latest')
images.add("gitea.woggioni.net/woggioni/gbcs:${version}")
}
Provider<DockerTagImage> dockerTag = tasks.register('dockerTagImage', DockerTagImage) {
group = 'docker'
repository = 'gitea.woggioni.net/woggioni/gbcs'
imageId = 'gitea.woggioni.net/woggioni/gbcs:latest'
tag = version
}
Provider<DockerTagImage> dockerTagMemcached = tasks.register('dockerTagMemcachedImage', DockerTagImage) {
group = 'docker'
repository = 'gitea.woggioni.net/woggioni/gbcs'
imageId = 'gitea.woggioni.net/woggioni/gbcs:memcached'
tag = "${version}-memcached"
}
Provider<DockerPushImage> dockerPush = tasks.register('dockerPushImage', DockerPushImage) {
group = 'docker'
dependsOn dockerTag, dockerTagMemcached
registryCredentials {
url = getProperty('docker.registry.url')
username = 'woggioni'
password = System.getenv().get("PUBLISHER_TOKEN")
}
images = [dockerTag.flatMap{ it.tag }, dockerTagMemcached.flatMap{ it.tag }]
}

View File

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

View File

@@ -7,12 +7,11 @@ plugins {
id 'maven-publish' id 'maven-publish'
} }
import net.woggioni.gradle.envelope.EnvelopeJarTask import net.woggioni.gradle.envelope.EnvelopeJarTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import net.woggioni.gradle.graalvm.NativeImagePlugin import net.woggioni.gradle.graalvm.NativeImagePlugin
import net.woggioni.gradle.graalvm.NativeImageTask 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) Property<String> mainClassName = objects.property(String.class)
mainClassName.set('net.woggioni.gbcs.cli.GradleBuildCacheServerCli') mainClassName.set('net.woggioni.gbcs.cli.GradleBuildCacheServerCli')
@@ -21,6 +20,15 @@ tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) {
options.javaModuleMainClass = mainClassName options.javaModuleMainClass = mainClassName
} }
configurations {
release {
transitive = false
canBeConsumed = true
canBeResolved = true
visible = true
}
}
envelopeJar { envelopeJar {
mainModule = 'net.woggioni.gbcs.cli' mainModule = 'net.woggioni.gbcs.cli'
mainClass = mainClassName mainClass = mainClassName
@@ -34,6 +42,7 @@ dependencies {
implementation catalog.netty.codec.http implementation catalog.netty.codec.http
implementation catalog.picocli implementation catalog.picocli
implementation project(":gbcs-client")
implementation rootProject implementation rootProject
// runtimeOnly catalog.slf4j.jdk14 // runtimeOnly catalog.slf4j.jdk14
@@ -56,6 +65,10 @@ tasks.named(NativeImagePlugin.NATIVE_IMAGE_TASK_NAME, NativeImageTask) {
buildStaticImage = true buildStaticImage = true
} }
artifacts {
release(envelopeJarTaskProvider)
}
publishing { publishing {
publications { publications {
maven(MavenPublication) { maven(MavenPublication) {
@@ -64,3 +77,4 @@ publishing {
} }
} }

View File

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

View File

@@ -1,28 +1,22 @@
package net.woggioni.gbcs.cli 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.GbcsUrlStreamHandlerFactory
import net.woggioni.gbcs.base.contextLogger 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.AbstractVersionProvider
import net.woggioni.gbcs.cli.impl.GbcsCommand 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.PasswordHashCommand
import net.woggioni.gbcs.cli.impl.commands.ServerCommand
import net.woggioni.jwo.Application import net.woggioni.jwo.Application
import net.woggioni.jwo.JWO
import org.slf4j.Logger
import picocli.CommandLine import picocli.CommandLine
import picocli.CommandLine.Model.CommandSpec import picocli.CommandLine.Model.CommandSpec
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.nio.file.Path
@CommandLine.Command( @CommandLine.Command(
name = "gbcs", versionProvider = GradleBuildCacheServerCli.VersionProvider::class name = "gbcs", versionProvider = GradleBuildCacheServerCli.VersionProvider::class
) )
class GradleBuildCacheServerCli(application : Application, private val log : Logger) : GbcsCommand() { class GradleBuildCacheServerCli : GbcsCommand() {
class VersionProvider : AbstractVersionProvider() class VersionProvider : AbstractVersionProvider()
companion object { companion object {
@@ -35,24 +29,22 @@ class GradleBuildCacheServerCli(application : Application, private val log : Log
.configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR") .configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR")
.configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir") .configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir")
.build() .build()
val gbcsCli = GradleBuildCacheServerCli(app, log) val gbcsCli = GradleBuildCacheServerCli()
val commandLine = CommandLine(gbcsCli) val commandLine = CommandLine(gbcsCli)
commandLine.setExecutionExceptionHandler { ex, cl, parseResult -> commandLine.setExecutionExceptionHandler { ex, cl, parseResult ->
log.error(ex.message, ex) log.error(ex.message, ex)
CommandLine.ExitCode.SOFTWARE CommandLine.ExitCode.SOFTWARE
} }
commandLine.addSubcommand(ServerCommand(app))
commandLine.addSubcommand(PasswordHashCommand()) commandLine.addSubcommand(PasswordHashCommand())
commandLine.addSubcommand(
CommandLine(ClientCommand(app)).apply {
addSubcommand(BenchmarkCommand())
})
System.exit(commandLine.execute(*args)) 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) @CommandLine.Option(names = ["-V", "--version"], versionHelp = true)
var versionHelp = false var versionHelp = false
private set private set
@@ -60,39 +52,8 @@ class GradleBuildCacheServerCli(application : Application, private val log : Log
@CommandLine.Spec @CommandLine.Spec
private lateinit var spec: CommandSpec 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() { override fun run() {
if (!Files.exists(configurationFile)) { spec.commandLine().usage(System.out);
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())}"
}
}
GradleBuildCacheServer(configuration).run().use {
}
} }
} }

View File

@@ -1,8 +1,6 @@
package net.woggioni.gbcs.cli.impl package net.woggioni.gbcs.cli.impl
import picocli.CommandLine import picocli.CommandLine
import java.net.URL
import java.util.Enumeration
import java.util.jar.Attributes import java.util.jar.Attributes
import java.util.jar.JarFile import java.util.jar.JarFile
import java.util.jar.Manifest import java.util.jar.Manifest

View File

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

View File

@@ -0,0 +1,119 @@
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

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

View File

@@ -0,0 +1,67 @@
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,10 +12,11 @@
</encoder> </encoder>
</appender> </appender>
<root level="debug"> <root level="info">
<appender-ref ref="console"/> <appender-ref ref="console"/>
</root> </root>
<logger name="io.netty" level="debug"/> <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="com.google.code.yanf4j" level="warn"/>
<logger name="net.rubyeye.xmemcached" level="warn"/> <logger name="net.rubyeye.xmemcached" level="warn"/>
</configuration> </configuration>

14
gbcs-client/build.gradle Normal file
View File

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

View File

@@ -0,0 +1,262 @@
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

@@ -0,0 +1,6 @@
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

@@ -0,0 +1,16 @@
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;
requires org.slf4j;
exports net.woggioni.gbcs.client;
opens net.woggioni.gbcs.client.schema;
}

View File

@@ -0,0 +1,254 @@
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.ChannelOption
import io.netty.channel.ChannelPipeline
import io.netty.channel.SimpleChannelInboundHandler
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.pool.AbstractChannelPoolHandler
import io.netty.channel.pool.ChannelPool
import io.netty.channel.pool.FixedChannelPool
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 io.netty.util.concurrent.Future
import io.netty.util.concurrent.GenericFutureListener
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.debug
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.client.impl.Parser
import java.net.InetSocketAddress
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 java.util.concurrent.atomic.AtomicInteger
import io.netty.util.concurrent.Future as NettyFuture
class GbcsClient(private val profile: Configuration.Profile) : AutoCloseable {
private val group: NioEventLoopGroup
private var sslContext: SslContext
private val log = contextLogger()
private val pool: ChannelPool
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?,
val maxConnections : Int
)
companion object {
fun parse(path: Path): Configuration {
return Files.newInputStream(path).use {
Xml.parseXml(path.toUri().toURL(), it)
}.let(Parser::parse)
}
}
}
init {
group = NioEventLoopGroup()
sslContext = SslContextBuilder.forClient().also { builder ->
(profile.authentication as? Configuration.Authentication.TlsClientAuthenticationCredentials)?.let { tlsClientAuthenticationCredentials ->
builder.keyManager(
tlsClientAuthenticationCredentials.key,
*tlsClientAuthenticationCredentials.certificateChain
)
}
}.build()
val (scheme, host, port) = profile.serverURI.run {
Triple(
if (scheme == null) "http" else profile.serverURI.scheme,
host,
port.takeIf { it > 0 } ?: if ("https" == scheme.lowercase()) 443 else 80
)
}
val bootstrap = Bootstrap().apply {
group(group)
channel(NioSocketChannel::class.java)
option(ChannelOption.TCP_NODELAY, true)
option(ChannelOption.SO_KEEPALIVE, true)
remoteAddress(InetSocketAddress(host, port))
}
val channelPoolHandler = object : AbstractChannelPoolHandler() {
@Volatile
private var connectionCount = AtomicInteger()
@Volatile
private var leaseCount = AtomicInteger()
override fun channelReleased(ch: Channel) {
log.debug {
"Released lease ${leaseCount.decrementAndGet()}"
}
}
override fun channelAcquired(ch: Channel?) {
log.debug {
"Acquired lease ${leaseCount.getAndIncrement()}"
}
}
override fun channelCreated(ch: Channel) {
log.debug {
"Created connection ${connectionCount.getAndIncrement()}"
}
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())
}
}
pool = FixedChannelPool(bootstrap, channelPoolHandler, profile.maxConnections)
}
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>()
// Custom handler for processing responses
pool.acquire().addListener(object : GenericFutureListener<NettyFuture<Channel>> {
override fun operationComplete(channelFuture: Future<Channel>) {
if (channelFuture.isSuccess) {
val channel = channelFuture.now
val pipeline = channel.pipeline()
channel.pipeline().addLast("handler", object : SimpleChannelInboundHandler<FullHttpResponse>() {
override fun channelRead0(
ctx: ChannelHandlerContext,
response: FullHttpResponse
) {
responseFuture.complete(response)
pipeline.removeLast()
pool.release(channel)
}
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
val ex = when (cause) {
is DecoderException -> cause.cause
else -> cause
}
responseFuture.completeExceptionally(ex)
ctx.close()
pipeline.removeLast()
pool.release(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, profile.serverURI.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)
}
}
})
return responseFuture
}
fun shutDown(): NettyFuture<*> {
return group.shutdownGracefully()
}
override fun close() {
shutDown().sync()
}
}

View File

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,68 @@
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)
}
}
}
val maxConnections = child.getAttribute("max-connections")
.takeIf(String::isNotEmpty)
?.let(String::toInt)
?: 50
profiles[name] = GbcsClient.Configuration.Profile(uri, authentication, maxConnections)
}
}
}
return GbcsClient.Configuration(profiles)
}
}

View File

@@ -0,0 +1,37 @@
<?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:attribute name="max-connections" type="xs:positiveInteger" default="50"/>
</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

@@ -0,0 +1,16 @@
<?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

@@ -1,6 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
id 'java-library' id 'java-library'
id 'maven-publish' id 'maven-publish'
@@ -22,7 +19,15 @@ configurations {
} }
} }
} }
release {
transitive = false
canBeConsumed = true
canBeResolved = true
visible = true
}
} }
dependencies { dependencies {
compileOnly project(':gbcs-base') compileOnly project(':gbcs-base')
compileOnly project(':gbcs-api') compileOnly project(':gbcs-api')
@@ -40,6 +45,10 @@ tasks.named(BasePlugin.ASSEMBLE_TASK_NAME) {
dependsOn(bundleTask) dependsOn(bundleTask)
} }
artifacts {
release(bundleTask)
}
publishing { publishing {
publications { publications {
maven(MavenPublication) { maven(MavenPublication) {

View File

@@ -1,6 +1,5 @@
package net.woggioni.gbcs.memcached package net.woggioni.gbcs.memcached
import net.rubyeye.xmemcached.MemcachedClient
import net.rubyeye.xmemcached.XMemcachedClientBuilder import net.rubyeye.xmemcached.XMemcachedClientBuilder
import net.rubyeye.xmemcached.command.BinaryCommandFactory import net.rubyeye.xmemcached.command.BinaryCommandFactory
import net.rubyeye.xmemcached.transcoders.CompressionMode import net.rubyeye.xmemcached.transcoders.CompressionMode

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ org.gradle.caching=true
gbcs.version = 0.0.1 gbcs.version = 0.0.1
lys.version = 2025.01.09 lys.version = 2025.01.10
gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven
docker.registry.url=gitea.woggioni.net

View File

@@ -30,5 +30,6 @@ include 'gbcs-api'
include 'gbcs-base' include 'gbcs-base'
include 'gbcs-memcached' include 'gbcs-memcached'
include 'gbcs-cli' include 'gbcs-cli'
include 'docker'
include 'gbcs-client'

View File

@@ -12,7 +12,6 @@ import io.netty.channel.ChannelInitializer
import io.netty.channel.ChannelOption import io.netty.channel.ChannelOption
import io.netty.channel.ChannelPromise import io.netty.channel.ChannelPromise
import io.netty.channel.DefaultFileRegion import io.netty.channel.DefaultFileRegion
import io.netty.channel.EventLoopGroup
import io.netty.channel.SimpleChannelInboundHandler import io.netty.channel.SimpleChannelInboundHandler
import io.netty.channel.nio.NioEventLoopGroup import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.nio.NioServerSocketChannel import io.netty.channel.socket.nio.NioServerSocketChannel
@@ -55,6 +54,7 @@ import net.woggioni.gbcs.base.PasswordSecurity.decodePasswordHash
import net.woggioni.gbcs.base.PasswordSecurity.hashPassword import net.woggioni.gbcs.base.PasswordSecurity.hashPassword
import net.woggioni.gbcs.base.Xml import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.base.contextLogger import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.base.info
import net.woggioni.gbcs.configuration.Parser import net.woggioni.gbcs.configuration.Parser
import net.woggioni.gbcs.configuration.Serializer import net.woggioni.gbcs.configuration.Serializer
import net.woggioni.jwo.JWO import net.woggioni.jwo.JWO
@@ -69,7 +69,6 @@ import java.security.PrivateKey
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.Arrays import java.util.Arrays
import java.util.Base64 import java.util.Base64
import java.util.concurrent.Executors
import java.util.regex.Matcher import java.util.regex.Matcher
import java.util.regex.Pattern import java.util.regex.Pattern
import javax.naming.ldap.LdapName import javax.naming.ldap.LdapName
@@ -103,6 +102,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
private class ClientCertificateAuthenticator( private class ClientCertificateAuthenticator(
authorizer: Authorizer, authorizer: Authorizer,
private val sslEngine: SSLEngine, private val sslEngine: SSLEngine,
private val anonymousUserRoles: Set<Role>?,
private val userExtractor: Configuration.UserExtractor?, private val userExtractor: Configuration.UserExtractor?,
private val groupExtractor: Configuration.GroupExtractor?, private val groupExtractor: Configuration.GroupExtractor?,
) : AbstractNettyHttpAuthenticator(authorizer) { ) : AbstractNettyHttpAuthenticator(authorizer) {
@@ -113,16 +113,16 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set<Role>? { override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set<Role>? {
return try { return try {
sslEngine.session.peerCertificates 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
} catch (es: SSLPeerUnverifiedException) { } catch (es: SSLPeerUnverifiedException) {
null anonymousUserRoles
}?.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 +140,21 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
log.debug(ctx) { log.debug(ctx) {
"Missing Authorization header" "Missing Authorization header"
} }
return null return users[""]?.roles
} }
val cursor = authorizationHeader.indexOf(' ') val cursor = authorizationHeader.indexOf(' ')
if (cursor < 0) { if (cursor < 0) {
log.debug(ctx) { log.debug(ctx) {
"Invalid Authorization header: '$authorizationHeader'" "Invalid Authorization header: '$authorizationHeader'"
} }
return null return users[""]?.roles
} }
val authenticationType = authorizationHeader.substring(0, cursor) val authenticationType = authorizationHeader.substring(0, cursor)
if ("Basic" != authenticationType) { if ("Basic" != authenticationType) {
log.debug(ctx) { log.debug(ctx) {
"Invalid authentication type header: '$authenticationType'" "Invalid authentication type header: '$authenticationType'"
} }
return null return users[""]?.roles
} }
val (username, password) = Base64.getDecoder().decode(authorizationHeader.substring(cursor + 1)) val (username, password) = Base64.getDecoder().decode(authorizationHeader.substring(cursor + 1))
.let(::String) .let(::String)
@@ -178,40 +178,38 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
} }
private class ServerInitializer(private val cfg: Configuration) : ChannelInitializer<Channel>() { private class ServerInitializer(
private val cfg: Configuration,
private fun createSslCtx(tls: Configuration.Tls): SslContext { private val eventExecutorGroup: EventExecutorGroup
val keyStore = tls.keyStore ) : ChannelInitializer<Channel>() {
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 } }
SslContextBuilder.forServer(serverKey, *serverCert).apply {
if (tls.isVerifyClients) {
clientAuth(ClientAuth.OPTIONAL)
val trustStore = tls.trustStore
if (trustStore != null) {
val ts = loadKeystore(trustStore.file, trustStore.password)
trustManager(
ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus)
)
}
}
}.build()
}
}
private val sslContext: SslContext? = cfg.tls?.let(this::createSslCtx)
private val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors())
companion object { companion object {
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 } }
SslContextBuilder.forServer(serverKey, *serverCert).apply {
if (tls.isVerifyClients) {
clientAuth(ClientAuth.OPTIONAL)
tls.trustStore?.let { trustStore ->
val ts = loadKeystore(trustStore.file, trustStore.password)
trustManager(
ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus)
)
}
}
}.build()
}
}
fun loadKeystore(file: Path, password: String?): KeyStore { fun loadKeystore(file: Path, password: String?): KeyStore {
val ext = JWO.splitExtension(file) val ext = JWO.splitExtension(file)
@@ -235,6 +233,8 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
} }
private val sslContext: SslContext? = cfg.tls?.let(Companion::createSslCtx)
private fun userExtractor(authentication: Configuration.ClientCertificateAuthentication) = private fun userExtractor(authentication: Configuration.ClientCertificateAuthentication) =
authentication.userExtractor?.let { extractor -> authentication.userExtractor?.let { extractor ->
val pattern = Pattern.compile(extractor.pattern) val pattern = Pattern.compile(extractor.pattern)
@@ -268,18 +268,17 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
val auth = cfg.authentication val auth = cfg.authentication
var authenticator: AbstractNettyHttpAuthenticator? = null var authenticator: AbstractNettyHttpAuthenticator? = null
if (auth is Configuration.BasicAuthentication) { if (auth is Configuration.BasicAuthentication) {
val roleAuthorizer = RoleAuthorizer() authenticator = (NettyHttpBasicAuthenticator(cfg.users, RoleAuthorizer()))
authenticator = (NettyHttpBasicAuthenticator(cfg.users, roleAuthorizer))
} }
if (sslContext != null) { if (sslContext != null) {
val sslHandler = sslContext.newHandler(ch.alloc()) val sslHandler = sslContext.newHandler(ch.alloc())
pipeline.addLast(sslHandler) pipeline.addLast(sslHandler)
if (auth is Configuration.ClientCertificateAuthentication) { if (auth is Configuration.ClientCertificateAuthentication) {
val roleAuthorizer = RoleAuthorizer()
authenticator = ClientCertificateAuthenticator( authenticator = ClientCertificateAuthenticator(
roleAuthorizer, RoleAuthorizer(),
sslHandler.engine(), sslHandler.engine(),
cfg.users[""]?.roles,
userExtractor(auth), userExtractor(auth),
groupExtractor(auth) groupExtractor(auth)
) )
@@ -294,7 +293,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
val cacheImplementation = cfg.cache.materialize() val cacheImplementation = cfg.cache.materialize()
val prefix = Path.of("/").resolve(Path.of(cfg.serverPath ?: "/")) val prefix = Path.of("/").resolve(Path.of(cfg.serverPath ?: "/"))
pipeline.addLast(group, ServerHandler(cacheImplementation, prefix)) pipeline.addLast(eventExecutorGroup, ServerHandler(cacheImplementation, prefix))
pipeline.addLast(ExceptionHandler()) pipeline.addLast(ExceptionHandler())
} }
} }
@@ -446,8 +445,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
class ServerHandle( class ServerHandle(
httpChannelFuture: ChannelFuture, httpChannelFuture: ChannelFuture,
private val bossGroup: EventLoopGroup, private val executorGroups: Iterable<EventExecutorGroup>
private val workerGroup: EventLoopGroup
) : AutoCloseable { ) : AutoCloseable {
private val httpChannel: Channel = httpChannelFuture.channel() private val httpChannel: Channel = httpChannelFuture.channel()
@@ -461,31 +459,35 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
try { try {
closeFuture.sync() closeFuture.sync()
} finally { } finally {
val fut1 = workerGroup.shutdownGracefully() executorGroups.forEach {
val fut2 = if (bossGroup !== workerGroup) { it.shutdownGracefully().sync()
bossGroup.shutdownGracefully() }
} else null }
fut1.sync() log.info {
fut2?.sync() "GradleBuildCacheServer has been gracefully shut down"
} }
} }
} }
fun run(): ServerHandle { fun run(): ServerHandle {
// Create the multithreaded event loops for the server // Create the multithreaded event loops for the server
val bossGroup = NioEventLoopGroup(0, Executors.defaultThreadFactory()) val bossGroup = NioEventLoopGroup(0)
val serverSocketChannel = NioServerSocketChannel::class.java val serverSocketChannel = NioServerSocketChannel::class.java
val workerGroup = if (cfg.isUseVirtualThread) { val workerGroup = bossGroup
NioEventLoopGroup(0, Executors.newVirtualThreadPerTaskExecutor()) val eventExecutorGroup = run {
} else { val threadFactory = if (cfg.isUseVirtualThread) {
bossGroup Thread.ofVirtual().factory()
} else {
null
}
DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors(), threadFactory)
} }
// A helper class that simplifies server configuration // A helper class that simplifies server configuration
val bootstrap = ServerBootstrap().apply { val bootstrap = ServerBootstrap().apply {
// Configure the server // Configure the server
group(bossGroup, workerGroup) group(bossGroup, workerGroup)
channel(serverSocketChannel) channel(serverSocketChannel)
childHandler(ServerInitializer(cfg)) childHandler(ServerInitializer(cfg, eventExecutorGroup))
option(ChannelOption.SO_BACKLOG, 128) option(ChannelOption.SO_BACKLOG, 128)
childOption(ChannelOption.SO_KEEPALIVE, true) childOption(ChannelOption.SO_KEEPALIVE, true)
} }
@@ -494,7 +496,10 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
// Bind and start to accept incoming connections. // Bind and start to accept incoming connections.
val bindAddress = InetSocketAddress(cfg.host, cfg.port) val bindAddress = InetSocketAddress(cfg.host, cfg.port)
val httpChannel = bootstrap.bind(bindAddress).sync() val httpChannel = bootstrap.bind(bindAddress).sync()
return ServerHandle(httpChannel, bossGroup, workerGroup) log.info {
"GradleBuildCacheServer is listening on ${cfg.host}:${cfg.port}"
}
return ServerHandle(httpChannel, setOf(bossGroup, workerGroup, eventExecutorGroup))
} }
companion object { companion object {
@@ -508,8 +513,10 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
return Parser.parse(doc) return Parser.parse(doc)
} }
fun dumpConfiguration(conf : Configuration, outputStream: OutputStream) { fun dumpConfiguration(conf: Configuration, outputStream: OutputStream) {
Xml.write(Serializer.serialize(conf), outputStream) Xml.write(Serializer.serialize(conf), outputStream)
} }
private val log = contextLogger()
} }
} }

View File

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

View File

@@ -16,35 +16,36 @@ import net.woggioni.gbcs.base.Xml.Companion.asIterable
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import org.w3c.dom.TypeInfo import org.w3c.dom.TypeInfo
import java.lang.IllegalArgumentException
import java.nio.file.Paths import java.nio.file.Paths
object Parser { object Parser {
fun parse(document: Document): Configuration { fun parse(document: Document): Configuration {
val root = document.documentElement val root = document.documentElement
val anonymousUser = User("", null, emptySet())
var cache: Cache? = null var cache: Cache? = null
var host = "127.0.0.1" var host = "127.0.0.1"
var port = 11080 var port = 11080
var users = emptyMap<String, User>() var users : Map<String, User> = mapOf(anonymousUser.name to anonymousUser)
var groups = emptyMap<String, Group>() var groups = emptyMap<String, Group>()
var tls: Tls? = null var tls: Tls? = null
val serverPath = root.getAttribute("path") val serverPath = root.getAttribute("path")
val useVirtualThread = root.getAttribute("useVirtualThreads") val useVirtualThread = root.getAttribute("useVirtualThreads")
.takeIf(String::isNotEmpty) .takeIf(String::isNotEmpty)
?.let(String::toBoolean) ?: false ?.let(String::toBoolean) ?: true
var authentication: Authentication? = null var authentication: Authentication? = null
for (child in root.asIterable()) { for (child in root.asIterable()) {
when (child.localName) { val tagName = child.localName
when (tagName) {
"authorization" -> { "authorization" -> {
var knownUsers = sequenceOf(anonymousUser)
for (gchild in child.asIterable()) { for (gchild in child.asIterable()) {
when (child.localName) { when (gchild.localName) {
"users" -> { "users" -> {
users = parseUsers(child) knownUsers += parseUsers(gchild)
} }
"groups" -> { "groups" -> {
val pair = parseGroups(child, users) val pair = parseGroups(gchild, knownUsers)
users = pair.first users = pair.first
groups = pair.second groups = pair.second
} }
@@ -76,17 +77,17 @@ object Parser {
"client-certificate" -> { "client-certificate" -> {
var tlsExtractorUser: TlsCertificateExtractor? = null var tlsExtractorUser: TlsCertificateExtractor? = null
var tlsExtractorGroup: TlsCertificateExtractor? = null var tlsExtractorGroup: TlsCertificateExtractor? = null
for (gchild in child.asIterable()) { for (ggchild in gchild.asIterable()) {
when (gchild.localName) { when (ggchild.localName) {
"group-extractor" -> { "group-extractor" -> {
val attrName = gchild.getAttribute("attribute-name") val attrName = ggchild.getAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern") val pattern = ggchild.getAttribute("pattern")
tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern)
} }
"user-extractor" -> { "user-extractor" -> {
val attrName = gchild.getAttribute("attribute-name") val attrName = ggchild.getAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern") val pattern = ggchild.getAttribute("pattern")
tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) tlsExtractorUser = TlsCertificateExtractor(attrName, pattern)
} }
} }
@@ -151,23 +152,22 @@ object Parser {
} }
}.toSet() }.toSet()
private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter { private fun parseUserRefs(root: Element) = root.asIterable().asSequence().map {
it.localName == "user"
}.map {
it.getAttribute("ref") it.getAttribute("ref")
}.toSet() }.toSet()
private fun parseUsers(root: Element): Map<String, User> { private fun parseUsers(root: Element): Sequence<User> {
return root.asIterable().asSequence().filter { return root.asIterable().asSequence().filter {
it.localName == "user" it.localName == "user"
}.map { el -> }.map { el ->
val username = el.getAttribute("name") val username = el.getAttribute("name")
val password = el.getAttribute("password").takeIf(String::isNotEmpty) val password = el.getAttribute("password").takeIf(String::isNotEmpty)
username to User(username, password, emptySet()) User(username, password, emptySet())
}.toMap() }
} }
private fun parseGroups(root: Element, knownUsers: Map<String, User>): Pair<Map<String, User>, Map<String, Group>> { private fun parseGroups(root: Element, knownUsers: Sequence<User>): Pair<Map<String, User>, Map<String, Group>> {
val knownUsersMap = knownUsers.associateBy(User::getName)
val userGroups = mutableMapOf<String, MutableSet<String>>() val userGroups = mutableMapOf<String, MutableSet<String>>()
val groups = root.asIterable().asSequence().filter { val groups = root.asIterable().asSequence().filter {
it.localName == "group" it.localName == "group"
@@ -177,7 +177,7 @@ object Parser {
for (child in el.asIterable()) { for (child in el.asIterable()) {
when (child.localName) { when (child.localName) {
"users" -> { "users" -> {
parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user -> parseUserRefs(child).mapNotNull(knownUsersMap::get).forEach { user ->
userGroups.computeIfAbsent(user.name) { userGroups.computeIfAbsent(user.name) {
mutableSetOf() mutableSetOf()
}.add(groupName) }.add(groupName)
@@ -191,7 +191,7 @@ object Parser {
} }
groupName to Group(groupName, roles) groupName to Group(groupName, roles)
}.toMap() }.toMap()
val users = knownUsers.map { (name, user) -> val users = knownUsersMap.map { (name, user) ->
name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet()) name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet())
}.toMap() }.toMap()
return users to groups return users to groups

View File

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

View File

@@ -10,9 +10,6 @@
<xs:sequence minOccurs="0"> <xs:sequence minOccurs="0">
<xs:element name="bind" type="gbcs:bindType" maxOccurs="1"/> <xs:element name="bind" type="gbcs:bindType" maxOccurs="1"/>
<xs:element name="cache" type="gbcs:cacheType" 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:element name="authorization" type="gbcs:authorizationType" minOccurs="0">
<xs:key name="userId"> <xs:key name="userId">
<xs:selector xpath="users/user"/> <xs:selector xpath="users/user"/>
@@ -24,11 +21,10 @@
</xs:keyref> </xs:keyref>
</xs:element> </xs:element>
<xs:element name="authentication" type="gbcs:authenticationType" minOccurs="0" maxOccurs="1"/> <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:element name="tls" type="gbcs:tlsType" minOccurs="0" maxOccurs="1"/>
</xs:sequence> </xs:sequence>
<xs:attribute name="path" type="xs:string" use="optional"/> <xs:attribute name="path" type="xs:string" use="optional"/>
<xs:attribute name="useVirtualThreads" type="xs:boolean" use="optional"/> <xs:attribute name="useVirtualThreads" type="xs:boolean" use="optional" default="true"/>
</xs:complexType> </xs:complexType>
<xs:complexType name="bindType"> <xs:complexType name="bindType">
@@ -130,7 +126,8 @@
<xs:complexType name="userRefsType"> <xs:complexType name="userRefsType">
<xs:sequence> <xs:sequence>
<xs:element name="user" type="gbcs:userRefType" maxOccurs="unbounded" minOccurs="0"/> <xs:element name="user" type="gbcs:userRefType" maxOccurs="unbounded" minOccurs="0"/>
<xs:element name="anonymous" minOccurs="0" maxOccurs="1"/>
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>

View File

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,187 @@
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.CertificateUtils
import net.woggioni.gbcs.utils.CertificateUtils.X509Credentials
import net.woggioni.gbcs.utils.NetworkUtils
import org.bouncycastle.asn1.x500.X500Name
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
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,90 +4,26 @@ import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.PasswordSecurity.hashPassword 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.Assertions
import org.junit.jupiter.api.Order import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test 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.HttpClient
import java.net.http.HttpRequest import java.net.http.HttpRequest
import java.net.http.HttpResponse 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 : AbstractServerTest() { class BasicAuthServerTest : AbstractBasicAuthServerTest() {
companion object { companion object {
private const val PASSWORD = "password" private const val PASSWORD = "password"
} }
private lateinit var cacheDir : Path override val users = listOf(
Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)),
private val random = Random(101325) Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)),
private val keyValuePair = newEntry(random) Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)),
private val serverPath = "gbcs" Configuration.User("", null, setOf(readersGroup))
)
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 @Test
@Order(1) @Order(1)
@@ -100,7 +36,7 @@ class BasicAuthServerTest : AbstractServerTest() {
.PUT(HttpRequest.BodyPublishers.ofByteArray(value)) .PUT(HttpRequest.BodyPublishers.ofByteArray(value))
val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode()) Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode())
} }
@Test @Test
@@ -179,6 +115,20 @@ class BasicAuthServerTest : AbstractServerTest() {
@Test @Test
@Order(6) @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() { fun getMissingKeyAsAReaderUser() {
val client: HttpClient = HttpClient.newHttpClient() val client: HttpClient = HttpClient.newHttpClient()

View File

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

View File

@@ -0,0 +1,52 @@
package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration
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

@@ -0,0 +1,47 @@
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

@@ -1,15 +1,14 @@
package net.woggioni.gbcs.test package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.api.Configuration import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.cache.FileSystemCacheConfiguration import net.woggioni.gbcs.cache.FileSystemCacheConfiguration
import net.woggioni.gbcs.configuration.Serializer import net.woggioni.gbcs.configuration.Serializer
import net.woggioni.gbcs.utils.NetworkUtils import net.woggioni.gbcs.utils.NetworkUtils
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Order import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.net.ServerSocket
import java.net.URI import java.net.URI
import java.net.http.HttpClient import java.net.http.HttpClient
import java.net.http.HttpRequest import java.net.http.HttpRequest
@@ -105,4 +104,28 @@ class NoAuthServerTest : AbstractServerTest() {
val response: HttpResponse<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) val response: HttpResponse<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()) Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode())
} }
// @Test
// @Order(4)
// fun manyRequestsTest() {
// val client: HttpClient = HttpClient.newHttpClient()
//
// for(i in 0 until 100000) {
//
// val newEntry = random.nextBoolean()
// val (key, _) = if(newEntry) {
// newEntry(random)
// } else {
// keyValuePair
// }
// val requestBuilder = newRequestBuilder(key).GET()
//
// val response: HttpResponse<ByteArray> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
// if(newEntry) {
// Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode())
// } else {
// Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode())
// }
// }
// }
} }

View File

@@ -3,214 +3,26 @@ package net.woggioni.gbcs.test
import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpResponseStatus
import net.woggioni.gbcs.api.Configuration import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.api.Role 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.bouncycastle.asn1.x500.X500Name
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Order import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.net.ServerSocket
import java.net.URI
import java.net.http.HttpClient import java.net.http.HttpClient
import java.net.http.HttpRequest import java.net.http.HttpRequest
import java.net.http.HttpResponse 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
class TlsServerTest : AbstractServerTest() { class TlsServerTest : AbstractTlsServerTest() {
companion object { override val users = listOf(
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("user1", null, setOf(readersGroup)),
Configuration.User("user2", null, setOf(writersGroup)), Configuration.User("user2", null, setOf(writersGroup)),
Configuration.User("user3", null, setOf(readersGroup, writersGroup)) Configuration.User("user3", null, setOf(readersGroup, writersGroup)),
Configuration.User("", null, setOf(readersGroup))
) )
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 @Test
@Order(1) @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() { fun putAsAReaderUser() {
val (key, value) = keyValuePair val (key, value) = keyValuePair
val user = cfg.users.values.find { val user = cfg.users.values.find {
@@ -226,7 +38,7 @@ class TlsServerTest : AbstractServerTest() {
} }
@Test @Test
@Order(3) @Order(2)
fun getAsAWriterUser() { fun getAsAWriterUser() {
val (key, _) = keyValuePair val (key, _) = keyValuePair
val user = cfg.users.values.find { val user = cfg.users.values.find {
@@ -235,7 +47,6 @@ class TlsServerTest : AbstractServerTest() {
val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}")))
val requestBuilder = newRequestBuilder(key) val requestBuilder = newRequestBuilder(key)
.header("Authorization", buildAuthorizationHeader(user, PASSWORD))
.GET() .GET()
val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
@@ -243,7 +54,7 @@ class TlsServerTest : AbstractServerTest() {
} }
@Test @Test
@Order(4) @Order(3)
fun putAsAWriterUser() { fun putAsAWriterUser() {
val (key, value) = keyValuePair val (key, value) = keyValuePair
val user = cfg.users.values.find { val user = cfg.users.values.find {
@@ -253,7 +64,6 @@ class TlsServerTest : AbstractServerTest() {
val requestBuilder = newRequestBuilder(key) val requestBuilder = newRequestBuilder(key)
.header("Content-Type", "application/octet-stream") .header("Content-Type", "application/octet-stream")
.header("Authorization", buildAuthorizationHeader(user, PASSWORD))
.PUT(HttpRequest.BodyPublishers.ofByteArray(value)) .PUT(HttpRequest.BodyPublishers.ofByteArray(value))
val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) val response: HttpResponse<String> = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
@@ -261,7 +71,7 @@ class TlsServerTest : AbstractServerTest() {
} }
@Test @Test
@Order(5) @Order(4)
fun getAsAReaderUser() { fun getAsAReaderUser() {
val (key, value) = keyValuePair val (key, value) = keyValuePair
val user = cfg.users.values.find { val user = cfg.users.values.find {
@@ -270,7 +80,6 @@ class TlsServerTest : AbstractServerTest() {
val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}")))
val requestBuilder = newRequestBuilder(key) val requestBuilder = newRequestBuilder(key)
.header("Authorization", buildAuthorizationHeader(user, PASSWORD))
.GET() .GET()
val response: HttpResponse<ByteArray> = val response: HttpResponse<ByteArray> =
@@ -280,7 +89,7 @@ class TlsServerTest : AbstractServerTest() {
} }
@Test @Test
@Order(6) @Order(5)
fun getMissingKeyAsAReaderUser() { fun getMissingKeyAsAReaderUser() {
val (key, _) = newEntry(random) val (key, _) = newEntry(random)
val user = cfg.users.values.find { val user = cfg.users.values.find {
@@ -289,11 +98,39 @@ class TlsServerTest : AbstractServerTest() {
val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}"))) val client: HttpClient = getHttpClient(getClientKeyStore(ca, X500Name("CN=${user.name}")))
val requestBuilder = newRequestBuilder(key) val requestBuilder = newRequestBuilder(key)
.header("Authorization", buildAuthorizationHeader(user, PASSWORD))
.GET() .GET()
val response: HttpResponse<ByteArray> = val response: HttpResponse<ByteArray> =
client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())
Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()) 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

@@ -0,0 +1,21 @@
<?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

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

View File

@@ -0,0 +1,53 @@
<?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>