Compare commits

...

14 Commits

Author SHA1 Message Date
241d95fe1c added env variable and java properties substitution in configuration attributes
All checks were successful
CI / build (push) Successful in 3m29s
2025-01-16 21:11:35 +08:00
3b7030c302 fixed mainClassName for native image build
All checks were successful
CI / build (push) Successful in 3m22s
2025-01-16 17:23:03 +08:00
a8670277e7 fixed XML error handler for server command 2025-01-16 17:01:14 +08:00
03ee75266d added average turnaround time calculation in benchmark 2025-01-16 14:54:21 +08:00
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
d701157b06 added jpms url protocol
Some checks failed
CI / build (push) Successful in 31s
CI / Build Docker images (push) Failing after 15s
2025-01-10 17:09:40 +08:00
01d5b1462c added dedicated cli module 2025-01-09 16:58:02 +08:00
68 changed files with 2146 additions and 918 deletions

View File

@@ -17,45 +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: run: ./gradlew build
PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} - name: Prepare Docker image build
run: ./gradlew build publish run: ./gradlew prepareDockerBuild
build-docker: - name: Get project version
name: "Build Docker images" id: retrieve-version
runs-on: hostinger run: ./gradlew -q version >> "$GITHUB_OUTPUT"
steps: - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.4.0 uses: docker/setup-buildx-action@v3
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

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
gbcs-cli/native-image/*.json

View File

@@ -1,46 +1,2 @@
FROM container-registry.oracle.com/graalvm/native-image:21 AS oracle FROM gitea.woggioni.net/woggioni/gbcs:memcached
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 native-image native-image
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 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/build,target=/home/luser/build cp build/libs/gbcs-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/build,target=/home/luser/build cp build/libs/gbcs-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 COPY --chown=luser:luser conf/gbcs-memcached.xml /home/luser/.config/gbcs/gbcs.xml

View File

@@ -1,31 +1,24 @@
plugins { plugins {
id 'java-library' id 'java-library'
id 'jvm-test-suite'
alias catalog.plugins.kotlin.jvm alias catalog.plugins.kotlin.jvm
alias catalog.plugins.envelope
alias catalog.plugins.sambal alias catalog.plugins.sambal
alias catalog.plugins.lombok alias catalog.plugins.lombok
alias catalog.plugins.graalvm.native.image
id 'maven-publish' id 'maven-publish'
} }
import net.woggioni.gradle.envelope.EnvelopeJarTask
import net.woggioni.gradle.graalvm.NativeImagePlugin
import net.woggioni.gradle.graalvm.NativeImageTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
allprojects { subproject ->
allprojects {
group = 'net.woggioni' group = 'net.woggioni'
if(project.currentTag.isPresent()) { if(project.currentTag.isPresent()) {
version = project.currentTag version = project.currentTag.map { it[0] }.get()
} else { } else {
version = project.gitRevision.map { gitRevision -> version = project.gitRevision.map { gitRevision ->
"${getProperty('gbcs.version')}.${gitRevision[0..10]}" "${getProperty('gbcs.version')}.${gitRevision[0..10]}"
} }.get()
} }
repositories { repositories {
@@ -41,6 +34,11 @@ allprojects {
} }
pluginManager.withPlugin('java-library') { pluginManager.withPlugin('java-library') {
ext {
jpmsModuleName = subproject.group + '.' + subproject.name.replace('-', '.')
}
java { java {
withSourcesJar() withSourcesJar()
modularity.inferModulePath = true modularity.inferModulePath = true
@@ -58,6 +56,16 @@ allprojects {
modularity.inferModulePath = true modularity.inferModulePath = true
options.release = 21 options.release = 21
} }
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) {
options.compilerArgumentProviders << new CommandLineArgumentProvider() {
@Override
Iterable<String> asArguments() {
return ['--patch-module', subproject.jpmsModuleName + '=' + subproject.sourceSets.main.output.asPath]
}
}
options.javaModuleVersion = version
}
} }
pluginManager.withPlugin(catalog.plugins.kotlin.jvm.get().pluginId) { pluginManager.withPlugin(catalog.plugins.kotlin.jvm.get().pluginId) {
@@ -94,32 +102,16 @@ allprojects {
} }
} }
Property<String> mainClassName = objects.property(String.class)
mainClassName.set('net.woggioni.gbcs.GradleBuildCacheServer')
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) {
options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs=' + project.sourceSets.main.output.asPath
options.javaModuleVersion = version
options.javaModuleMainClass = mainClassName
}
envelopeJar {
mainModule = 'net.woggioni.gbcs'
mainClass = mainClassName
extraClasspath = ["plugins"]
}
dependencies { dependencies {
implementation catalog.jwo implementation catalog.jwo
implementation catalog.slf4j.api implementation catalog.slf4j.api
implementation catalog.netty.codec.http implementation catalog.netty.codec.http
implementation project('gbcs-base') api project('gbcs-base')
implementation project('gbcs-api') api project('gbcs-api')
// runtimeOnly catalog.slf4j.jdk14 // runtimeOnly catalog.slf4j.jdk14
runtimeOnly catalog.logback.classic testRuntimeOnly catalog.logback.classic
testImplementation catalog.bcprov.jdk18on testImplementation catalog.bcprov.jdk18on
testImplementation catalog.bcpkix.jdk18on testImplementation catalog.bcpkix.jdk18on
@@ -130,33 +122,17 @@ dependencies {
testRuntimeOnly project("gbcs-memcached") testRuntimeOnly project("gbcs-memcached")
} }
Provider<EnvelopeJarTask> envelopeJarTaskProvider = tasks.named('envelopeJar', EnvelopeJarTask.class) {
// systemProperties['java.util.logging.config.class'] = 'net.woggioni.gbcs.LoggingConfig'
// systemProperties['log.config.source'] = 'logging.properties'
systemProperties['logback.configurationFile'] = 'classpath:net/woggioni/gbcs/logback.xml'
}
def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get().archiveFile.get().asFile) {
type = 'jar'
builtBy envelopeJarTaskProvider
}
tasks.named(NativeImagePlugin.CONFIGURE_NATIVE_IMAGE_TASK_NAME, NativeImageConfigurationTask) {
mainClass = 'net.woggioni.gbcs.GraalNativeImageConfiguration'
}
tasks.named(NativeImagePlugin.NATIVE_IMAGE_TASK_NAME, NativeImageTask) {
mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer'
useMusl = true
buildStaticImage = true
}
publishing { publishing {
publications { publications {
maven(MavenPublication) { maven(MavenPublication) {
artifact envelopeJarArtifact from(components["java"])
} }
} }
} }
tasks.register('version') {
doLast {
println("VERSION=$version")
}
}

View File

@@ -1,8 +1,8 @@
<?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 classpath:net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd urn:net.woggioni.gbcs classpath: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="0.0.0.0" port="13080" /> <bind host="0.0.0.0" port="13080" />
<cache xs:type="gbcs-memcached:memcachedCacheType" max-age="P7D" max-size="16777216" compression-mode="zip"> <cache xs:type="gbcs-memcached:memcachedCacheType" max-age="P7D" max-size="16777216" compression-mode="zip">
<server host="memcached" port="11211"/> <server host="memcached" port="11211"/>

View File

@@ -6,13 +6,13 @@
<import class="ch.qos.logback.core.ConsoleAppender"/> <import class="ch.qos.logback.core.ConsoleAppender"/>
<appender name="console" class="ConsoleAppender"> <appender name="console" class="ConsoleAppender">
<target>System.out</target> <target>System.err</target>
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<pattern>%d [%highlight(%-5level)] \(%thread\) %logger{36} -%kvp- %msg %n</pattern> <pattern>%d [%highlight(%-5level)] \(%thread\) %logger{36} -%kvp- %msg %n</pattern>
</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"/>

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

@@ -1,11 +1,16 @@
plugins { plugins {
id 'java-library' id 'java-library'
id 'maven-publish'
alias catalog.plugins.lombok alias catalog.plugins.lombok
} }
dependencies { dependencies {
} }
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { publishing {
options.javaModuleVersion = version publications {
maven(MavenPublication) {
from(components["java"])
}
}
} }

View File

@@ -1,6 +1,7 @@
package net.woggioni.gbcs.api; package net.woggioni.gbcs.api;
import lombok.EqualsAndHashCode;
import lombok.Value; import lombok.Value;
import java.nio.file.Path; import java.nio.file.Path;
@@ -23,25 +24,18 @@ public class Configuration {
@Value @Value
public static class Group { public static class Group {
@EqualsAndHashCode.Include
String name; String name;
Set<Role> roles; Set<Role> roles;
@Override
public int hashCode() {
return name.hashCode();
}
} }
@Value @Value
public static class User { public static class User {
@EqualsAndHashCode.Include
String name; String name;
String password; String password;
Set<Group> groups; Set<Group> groups;
@Override
public int hashCode() {
return name.hashCode();
}
public Set<Role> getRoles() { public Set<Role> getRoles() {
return groups.stream() return groups.stream()

View File

@@ -0,0 +1,11 @@
package net.woggioni.gbcs.api.exception;
public class ConfigurationException extends GbcsException {
public ConfigurationException(String message, Throwable cause) {
super(message, cause);
}
public ConfigurationException(String message) {
this(message, null);
}
}

View File

@@ -1,21 +1,20 @@
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'
alias catalog.plugins.kotlin.jvm alias catalog.plugins.kotlin.jvm
} }
dependencies { dependencies {
compileOnly project(':gbcs-api') implementation project(':gbcs-api')
compileOnly catalog.slf4j.api implementation catalog.slf4j.api
implementation catalog.jwo
} }
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { publishing {
options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs.base=' + project.sourceSets.main.output.asPath publications {
options.javaModuleVersion = version maven(MavenPublication) {
} from(components["java"])
}
tasks.named("compileKotlin", KotlinCompile.class) { }
compilerOptions.jvmTarget = JvmTarget.JVM_21
} }

View File

@@ -3,6 +3,7 @@ module net.woggioni.gbcs.base {
requires java.logging; requires java.logging;
requires org.slf4j; requires org.slf4j;
requires kotlin.stdlib; requires kotlin.stdlib;
requires net.woggioni.jwo;
exports net.woggioni.gbcs.base; exports net.woggioni.gbcs.base;
} }

View File

@@ -0,0 +1,108 @@
package net.woggioni.gbcs.base
import java.io.IOException
import java.io.InputStream
import java.net.URL
import java.net.URLConnection
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import java.util.stream.Collectors
class GbcsUrlStreamHandlerFactory : URLStreamHandlerFactory {
private class ClasspathHandler(private val classLoader: ClassLoader = GbcsUrlStreamHandlerFactory::class.java.classLoader) :
URLStreamHandler() {
override fun openConnection(u: URL): URLConnection? {
return javaClass.module
?.takeIf { m: Module -> m.layer != null }
?.let {
val path = u.path
val i = path.lastIndexOf('/')
val packageName = path.substring(0, i).replace('/', '.')
val modules = packageMap[packageName]!!
ClasspathResourceURLConnection(
u,
modules
)
}
?: classLoader.getResource(u.path)?.let(URL::openConnection)
}
}
private class JpmsHandler : URLStreamHandler() {
override fun openConnection(u: URL): URLConnection {
val thisModule = javaClass.module
val sourceModule = Optional.ofNullable(thisModule)
.map { obj: Module -> obj.layer }
.flatMap { layer: ModuleLayer ->
val moduleName = u.host
layer.findModule(moduleName)
}.orElse(thisModule)
return JpmsResourceURLConnection(u, sourceModule)
}
}
private class JpmsResourceURLConnection(url: URL, private val module: Module) : URLConnection(url) {
override fun connect() {
}
@Throws(IOException::class)
override fun getInputStream(): InputStream {
return module.getResourceAsStream(getURL().path)
}
}
override fun createURLStreamHandler(protocol: String): URLStreamHandler? {
return when (protocol) {
"classpath" -> ClasspathHandler()
"jpms" -> JpmsHandler()
else -> null
}
}
private class ClasspathResourceURLConnection(url: URL?, private val modules: List<Module>) :
URLConnection(url) {
override fun connect() {}
override fun getInputStream(): InputStream? {
for (module in modules) {
val result = module.getResourceAsStream(getURL().path)
if (result != null) return result
}
return null
}
}
companion object {
private val installed = AtomicBoolean(false)
fun install() {
if (!installed.getAndSet(true)) {
URL.setURLStreamHandlerFactory(GbcsUrlStreamHandlerFactory())
}
}
private val packageMap: Map<String, List<Module>> by lazy {
GbcsUrlStreamHandlerFactory::class.java.module.layer
.modules()
.stream()
.flatMap { m: Module ->
m.packages.stream()
.map { p: String -> p to m }
}
.collect(
Collectors.groupingBy(
Pair<String, Module>::first,
Collectors.mapping(
Pair<String, Module>::second,
Collectors.toUnmodifiableList<Module>()
)
)
)
}
}
}

View File

@@ -2,6 +2,7 @@ package net.woggioni.gbcs.base
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.util.logging.LogManager import java.util.logging.LogManager
@@ -52,6 +53,12 @@ inline fun log(log : Logger,
} }
} }
inline fun Logger.log(level : Level, messageBuilder : () -> String) {
if(isEnabledForLevel(level)) {
makeLoggingEventBuilder(level).log(messageBuilder())
}
}
inline fun Logger.trace(messageBuilder : () -> String) { inline fun Logger.trace(messageBuilder : () -> String) {
if(isTraceEnabled) { if(isTraceEnabled) {
trace(messageBuilder()) trace(messageBuilder())

View File

@@ -0,0 +1,46 @@
package net.woggioni.gbcs.base
import java.security.SecureRandom
import java.security.spec.KeySpec
import java.util.Base64
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
object PasswordSecurity {
private const val KEY_LENGTH = 256
private fun concat(arr1: ByteArray, arr2: ByteArray): ByteArray {
val result = ByteArray(arr1.size + arr2.size)
var j = 0
for(element in arr1) {
result[j] = element
j += 1
}
for(element in arr2) {
result[j] = element
j += 1
}
return result
}
fun hashPassword(password : String, salt : String? = null) : String {
val actualSalt = salt?.let(Base64.getDecoder()::decode) ?: SecureRandom().run {
val result = ByteArray(16)
nextBytes(result)
result
}
val spec: KeySpec = PBEKeySpec(password.toCharArray(), actualSalt, 10, KEY_LENGTH)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val hash = factory.generateSecret(spec).encoded
return String(Base64.getEncoder().encode(concat(hash, actualSalt)))
}
fun decodePasswordHash(passwordHash : String) : Pair<ByteArray, ByteArray> {
val decoded = Base64.getDecoder().decode(passwordHash)
val hash = ByteArray(KEY_LENGTH / 8)
val salt = ByteArray(decoded.size - KEY_LENGTH / 8)
System.arraycopy(decoded, 0, hash, 0, hash.size)
System.arraycopy(decoded, hash.size, salt, 0, salt.size)
return hash to salt
}
}

View File

@@ -1,6 +1,11 @@
package net.woggioni.gbcs.base package net.woggioni.gbcs.base
import net.woggioni.jwo.CollectionUtils.mapValues
import net.woggioni.jwo.CollectionUtils.toUnmodifiableTreeMap
import net.woggioni.jwo.JWO
import net.woggioni.jwo.MapBuilder
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.slf4j.event.Level
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import org.w3c.dom.Node import org.w3c.dom.Node
@@ -11,6 +16,8 @@ import org.xml.sax.SAXParseException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.net.URL import java.net.URL
import java.util.Collections
import java.util.TreeMap
import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD
import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA
import javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING import javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING
@@ -80,31 +87,36 @@ class Xml(val doc: Document, val element: Element) {
private val log = LoggerFactory.getLogger(ErrorHandler::class.java) private val log = LoggerFactory.getLogger(ErrorHandler::class.java)
} }
override fun warning(ex: SAXParseException) { override fun warning(ex: SAXParseException)= err(ex, Level.WARN)
log.warn(
"Problem at {}:{}:{} parsing deployment configuration: {}",
fileURL, ex.lineNumber, ex.columnNumber, ex.message
)
}
override fun error(ex: SAXParseException) { private fun err(ex: SAXParseException, level: Level) {
log.error( log.log(level) {
"Problem at {}:{}:{} parsing deployment configuration: {}", "Problem at ${fileURL}:${ex.lineNumber}:${ex.columnNumber} parsing deployment configuration: ${ex.message}"
fileURL, ex.lineNumber, ex.columnNumber, ex.message }
)
throw ex throw ex
} }
override fun fatalError(ex: SAXParseException) { override fun error(ex: SAXParseException) = err(ex, Level.ERROR)
log.error( override fun fatalError(ex: SAXParseException) = err(ex, Level.ERROR)
"Problem at {}:{}:{} parsing deployment configuration: {}",
fileURL, ex.lineNumber, ex.columnNumber, ex.message
)
throw ex
}
} }
companion object { companion object {
private val dictMap: Map<String, Map<String, Any>> = sequenceOf(
"env" to System.getenv().asSequence().map { (k, v) -> k to (v as Any) }.toMap(),
"sys" to System.getProperties().asSequence().map { (k, v) -> k as String to (v as Any) }.toMap()
).toMap()
private fun renderConfigurationTemplate(template: String): String {
return JWO.renderTemplate(template, emptyMap(), dictMap)
}
fun Element.renderAttribute(name : String, namespaceURI: String? = null) = if(namespaceURI == null) {
getAttribute(name)
} else {
getAttributeNS(name, namespaceURI)
}.takeIf(String::isNotEmpty)?.let(Companion::renderConfigurationTemplate)
fun Element.asIterable() = Iterable { ElementIterator(this, null) } fun Element.asIterable() = Iterable { ElementIterator(this, null) }
fun NodeList.asIterable() = Iterable { NodeListIterator(this) } fun NodeList.asIterable() = Iterable { NodeListIterator(this) }
@@ -146,8 +158,8 @@ 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)
} }
@@ -165,7 +177,7 @@ class Xml(val doc: Document, val element: Element) {
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)
} }
@@ -183,7 +195,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 +224,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 +239,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)
} }

89
gbcs-cli/build.gradle Normal file
View File

@@ -0,0 +1,89 @@
plugins {
id 'java-library'
alias catalog.plugins.kotlin.jvm
alias catalog.plugins.envelope
alias catalog.plugins.sambal
alias catalog.plugins.graalvm.native.image
alias catalog.plugins.graalvm.jlink
alias catalog.plugins.jpms.check
id 'maven-publish'
}
import net.woggioni.gradle.envelope.EnvelopeJarTask
import net.woggioni.gradle.graalvm.NativeImageConfigurationTask
import net.woggioni.gradle.graalvm.NativeImagePlugin
import net.woggioni.gradle.graalvm.NativeImageTask
import net.woggioni.gradle.graalvm.JlinkPlugin
import net.woggioni.gradle.graalvm.JlinkTask
Property<String> mainClassName = objects.property(String.class)
mainClassName.set('net.woggioni.gbcs.cli.GradleBuildCacheServerCli')
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) {
options.javaModuleMainClass = mainClassName
}
configurations {
release {
transitive = false
canBeConsumed = true
canBeResolved = true
visible = true
}
}
envelopeJar {
mainModule = 'net.woggioni.gbcs.cli'
mainClass = mainClassName
extraClasspath = ["plugins"]
}
dependencies {
implementation catalog.jwo
implementation catalog.slf4j.api
implementation catalog.netty.codec.http
implementation catalog.picocli
implementation project(":gbcs-client")
implementation rootProject
// runtimeOnly catalog.slf4j.jdk14
runtimeOnly catalog.logback.classic
}
Provider<EnvelopeJarTask> envelopeJarTaskProvider = tasks.named('envelopeJar', EnvelopeJarTask.class) {
// systemProperties['java.util.logging.config.class'] = 'net.woggioni.gbcs.LoggingConfig'
// systemProperties['log.config.source'] = 'logging.properties'
systemProperties['logback.configurationFile'] = 'classpath:net/woggioni/gbcs/cli/logback.xml'
}
tasks.named(NativeImagePlugin.CONFIGURE_NATIVE_IMAGE_TASK_NAME, NativeImageConfigurationTask) {
mainClass = mainClassName
}
tasks.named(NativeImagePlugin.NATIVE_IMAGE_TASK_NAME, NativeImageTask) {
mainClass = mainClassName
useMusl = true
buildStaticImage = true
}
tasks.named(JlinkPlugin.JLINK_TASK_NAME, JlinkTask) {
mainClass = mainClassName
mainModule = 'net.woggioni.gbcs.cli'
}
artifacts {
release(envelopeJarTaskProvider)
}
publishing {
publications {
maven(MavenPublication) {
artifact envelopeJar
}
}
}

View File

@@ -0,0 +1,17 @@
module net.woggioni.gbcs.cli {
requires org.slf4j;
requires net.woggioni.gbcs;
requires info.picocli;
requires net.woggioni.gbcs.base;
requires net.woggioni.gbcs.client;
requires kotlin.stdlib;
requires net.woggioni.jwo;
requires net.woggioni.gbcs.api;
exports net.woggioni.gbcs.cli.impl.converters to info.picocli;
opens net.woggioni.gbcs.cli.impl.commands to info.picocli;
opens net.woggioni.gbcs.cli.impl to info.picocli;
opens net.woggioni.gbcs.cli to info.picocli, net.woggioni.gbcs.base;
exports net.woggioni.gbcs.cli;
}

View File

@@ -0,0 +1,59 @@
package net.woggioni.gbcs.cli
import net.woggioni.gbcs.base.GbcsUrlStreamHandlerFactory
import net.woggioni.gbcs.base.contextLogger
import net.woggioni.gbcs.cli.impl.AbstractVersionProvider
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.cli.impl.commands.BenchmarkCommand
import net.woggioni.gbcs.cli.impl.commands.ClientCommand
import net.woggioni.gbcs.cli.impl.commands.PasswordHashCommand
import net.woggioni.gbcs.cli.impl.commands.ServerCommand
import net.woggioni.jwo.Application
import picocli.CommandLine
import picocli.CommandLine.Model.CommandSpec
@CommandLine.Command(
name = "gbcs", versionProvider = GradleBuildCacheServerCli.VersionProvider::class
)
class GradleBuildCacheServerCli : GbcsCommand() {
class VersionProvider : AbstractVersionProvider()
companion object {
@JvmStatic
fun main(vararg args: String) {
Thread.currentThread().contextClassLoader = GradleBuildCacheServerCli::class.java.classLoader
GbcsUrlStreamHandlerFactory.install()
val log = contextLogger()
val app = Application.builder("gbcs")
.configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR")
.configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir")
.build()
val gbcsCli = GradleBuildCacheServerCli()
val commandLine = CommandLine(gbcsCli)
commandLine.setExecutionExceptionHandler { ex, cl, parseResult ->
log.error(ex.message, ex)
CommandLine.ExitCode.SOFTWARE
}
commandLine.addSubcommand(ServerCommand(app))
commandLine.addSubcommand(PasswordHashCommand())
commandLine.addSubcommand(
CommandLine(ClientCommand(app)).apply {
addSubcommand(BenchmarkCommand())
})
System.exit(commandLine.execute(*args))
}
}
@CommandLine.Option(names = ["-V", "--version"], versionHelp = true)
var versionHelp = false
private set
@CommandLine.Spec
private lateinit var spec: CommandSpec
override fun run() {
spec.commandLine().usage(System.out);
}
}

View File

@@ -0,0 +1,30 @@
package net.woggioni.gbcs.cli.impl
import picocli.CommandLine
import java.util.jar.Attributes
import java.util.jar.JarFile
import java.util.jar.Manifest
abstract class AbstractVersionProvider : CommandLine.IVersionProvider {
private val version: String
private val vcsHash: String
init {
val mf = Manifest()
javaClass.module.getResourceAsStream(JarFile.MANIFEST_NAME).use { `is` ->
mf.read(`is`)
}
val mainAttributes = mf.mainAttributes
version = mainAttributes.getValue(Attributes.Name.SPECIFICATION_VERSION) ?: throw RuntimeException("Version information not found in manifest")
vcsHash = mainAttributes.getValue(Attributes.Name.IMPLEMENTATION_VERSION) ?: throw RuntimeException("Version information not found in manifest")
}
override fun getVersion(): Array<String?> {
return if (version.endsWith("-SNAPSHOT")) {
arrayOf(version, vcsHash)
} else {
arrayOf(version)
}
}
}

View File

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

View File

@@ -0,0 +1,132 @@
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 java.util.concurrent.atomic.AtomicLong
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()
val totalElapsedTime = AtomicLong(0)
entryGenerator.take(numberOfEntries).forEach { entry ->
val requestStart = System.nanoTime()
val future = client.put(entry.first, entry.second).thenApply { entry }
future.whenComplete { _, _ ->
totalElapsedTime.addAndGet((System.nanoTime() - requestStart))
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"
}
log.info {
"Average time per insertion: ${totalElapsedTime.get() / numberOfEntries.toDouble() * 1000} ms"
}
inserted
}
log.info {
"Inserted ${entries.size} entries"
}
if (entries.isNotEmpty()) {
val completionQueue = LinkedBlockingQueue<Future<Unit>>(entries.size)
val start = Instant.now()
val totalElapsedTime = AtomicLong(0)
entries.forEach { entry ->
val requestStart = System.nanoTime()
val future = client.get(entry.first).thenApply {
totalElapsedTime.addAndGet((System.nanoTime() - requestStart))
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"
}
log.info {
"Average time per retrieval: ${totalElapsedTime.get() / numberOfEntries.toDouble() * 1e6} ms"
}
} 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

@@ -0,0 +1,37 @@
package net.woggioni.gbcs.cli.impl.commands
import net.woggioni.gbcs.base.PasswordSecurity.hashPassword
import net.woggioni.gbcs.cli.impl.GbcsCommand
import net.woggioni.gbcs.cli.impl.converters.OutputStreamConverter
import net.woggioni.jwo.UncloseableOutputStream
import picocli.CommandLine
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.io.PrintWriter
@CommandLine.Command(
name = "password",
description = ["Generate a password hash to add to GBCS configuration file"],
showDefaultValues = true
)
class PasswordHashCommand : GbcsCommand() {
@CommandLine.Option(
names = ["-o", "--output-file"],
description = ["Write the output to a file instead of stdout"],
converter = [OutputStreamConverter::class],
showDefaultValue = CommandLine.Help.Visibility.NEVER,
paramLabel = "OUTPUT_FILE"
)
private var outputStream: OutputStream = UncloseableOutputStream(System.out)
override fun run() {
val password1 = String(System.console().readPassword("Type your password:"))
val password2 = String(System.console().readPassword("Type your password again for confirmation:"))
if(password1 != password2) throw IllegalArgumentException("Passwords do not match")
PrintWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)).use {
it.println(hashPassword(password1))
}
}
}

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.api.Configuration
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.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 : Configuration by lazy {
GradleBuildCacheServer.loadConfiguration(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

@@ -0,0 +1,13 @@
package net.woggioni.gbcs.cli.impl.converters
import picocli.CommandLine
import java.io.OutputStream
import java.nio.file.Files
import java.nio.file.Paths
class OutputStreamConverter : CommandLine.ITypeConverter<OutputStream> {
override fun convert(value: String): OutputStream {
return Files.newOutputStream(Paths.get(value))
}
}

View File

@@ -0,0 +1,20 @@
<?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="com.google.code.yanf4j" level="warn"/>
<logger name="net.rubyeye.xmemcached" level="warn"/>
</configuration>

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

@@ -0,0 +1,15 @@
plugins {
id 'java-library'
alias catalog.plugins.kotlin.jvm
}
dependencies {
implementation project(':gbcs-api')
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,17 @@
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 net.woggioni.gbcs.api;
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,69 @@
package net.woggioni.gbcs.client.impl
import net.woggioni.gbcs.api.exception.ConfigurationException
import net.woggioni.gbcs.base.Xml.Companion.asIterable
import net.woggioni.gbcs.base.Xml.Companion.renderAttribute
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.renderAttribute("name") ?: throw ConfigurationException("name attribute is required")
val uri = child.renderAttribute("base-url")?.let(::URI) ?: throw ConfigurationException("base-url attribute is required")
var authentication: GbcsClient.Configuration.Authentication? = null
for (gchild in child.asIterable()) {
when (gchild.localName) {
"tls-client-auth" -> {
val keyStoreFile = gchild.renderAttribute("key-store-file")
val keyStorePassword =
gchild.renderAttribute("key-store-password")
val keyAlias = gchild.renderAttribute("key-alias")
val keyPassword = gchild.renderAttribute("key-password")
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.renderAttribute("user") ?: throw ConfigurationException("username attribute is required")
val password = gchild.renderAttribute("password") ?: throw ConfigurationException("password attribute is required")
authentication =
GbcsClient.Configuration.Authentication.BasicAuthenticationCredentials(username, password)
}
}
}
val maxConnections = child.renderAttribute("max-connections")
?.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:token" 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:token" 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')
@@ -30,15 +35,6 @@ dependencies {
implementation catalog.xmemcached implementation catalog.xmemcached
} }
tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) {
options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs.memcached=' + project.sourceSets.main.output.asPath
options.javaModuleVersion = version
}
tasks.named("compileKotlin", KotlinCompile.class) {
compilerOptions.jvmTarget = JvmTarget.JVM_21
}
Provider<Tar> bundleTask = tasks.register("bundle", Tar) { Provider<Tar> bundleTask = tasks.register("bundle", Tar) {
from(tasks.named(JavaPlugin.JAR_TASK_NAME)) from(tasks.named(JavaPlugin.JAR_TASK_NAME))
from(configurations.bundle) from(configurations.bundle)
@@ -49,16 +45,14 @@ tasks.named(BasePlugin.ASSEMBLE_TASK_NAME) {
dependsOn(bundleTask) dependsOn(bundleTask)
} }
def bundleArtifact = artifacts.add('archives', bundleTask.get().archiveFile.get().asFile) { artifacts {
type = 'tar' release(bundleTask)
builtBy bundleTask
} }
publishing { publishing {
publications { publications {
maven(MavenPublication) { maven(MavenPublication) {
artifact bundleArtifact artifact bundleTask
} }
} }
} }

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

@@ -2,51 +2,50 @@ package net.woggioni.gbcs.memcached
import net.rubyeye.xmemcached.transcoders.CompressionMode import net.rubyeye.xmemcached.transcoders.CompressionMode
import net.woggioni.gbcs.api.CacheProvider import net.woggioni.gbcs.api.CacheProvider
import net.woggioni.gbcs.api.exception.ConfigurationException
import net.woggioni.gbcs.base.GBCS import net.woggioni.gbcs.base.GBCS
import net.woggioni.gbcs.base.HostAndPort import net.woggioni.gbcs.base.HostAndPort
import net.woggioni.gbcs.base.Xml import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.base.Xml.Companion.asIterable import net.woggioni.gbcs.base.Xml.Companion.asIterable
import net.woggioni.gbcs.base.Xml.Companion.renderAttribute
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>()
val maxAge = el.getAttribute("max-age") val maxAge = el.renderAttribute("max-age")
.takeIf(String::isNotEmpty)
?.let(Duration::parse) ?.let(Duration::parse)
?: Duration.ofDays(1) ?: Duration.ofDays(1)
val maxSize = el.getAttribute("max-size") val maxSize = el.renderAttribute("max-size")
.takeIf(String::isNotEmpty)
?.let(String::toInt) ?.let(String::toInt)
?: 0x100000 ?: 0x100000
val enableCompression = el.getAttribute("enable-compression") val compressionMode = el.renderAttribute("compression-mode")
.takeIf(String::isNotEmpty)
?.let(String::toBoolean)
?: false
val compressionMode = el.getAttribute("compression-mode")
.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
} }
} }
?: CompressionMode.ZIP ?: CompressionMode.ZIP
val digestAlgorithm = el.getAttribute("digest").takeIf(String::isNotEmpty) val digestAlgorithm = el.renderAttribute("digest")
for (child in el.asIterable()) { for (child in el.asIterable()) {
when (child.nodeName) { when (child.nodeName) {
"server" -> { "server" -> {
servers.add(HostAndPort(child.getAttribute("host"), child.getAttribute("port").toInt())) val host = child.renderAttribute("host") ?: throw ConfigurationException("host attribute is required")
val port = child.renderAttribute("port")?.toInt() ?: throw ConfigurationException("port attribute is required")
servers.add(HostAndPort(host, port))
} }
} }
} }
@@ -60,12 +59,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 +76,12 @@ class MemcachedCacheProvider : CacheProvider<MemcachedCacheConfiguration> {
digestAlgorithm?.let { digestAlgorithm -> digestAlgorithm?.let { digestAlgorithm ->
attr("digest", digestAlgorithm) attr("digest", digestAlgorithm)
} }
attr("compression-mode", when(compressionMode) { attr(
"compression-mode", when (compressionMode) {
CompressionMode.GZIP -> "gzip" CompressionMode.GZIP -> "gzip"
CompressionMode.ZIP -> "zip" CompressionMode.ZIP -> "zip"
}) }
)
} }
result result
} }

View File

@@ -7,7 +7,7 @@
<xs:import schemaLocation="classpath:net/woggioni/gbcs/schema/gbcs.xsd" namespace="urn:net.woggioni.gbcs"/> <xs:import schemaLocation="classpath:net/woggioni/gbcs/schema/gbcs.xsd" namespace="urn:net.woggioni.gbcs"/>
<xs:complexType name="memcachedServerType"> <xs:complexType name="memcachedServerType">
<xs:attribute name="host" type="xs:string" use="required"/> <xs:attribute name="host" type="xs:token" use="required"/>
<xs:attribute name="port" type="xs:positiveInteger" use="required"/> <xs:attribute name="port" type="xs:positiveInteger" use="required"/>
</xs:complexType> </xs:complexType>
@@ -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.08 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

@@ -29,5 +29,7 @@ rootProject.name = 'gbcs'
include 'gbcs-api' include 'gbcs-api'
include 'gbcs-base' include 'gbcs-base'
include 'gbcs-memcached' include 'gbcs-memcached'
include 'gbcs-cli'
include 'docker'
include 'gbcs-client'

View File

@@ -1,8 +1,7 @@
import net.woggioni.gbcs.api.CacheProvider; import net.woggioni.gbcs.api.CacheProvider;
import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider;
import net.woggioni.gbcs.cache.FileSystemCacheProvider; import net.woggioni.gbcs.cache.FileSystemCacheProvider;
open module net.woggioni.gbcs { module net.woggioni.gbcs {
requires java.sql; requires java.sql;
requires java.xml; requires java.xml;
requires java.logging; requires java.logging;
@@ -19,9 +18,11 @@ open module net.woggioni.gbcs {
requires net.woggioni.gbcs.base; requires net.woggioni.gbcs.base;
requires net.woggioni.gbcs.api; requires net.woggioni.gbcs.api;
provides java.net.URLStreamHandlerFactory with ClasspathUrlStreamHandlerFactoryProvider; exports net.woggioni.gbcs;
uses java.net.URLStreamHandlerFactory;
uses CacheProvider;
opens net.woggioni.gbcs;
opens net.woggioni.gbcs.schema;
uses CacheProvider;
provides CacheProvider with FileSystemCacheProvider; provides CacheProvider with FileSystemCacheProvider;
} }

View File

@@ -1,104 +0,0 @@
package net.woggioni.gbcs.url;
import net.woggioni.jwo.Fun;
import net.woggioni.jwo.LazyValue;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
import java.net.URLStreamHandlerFactory;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
public class ClasspathUrlStreamHandlerFactoryProvider implements URLStreamHandlerFactory {
private static final AtomicBoolean installed = new AtomicBoolean(false);
public static void install() {
if(!installed.getAndSet(true)) {
URL.setURLStreamHandlerFactory(new ClasspathUrlStreamHandlerFactoryProvider());
}
}
private static final LazyValue<Map<String, List<Module>>> packageMap = LazyValue.of(() ->
ClasspathUrlStreamHandlerFactoryProvider.class.getModule().getLayer()
.modules()
.stream()
.flatMap(m -> m.getPackages().stream().map(p -> Map.entry(p, m)))
.collect(
Collectors.groupingBy(
Map.Entry::getKey,
Collectors.mapping(
Map.Entry::getValue,
Collectors.toUnmodifiableList()
)
)
),
LazyValue.ThreadSafetyMode.NONE
);
private static class Handler extends URLStreamHandler {
private final ClassLoader classLoader;
public Handler() {
this.classLoader = getClass().getClassLoader();
}
public Handler(ClassLoader classLoader) {
this.classLoader = classLoader;
}
@Override
protected URLConnection openConnection(URL u) throws IOException {
return Optional.ofNullable(getClass().getModule())
.filter(m -> m.getLayer() != null)
.map(m -> {
final var path = u.getPath();
final var i = path.lastIndexOf('/');
final var packageName = path.substring(0, i).replace('/', '.');
final var modules = packageMap.get().get(packageName);
return (URLConnection) new ModuleResourceURLConnection(u, modules);
})
.or(() -> Optional.of(classLoader).map(cl -> cl.getResource(u.getPath())).map((Fun<URL, URLConnection>) URL::openConnection))
.orElse(null);
}
}
@Override
public URLStreamHandler createURLStreamHandler(String protocol) {
URLStreamHandler result;
switch (protocol) {
case "classpath":
result = new Handler();
break;
default:
result = null;
}
return result;
}
private static final class ModuleResourceURLConnection extends URLConnection {
private final List<Module> modules;
ModuleResourceURLConnection(URL url, List<Module> modules) {
super(url);
this.modules = modules;
}
public void connect() {
}
public InputStream getInputStream() throws IOException {
for(final var module : modules) {
final var result = module.getResourceAsStream(getURL().getPath());
if(result != null) return result;
}
return null;
}
}
}

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
@@ -46,21 +45,22 @@ import net.woggioni.gbcs.api.Cache
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.api.exception.ContentTooLargeException import net.woggioni.gbcs.api.exception.ContentTooLargeException
import net.woggioni.gbcs.auth.AbstractNettyHttpAuthenticator
import net.woggioni.gbcs.auth.Authorizer
import net.woggioni.gbcs.auth.ClientCertificateValidator
import net.woggioni.gbcs.auth.RoleAuthorizer
import net.woggioni.gbcs.base.GBCS.toUrl import net.woggioni.gbcs.base.GBCS.toUrl
import net.woggioni.gbcs.base.PasswordSecurity.decodePasswordHash
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.debug
import net.woggioni.gbcs.base.info 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.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider
import net.woggioni.jwo.Application
import net.woggioni.jwo.JWO import net.woggioni.jwo.JWO
import net.woggioni.jwo.Tuple2 import net.woggioni.jwo.Tuple2
import java.io.ByteArrayOutputStream import java.io.OutputStream
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.URL
import java.net.URLStreamHandlerFactory
import java.nio.channels.FileChannel import java.nio.channels.FileChannel
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@@ -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 {
} catch (es: SSLPeerUnverifiedException) {
null
}?.takeIf {
it.isNotEmpty() it.isNotEmpty()
}?.let { peerCertificates -> }?.let { peerCertificates ->
val clientCertificate = peerCertificates.first() as X509Certificate val clientCertificate = peerCertificates.first() as X509Certificate
val user = userExtractor?.extract(clientCertificate) val user = userExtractor?.extract(clientCertificate)
val group = groupExtractor?.extract(clientCertificate) val group = groupExtractor?.extract(clientCertificate)
(group?.roles ?: emptySet()) + (user?.roles ?: emptySet()) (group?.roles ?: emptySet()) + (user?.roles ?: emptySet())
} ?: anonymousUserRoles
} catch (es: SSLPeerUnverifiedException) {
anonymousUserRoles
} }
} }
} }
@@ -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,8 +178,12 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
} }
private class ServerInitializer(private val cfg: Configuration) : ChannelInitializer<Channel>() { private class ServerInitializer(
private val cfg: Configuration,
private val eventExecutorGroup: EventExecutorGroup
) : ChannelInitializer<Channel>() {
companion object {
private fun createSslCtx(tls: Configuration.Tls): SslContext { private fun createSslCtx(tls: Configuration.Tls): SslContext {
val keyStore = tls.keyStore val keyStore = tls.keyStore
return if (keyStore == null) { return if (keyStore == null) {
@@ -187,7 +191,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} else { } else {
val javaKeyStore = loadKeystore(keyStore.file, keyStore.password) val javaKeyStore = loadKeystore(keyStore.file, keyStore.password)
val serverKey = javaKeyStore.getKey( val serverKey = javaKeyStore.getKey(
keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray) keyStore.keyAlias, (keyStore.keyPassword ?: "").let(String::toCharArray)
) as PrivateKey ) as PrivateKey
val serverCert: Array<X509Certificate> = val serverCert: Array<X509Certificate> =
Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias)) Arrays.stream(javaKeyStore.getCertificateChain(keyStore.keyAlias))
@@ -196,8 +200,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
SslContextBuilder.forServer(serverKey, *serverCert).apply { SslContextBuilder.forServer(serverKey, *serverCert).apply {
if (tls.isVerifyClients) { if (tls.isVerifyClients) {
clientAuth(ClientAuth.OPTIONAL) clientAuth(ClientAuth.OPTIONAL)
val trustStore = tls.trustStore tls.trustStore?.let { trustStore ->
if (trustStore != null) {
val ts = loadKeystore(trustStore.file, trustStore.password) val ts = loadKeystore(trustStore.file, trustStore.password)
trustManager( trustManager(
ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus) ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus)
@@ -208,11 +211,6 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
} }
private val sslContext: SslContext? = cfg.tls?.let(this::createSslCtx)
private val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors())
companion object {
fun loadKeystore(file: Path, password: String?): KeyStore { fun loadKeystore(file: Path, password: String?): KeyStore {
val ext = JWO.splitExtension(file) val ext = JWO.splitExtension(file)
.map(Tuple2<String, String>::get_2) .map(Tuple2<String, String>::get_2)
@@ -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())
} }
} }
@@ -344,20 +343,12 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
companion object { companion object {
private val log = contextLogger() private val log = contextLogger()
private fun splitPath(req: HttpRequest): Pair<String?, String> {
val uri = req.uri()
val i = uri.lastIndexOf('/')
if (i < 0) throw RuntimeException(String.format("Malformed request URI: '%s'", uri))
return uri.substring(0, i) to uri.substring(i + 1)
}
} }
override fun channelRead0(ctx: ChannelHandlerContext, msg: FullHttpRequest) { override fun channelRead0(ctx: ChannelHandlerContext, msg: FullHttpRequest) {
val keepAlive: Boolean = HttpUtil.isKeepAlive(msg) val keepAlive: Boolean = HttpUtil.isKeepAlive(msg)
val method = msg.method() val method = msg.method()
if (method === HttpMethod.GET) { if (method === HttpMethod.GET) {
// val (prefix, key) = splitPath(msg)
val path = Path.of(msg.uri()) val path = Path.of(msg.uri())
val prefix = path.parent val prefix = path.parent
val key = path.fileName.toString() val key = path.fileName.toString()
@@ -453,46 +444,50 @@ class GradleBuildCacheServer(private val cfg: Configuration) {
} }
class ServerHandle( class ServerHandle(
private val httpChannel: 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 closeFuture: ChannelFuture = httpChannel.channel().closeFuture() private val closeFuture: ChannelFuture = httpChannel.closeFuture()
fun shutdown(): ChannelFuture { fun shutdown(): ChannelFuture {
return httpChannel.channel().close() return httpChannel.close()
} }
override fun close() { override fun close() {
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() 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 {
val threadFactory = if (cfg.isUseVirtualThread) {
Thread.ofVirtual().factory()
} else { } else {
NioEventLoopGroup(0, Executors.newWorkStealingPool()) 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)
} }
@@ -501,96 +496,27 @@ 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 {
private val log by lazy {
contextLogger()
}
private const val PROTOCOL_HANDLER = "java.protocol.handler.pkgs"
private const val HANDLERS_PACKAGE = "net.woggioni.gbcs.url"
val DEFAULT_CONFIGURATION_URL by lazy { "classpath:net/woggioni/gbcs/gbcs-default.xml".toUrl() } val DEFAULT_CONFIGURATION_URL by lazy { "classpath:net/woggioni/gbcs/gbcs-default.xml".toUrl() }
/** fun loadConfiguration(configurationFile: Path): Configuration {
* Reset any cached handlers just in case a jar protocol has already been used. We val doc = Files.newInputStream(configurationFile).use {
* reset the handler by trying to set a null [URLStreamHandlerFactory] which Xml.parseXml(configurationFile.toUri().toURL(), it)
* should have no effect other than clearing the handlers cache.
*/
private fun resetCachedUrlHandlers() {
try {
URL.setURLStreamHandlerFactory(null)
} catch (ex: Error) {
// Ignore
} }
}
fun registerUrlProtocolHandler() {
val handlers = System.getProperty(PROTOCOL_HANDLER, "")
System.setProperty(
PROTOCOL_HANDLER,
if (handlers == null || handlers.isEmpty()) HANDLERS_PACKAGE else "$handlers|$HANDLERS_PACKAGE"
)
resetCachedUrlHandlers()
}
fun loadConfiguration(args: Array<String>): Configuration {
// Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader
val app = Application.builder("gbcs")
.configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR")
.configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir")
.build()
val confDir = app.computeConfigurationDirectory()
val configurationFile = confDir.resolve("gbcs.xml")
if (!Files.exists(configurationFile)) {
log.info {
"Creating default configuration file at '$configurationFile'"
}
Files.createDirectories(confDir)
val defaultConfigurationFileResource = DEFAULT_CONFIGURATION_URL
Files.newOutputStream(configurationFile).use { outputStream ->
defaultConfigurationFileResource.openStream().use { inputStream ->
JWO.copy(inputStream, outputStream)
}
}
}
// val schemaUrl = javaClass.getResource("/net/woggioni/gbcs/gbcs.xsd")
// val schemaUrl = GBCS.CONFIGURATION_SCHEMA_URL
val dbf = Xml.newDocumentBuilderFactory(null)
// dbf.schema = Xml.getSchema(this::class.java.module.getResourceAsStream("/net/woggioni/gbcs/gbcs.xsd"))
// dbf.schema = Xml.getSchema(schemaUrl)
val db = dbf.newDocumentBuilder()
val doc = Files.newInputStream(configurationFile).use(db::parse)
return Parser.parse(doc) return Parser.parse(doc)
} }
@JvmStatic fun dumpConfiguration(conf: Configuration, outputStream: OutputStream) {
fun main(args: Array<String>) { Xml.write(Serializer.serialize(conf), outputStream)
ClasspathUrlStreamHandlerFactoryProvider.install()
val configuration = loadConfiguration(args)
log.debug {
ByteArrayOutputStream().also {
Xml.write(Serializer.serialize(configuration), it)
}.let {
"Server configuration:\n${String(it.toByteArray())}"
} }
}
GradleBuildCacheServer(configuration).run().use {
}
}
}
}
object GraalNativeImageConfiguration { private val log = contextLogger()
@JvmStatic
fun main(args: Array<String>) {
val conf = GradleBuildCacheServer.loadConfiguration(args)
GradleBuildCacheServer(conf).run().use {
Thread.sleep(3000)
it.shutdown()
}
} }
} }

View File

@@ -1,4 +1,4 @@
package net.woggioni.gbcs package net.woggioni.gbcs.auth
import io.netty.buffer.Unpooled import io.netty.buffer.Unpooled
import io.netty.channel.ChannelFutureListener import io.netty.channel.ChannelFutureListener
@@ -12,18 +12,12 @@ import io.netty.handler.codec.http.HttpResponseStatus
import io.netty.handler.codec.http.HttpVersion import io.netty.handler.codec.http.HttpVersion
import io.netty.util.ReferenceCountUtil import io.netty.util.ReferenceCountUtil
import net.woggioni.gbcs.api.Role import net.woggioni.gbcs.api.Role
import java.security.SecureRandom
import java.security.spec.KeySpec
import java.util.Base64
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
abstract class AbstractNettyHttpAuthenticator(private val authorizer : Authorizer) abstract class AbstractNettyHttpAuthenticator(private val authorizer : Authorizer)
: ChannelInboundHandlerAdapter() { : ChannelInboundHandlerAdapter() {
companion object { companion object {
private const val KEY_LENGTH = 256
private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse( private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER).apply { HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER).apply {
headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" headers()[HttpHeaderNames.CONTENT_LENGTH] = "0"
@@ -33,42 +27,6 @@ abstract class AbstractNettyHttpAuthenticator(private val authorizer : Authorize
HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, Unpooled.EMPTY_BUFFER).apply { HttpVersion.HTTP_1_1, HttpResponseStatus.FORBIDDEN, Unpooled.EMPTY_BUFFER).apply {
headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" headers()[HttpHeaderNames.CONTENT_LENGTH] = "0"
} }
private fun concat(arr1: ByteArray, arr2: ByteArray): ByteArray {
val result = ByteArray(arr1.size + arr2.size)
var j = 0
for(element in arr1) {
result[j] = element
j += 1
}
for(element in arr2) {
result[j] = element
j += 1
}
return result
}
fun hashPassword(password : String, salt : String? = null) : String {
val actualSalt = salt?.let(Base64.getDecoder()::decode) ?: SecureRandom().run {
val result = ByteArray(16)
nextBytes(result)
result
}
val spec: KeySpec = PBEKeySpec(password.toCharArray(), actualSalt, 10, KEY_LENGTH)
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
val hash = factory.generateSecret(spec).encoded
return String(Base64.getEncoder().encode(concat(hash, actualSalt)))
}
fun decodePasswordHash(passwordHash : String) : Pair<ByteArray, ByteArray> {
val decoded = Base64.getDecoder().decode(passwordHash)
val hash = ByteArray(KEY_LENGTH / 8)
val salt = ByteArray(decoded.size - KEY_LENGTH / 8)
System.arraycopy(decoded, 0, hash, 0, hash.size)
System.arraycopy(decoded, hash.size, salt, 0, salt.size)
return hash to salt
}
} }

View File

@@ -1,4 +1,4 @@
package net.woggioni.gbcs package net.woggioni.gbcs.auth
import io.netty.handler.codec.http.HttpRequest import io.netty.handler.codec.http.HttpRequest
import net.woggioni.gbcs.api.Role import net.woggioni.gbcs.api.Role

View File

@@ -1,16 +1,18 @@
package net.woggioni.gbcs 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())
try {
validator.validate(clientCertificateChain, params) 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

@@ -1,4 +1,4 @@
package net.woggioni.gbcs package net.woggioni.gbcs.auth
import io.netty.handler.codec.http.HttpMethod import io.netty.handler.codec.http.HttpMethod
import io.netty.handler.codec.http.HttpRequest import io.netty.handler.codec.http.HttpRequest

View File

@@ -3,6 +3,7 @@ package net.woggioni.gbcs.cache
import net.woggioni.gbcs.api.CacheProvider import net.woggioni.gbcs.api.CacheProvider
import net.woggioni.gbcs.base.GBCS import net.woggioni.gbcs.base.GBCS
import net.woggioni.gbcs.base.Xml import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.base.Xml.Companion.renderAttribute
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Element import org.w3c.dom.Element
import java.nio.file.Path import java.nio.file.Path
@@ -18,22 +19,18 @@ class FileSystemCacheProvider : CacheProvider<FileSystemCacheConfiguration> {
override fun getXmlNamespace() = "urn:net.woggioni.gbcs" override fun getXmlNamespace() = "urn:net.woggioni.gbcs"
override fun deserialize(el: Element): FileSystemCacheConfiguration { override fun deserialize(el: Element): FileSystemCacheConfiguration {
val path = el.getAttribute("path") val path = el.renderAttribute("path")
.takeIf(String::isNotEmpty)
?.let(Path::of) ?.let(Path::of)
val maxAge = el.getAttribute("max-age") val maxAge = el.renderAttribute("max-age")
.takeIf(String::isNotEmpty)
?.let(Duration::parse) ?.let(Duration::parse)
?: Duration.ofDays(1) ?: Duration.ofDays(1)
val enableCompression = el.getAttribute("enable-compression") val enableCompression = el.renderAttribute("enable-compression")
.takeIf(String::isNotEmpty)
?.let(String::toBoolean) ?.let(String::toBoolean)
?: true ?: true
val compressionLevel = el.getAttribute("compression-level") val compressionLevel = el.renderAttribute("compression-level")
.takeIf(String::isNotEmpty)
?.let(String::toInt) ?.let(String::toInt)
?: Deflater.DEFAULT_COMPRESSION ?: Deflater.DEFAULT_COMPRESSION
val digestAlgorithm = el.getAttribute("digest").takeIf(String::isNotEmpty) ?: "MD5" val digestAlgorithm = el.renderAttribute("digest") ?: "MD5"
return FileSystemCacheConfiguration( return FileSystemCacheConfiguration(
path, path,

View File

@@ -12,39 +12,40 @@ import net.woggioni.gbcs.api.Configuration.TlsCertificateExtractor
import net.woggioni.gbcs.api.Configuration.TrustStore import net.woggioni.gbcs.api.Configuration.TrustStore
import net.woggioni.gbcs.api.Configuration.User import net.woggioni.gbcs.api.Configuration.User
import net.woggioni.gbcs.api.Role import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.api.exception.ConfigurationException
import net.woggioni.gbcs.base.Xml.Companion.asIterable import net.woggioni.gbcs.base.Xml.Companion.asIterable
import net.woggioni.gbcs.base.Xml.Companion.renderAttribute
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.renderAttribute("path")
val useVirtualThread = root.getAttribute("useVirtualThreads") val useVirtualThread = root.renderAttribute("useVirtualThreads")
.takeIf(String::isNotEmpty) ?.let(String::toBoolean) ?: true
?.let(String::toBoolean) ?: false
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
} }
@@ -53,8 +54,8 @@ object Parser {
} }
"bind" -> { "bind" -> {
host = child.getAttribute("host") host = child.renderAttribute("host") ?: throw ConfigurationException("host attribute is required")
port = Integer.parseInt(child.getAttribute("port")) port = Integer.parseInt(child.renderAttribute("port"))
} }
"cache" -> { "cache" -> {
@@ -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.renderAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern") val pattern = ggchild.renderAttribute("pattern")
tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern)
} }
"user-extractor" -> { "user-extractor" -> {
val attrName = gchild.getAttribute("attribute-name") val attrName = ggchild.renderAttribute("attribute-name")
val pattern = gchild.getAttribute("pattern") val pattern = ggchild.renderAttribute("pattern")
tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) tlsExtractorUser = TlsCertificateExtractor(attrName, pattern)
} }
} }
@@ -98,20 +99,17 @@ object Parser {
} }
"tls" -> { "tls" -> {
val verifyClients = child.getAttribute("verify-clients") val verifyClients = child.renderAttribute("verify-clients")
.takeIf(String::isNotEmpty)
?.let(String::toBoolean) ?: false ?.let(String::toBoolean) ?: false
var keyStore: KeyStore? = null var keyStore: KeyStore? = null
var trustStore: TrustStore? = null var trustStore: TrustStore? = null
for (granChild in child.asIterable()) { for (granChild in child.asIterable()) {
when (granChild.localName) { when (granChild.localName) {
"keystore" -> { "keystore" -> {
val keyStoreFile = Paths.get(granChild.getAttribute("file")) val keyStoreFile = Paths.get(granChild.renderAttribute("file"))
val keyStorePassword = granChild.getAttribute("password") val keyStorePassword = granChild.renderAttribute("password")
.takeIf(String::isNotEmpty) val keyAlias = granChild.renderAttribute("key-alias")
val keyAlias = granChild.getAttribute("key-alias") val keyPassword = granChild.renderAttribute("key-password")
val keyPassword = granChild.getAttribute("key-password")
.takeIf(String::isNotEmpty)
keyStore = KeyStore( keyStore = KeyStore(
keyStoreFile, keyStoreFile,
keyStorePassword, keyStorePassword,
@@ -121,11 +119,9 @@ object Parser {
} }
"truststore" -> { "truststore" -> {
val trustStoreFile = Paths.get(granChild.getAttribute("file")) val trustStoreFile = Paths.get(granChild.renderAttribute("file"))
val trustStorePassword = granChild.getAttribute("password") val trustStorePassword = granChild.renderAttribute("password")
.takeIf(String::isNotEmpty) val checkCertificateStatus = granChild.renderAttribute("check-certificate-status")
val checkCertificateStatus = granChild.getAttribute("check-certificate-status")
.takeIf(String::isNotEmpty)
?.let(String::toBoolean) ?.let(String::toBoolean)
?: false ?: false
trustStore = TrustStore( trustStore = TrustStore(
@@ -151,33 +147,32 @@ 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" it.renderAttribute("ref")
}.map {
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.renderAttribute("name")
val password = el.getAttribute("password").takeIf(String::isNotEmpty) val password = el.renderAttribute("password")
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"
}.map { el -> }.map { el ->
val groupName = el.getAttribute("name") val groupName = el.renderAttribute("name") ?: throw ConfigurationException("Group name is required")
var roles = emptySet<Role>() var roles = emptySet<Role>()
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 +186,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,6 +35,7 @@ object Serializer {
node("authorization") { node("authorization") {
node("users") { node("users") {
for(user in conf.users.values) { for(user in conf.users.values) {
if(user.name.isNotEmpty()) {
node("user") { node("user") {
attr("name", user.name) attr("name", user.name)
user.password?.let { password -> user.password?.let { password ->
@@ -43,6 +44,7 @@ object Serializer {
} }
} }
} }
}
node("groups") { node("groups") {
val groups = conf.users.values.asSequence() val groups = conf.users.values.asSequence()
.flatMap { .flatMap {
@@ -55,10 +57,18 @@ 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) {
if(user.name.isNotEmpty()) {
node("user") { node("user") {
attr("ref", user.name) attr("ref", user.name)
} }
} else {
anonymousUser = user
}
}
if(anonymousUser != null) {
node("anonymous")
} }
} }
} }
@@ -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

@@ -1,8 +1,8 @@
<?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="false" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs="urn:net.woggioni.gbcs" xmlns:gbcs="urn:net.woggioni.gbcs"
xs:schemaLocation="urn:net.woggioni.gbcs classpath:net/woggioni/gbcs/schema/gbcs.xsd"> xs:schemaLocation="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="8080"/>
<cache xs:type="gbcs:fileSystemCacheType" path="/tmp/gbcs" max-age="P7D"/> <cache xs:type="gbcs:fileSystemCacheType" path="/tmp/gbcs" max-age="P7D"/>
<authentication> <authentication>
<none/> <none/>

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,15 +21,14 @@
</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">
<xs:attribute name="host" type="xs:string" use="required"/> <xs:attribute name="host" type="xs:token" use="required"/>
<xs:attribute name="port" type="xs:unsignedShort" use="required"/> <xs:attribute name="port" type="xs:unsignedShort" use="required"/>
</xs:complexType> </xs:complexType>
@@ -52,14 +48,14 @@
<xs:complexType name="tlsCertificateAuthorizationType"> <xs:complexType name="tlsCertificateAuthorizationType">
<xs:sequence> <xs:sequence>
<xs:element name="group-extractor" type="gbcs:X500NameExtractorType"/> <xs:element name="group-extractor" type="gbcs:X500NameExtractorType" minOccurs="0"/>
<xs:element name="user-extractor" type="gbcs:X500NameExtractorType"/> <xs:element name="user-extractor" type="gbcs:X500NameExtractorType" minOccurs="0"/>
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
<xs:complexType name="X500NameExtractorType"> <xs:complexType name="X500NameExtractorType">
<xs:attribute name="attribute-name" type="xs:string"/> <xs:attribute name="attribute-name" type="xs:token"/>
<xs:attribute name="pattern" type="xs:string"/> <xs:attribute name="pattern" type="xs:token"/>
</xs:complexType> </xs:complexType>
<xs:complexType name="authorizationType"> <xs:complexType name="authorizationType">
@@ -89,7 +85,7 @@
</xs:complexType> </xs:complexType>
<xs:complexType name="userType"> <xs:complexType name="userType">
<xs:attribute name="name" type="xs:string" use="required"/> <xs:attribute name="name" type="xs:token" use="required"/>
<xs:attribute name="password" type="xs:string" use="optional"/> <xs:attribute name="password" type="xs:string" use="optional"/>
</xs:complexType> </xs:complexType>
@@ -109,11 +105,11 @@
</xs:element> </xs:element>
<xs:element name="roles" type="gbcs:rolesType" maxOccurs="1" minOccurs="0"/> <xs:element name="roles" type="gbcs:rolesType" maxOccurs="1" minOccurs="0"/>
</xs:sequence> </xs:sequence>
<xs:attribute name="name" type="xs:string"/> <xs:attribute name="name" type="xs:token"/>
</xs:complexType> </xs:complexType>
<xs:simpleType name="role" final="restriction" > <xs:simpleType name="role" final="restriction" >
<xs:restriction base="xs:string"> <xs:restriction base="xs:token">
<xs:enumeration value="READER" /> <xs:enumeration value="READER" />
<xs:enumeration value="WRITER" /> <xs:enumeration value="WRITER" />
</xs:restriction> </xs:restriction>
@@ -131,6 +127,7 @@
<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,30 @@
package net.woggioni.gbcs.utils;
import net.woggioni.jwo.JWO;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
public class NetworkUtils {
private static final int MAX_ATTEMPTS = 50;
public static int getFreePort() {
int count = 0;
while(count < MAX_ATTEMPTS) {
try (ServerSocket serverSocket = new ServerSocket(0, 50, InetAddress.getLocalHost())) {
final var candidate = serverSocket.getLocalPort();
if (candidate > 0) {
return candidate;
} else {
JWO.newThrowable(RuntimeException.class, "Got invalid port number: %d", candidate);
throw new RuntimeException("Error trying to find an open port");
}
} catch (IOException ignored) {
++count;
}
}
throw new RuntimeException("Error trying to find an open port");
}
}

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

@@ -1,91 +1,29 @@
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.AbstractNettyHttpAuthenticator.Companion.hashPassword
import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.base.Xml
import net.woggioni.gbcs.api.Configuration import net.woggioni.gbcs.api.Configuration
import net.woggioni.gbcs.cache.FileSystemCacheConfiguration import net.woggioni.gbcs.api.Role
import net.woggioni.gbcs.configuration.Serializer import net.woggioni.gbcs.base.PasswordSecurity.hashPassword
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.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(
private val random = Random(101325)
private val keyValuePair = newEntry(random)
private val serverPath = "gbcs"
override fun setUp() {
this.cacheDir = testDir.resolve("cache")
val readersGroup = Configuration.Group("readers", setOf(Role.Reader))
val writersGroup = Configuration.Group("writers", setOf(Role.Writer))
cfg = Configuration(
"127.0.0.1",
ServerSocket(0).localPort + 1,
serverPath,
listOf(
Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)), Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)),
Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)), Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)),
Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)) Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)),
).asSequence().map { it.name to it}.toMap(), Configuration.User("", null, setOf(readersGroup))
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)
@@ -98,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
@@ -177,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,10 +1,10 @@
package net.woggioni.gbcs.test package net.woggioni.gbcs.test
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
import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
@@ -18,11 +18,12 @@ 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
fun test(configurationUrl: String, @TempDir testDir: Path) { fun test(configurationUrl: String, @TempDir testDir: Path) {
ClasspathUrlStreamHandlerFactoryProvider.install() GbcsUrlStreamHandlerFactory.install()
val doc = Xml.parseXml(configurationUrl.toUrl()) val doc = Xml.parseXml(configurationUrl.toUrl())
val cfg = Parser.parse(doc) val cfg = Parser.parse(doc)
val configFile = testDir.resolve("gbcs.xml") val configFile = testDir.resolve("gbcs.xml")

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,14 +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 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
@@ -32,7 +32,7 @@ class NoAuthServerTest : AbstractServerTest() {
this.cacheDir = testDir.resolve("cache") this.cacheDir = testDir.resolve("cache")
cfg = Configuration( cfg = Configuration(
"127.0.0.1", "127.0.0.1",
ServerSocket(0).localPort + 1, NetworkUtils.getFreePort(),
serverPath, serverPath,
emptyMap(), emptyMap(),
emptyMap(), emptyMap(),
@@ -104,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,213 +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 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",
ServerSocket(0).localPort + 1,
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 {
@@ -225,9 +38,8 @@ 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 {
Role.Writer in it.roles Role.Writer in it.roles
@@ -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,9 +54,8 @@ 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 {
Role.Writer in it.roles Role.Writer in it.roles
@@ -254,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())
@@ -262,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 {
@@ -271,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> =
@@ -281,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 {
@@ -290,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

@@ -1,7 +1,7 @@
<?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="false" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gbcs="urn:net.woggioni.gbcs" xmlns:gbcs="urn:net.woggioni.gbcs"
xs:schemaLocation="urn:net.woggioni.gbcs classpath:net/woggioni/gbcs/schema/gbcs.xsd"> xs:schemaLocation="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:fileSystemCacheType" path="/tmp/gbcs" max-age="P7D"/> <cache xs:type="gbcs:fileSystemCacheType" path="/tmp/gbcs" max-age="P7D"/>
<authentication> <authentication>

View File

@@ -2,9 +2,9 @@
<gbcs:server useVirtualThreads="false" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" <gbcs:server useVirtualThreads="false" 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 classpath:net/woggioni/gbcs/schema/gbcs.xsd urn:net.woggioni.gbcs-memcached classpath:net/woggioni/gbcs/memcached/schema/gbcs-memcached.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>