diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 9522b22..75acf5d 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -9,9 +9,53 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: graalvm + java-version: 21 - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 - name: Execute Gradle build env: PUBLISHER_TOKEN: ${{ secrets.PUBLISHER_TOKEN }} - run: ./gradlew build publish \ No newline at end of file + run: ./gradlew build publish + build-docker: + name: "Build Docker images" + runs-on: hostinger + steps: + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.4.0 + with: + driver: docker-container + - + name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: gitea.woggioni.net + username: woggioni + password: ${{ secrets.PUBLISHER_TOKEN }} + - + name: Build and push gbcs images + uses: docker/build-push-action@v6 + with: + push: true + pull: true + tags: | + "gitea.woggioni.net/woggioni/gbcs:slim" + 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 + target: release + - + name: Build and push gbcs memcached image + uses: docker/build-push-action@v6 + with: + push: true + pull: true + tags: | + "gitea.woggioni.net/woggioni/gbcs:latest" + "gitea.woggioni.net/woggioni/gbcs:memcached" + 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 + target: release-memcached diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26f791e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM container-registry.oracle.com/graalvm/native-image:21 AS oracle + +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 \ No newline at end of file diff --git a/build.gradle b/build.gradle index bf20066..4ea9799 100644 --- a/build.gradle +++ b/build.gradle @@ -1,40 +1,198 @@ plugins { id 'java-library' + id 'jvm-test-suite' alias catalog.plugins.kotlin.jvm alias catalog.plugins.envelope alias catalog.plugins.sambal + alias catalog.plugins.lombok + alias catalog.plugins.graalvm.native.image 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 -group = 'net.woggioni' -version = project.currentTag ?: "${getProperty('gbcs.version')}.${project.gitRevision[0..10]}" +allprojects { + group = 'net.woggioni' + + if(project.currentTag.isPresent()) { + version = project.currentTag + } else { + version = project.gitRevision.map { gitRevision -> + "${getProperty('gbcs.version')}.${gitRevision[0..10]}" + } + } + + repositories { + maven { + url = getProperty('gitea.maven.url') + content { + includeModule 'net.woggioni', 'jwo' + includeModule 'net.woggioni', 'xmemcached' + includeGroup 'com.lys' + } + } + mavenCentral() + } + + pluginManager.withPlugin('java-library') { + java { + withSourcesJar() + modularity.inferModulePath = true + toolchain { + languageVersion = JavaLanguageVersion.of(21) + vendor = JvmVendorSpec.ORACLE + } + } + + test { + useJUnitPlatform() + } + + tasks.withType(JavaCompile) { + modularity.inferModulePath = true + options.release = 21 + } + } + + pluginManager.withPlugin(catalog.plugins.kotlin.jvm.get().pluginId) { + tasks.withType(KotlinCompile.class) { + compilerOptions.jvmTarget = JvmTarget.JVM_21 + } + } + + pluginManager.withPlugin(catalog.plugins.lombok.get().pluginId) { + lombok { + version = catalog.versions.lombok + } + } + + pluginManager.withPlugin('maven-publish') { + + publishing { + repositories { + maven { + name = "Gitea" + url = uri(getProperty('gitea.maven.url')) + + credentials(HttpHeaderCredentials) { + name = "Authorization" + value = "token ${System.getenv()["PUBLISHER_TOKEN"]}" + } + + authentication { + header(HttpHeaderAuthentication) + } + } + } + } + } +} + +Property 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 +} + + +//tasks.named(JavaPlugin.COMPILE_TEST_JAVA_TASK_NAME, JavaCompile) { +// options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs.test=' + project.sourceSets.test.output.asPath +// classpath = configurations.testCompileClasspath + files(tasks.jar.archiveFile) +// modularity.inferModulePath = true +// javaModuleDetector +//} + +//tasks.named(JavaPlugin.TEST_TASK_NAME, JavaForkOptions) { +// classpath = configurations.testRuntimeClasspath + project.files(tasks.jar.archiveFile) + project.sourceSets.test.output +// jvmArgumentProviders << new CommandLineArgumentProvider() { +// @CompileClasspath +// def kotlinClassesMain = kotlin.sourceSets.main.collect { it.kotlin.classesDirectory } +// +// @CompileClasspath +// def kotlinClassesTest = kotlin.sourceSets.main.collect { it.kotlin.classesDirectory } + +// @Override +// Iterable asArguments() { +// return [ +// "--patch-module", +// 'net.woggioni.gbcs=' + kotlinClassesMain.collect { it.get().asFile.absolutePath }, +// "--patch-module", +// 'net.woggioni.gbcs.test=' + project.sourceSets.test.output.asPath, +// ] +// } +// } +//} + +//configurations { +// integrationTestImplementation { +// attributes { +// attribute(LIBRARY_ELEMENTS_ATTRIBUTE, project.objects.named(LibraryElements.class, JAR)) +// } +// } +// +// integrationTestCompileClasspath { +// attributes { +// attribute(LIBRARY_ELEMENTS_ATTRIBUTE, project.objects.named(LibraryElements.class, JAR)) +// } +// } +//} +// envelopeJar { mainModule = 'net.woggioni.gbcs' - mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer' + mainClass = mainClassName + + extraClasspath = ["plugins"] } -repositories { - maven { - url = getProperty('gitea.maven.url') - content { - includeModule 'net.woggioni', 'jwo' - includeGroup 'com.lys' - } - } - mavenCentral() -} +// +//testing { +// suites { +// test { +// useJUnitJupiter(catalog.versions.junit.jupiter.get()) +// } +// +// integrationTest(JvmTestSuite) { +// dependencies { +// implementation project() +// implementation catalog.bcprov.jdk18on +// implementation catalog.bcpkix.jdk18on +// annotationProcessor catalog.lombok +// compileOnly catalog.lombok +// implementation project('gbcs-base') +// implementation project('gbcs-api') +// +// runtimeOnly project("gbcs-memcached") +// } +// +// targets { +// all { +// testTask.configure { +// shouldRunAfter(test) +// } +// } +// } +// } +// } +//} dependencies { implementation catalog.jwo implementation catalog.slf4j.api implementation catalog.netty.codec.http + implementation project('gbcs-base') + implementation project('gbcs-api') + // runtimeOnly catalog.slf4j.jdk14 runtimeOnly catalog.logback.classic @@ -43,40 +201,14 @@ dependencies { testImplementation catalog.junit.jupiter.api testImplementation catalog.junit.jupiter.params testRuntimeOnly catalog.junit.jupiter.engine -} -java { - withSourcesJar() - modularity.inferModulePath = true - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} - -test { - useJUnitPlatform() -} - -tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { - options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs=' + project.sourceSets.main.output.asPath - options.javaModuleVersion = provider { version } - options.javaModuleMainClass = envelopeJar.mainClass -} - -tasks.withType(JavaCompile) { - modularity.inferModulePath = true - options.release = 21 -} - - -tasks.named("compileKotlin", KotlinCompile.class) { - compilerOptions.jvmTarget = JvmTarget.JVM_21 + testRuntimeOnly project("gbcs-memcached") } Provider 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:logback.xml' + systemProperties['logback.configurationFile'] = 'classpath:net/woggioni/gbcs/logback.xml' } @@ -85,25 +217,37 @@ def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get( 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 { - repositories { - maven { - name = "Gitea" - url = uri(getProperty('gitea.maven.url')) - - credentials(HttpHeaderCredentials) { - name = "Authorization" - value = "token ${System.getenv()["PUBLISHER_TOKEN"]}" - } - - authentication { - header(HttpHeaderAuthentication) - } - } - } publications { maven(MavenPublication) { artifact envelopeJarArtifact } } -} \ No newline at end of file +} + + +//tasks.named('check') { +// dependsOn(testing.suites.integrationTest) +//} +// +//tasks.named("integrationTest", JavaForkOptions) { +// jvmArgumentProviders << new CommandLineArgumentProvider() { +// @Override +// Iterable asArguments() { +// return [ +// "--patch-module", +// 'net.woggioni.gbcs.test=' + project.sourceSets.integrationTest.output.asPath, +// ] +// } +// } +//} \ No newline at end of file diff --git a/conf/gbcs-memcached.xml b/conf/gbcs-memcached.xml new file mode 100644 index 0000000..3690025 --- /dev/null +++ b/conf/gbcs-memcached.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0c0ba68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +networks: + default: + external: false + ipam: + driver: default + config: + - subnet: 172.118.0.0/16 + ip_range: 172.118.0.0/16 + gateway: 172.118.0.254 +services: + gbcs: + build: + context: . + target: compose + container_name: gbcs + restart: unless-stopped + ports: + - "127.0.0.1:8080:13080" + - "[::1]:8080:13080" + depends_on: + memcached: + condition: service_started + deploy: + resources: + limits: + cpus: "2.00" + memory: 256M + memcached: + image: memcached + container_name: memcached + restart: unless-stopped + command: -I 64m -m 900m + deploy: + resources: + limits: + cpus: "1.00" + memory: 1G diff --git a/gbcs-api/build.gradle b/gbcs-api/build.gradle new file mode 100644 index 0000000..e40611f --- /dev/null +++ b/gbcs-api/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'java-library' + alias catalog.plugins.lombok +} + +dependencies { +} + +tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { + options.javaModuleVersion = version +} diff --git a/gbcs-api/src/main/java/module-info.java b/gbcs-api/src/main/java/module-info.java new file mode 100644 index 0000000..e825602 --- /dev/null +++ b/gbcs-api/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module net.woggioni.gbcs.api { + requires static lombok; + requires java.xml; + exports net.woggioni.gbcs.api; + exports net.woggioni.gbcs.api.exception; +} \ No newline at end of file diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/Cache.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Cache.java new file mode 100644 index 0000000..c655bfa --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Cache.java @@ -0,0 +1,11 @@ +package net.woggioni.gbcs.api; + +import net.woggioni.gbcs.api.exception.ContentTooLargeException; + +import java.nio.channels.ReadableByteChannel; + +public interface Cache extends AutoCloseable { + ReadableByteChannel get(String key); + + void put(String key, byte[] content) throws ContentTooLargeException; +} diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/CacheProvider.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/CacheProvider.java new file mode 100644 index 0000000..45cdef4 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/CacheProvider.java @@ -0,0 +1,17 @@ +package net.woggioni.gbcs.api; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +public interface CacheProvider { + + String getXmlSchemaLocation(); + + String getXmlNamespace(); + + String getXmlType(); + + T deserialize(Element parent); + + Element serialize(Document doc, T cache); +} diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/Configuration.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Configuration.java new file mode 100644 index 0000000..d895c72 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Configuration.java @@ -0,0 +1,138 @@ +package net.woggioni.gbcs.api; + + +import lombok.Value; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Value +public class Configuration { + String host; + int port; + String serverPath; + Map users; + Map groups; + Cache cache; + Authentication authentication; + Tls tls; + boolean useVirtualThread; + + @Value + public static class Group { + String name; + Set roles; + + @Override + public int hashCode() { + return name.hashCode(); + } + } + + @Value + public static class User { + String name; + String password; + Set groups; + + @Override + public int hashCode() { + return name.hashCode(); + } + + public Set getRoles() { + return groups.stream() + .flatMap(group -> group.getRoles().stream()) + .collect(Collectors.toSet()); + } + } + + @FunctionalInterface + public interface UserExtractor { + User extract(X509Certificate cert); + } + + @FunctionalInterface + public interface GroupExtractor { + Group extract(X509Certificate cert); + } + + @Value + public static class Tls { + KeyStore keyStore; + TrustStore trustStore; + boolean verifyClients; + } + + @Value + public static class KeyStore { + Path file; + String password; + String keyAlias; + String keyPassword; + } + + @Value + public static class TrustStore { + Path file; + String password; + boolean checkCertificateStatus; + } + + @Value + public static class TlsCertificateExtractor { + String rdnType; + String pattern; + } + + public interface Authentication {} + + public static class BasicAuthentication implements Authentication {} + + @Value + public static class ClientCertificateAuthentication implements Authentication { + TlsCertificateExtractor userExtractor; + TlsCertificateExtractor groupExtractor; + } + + public interface Cache { + net.woggioni.gbcs.api.Cache materialize(); + String getNamespaceURI(); + String getTypeName(); + } + +// @Value +// public static class FileSystemCache implements Cache { +// Path root; +// Duration maxAge; +// } + + public static Configuration of( + String host, + int port, + String serverPath, + Map users, + Map groups, + Cache cache, + Authentication authentication, + Tls tls, + boolean useVirtualThread + ) { + return new Configuration( + host, + port, + serverPath != null && !serverPath.isEmpty() && !serverPath.equals("/") ? serverPath : null, + users, + groups, + cache, + authentication, + tls, + useVirtualThread + ); + } +} \ No newline at end of file diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/ConfigurationParser.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/ConfigurationParser.java new file mode 100644 index 0000000..583e070 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/ConfigurationParser.java @@ -0,0 +1,237 @@ +package net.woggioni.gbcs.api; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class ConfigurationParser { + + public static Configuration parse(Document document) { + Element root = document.getDocumentElement(); + Configuration.Cache cache = null; + String host = "127.0.0.1"; + int port = 11080; + Map users = Collections.emptyMap(); + Map groups = Collections.emptyMap(); + Configuration.Tls tls = null; + String serverPath = root.getAttribute("path"); + boolean useVirtualThread = !root.getAttribute("useVirtualThreads").isEmpty() && + Boolean.parseBoolean(root.getAttribute("useVirtualThreads")); + Configuration.Authentication authentication = null; + + for (Node child : iterableOf(root)) { + switch (child.getNodeName()) { + case "authorization": + for (Node gchild : iterableOf((Element) child)) { + switch (gchild.getNodeName()) { + case "users": + users = parseUsers((Element) gchild); + break; + case "groups": + Map.Entry, Map> pair = parseGroups((Element) gchild, users); + users = pair.getKey(); + groups = pair.getValue(); + break; + } + } + break; + + case "bind": + Element bindEl = (Element) child; + host = bindEl.getAttribute("host"); + port = Integer.parseInt(bindEl.getAttribute("port")); + break; + + case "cache": + Element cacheEl = (Element) child; + cacheEl.getAttributeNode("xs:type").getSchemaTypeInfo(); + if ("gbcs:fileSystemCacheType".equals(cacheEl.getAttribute("xs:type"))) { + String cacheFolder = cacheEl.getAttribute("path"); + Path cachePath = !cacheFolder.isEmpty() + ? Paths.get(cacheFolder) + : Paths.get(System.getProperty("user.home")).resolve(".gbcs"); + + String maxAgeStr = cacheEl.getAttribute("max-age"); + Duration maxAge = !maxAgeStr.isEmpty() + ? Duration.parse(maxAgeStr) + : Duration.ofDays(1); + +// cache = new Configuration.FileSystemCache(cachePath, maxAge); + } + break; + + case "authentication": + for (Node gchild : iterableOf((Element) child)) { + switch (gchild.getNodeName()) { + case "basic": + authentication = new Configuration.BasicAuthentication(); + break; + case "client-certificate": + Configuration.TlsCertificateExtractor tlsExtractorUser = null; + Configuration.TlsCertificateExtractor tlsExtractorGroup = null; + + for (Node authChild : iterableOf((Element) gchild)) { + Element authEl = (Element) authChild; + switch (authChild.getNodeName()) { + case "group-extractor": + String groupAttrName = authEl.getAttribute("attribute-name"); + String groupPattern = authEl.getAttribute("pattern"); + tlsExtractorGroup = new Configuration.TlsCertificateExtractor(groupAttrName, groupPattern); + break; + case "user-extractor": + String userAttrName = authEl.getAttribute("attribute-name"); + String userPattern = authEl.getAttribute("pattern"); + tlsExtractorUser = new Configuration.TlsCertificateExtractor(userAttrName, userPattern); + break; + } + } + authentication = new Configuration.ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup); + break; + } + } + break; + + case "tls": + Element tlsEl = (Element) child; + boolean verifyClients = !tlsEl.getAttribute("verify-clients").isEmpty() && + Boolean.parseBoolean(tlsEl.getAttribute("verify-clients")); + Configuration.KeyStore keyStore = null; + Configuration.TrustStore trustStore = null; + + for (Node gchild : iterableOf(tlsEl)) { + Element tlsChild = (Element) gchild; + switch (gchild.getNodeName()) { + case "keystore": + Path keyStoreFile = Paths.get(tlsChild.getAttribute("file")); + String keyStorePassword = !tlsChild.getAttribute("password").isEmpty() + ? tlsChild.getAttribute("password") + : null; + String keyAlias = tlsChild.getAttribute("key-alias"); + String keyPassword = !tlsChild.getAttribute("key-password").isEmpty() + ? tlsChild.getAttribute("key-password") + : null; + keyStore = new Configuration.KeyStore(keyStoreFile, keyStorePassword, keyAlias, keyPassword); + break; + + case "truststore": + Path trustStoreFile = Paths.get(tlsChild.getAttribute("file")); + String trustStorePassword = !tlsChild.getAttribute("password").isEmpty() + ? tlsChild.getAttribute("password") + : null; + boolean checkCertificateStatus = !tlsChild.getAttribute("check-certificate-status").isEmpty() && + Boolean.parseBoolean(tlsChild.getAttribute("check-certificate-status")); + trustStore = new Configuration.TrustStore(trustStoreFile, trustStorePassword, checkCertificateStatus); + break; + } + } + tls = new Configuration.Tls(keyStore, trustStore, verifyClients); + break; + } + } + + return Configuration.of(host, port, serverPath, users, groups, cache, authentication, tls, useVirtualThread); + } + + private static Set parseRoles(Element root) { + return StreamSupport.stream(iterableOf(root).spliterator(), false) + .map(node -> switch (node.getNodeName()) { + case "reader" -> Role.Reader; + case "writer" -> Role.Writer; + default -> throw new UnsupportedOperationException("Illegal node '" + node.getNodeName() + "'"); + }) + .collect(Collectors.toSet()); + } + + private static Set parseUserRefs(Element root) { + return StreamSupport.stream(iterableOf(root).spliterator(), false) + .filter(node -> "user".equals(node.getNodeName())) + .map(node -> ((Element) node).getAttribute("ref")) + .collect(Collectors.toSet()); + } + + private static Map parseUsers(Element root) { + return StreamSupport.stream(iterableOf(root).spliterator(), false) + .filter(node -> "user".equals(node.getNodeName())) + .map(node -> { + Element el = (Element) node; + String username = el.getAttribute("name"); + String password = !el.getAttribute("password").isEmpty() ? el.getAttribute("password") : null; + return new AbstractMap.SimpleEntry<>(username, new Configuration.User(username, password, Collections.emptySet())); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static Map.Entry, Map> parseGroups(Element root, Map knownUsers) { + Map> userGroups = new HashMap<>(); + Map groups = StreamSupport.stream(iterableOf(root).spliterator(), false) + .filter(node -> "group".equals(node.getNodeName())) + .map(node -> { + Element el = (Element) node; + String groupName = el.getAttribute("name"); + Set roles = Collections.emptySet(); + + for (Node child : iterableOf(el)) { + switch (child.getNodeName()) { + case "users": + parseUserRefs((Element) child).stream() + .map(knownUsers::get) + .filter(Objects::nonNull) + .forEach(user -> + userGroups.computeIfAbsent(user.getName(), k -> new HashSet<>()) + .add(groupName)); + break; + case "roles": + roles = parseRoles((Element) child); + break; + } + } + return new AbstractMap.SimpleEntry<>(groupName, new Configuration.Group(groupName, roles)); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Map users = knownUsers.entrySet().stream() + .map(entry -> { + String name = entry.getKey(); + Configuration.User user = entry.getValue(); + Set userGroupSet = userGroups.getOrDefault(name, Collections.emptySet()).stream() + .map(groups::get) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return new AbstractMap.SimpleEntry<>(name, new Configuration.User(name, user.getPassword(), userGroupSet)); + }) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return new AbstractMap.SimpleEntry<>(users, groups); + } + + private static Iterable iterableOf(Element element) { + return () -> new Iterator() { + private Node current = element.getFirstChild(); + + @Override + public boolean hasNext() { + while (current != null && !(current instanceof Element)) { + current = current.getNextSibling(); + } + return current != null; + } + + @Override + public Node next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + Node result = current; + current = current.getNextSibling(); + return result; + } + }; + } +} diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/Role.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Role.java new file mode 100644 index 0000000..6335aa5 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/Role.java @@ -0,0 +1,5 @@ +package net.woggioni.gbcs.api; + +public enum Role { + Reader, Writer +} \ No newline at end of file diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/ContentTooLargeException.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/ContentTooLargeException.java new file mode 100644 index 0000000..468cc01 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/ContentTooLargeException.java @@ -0,0 +1,7 @@ +package net.woggioni.gbcs.api.exception; + +public class ContentTooLargeException extends GbcsException { + public ContentTooLargeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/GbcsException.java b/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/GbcsException.java new file mode 100644 index 0000000..66edaf9 --- /dev/null +++ b/gbcs-api/src/main/java/net/woggioni/gbcs/api/exception/GbcsException.java @@ -0,0 +1,7 @@ +package net.woggioni.gbcs.api.exception; + +public class GbcsException extends RuntimeException { + public GbcsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/gbcs-base/build.gradle b/gbcs-base/build.gradle new file mode 100644 index 0000000..3445dfa --- /dev/null +++ b/gbcs-base/build.gradle @@ -0,0 +1,21 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'java-library' + alias catalog.plugins.kotlin.jvm +} + +dependencies { + compileOnly project(':gbcs-api') + compileOnly catalog.slf4j.api +} + +tasks.named(JavaPlugin.COMPILE_JAVA_TASK_NAME, JavaCompile) { + options.compilerArgs << '--patch-module' << 'net.woggioni.gbcs.base=' + project.sourceSets.main.output.asPath + options.javaModuleVersion = version +} + +tasks.named("compileKotlin", KotlinCompile.class) { + compilerOptions.jvmTarget = JvmTarget.JVM_21 +} diff --git a/gbcs-base/src/main/java/module-info.java b/gbcs-base/src/main/java/module-info.java new file mode 100644 index 0000000..2fa3505 --- /dev/null +++ b/gbcs-base/src/main/java/module-info.java @@ -0,0 +1,8 @@ +module net.woggioni.gbcs.base { + requires java.xml; + requires java.logging; + requires org.slf4j; + requires kotlin.stdlib; + + exports net.woggioni.gbcs.base; +} \ No newline at end of file diff --git a/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/GBCS.kt b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/GBCS.kt new file mode 100644 index 0000000..8edd0e1 --- /dev/null +++ b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/GBCS.kt @@ -0,0 +1,12 @@ +package net.woggioni.gbcs.base + +import java.net.URI +import java.net.URL + +object GBCS { + fun String.toUrl() : URL = URL.of(URI(this), null) + + const val GBCS_NAMESPACE_URI: String = "urn:net.woggioni.gbcs" + const val GBCS_PREFIX: String = "gbcs" + const val XML_SCHEMA_NAMESPACE_URI = "http://www.w3.org/2001/XMLSchema-instance" +} \ No newline at end of file diff --git a/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/HostAndPort.kt b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/HostAndPort.kt new file mode 100644 index 0000000..8566065 --- /dev/null +++ b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/HostAndPort.kt @@ -0,0 +1,8 @@ +package net.woggioni.gbcs.base + + +data class HostAndPort(val host: String, val port: Int = 0) { + override fun toString(): String { + return "$host:$port" + } +} \ No newline at end of file diff --git a/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Logging.kt b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Logging.kt new file mode 100644 index 0000000..b7a9aef --- /dev/null +++ b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Logging.kt @@ -0,0 +1,104 @@ +package net.woggioni.gbcs.base + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path +import java.util.logging.LogManager + +inline fun T.contextLogger() = LoggerFactory.getLogger(T::class.java) + +inline fun Logger.traceParam(messageBuilder : () -> Pair>) { + if(isTraceEnabled) { + val (format, params) = messageBuilder() + trace(format, params) + } +} + +inline fun Logger.debugParam(messageBuilder : () -> Pair>) { + if(isDebugEnabled) { + val (format, params) = messageBuilder() + info(format, params) + } +} + +inline fun Logger.infoParam(messageBuilder : () -> Pair>) { + if(isInfoEnabled) { + val (format, params) = messageBuilder() + info(format, params) + } +} + +inline fun Logger.warnParam(messageBuilder : () -> Pair>) { + if(isWarnEnabled) { + val (format, params) = messageBuilder() + warn(format, params) + } +} + +inline fun Logger.errorParam(messageBuilder : () -> Pair>) { + if(isErrorEnabled) { + val (format, params) = messageBuilder() + error(format, params) + } +} + + +inline fun log(log : Logger, + filter : Logger.() -> Boolean, + loggerMethod : Logger.(String) -> Unit, messageBuilder : () -> String) { + if(log.filter()) { + log.loggerMethod(messageBuilder()) + } +} + +inline fun Logger.trace(messageBuilder : () -> String) { + if(isTraceEnabled) { + trace(messageBuilder()) + } +} + +inline fun Logger.debug(messageBuilder : () -> String) { + if(isDebugEnabled) { + debug(messageBuilder()) + } +} + +inline fun Logger.info(messageBuilder : () -> String) { + if(isInfoEnabled) { + info(messageBuilder()) + } +} + +inline fun Logger.warn(messageBuilder : () -> String) { + if(isWarnEnabled) { + warn(messageBuilder()) + } +} + +inline fun Logger.error(messageBuilder : () -> String) { + if(isErrorEnabled) { + error(messageBuilder()) + } +} + + +class LoggingConfig { + + init { + val logManager = LogManager.getLogManager() + System.getProperty("log.config.source")?.let withSource@ { source -> + val urls = LoggingConfig::class.java.classLoader.getResources(source) + while(urls.hasMoreElements()) { + val url = urls.nextElement() + url.openStream().use { inputStream -> + logManager.readConfiguration(inputStream) + return@withSource + } + } + Path.of(source).takeIf(Files::exists) + ?.let(Files::newInputStream) + ?.use(logManager::readConfiguration) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Xml.kt b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Xml.kt similarity index 89% rename from src/main/kotlin/net/woggioni/gbcs/Xml.kt rename to gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Xml.kt index 3f5cddd..82e8e47 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Xml.kt +++ b/gbcs-base/src/main/kotlin/net/woggioni/gbcs/base/Xml.kt @@ -1,4 +1,4 @@ -package net.woggioni.gbcs +package net.woggioni.gbcs.base import org.slf4j.LoggerFactory import org.w3c.dom.Document @@ -72,7 +72,7 @@ class ElementIterator(parent: Element, name: String? = null) : Iterator } } -class Xml(private val doc: Document, val element: Element) { +class Xml(val doc: Document, val element: Element) { class ErrorHandler(private val fileURL: URL) : ErrHandler { @@ -127,7 +127,7 @@ class Xml(private val doc: Document, val element: Element) { fun getSchema(schema: URL): Schema { val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) - sf.setFeature(FEATURE_SECURE_PROCESSING, true) + sf.setFeature(FEATURE_SECURE_PROCESSING, false) // disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) // disableProperty(sf, ACCESS_EXTERNAL_DTD) sf.errorHandler = ErrorHandler(schema) @@ -144,13 +144,15 @@ class Xml(private val doc: Document, val element: Element) { fun newDocumentBuilderFactory(schemaResourceURL: URL?): DocumentBuilderFactory { val dbf = DocumentBuilderFactory.newInstance() - dbf.setFeature(FEATURE_SECURE_PROCESSING, true) - disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA) + dbf.setFeature(FEATURE_SECURE_PROCESSING, false) +// disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA) + dbf.setAttribute(ACCESS_EXTERNAL_SCHEMA, "all") disableProperty(dbf, ACCESS_EXTERNAL_DTD) - dbf.isExpandEntityReferences = false + dbf.isExpandEntityReferences = true dbf.isIgnoringComments = true dbf.isNamespaceAware = true dbf.isValidating = false + dbf.setFeature("http://apache.org/xml/features/validation/schema", true); schemaResourceURL?.let { dbf.schema = getSchema(it) } @@ -233,10 +235,11 @@ class Xml(private val doc: Document, val element: Element) { fun node( name: String, + namespaceURI : String? = null, attrs: Map = emptyMap(), cb: Xml.(el: Element) -> Unit = {} ): Element { - val child = doc.createElement(name) + val child = doc.createElementNS(namespaceURI, name) for ((key, value) in attrs) { child.setAttribute(key, value) } @@ -248,12 +251,16 @@ class Xml(private val doc: Document, val element: Element) { } - fun attrs(vararg attributes: Pair) { - for (attr in attributes) element.setAttribute(attr.first, attr.second) - } +// fun attrs(vararg attributes: Pair) { +// for (attr in attributes) element.setAttribute(attr.first, attr.second) +// } +// +// fun attrs(vararg attributes: Pair, String>) { +// for (attr in attributes) element.setAttributeNS(attr.first.first, attr.first.second, attr.second) +// } - fun attr(key: String, value: String) { - element.setAttribute(key, value) + fun attr(key: String, value: String, namespaceURI : String? = null) { + element.setAttributeNS(namespaceURI, key, value) } fun text(txt: String) { diff --git a/gbcs-memcached/build.gradle b/gbcs-memcached/build.gradle new file mode 100644 index 0000000..2d50461 --- /dev/null +++ b/gbcs-memcached/build.gradle @@ -0,0 +1,64 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + id 'java-library' + id 'maven-publish' + alias catalog.plugins.kotlin.jvm +} + +configurations { + bundle { + extendsFrom runtimeClasspath + canBeResolved = true + canBeConsumed = false + visible = false + + resolutionStrategy { + dependencies { + exclude group: 'org.slf4j', module: 'slf4j-api' + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib' + exclude group: 'org.jetbrains', module: 'annotations' + } + } + } +} +dependencies { + compileOnly project(':gbcs-base') + compileOnly project(':gbcs-api') + compileOnly catalog.jwo + 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 bundleTask = tasks.register("bundle", Tar) { + from(tasks.named(JavaPlugin.JAR_TASK_NAME)) + from(configurations.bundle) + group = BasePlugin.BUILD_GROUP +} + +tasks.named(BasePlugin.ASSEMBLE_TASK_NAME) { + dependsOn(bundleTask) +} + +def bundleArtifact = artifacts.add('archives', bundleTask.get().archiveFile.get().asFile) { + type = 'tar' + builtBy bundleTask +} + + +publishing { + publications { + maven(MavenPublication) { + artifact bundleArtifact + } + } +} \ No newline at end of file diff --git a/gbcs-memcached/src/main/java/module-info.java b/gbcs-memcached/src/main/java/module-info.java new file mode 100644 index 0000000..9413d31 --- /dev/null +++ b/gbcs-memcached/src/main/java/module-info.java @@ -0,0 +1,14 @@ +import net.woggioni.gbcs.api.CacheProvider; + +module net.woggioni.gbcs.memcached { + requires net.woggioni.gbcs.base; + requires net.woggioni.gbcs.api; + requires com.googlecode.xmemcached; + requires net.woggioni.jwo; + requires java.xml; + requires kotlin.stdlib; + + provides CacheProvider with net.woggioni.gbcs.memcached.MemcachedCacheProvider; + + opens net.woggioni.gbcs.memcached.schema; +} \ No newline at end of file diff --git a/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCache.kt b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCache.kt new file mode 100644 index 0000000..5db0612 --- /dev/null +++ b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCache.kt @@ -0,0 +1,60 @@ +package net.woggioni.gbcs.memcached + +import net.rubyeye.xmemcached.MemcachedClient +import net.rubyeye.xmemcached.XMemcachedClientBuilder +import net.rubyeye.xmemcached.command.BinaryCommandFactory +import net.rubyeye.xmemcached.transcoders.CompressionMode +import net.rubyeye.xmemcached.transcoders.SerializingTranscoder +import net.woggioni.gbcs.api.Cache +import net.woggioni.gbcs.api.exception.ContentTooLargeException +import net.woggioni.gbcs.base.HostAndPort +import net.woggioni.jwo.JWO +import java.io.ByteArrayInputStream +import java.net.InetSocketAddress +import java.nio.channels.Channels +import java.nio.channels.ReadableByteChannel +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Duration + +class MemcachedCache( + servers: List, + private val maxAge: Duration, + maxSize : Int, + digestAlgorithm: String?, + compressionMode: CompressionMode, +) : Cache { + private val memcachedClient = XMemcachedClientBuilder( + servers.stream().map { addr: HostAndPort -> InetSocketAddress(addr.host, addr.port) }.toList() + ).apply { + commandFactory = BinaryCommandFactory() + digestAlgorithm?.let { dAlg -> + setKeyProvider { key -> + val md = MessageDigest.getInstance(dAlg) + md.update(key.toByteArray(StandardCharsets.UTF_8)) + JWO.bytesToHex(md.digest()) + } + } + transcoder = SerializingTranscoder(maxSize).apply { + setCompressionMode(compressionMode) + } + }.build() + + override fun get(key: String): ReadableByteChannel? { + return memcachedClient.get(key) + ?.let(::ByteArrayInputStream) + ?.let(Channels::newChannel) + } + + override fun put(key: String, content: ByteArray) { + try { + memcachedClient[key, maxAge.toSeconds().toInt()] = content + } catch (e: IllegalArgumentException) { + throw ContentTooLargeException(e.message, e) + } + } + + override fun close() { + memcachedClient.shutdown() + } +} diff --git a/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheConfiguration.kt b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheConfiguration.kt new file mode 100644 index 0000000..2ef7921 --- /dev/null +++ b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheConfiguration.kt @@ -0,0 +1,26 @@ +package net.woggioni.gbcs.memcached + +import net.rubyeye.xmemcached.transcoders.CompressionMode +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.base.HostAndPort +import java.time.Duration + +data class MemcachedCacheConfiguration( + var servers: List, + var maxAge: Duration = Duration.ofDays(1), + var maxSize: Int = 0x100000, + var digestAlgorithm: String? = null, + var compressionMode: CompressionMode = CompressionMode.ZIP, +) : Configuration.Cache { + override fun materialize() = MemcachedCache( + servers, + maxAge, + maxSize, + digestAlgorithm, + compressionMode + ) + + override fun getNamespaceURI() = "urn:net.woggioni.gbcs-memcached" + + override fun getTypeName() = "memcachedCacheType" +} diff --git a/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheProvider.kt b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheProvider.kt new file mode 100644 index 0000000..ed82409 --- /dev/null +++ b/gbcs-memcached/src/main/kotlin/net/woggioni/gbcs/memcached/MemcachedCacheProvider.kt @@ -0,0 +1,85 @@ +package net.woggioni.gbcs.memcached + +import net.rubyeye.xmemcached.transcoders.CompressionMode +import net.woggioni.gbcs.api.CacheProvider +import net.woggioni.gbcs.base.GBCS +import net.woggioni.gbcs.base.HostAndPort +import net.woggioni.gbcs.base.Xml +import net.woggioni.gbcs.base.Xml.Companion.asIterable +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.time.Duration +import java.util.zip.Deflater + +class MemcachedCacheProvider : CacheProvider { + override fun getXmlSchemaLocation() = "classpath:net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd" + + override fun getXmlType() = "memcachedCacheType" + + override fun getXmlNamespace()= "urn:net.woggioni.gbcs-memcached" + + override fun deserialize(el: Element): MemcachedCacheConfiguration { + val servers = mutableListOf() + val maxAge = el.getAttribute("max-age") + .takeIf(String::isNotEmpty) + ?.let(Duration::parse) + ?: Duration.ofDays(1) + val maxSize = el.getAttribute("max-size") + .takeIf(String::isNotEmpty) + ?.let(String::toInt) + ?: 0x100000 + val enableCompression = el.getAttribute("enable-compression") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) + ?: false + val compressionMode = el.getAttribute("compression-mode") + .takeIf(String::isNotEmpty) + ?.let { + when(it) { + "gzip" -> CompressionMode.GZIP + "zip" -> CompressionMode.ZIP + else -> CompressionMode.ZIP + } + } + ?: CompressionMode.ZIP + val digestAlgorithm = el.getAttribute("digest").takeIf(String::isNotEmpty) + for (child in el.asIterable()) { + when (child.nodeName) { + "server" -> { + servers.add(HostAndPort(child.getAttribute("host"), child.getAttribute("port").toInt())) + } + } + } + + return MemcachedCacheConfiguration( + servers, + maxAge, + maxSize, + digestAlgorithm, + compressionMode, + ) + } + + override fun serialize(doc: Document, cache : MemcachedCacheConfiguration) = cache.run { + val result = doc.createElementNS(xmlNamespace,"cache") + Xml.of(doc, result) { + attr("xs:type", xmlType, GBCS.XML_SCHEMA_NAMESPACE_URI) + for (server in servers) { + node("server", xmlNamespace) { + attr("host", server.host) + attr("port", server.port.toString()) + } + } + attr("max-age", maxAge.toString()) + attr("max-size", maxSize.toString()) + digestAlgorithm?.let { digestAlgorithm -> + attr("digest", digestAlgorithm) + } + attr("compression-mode", when(compressionMode) { + CompressionMode.GZIP -> "gzip" + CompressionMode.ZIP -> "zip" + }) + } + result + } +} diff --git a/gbcs-memcached/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider b/gbcs-memcached/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider new file mode 100644 index 0000000..8883758 --- /dev/null +++ b/gbcs-memcached/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider @@ -0,0 +1 @@ +net.woggioni.gbcs.memcached.MemcachedCacheProvider \ No newline at end of file diff --git a/gbcs-memcached/src/main/resources/net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd b/gbcs-memcached/src/main/resources/net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd new file mode 100644 index 0000000..a219a9a --- /dev/null +++ b/gbcs-memcached/src/main/resources/net/woggioni/gbcs/memcached/schema/gbcs-memcached.xsd @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index fe919ae..2caf664 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,9 @@ -gbcs.version = 1.0.0 +org.gradle.configuration-cache=false +org.gradle.parallel=true +org.gradle.caching=true -lys.version = 2024.12.21 +gbcs.version = 0.0.1 + +lys.version = 2025.01.08 gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd49..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..cea7a79 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30d..9d21a21 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/native-image/native-image.properties b/native-image/native-image.properties index 1382f92..150581f 100644 --- a/native-image/native-image.properties +++ b/native-image/native-image.properties @@ -1 +1,2 @@ -Args=-H:Optimize=3 --gc=serial --libc=musl --static -H:TraceClassInitialization=io.netty.handler.ssl.BouncyCastleAlpnSslUtils \ No newline at end of file +Args=-H:Optimize=3 --gc=serial +#-H:TraceClassInitialization=io.netty.handler.ssl.BouncyCastleAlpnSslUtils \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 1fa5696..a68cb7c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,10 +20,14 @@ dependencyResolutionManagement { versionCatalogs { catalog { from group: 'com.lys', name: 'lys-catalog', version: getProperty('lys.version') - version('envelope', '2024.12.15') } } } rootProject.name = 'gbcs' +include 'gbcs-api' +include 'gbcs-base' +include 'gbcs-memcached' + + diff --git a/src/integrationTest/java/module-info.java b/src/integrationTest/java/module-info.java new file mode 100644 index 0000000..19000f1 --- /dev/null +++ b/src/integrationTest/java/module-info.java @@ -0,0 +1,15 @@ +open module net.woggioni.gbcs.test { + requires net.woggioni.gbcs; + requires net.woggioni.gbcs.api; + requires java.naming; + requires org.bouncycastle.pkix; + requires org.bouncycastle.provider; + requires io.netty.codec.http; + requires net.woggioni.gbcs.base; + requires java.net.http; + requires static lombok; + requires org.junit.jupiter.params; + + exports net.woggioni.gbcs.test to org.junit.platform.commons; +// opens net.woggioni.gbcs.test to org.junit.platform.commons; +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/AbstractServerTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/AbstractServerTest.java new file mode 100644 index 0000000..de49b4c --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/AbstractServerTest.java @@ -0,0 +1,51 @@ +package net.woggioni.gbcs.test; + + +import net.woggioni.gbcs.GradleBuildCacheServer; +import net.woggioni.gbcs.api.Configuration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public abstract class AbstractServerTest { + + protected Configuration cfg; + protected Path testDir; + private GradleBuildCacheServer.ServerHandle serverHandle; + + @BeforeAll + public void setUp0(@TempDir Path tmpDir) { + this.testDir = tmpDir; + setUp(); + startServer(cfg); + } + + @AfterAll + public void tearDown0() { + tearDown(); + stopServer(); + } + + protected abstract void setUp(); + + protected abstract void tearDown(); + + private void startServer(Configuration cfg) { + this.serverHandle = new GradleBuildCacheServer(cfg).run(); + } + + private void stopServer() { + if (serverHandle != null) { + try (GradleBuildCacheServer.ServerHandle handle = serverHandle) { + handle.shutdown(); + } + } + } +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/BasicAuthServerTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/BasicAuthServerTest.java new file mode 100644 index 0000000..2d6d6d4 --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/BasicAuthServerTest.java @@ -0,0 +1,229 @@ +package net.woggioni.gbcs.test; + +import io.netty.handler.codec.http.HttpResponseStatus; +import lombok.SneakyThrows; +import net.woggioni.gbcs.AbstractNettyHttpAuthenticator; +import net.woggioni.gbcs.api.Role; +import net.woggioni.gbcs.base.Xml; +import net.woggioni.gbcs.api.Configuration; +import net.woggioni.gbcs.cache.FileSystemCacheConfiguration; +import net.woggioni.gbcs.configuration.Serializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.Deflater; +import java.io.IOException; + +public class BasicAuthServerTest extends AbstractServerTest { + + private static final String PASSWORD = "password"; + private Path cacheDir; + private final Random random = new Random(101325); + private final Map.Entry keyValuePair; + + public BasicAuthServerTest() { + this.keyValuePair = newEntry(random); + } + + @Override + @SneakyThrows + protected void setUp() { + this.cacheDir = testDir.resolve("cache"); + Configuration.Group readersGroup = new Configuration.Group("readers", Set.of(Role.Reader)); + Configuration.Group writersGroup = new Configuration.Group("writers", Set.of(Role.Writer)); + + List users = Arrays.asList( + new Configuration.User("user1", AbstractNettyHttpAuthenticator.Companion.hashPassword(PASSWORD, null), Set.of(readersGroup)), + new Configuration.User("user2", AbstractNettyHttpAuthenticator.Companion.hashPassword(PASSWORD, null), Set.of(writersGroup)), + new Configuration.User("user3", AbstractNettyHttpAuthenticator.Companion.hashPassword(PASSWORD, null), Set.of(readersGroup, writersGroup)) + ); + + Map usersMap = users.stream() + .collect(Collectors.toMap(user -> user.getName(), user -> user)); + + Map groupsMap = Stream.of(writersGroup, readersGroup) + .collect(Collectors.toMap(group -> group.getName(), group -> group)); + + cfg = new Configuration( + "127.0.0.1", + new ServerSocket(0).getLocalPort() + 1, + "/", + usersMap, + groupsMap, + new FileSystemCacheConfiguration( + this.cacheDir, + Duration.ofSeconds(3600 * 24), + "MD5", + false, + Deflater.DEFAULT_COMPRESSION + ), + new Configuration.BasicAuthentication(), + null, + true + ); + + Xml.Companion.write(Serializer.INSTANCE.serialize(cfg), System.out); + } + + @Override + protected void tearDown() { + // Empty implementation + } + + private String buildAuthorizationHeader(Configuration.User user, String password) { + String credentials = user.getName() + ":" + password; + byte[] encodedCredentials = Base64.getEncoder().encode(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + new String(encodedCredentials, StandardCharsets.UTF_8); + } + + private HttpRequest.Builder newRequestBuilder(String key) { + return HttpRequest.newBuilder() + .uri(URI.create(String.format("http://%s:%d/%s", cfg.getHost(), cfg.getPort(), key))); + } + + private Map.Entry newEntry(Random random) { + byte[] keyBytes = new byte[0x10]; + random.nextBytes(keyBytes); + String key = Base64.getUrlEncoder().encodeToString(keyBytes); + + byte[] value = new byte[0x1000]; + random.nextBytes(value); + + return Map.entry(key, value); + } + + @Test + @Order(1) + public void putWithNoAuthorizationHeader() throws IOException, InterruptedException { + try(HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode()); + } + } + + @Test + @Order(2) + public void putAsAReaderUser() throws IOException, InterruptedException { + try(HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader) && !u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()); + } + } + + @Test + @Order(3) + public void getAsAWriterUser() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Writer user not found")); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()); + } + } + + @Test + @Order(4) + public void putAsAWriterUser() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Writer user not found")); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()); + } + } + + @Test + @Order(5) + public void getAsAReaderUser() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()); + Assertions.assertArrayEquals(value, response.body()); + } + } + + @Test + @Order(6) + public void getMissingKeyAsAReaderUser() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + Map.Entry entry = newEntry(random); + String key = entry.getKey(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()); + } + } +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/ConfigurationTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/ConfigurationTest.java new file mode 100644 index 0000000..2e7b91d --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/ConfigurationTest.java @@ -0,0 +1,48 @@ +package net.woggioni.gbcs.test; + +import net.woggioni.gbcs.GradleBuildCacheServer; +import net.woggioni.gbcs.base.GBCS; +import net.woggioni.gbcs.base.Xml; +import net.woggioni.gbcs.configuration.Parser; +import net.woggioni.gbcs.configuration.Serializer; +import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; + +class ConfigurationTest { + + @ParameterizedTest + @ValueSource(strings = { + // "classpath:net/woggioni/gbcs/gbcs-default.xml", + "classpath:net/woggioni/gbcs/test/gbcs-memcached.xml" + }) + void test(String configurationUrl, @TempDir Path testDir) throws IOException { + URL.setURLStreamHandlerFactory(new ClasspathUrlStreamHandlerFactoryProvider()); + // DocumentBuilderFactory dbf = Xml.newDocumentBuilderFactory(GradleBuildCacheServer.CONFIGURATION_SCHEMA_URL); + // DocumentBuilder db = dbf.newDocumentBuilder(); + // URL configurationUrl = GradleBuildCacheServer.DEFAULT_CONFIGURATION_URL; + + var doc = Xml.Companion.parseXml(GBCS.INSTANCE.toUrl(configurationUrl), null, null); + var cfg = Parser.INSTANCE.parse(doc); + Path configFile = testDir.resolve("gbcs.xml"); + + try (var outputStream = Files.newOutputStream(configFile)) { + Xml.Companion.write(Serializer.INSTANCE.serialize(cfg), outputStream); + } + + Xml.Companion.write(Serializer.INSTANCE.serialize(cfg), System.out); + + var parsed = Parser.INSTANCE.parse(Xml.Companion.parseXml( + configFile.toUri().toURL(), null, null + )); + + Assertions.assertEquals(cfg, parsed); + } +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/NoAuthServerTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/NoAuthServerTest.java new file mode 100644 index 0000000..962aff3 --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/NoAuthServerTest.java @@ -0,0 +1,128 @@ +package net.woggioni.gbcs.test; + +import io.netty.handler.codec.http.HttpResponseStatus; +import lombok.SneakyThrows; +import net.woggioni.gbcs.base.Xml; +import net.woggioni.gbcs.api.Configuration; +import net.woggioni.gbcs.cache.FileSystemCacheConfiguration; +import net.woggioni.gbcs.configuration.Serializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.Random; +import java.util.zip.Deflater; +import java.io.IOException; + +public class NoAuthServerTest extends AbstractServerTest { + + private Path cacheDir; + private final Random random = new Random(101325); + private final Map.Entry keyValuePair; + + public NoAuthServerTest() { + this.keyValuePair = newEntry(random); + } + + @Override + @SneakyThrows + protected void setUp() { + this.cacheDir = testDir.resolve("cache"); + cfg = new Configuration( + "127.0.0.1", + new ServerSocket(0).getLocalPort() + 1, + "/", + Collections.emptyMap(), + Collections.emptyMap(), + new FileSystemCacheConfiguration( + this.cacheDir, + Duration.ofSeconds(3600 * 24), + "MD5", + true, + Deflater.DEFAULT_COMPRESSION + ), + null, + null, + true + ); + Xml.Companion.write(Serializer.INSTANCE.serialize(cfg), System.out); + } + + @Override + protected void tearDown() { + // Empty implementation + } + + private HttpRequest.Builder newRequestBuilder(String key) { + return HttpRequest.newBuilder() + .uri(URI.create(String.format("http://%s:%d/%s", cfg.getHost(), cfg.getPort(), key))); + } + + private Map.Entry newEntry(Random random) { + byte[] keyBytes = new byte[0x10]; + random.nextBytes(keyBytes); + String key = Base64.getUrlEncoder().encodeToString(keyBytes); + + byte[] value = new byte[0x1000]; + random.nextBytes(value); + + return Map.entry(key, value); + } + + @Test + @Order(1) + public void putWithNoAuthorizationHeader() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()); + } + } + + @Test + @Order(2) + public void getWithNoAuthorizationHeader() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()); + Assertions.assertArrayEquals(value, response.body()); + } + } + + @Test + @Order(3) + public void getMissingKey() throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newHttpClient()) { + + Map.Entry entry = newEntry(random); + String key = entry.getKey(); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key).GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()); + } + } +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/TlsServerTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/TlsServerTest.java new file mode 100644 index 0000000..4d46533 --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/TlsServerTest.java @@ -0,0 +1,357 @@ +package net.woggioni.gbcs.test; + +import io.netty.handler.codec.http.HttpResponseStatus; +import lombok.SneakyThrows; +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 org.bouncycastle.asn1.x500.X500Name; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStore.PasswordProtection; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.Deflater; + +public class TlsServerTest extends AbstractServerTest { + + private static final String CA_CERTIFICATE_ENTRY = "gbcs-ca"; + private static final String CLIENT_CERTIFICATE_ENTRY = "gbcs-client"; + private static final String SERVER_CERTIFICATE_ENTRY = "gbcs-server"; + private static final String PASSWORD = "password"; + + private Path cacheDir; + private Path serverKeyStoreFile; + private Path clientKeyStoreFile; + private Path trustStoreFile; + private KeyStore serverKeyStore; + private KeyStore clientKeyStore; + private KeyStore trustStore; + private X509Credentials ca; + + private final Configuration.Group readersGroup = new Configuration.Group("readers", Set.of(Role.Reader)); + private final Configuration.Group writersGroup = new Configuration.Group("writers", Set.of(Role.Writer)); + private final Random random = new Random(101325); + private final Map.Entry keyValuePair; + + private final List users = Arrays.asList( + new Configuration.User("user1", null, Set.of(readersGroup)), + new Configuration.User("user2", null, Set.of(writersGroup)), + new Configuration.User("user3", null, Set.of(readersGroup, writersGroup)) + ); + + public TlsServerTest() { + this.keyValuePair = newEntry(random); + } + + private void createKeyStoreAndTrustStore() throws Exception { + ca = CertificateUtils.createCertificateAuthority(CA_CERTIFICATE_ENTRY, 30); + var serverCert = CertificateUtils.createServerCertificate(ca, new X500Name("CN=" + SERVER_CERTIFICATE_ENTRY), 30); + var clientCert = CertificateUtils.createClientCertificate(ca, new X500Name("CN=" + CLIENT_CERTIFICATE_ENTRY), 30); + + serverKeyStore = KeyStore.getInstance("PKCS12"); + serverKeyStore.load(null, null); + serverKeyStore.setEntry( + CA_CERTIFICATE_ENTRY, + new KeyStore.TrustedCertificateEntry(ca.certificate()), + new PasswordProtection(null) + ); + serverKeyStore.setEntry( + SERVER_CERTIFICATE_ENTRY, + new KeyStore.PrivateKeyEntry( + serverCert.keyPair().getPrivate(), + new java.security.cert.Certificate[]{serverCert.certificate(), ca.certificate()} + ), + new PasswordProtection(PASSWORD.toCharArray()) + ); + + try (var out = Files.newOutputStream(this.serverKeyStoreFile)) { + serverKeyStore.store(out, null); + } + + clientKeyStore = KeyStore.getInstance("PKCS12"); + clientKeyStore.load(null, null); + clientKeyStore.setEntry( + CA_CERTIFICATE_ENTRY, + new KeyStore.TrustedCertificateEntry(ca.certificate()), + new PasswordProtection(null) + ); + clientKeyStore.setEntry( + CLIENT_CERTIFICATE_ENTRY, + new KeyStore.PrivateKeyEntry( + clientCert.keyPair().getPrivate(), + new java.security.cert.Certificate[]{clientCert.certificate(), ca.certificate()} + ), + new PasswordProtection(PASSWORD.toCharArray()) + ); + + try (var out = Files.newOutputStream(this.clientKeyStoreFile)) { + clientKeyStore.store(out, null); + } + + trustStore = KeyStore.getInstance("PKCS12"); + trustStore.load(null, null); + trustStore.setEntry( + CA_CERTIFICATE_ENTRY, + new KeyStore.TrustedCertificateEntry(ca.certificate()), + new PasswordProtection(null) + ); + + try (var out = Files.newOutputStream(this.trustStoreFile)) { + trustStore.store(out, null); + } + } + + private KeyStore getClientKeyStore(X509Credentials ca, X500Name subject) throws Exception { + var clientCert = CertificateUtils.createClientCertificate(ca, subject, 30); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setEntry( + CA_CERTIFICATE_ENTRY, + new KeyStore.TrustedCertificateEntry(ca.certificate()), + new PasswordProtection(null) + ); + keyStore.setEntry( + CLIENT_CERTIFICATE_ENTRY, + new KeyStore.PrivateKeyEntry( + clientCert.keyPair().getPrivate(), + new java.security.cert.Certificate[]{clientCert.certificate(), ca.certificate()} + ), + new PasswordProtection(PASSWORD.toCharArray()) + ); + return keyStore; + } + + private HttpClient getHttpClient(KeyStore clientKeyStore) throws Exception { + KeyManagerFactory kmf = null; + if (clientKeyStore != null) { + kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(clientKeyStore, PASSWORD.toCharArray()); + } + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init( + kmf != null ? kmf.getKeyManagers() : null, + tmf.getTrustManagers(), + null + ); + + return HttpClient.newBuilder().sslContext(sslContext).build(); + } + + @Override + @SneakyThrows + protected void 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(); + + Map usersMap = users.stream() + .collect(Collectors.toMap(user -> user.getName(), user -> user)); + + Map groupsMap = Stream.of(writersGroup, readersGroup) + .collect(Collectors.toMap(group -> group.getName(), group -> group)); + + cfg = new Configuration( + "127.0.0.1", + new ServerSocket(0).getLocalPort() + 1, + "gbcs", + usersMap, + groupsMap, + new FileSystemCacheConfiguration( + this.cacheDir, + Duration.ofSeconds(3600 * 24), + "MD5", + true, + Deflater.DEFAULT_COMPRESSION + ), + new Configuration.ClientCertificateAuthentication( + new Configuration.TlsCertificateExtractor("CN", "(.*)"), + null + ), + new Configuration.Tls( + new Configuration.KeyStore(this.serverKeyStoreFile, null, SERVER_CERTIFICATE_ENTRY, PASSWORD), + new Configuration.TrustStore(this.trustStoreFile, null, false), + true + ), + true + ); + + Xml.Companion.write(Serializer.INSTANCE.serialize(cfg), System.out); + } + + @Override + protected void tearDown() { + // Empty implementation + } + + private HttpRequest.Builder newRequestBuilder(String key) { + return HttpRequest.newBuilder() + .uri(URI.create(String.format("https://%s:%d/%s", cfg.getHost(), cfg.getPort(), key))); + } + + private String buildAuthorizationHeader(Configuration.User user, String password) { + String credentials = user.getName() + ":" + password; + byte[] encodedCredentials = Base64.getEncoder().encode(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + new String(encodedCredentials, StandardCharsets.UTF_8); + } + + private Map.Entry newEntry(Random random) { + byte[] keyBytes = new byte[0x10]; + random.nextBytes(keyBytes); + String key = Base64.getUrlEncoder().encodeToString(keyBytes); + + byte[] value = new byte[0x1000]; + random.nextBytes(value); + + return Map.entry(key, value); + } + + @Test + @Order(1) + public void putWithNoClientCertificate() throws Exception { + try (HttpClient client = getHttpClient(null)) { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.UNAUTHORIZED.code(), response.statusCode()); + } + } + + @Test + @Order(2) + public void putAsAReaderUser() throws Exception { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader) && !u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + try (HttpClient client = getHttpClient(getClientKeyStore(ca, new X500Name("CN=" + user.getName())))) { + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()); + } + } + + @Test + @Order(3) + public void getAsAWriterUser() throws Exception { + String key = keyValuePair.getKey(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Writer user not found")); + + try (HttpClient client = getHttpClient(getClientKeyStore(ca, new X500Name("CN=" + user.getName())))) { + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.FORBIDDEN.code(), response.statusCode()); + } + } + + @Test + @Order(4) + public void putAsAWriterUser() throws Exception { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Writer)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Writer user not found")); + + try (HttpClient client = getHttpClient(getClientKeyStore(ca, new X500Name("CN=" + user.getName())))) { + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Content-Type", "application/octet-stream") + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(value)); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + Assertions.assertEquals(HttpResponseStatus.CREATED.code(), response.statusCode()); + } + } + + @Test + @Order(5) + public void getAsAReaderUser() throws Exception { + String key = keyValuePair.getKey(); + byte[] value = keyValuePair.getValue(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + try (HttpClient client = getHttpClient(getClientKeyStore(ca, new X500Name("CN=" + user.getName())))) { + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.OK.code(), response.statusCode()); + Assertions.assertArrayEquals(value, response.body()); + } + } + + @Test + @Order(6) + public void getMissingKeyAsAReaderUser() throws Exception { + Map.Entry entry = newEntry(random); + String key = entry.getKey(); + + Configuration.User user = cfg.getUsers().values().stream() + .filter(u -> u.getRoles().contains(Role.Reader)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Reader user not found")); + + try (HttpClient client = getHttpClient(getClientKeyStore(ca, new X500Name("CN=" + user.getName())))) { + HttpRequest.Builder requestBuilder = newRequestBuilder(key) + .header("Authorization", buildAuthorizationHeader(user, PASSWORD)) + .GET(); + + HttpResponse response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()); + Assertions.assertEquals(HttpResponseStatus.NOT_FOUND.code(), response.statusCode()); + } + } +} \ No newline at end of file diff --git a/src/integrationTest/java/net/woggioni/gbcs/test/X500NameTest.java b/src/integrationTest/java/net/woggioni/gbcs/test/X500NameTest.java new file mode 100644 index 0000000..e89351b --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/test/X500NameTest.java @@ -0,0 +1,28 @@ +package net.woggioni.gbcs.test; + +import lombok.SneakyThrows; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import java.util.Objects; + +public class X500NameTest { + + @Test + @SneakyThrows + void test() { + final var name = + "C=SG, L=Bugis, CN=woggioni@f6aa5663ef26, emailAddress=oggioni.walter@gmail.com, street=1 Fraser Street\\, Duo Residences #23-05, postalCode=189350, GN=Walter, SN=Oggioni, pseudonym=woggioni"; + final var ldapName = new LdapName(name); + final var value = ldapName.getRdns() + .stream() + .filter(it -> Objects.equals("CN", it.getType())) + .findFirst() + .map(Rdn::getValue) + .orElseThrow(Assertions::fail); + Assertions.assertEquals("woggioni@f6aa5663ef26", value); + } +} + diff --git a/src/integrationTest/java/net/woggioni/gbcs/utils/CertificateUtils.java b/src/integrationTest/java/net/woggioni/gbcs/utils/CertificateUtils.java new file mode 100644 index 0000000..e89d315 --- /dev/null +++ b/src/integrationTest/java/net/woggioni/gbcs/utils/CertificateUtils.java @@ -0,0 +1,227 @@ +package net.woggioni.gbcs.utils; + +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectAltPublicKeyInfo; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.io.FileOutputStream; +import java.math.BigInteger; +import java.net.InetAddress; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +public class CertificateUtils { + + public record X509Credentials( + KeyPair keyPair, + X509Certificate certificate + ){ } + public static class CertificateAuthority { + private final PrivateKey privateKey; + private final X509Certificate certificate; + + public CertificateAuthority(PrivateKey privateKey, X509Certificate certificate) { + this.privateKey = privateKey; + this.certificate = certificate; + } + + public PrivateKey getPrivateKey() { return privateKey; } + public X509Certificate getCertificate() { return certificate; } + } + + /** + * Creates a new Certificate Authority (CA) + * @param commonName The CA's common name + * @param validityDays How long the CA should be valid for + * @return The generated CA containing both private key and certificate + */ + public static X509Credentials createCertificateAuthority(String commonName, int validityDays) + throws Exception { + // Generate key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(4096); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name("CN=" + commonName); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + issuerName, + keyPair.getPublic() + ); + + // Add CA extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(true) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign) + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(keyPair.getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + return new X509Credentials(keyPair, cert); + } + + /** + * Creates a server certificate signed by the CA + * @param ca The Certificate Authority to sign with + * @param subjectName The server's common name + * @param validityDays How long the certificate should be valid for + * @return KeyPair containing the server's private key and certificate + */ + public static X509Credentials createServerCertificate(X509Credentials ca, X500Name subjectName, int validityDays) + throws Exception { + // Generate server key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair serverKeyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name(ca.certificate().getSubjectX500Principal().getName()); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + subjectName, + serverKeyPair.getPublic() + ); + + // Add server certificate extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(false) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment) + ); + certBuilder.addExtension( + Extension.extendedKeyUsage, + true, + new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth}) + ); + GeneralNames subjectAltNames = GeneralNames.getInstance( + new DERSequence( + new GeneralName[] { + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + } + ) + ); + certBuilder.addExtension( + Extension.subjectAlternativeName, + true, + subjectAltNames + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(ca.keyPair().getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + + return new X509Credentials(serverKeyPair, cert); + } + + /** + * Creates a client certificate signed by the CA + * @param ca The Certificate Authority to sign with + * @param subjectName The client's common name + * @param validityDays How long the certificate should be valid for + * @return KeyPair containing the client's private key and certificate + */ + public static X509Credentials createClientCertificate(X509Credentials ca, X500Name subjectName, int validityDays) + throws Exception { + // Generate client key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + KeyPair clientKeyPair = keyPairGenerator.generateKeyPair(); + + // Prepare certificate data + X500Name issuerName = new X500Name(ca.certificate().getSubjectX500Principal().getName()); + BigInteger serialNumber = new BigInteger(160, new SecureRandom()); + Instant now = Instant.now(); + Date startDate = Date.from(now); + Date endDate = Date.from(now.plus(validityDays, ChronoUnit.DAYS)); + + // Create certificate builder + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + issuerName, + serialNumber, + startDate, + endDate, + subjectName, + clientKeyPair.getPublic() + ); + + // Add client certificate extensions + certBuilder.addExtension( + Extension.basicConstraints, + true, + new BasicConstraints(false) + ); + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature) + ); + certBuilder.addExtension( + Extension.extendedKeyUsage, + true, + new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_clientAuth}) + ); + + // Sign the certificate + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .build(ca.keyPair().getPrivate()); + X509Certificate cert = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + return new X509Credentials(clientKeyPair, cert); + } +} \ No newline at end of file diff --git a/src/integrationTest/resources/net/woggioni/gbcs/test/gbcs-memcached.xml b/src/integrationTest/resources/net/woggioni/gbcs/test/gbcs-memcached.xml new file mode 100644 index 0000000..7abe02b --- /dev/null +++ b/src/integrationTest/resources/net/woggioni/gbcs/test/gbcs-memcached.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 43edb3e..df35496 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,6 +1,14 @@ +import net.woggioni.gbcs.api.CacheProvider; import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider; +import net.woggioni.gbcs.cache.FileSystemCacheProvider; open module net.woggioni.gbcs { +// exports net.woggioni.gbcs.cache to net.woggioni.gbcs.test; +// exports net.woggioni.gbcs.configuration to net.woggioni.gbcs.test; +// exports net.woggioni.gbcs.url to net.woggioni.gbcs.test; +// exports net.woggioni.gbcs to net.woggioni.gbcs.test; +// opens net.woggioni.gbcs.schema to net.woggioni.gbcs.test; + requires java.sql; requires java.xml; requires java.logging; @@ -14,10 +22,16 @@ open module net.woggioni.gbcs { requires io.netty.codec; requires org.slf4j; requires net.woggioni.jwo; + requires net.woggioni.gbcs.base; + requires net.woggioni.gbcs.api; - exports net.woggioni.gbcs; - exports net.woggioni.gbcs.url; +// exports net.woggioni.gbcs; +// exports net.woggioni.gbcs.url; // opens net.woggioni.gbcs to net.woggioni.envelope; provides java.net.URLStreamHandlerFactory with ClasspathUrlStreamHandlerFactoryProvider; uses java.net.URLStreamHandlerFactory; +// uses net.woggioni.gbcs.api.Cache; + uses CacheProvider; + + provides CacheProvider with FileSystemCacheProvider; } \ No newline at end of file diff --git a/src/main/java/net/woggioni/gbcs/NettyPingServer.java b/src/main/java/net/woggioni/gbcs/NettyPingServer.java deleted file mode 100644 index 1a2c363..0000000 --- a/src/main/java/net/woggioni/gbcs/NettyPingServer.java +++ /dev/null @@ -1,88 +0,0 @@ -package net.woggioni.gbcs; - -import io.netty.bootstrap.ServerBootstrap; -import io.netty.channel.*; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; -import io.netty.handler.codec.string.StringDecoder; -import io.netty.handler.codec.string.StringEncoder; -import io.netty.util.CharsetUtil; - -public class NettyPingServer { - private final int port; - - public NettyPingServer(int port) { - this.port = port; - } - - public void start() throws Exception { - // Create event loop groups for handling incoming connections and processing - EventLoopGroup bossGroup = new NioEventLoopGroup(); - EventLoopGroup workerGroup = new NioEventLoopGroup(); - - try { - // Create server bootstrap configuration - ServerBootstrap bootstrap = new ServerBootstrap() - .group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel ch) throws Exception { - ch.pipeline().addLast( - new StringDecoder(CharsetUtil.UTF_8), - new StringEncoder(CharsetUtil.UTF_8), - new PingServerHandler() - ); - } - }) - .option(ChannelOption.SO_BACKLOG, 128) - .childOption(ChannelOption.SO_KEEPALIVE, true); - - // Bind and start the server - ChannelFuture future = bootstrap.bind(port).sync(); - System.out.println("Ping Server started on port: " + port); - try(final var handle = new GradleBuildCacheServer.ServerHandle(future, bossGroup, workerGroup)) { - Thread.sleep(5000); - future.channel().close(); - // Wait until the server socket is closed - future.channel().closeFuture().sync(); - } - } finally { - // Shutdown event loop groups -// workerGroup.shutdownGracefully(); -// bossGroup.shutdownGracefully(); - } - } - - // Custom handler for processing ping requests - private static class PingServerHandler extends SimpleChannelInboundHandler { - @Override - protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception { - // Check if the received message is a ping request - if ("ping".equalsIgnoreCase(msg.trim())) { - // Respond with "pong" - ctx.writeAndFlush("pong\n"); - System.out.println("Received ping, sent pong"); - } - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - // Log and close the connection in case of any errors - cause.printStackTrace(); - ctx.close(); - } - } - - // Main method to start the server - public static void main(String[] args) throws Exception { - int port = 8080; // Default port - if (args.length > 0) { - port = Integer.parseInt(args[0]); - } - - new NettyPingServer(port).start(); - } -} - diff --git a/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java b/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java index d27d62f..8b15bbe 100644 --- a/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java +++ b/src/main/java/net/woggioni/gbcs/url/ClasspathUrlStreamHandlerFactoryProvider.java @@ -1,16 +1,47 @@ 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>> 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; @@ -24,10 +55,17 @@ public class ClasspathUrlStreamHandlerFactoryProvider implements URLStreamHandle @Override protected URLConnection openConnection(URL u) throws IOException { - final URL resourceUrl = classLoader.getResource(u.getPath()); - return Optional.ofNullable(resourceUrl) - .map((Fun) URL::openConnection) - .orElseThrow(IOException::new); + 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::openConnection)) + .orElse(null); } } @@ -43,4 +81,24 @@ public class ClasspathUrlStreamHandlerFactoryProvider implements URLStreamHandle } return result; } + + private static final class ModuleResourceURLConnection extends URLConnection { + private final List modules; + + ModuleResourceURLConnection(URL url, List 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; + } + } } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt b/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt index 58c0359..013cbfb 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Authenticator.kt @@ -11,6 +11,7 @@ import io.netty.handler.codec.http.HttpRequest import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpVersion import io.netty.util.ReferenceCountUtil +import net.woggioni.gbcs.api.Role import java.security.SecureRandom import java.security.spec.KeySpec import java.util.Base64 diff --git a/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt b/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt index 68ee054..d26f123 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Authorizer.kt @@ -1,6 +1,7 @@ package net.woggioni.gbcs import io.netty.handler.codec.http.HttpRequest +import net.woggioni.gbcs.api.Role fun interface Authorizer { fun authorize(roles : Set, request: HttpRequest) : Boolean diff --git a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt index dfc3660..dfaef5b 100644 --- a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt @@ -42,22 +42,30 @@ import io.netty.handler.stream.ChunkedNioStream import io.netty.handler.stream.ChunkedWriteHandler import io.netty.util.concurrent.DefaultEventExecutorGroup import io.netty.util.concurrent.EventExecutorGroup -import net.woggioni.gbcs.cache.Cache -import net.woggioni.gbcs.cache.FileSystemCache -import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.api.Cache +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.api.Role +import net.woggioni.gbcs.api.exception.ContentTooLargeException +import net.woggioni.gbcs.base.GBCS +import net.woggioni.gbcs.base.Xml +import net.woggioni.gbcs.base.GBCS.toUrl +import net.woggioni.gbcs.base.contextLogger +import net.woggioni.gbcs.base.debug +import net.woggioni.gbcs.base.info +import net.woggioni.gbcs.configuration.Parser +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.Tuple2 +import java.io.ByteArrayOutputStream import java.net.InetSocketAddress -import java.net.URI import java.net.URL import java.net.URLStreamHandlerFactory import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path import java.security.KeyStore -import java.security.MessageDigest import java.security.PrivateKey import java.security.cert.X509Certificate import java.util.Arrays @@ -68,6 +76,7 @@ import java.util.regex.Pattern import javax.naming.ldap.LdapName import javax.net.ssl.SSLEngine import javax.net.ssl.SSLPeerUnverifiedException +import kotlin.io.path.absolute class GradleBuildCacheServer(private val cfg: Configuration) { @@ -107,7 +116,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): Set? { return try { sslEngine.session.peerCertificates - } catch (es : SSLPeerUnverifiedException) { + } catch (es: SSLPeerUnverifiedException) { null }?.takeIf { it.isNotEmpty() @@ -187,13 +196,13 @@ class GradleBuildCacheServer(private val cfg: Configuration) { .map { it as X509Certificate } .toArray { size -> Array(size) { null } } SslContextBuilder.forServer(serverKey, *serverCert).apply { - if (tls.verifyClients) { + if (tls.isVerifyClients) { clientAuth(ClientAuth.OPTIONAL) val trustStore = tls.trustStore if (trustStore != null) { val ts = loadKeystore(trustStore.file, trustStore.password) trustManager( - ClientCertificateValidator.getTrustManager(ts, trustStore.checkCertificateStatus) + ClientCertificateValidator.getTrustManager(ts, trustStore.isCheckCertificateStatus) ) } } @@ -259,7 +268,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { override fun initChannel(ch: Channel) { val pipeline = ch.pipeline() val auth = cfg.authentication - var authenticator : AbstractNettyHttpAuthenticator? = null + var authenticator: AbstractNettyHttpAuthenticator? = null if (auth is Configuration.BasicAuthentication) { val roleAuthorizer = RoleAuthorizer() authenticator = (NettyHttpBasicAuthenticator(cfg.users, roleAuthorizer)) @@ -268,7 +277,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { val sslHandler = sslContext.newHandler(ch.alloc()) pipeline.addLast(sslHandler) - if(auth is Configuration.ClientCertificateAuthentication) { + if (auth is Configuration.ClientCertificateAuthentication) { val roleAuthorizer = RoleAuthorizer() authenticator = ClientCertificateAuthenticator( roleAuthorizer, @@ -282,16 +291,12 @@ class GradleBuildCacheServer(private val cfg: Configuration) { pipeline.addLast(HttpChunkContentCompressor(1024)) pipeline.addLast(ChunkedWriteHandler()) pipeline.addLast(HttpObjectAggregator(Int.MAX_VALUE)) - authenticator?.let{ + authenticator?.let { pipeline.addLast(it) } - val cacheImplementation = when(val cache = cfg.cache) { - is Configuration.FileSystemCache -> { - FileSystemCache(cache.root, cache.maxAge) - } - else -> throw NotImplementedError() - } - pipeline.addLast(group, ServerHandler(cacheImplementation, cfg.serverPath)) + val cacheImplementation = cfg.cache.materialize() + val prefix = Path.of("/").resolve(Path.of(cfg.serverPath ?: "/")) + pipeline.addLast(group, ServerHandler(cacheImplementation, prefix)) pipeline.addLast(ExceptionHandler()) } } @@ -305,6 +310,12 @@ class GradleBuildCacheServer(private val cfg: Configuration) { headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" } + private val TOO_BIG: FullHttpResponse = DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE, Unpooled.EMPTY_BUFFER + ).apply { + headers()[HttpHeaderNames.CONTENT_LENGTH] = "0" + } + override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { when (cause) { is DecoderException -> { @@ -317,6 +328,11 @@ class GradleBuildCacheServer(private val cfg: Configuration) { .addListener(ChannelFutureListener.CLOSE_ON_FAILURE) } + is ContentTooLargeException -> { + ctx.writeAndFlush(TOO_BIG.retainedDuplicate()) + .addListener(ChannelFutureListener.CLOSE_ON_FAILURE) + } + else -> { log.error(cause.message, cause) ctx.close() @@ -325,7 +341,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { } } - private class ServerHandler(private val cache: Cache, private val serverPrefix: String?) : + private class ServerHandler(private val cache: Cache, private val serverPrefix: Path) : SimpleChannelInboundHandler() { companion object { @@ -335,7 +351,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { 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).takeIf(String::isNotEmpty) to uri.substring(i + 1) + return uri.substring(0, i) to uri.substring(i + 1) } } @@ -343,9 +359,12 @@ class GradleBuildCacheServer(private val cfg: Configuration) { val keepAlive: Boolean = HttpUtil.isKeepAlive(msg) val method = msg.method() if (method === HttpMethod.GET) { - val (prefix, key) = splitPath(msg) +// val (prefix, key) = splitPath(msg) + val path = Path.of(msg.uri()) + val prefix = path.parent + val key = path.fileName.toString() if (serverPrefix == prefix) { - cache.get(digestString(key.toByteArray()))?.let { channel -> + cache.get(key)?.let { channel -> log.debug(ctx) { "Cache hit for key '$key'" } @@ -369,6 +388,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { .addListener(ChannelFutureListener.CLOSE) } } + else -> { ctx.write(ChunkedNioStream(channel)) ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) @@ -391,13 +411,24 @@ class GradleBuildCacheServer(private val cfg: Configuration) { ctx.writeAndFlush(response) } } else if (method === HttpMethod.PUT) { - val (prefix, key) = splitPath(msg) + val path = Path.of(msg.uri()) + val prefix = path.parent + val key = path.fileName.toString() + if (serverPrefix == prefix) { log.debug(ctx) { "Added value for key '$key' to build cache" } - val content = msg.content() - cache.put(digestString(key.toByteArray()), content) + val bodyBytes = msg.content().run { + if (isDirect) { + ByteArray(readableBytes()).also { + readBytes(it) + } + } else { + array() + } + } + cache.put(key, bodyBytes) val response = DefaultFullHttpResponse( msg.protocolVersion(), HttpResponseStatus.CREATED, Unpooled.copiedBuffer(key.toByteArray()) @@ -453,7 +484,7 @@ class GradleBuildCacheServer(private val cfg: Configuration) { // Create the multithreaded event loops for the server val bossGroup = NioEventLoopGroup() val serverSocketChannel = NioServerSocketChannel::class.java - val workerGroup = if (cfg.useVirtualThread) { + val workerGroup = if (cfg.isUseVirtualThread) { NioEventLoopGroup(0, Executors.newVirtualThreadPerTaskExecutor()) } else { NioEventLoopGroup(0, Executors.newWorkStealingPool()) @@ -477,8 +508,6 @@ class GradleBuildCacheServer(private val cfg: Configuration) { companion object { - private fun String.toUrl() : URL = URL.of(URI(this), null) - private val log by lazy { contextLogger() } @@ -486,9 +515,6 @@ class GradleBuildCacheServer(private val cfg: Configuration) { private const val PROTOCOL_HANDLER = "java.protocol.handler.pkgs" private const val HANDLERS_PACKAGE = "net.woggioni.gbcs.url" - val CONFIGURATION_SCHEMA_URL by lazy { - "classpath:net/woggioni/gbcs/gbcs.xsd".toUrl() - } val DEFAULT_CONFIGURATION_URL by lazy { "classpath:net/woggioni/gbcs/gbcs-default.xml".toUrl() } /** @@ -515,7 +541,6 @@ class GradleBuildCacheServer(private val cfg: Configuration) { fun loadConfiguration(args: Array): Configuration { // registerUrlProtocolHandler() - URL.setURLStreamHandlerFactory(ClasspathUrlStreamHandlerFactoryProvider()) // Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader val app = Application.builder("gbcs") .configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR") @@ -524,6 +549,9 @@ class GradleBuildCacheServer(private val cfg: Configuration) { 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 -> @@ -533,38 +561,29 @@ class GradleBuildCacheServer(private val cfg: Configuration) { } } // val schemaUrl = javaClass.getResource("/net/woggioni/gbcs/gbcs.xsd") - val schemaUrl = CONFIGURATION_SCHEMA_URL - val dbf = Xml.newDocumentBuilderFactory(schemaUrl) +// 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().apply { - setErrorHandler(Xml.ErrorHandler(schemaUrl)) - } +// dbf.schema = Xml.getSchema(schemaUrl) + val db = dbf.newDocumentBuilder() val doc = Files.newInputStream(configurationFile).use(db::parse) - return Configuration.parse(doc) + return Parser.parse(doc) } @JvmStatic fun main(args: Array) { + 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 { } } - - fun digest( - data: ByteArray, - md: MessageDigest = MessageDigest.getInstance("MD5") - ): ByteArray { - md.update(data) - return md.digest() - } - - fun digestString( - data: ByteArray, - md: MessageDigest = MessageDigest.getInstance("MD5") - ): String { - return JWO.bytesToHex(digest(data, md)) - } } } diff --git a/src/main/kotlin/net/woggioni/gbcs/Logging.kt b/src/main/kotlin/net/woggioni/gbcs/Logging.kt index a2e922d..2973490 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Logging.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Logging.kt @@ -2,87 +2,7 @@ package net.woggioni.gbcs import io.netty.channel.ChannelHandlerContext import org.slf4j.Logger -import org.slf4j.LoggerFactory import java.net.InetSocketAddress -import java.nio.file.Files -import java.nio.file.Path -import java.util.logging.LogManager - -inline fun T.contextLogger() = LoggerFactory.getLogger(T::class.java) - -inline fun Logger.traceParam(messageBuilder : () -> Pair>) { - if(isTraceEnabled) { - val (format, params) = messageBuilder() - trace(format, params) - } -} - -inline fun Logger.debugParam(messageBuilder : () -> Pair>) { - if(isDebugEnabled) { - val (format, params) = messageBuilder() - info(format, params) - } -} - -inline fun Logger.infoParam(messageBuilder : () -> Pair>) { - if(isInfoEnabled) { - val (format, params) = messageBuilder() - info(format, params) - } -} - -inline fun Logger.warnParam(messageBuilder : () -> Pair>) { - if(isWarnEnabled) { - val (format, params) = messageBuilder() - warn(format, params) - } -} - -inline fun Logger.errorParam(messageBuilder : () -> Pair>) { - if(isErrorEnabled) { - val (format, params) = messageBuilder() - error(format, params) - } -} - - -inline fun log(log : Logger, - filter : Logger.() -> Boolean, - loggerMethod : Logger.(String) -> Unit, messageBuilder : () -> String) { - if(log.filter()) { - log.loggerMethod(messageBuilder()) - } -} - -inline fun Logger.trace(messageBuilder : () -> String) { - if(isTraceEnabled) { - trace(messageBuilder()) - } -} - -inline fun Logger.debug(messageBuilder : () -> String) { - if(isDebugEnabled) { - debug(messageBuilder()) - } -} - -inline fun Logger.info(messageBuilder : () -> String) { - if(isInfoEnabled) { - info(messageBuilder()) - } -} - -inline fun Logger.warn(messageBuilder : () -> String) { - if(isWarnEnabled) { - warn(messageBuilder()) - } -} - -inline fun Logger.error(messageBuilder : () -> String) { - if(isErrorEnabled) { - error(messageBuilder()) - } -} inline fun Logger.trace(ctx : ChannelHandlerContext, messageBuilder : () -> String) { log(this, ctx, { isTraceEnabled }, { trace(it) } , messageBuilder) @@ -107,25 +27,4 @@ inline fun log(log : Logger, ctx : ChannelHandlerContext, val clientAddress = (ctx.channel().remoteAddress() as InetSocketAddress).address.hostAddress log.loggerMethod(clientAddress + " - " + messageBuilder()) } -} - - -class LoggingConfig { - - init { - val logManager = LogManager.getLogManager() - System.getProperty("log.config.source")?.let withSource@ { source -> - val urls = LoggingConfig::class.java.classLoader.getResources(source) - while(urls.hasMoreElements()) { - val url = urls.nextElement() - url.openStream().use { inputStream -> - logManager.readConfiguration(inputStream) - return@withSource - } - } - Path.of(source).takeIf(Files::exists) - ?.let(Files::newInputStream) - ?.use(logManager::readConfiguration) - } - } } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Role.kt b/src/main/kotlin/net/woggioni/gbcs/Role.kt deleted file mode 100644 index a9a5a2a..0000000 --- a/src/main/kotlin/net/woggioni/gbcs/Role.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.woggioni.gbcs - -enum class Role { - Reader, Writer -} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt b/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt index 3823155..b695a79 100644 --- a/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/UserAuthorizer.kt @@ -2,6 +2,7 @@ package net.woggioni.gbcs import io.netty.handler.codec.http.HttpMethod import io.netty.handler.codec.http.HttpRequest +import net.woggioni.gbcs.api.Role class RoleAuthorizer : Authorizer { diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt b/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt deleted file mode 100644 index 26761a1..0000000 --- a/src/main/kotlin/net/woggioni/gbcs/cache/Cache.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.woggioni.gbcs.cache - -import io.netty.buffer.ByteBuf -import java.nio.channels.ByteChannel - -interface Cache { - fun get(key : String) : ByteChannel? - - fun put(key : String, content : ByteBuf) : Unit -} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt index 3e882e5..6b8c0bb 100644 --- a/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt +++ b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCache.kt @@ -1,20 +1,32 @@ package net.woggioni.gbcs.cache -import io.netty.buffer.ByteBuf -import net.woggioni.gbcs.GradleBuildCacheServer.Companion.digestString +import net.woggioni.gbcs.api.Cache +import net.woggioni.jwo.JWO import net.woggioni.jwo.LockFile +import java.nio.channels.Channels import java.nio.channels.FileChannel import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption import java.nio.file.StandardOpenOption import java.nio.file.attribute.BasicFileAttributes +import java.security.MessageDigest import java.time.Duration import java.time.Instant import java.util.concurrent.atomic.AtomicReference +import java.util.zip.Deflater +import java.util.zip.DeflaterOutputStream +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream -class FileSystemCache(val root: Path, val maxAge: Duration) : Cache { +class FileSystemCache( + val root: Path, + val maxAge: Duration, + val digestAlgorithm: String?, + val compressionEnabled: Boolean, + val compressionLevel: Int +) : Cache { private fun lockFilePath(key: String): Path = root.resolve("$key.lock") @@ -22,40 +34,52 @@ class FileSystemCache(val root: Path, val maxAge: Duration) : Cache { Files.createDirectories(root) } - override fun equals(other: Any?): Boolean { - return when (other) { - is FileSystemCache -> { - other.root == root && other.maxAge == maxAge - } + private var nextGc = AtomicReference(Instant.now().plus(maxAge)) - else -> false + override fun get(key: String) = (digestAlgorithm + ?.let(MessageDigest::getInstance) + ?.let { md -> + digestString(key.toByteArray(), md) + } ?: key).let { digest -> + LockFile.acquire(lockFilePath(digest), true).use { + root.resolve(digest).takeIf(Files::exists)?.let { file -> + if (compressionEnabled) { + val inflater = Inflater() + Channels.newChannel(InflaterInputStream(Files.newInputStream(file), inflater)) + } else { + FileChannel.open(file, StandardOpenOption.READ) + } + } + }.also { + gc() } } - override fun hashCode(): Int { - return root.hashCode() xor maxAge.hashCode() - } - - private var nextGc = AtomicReference(Instant.now().plus(maxAge)) - - override fun get(key: String) = LockFile.acquire(lockFilePath(key), true).use { - root.resolve(key).takeIf(Files::exists)?.let { FileChannel.open(it, StandardOpenOption.READ) } - }.also { - gc() - } - - override fun put(key: String, content: ByteBuf) { - LockFile.acquire(lockFilePath(key), false).use { - val file = root.resolve(key) - val tmpFile = Files.createTempFile(root, null, ".tmp") - try { - Files.newOutputStream(tmpFile).use { - content.readBytes(it, content.readableBytes()) + override fun put(key: String, content: ByteArray) { + (digestAlgorithm + ?.let(MessageDigest::getInstance) + ?.let { md -> + digestString(key.toByteArray(), md) + } ?: key).let { digest -> + LockFile.acquire(lockFilePath(digest), false).use { + val file = root.resolve(digest) + val tmpFile = Files.createTempFile(root, null, ".tmp") + try { + Files.newOutputStream(tmpFile).let { + if (compressionEnabled) { + val deflater = Deflater(compressionLevel) + DeflaterOutputStream(it, deflater) + } else { + it + } + }.use { + it.write(content) + } + Files.move(tmpFile, file, StandardCopyOption.ATOMIC_MOVE) + } catch (t: Throwable) { + Files.delete(tmpFile) + throw t } - Files.move(tmpFile, file, StandardCopyOption.ATOMIC_MOVE) - } catch (t: Throwable) { - Files.delete(tmpFile) - throw t } }.also { gc() @@ -87,4 +111,23 @@ class FileSystemCache(val root: Path, val maxAge: Duration) : Cache { Files.delete(lockFile) } } + + override fun close() {} + + companion object { + fun digest( + data: ByteArray, + md: MessageDigest = MessageDigest.getInstance("MD5") + ): ByteArray { + md.update(data) + return md.digest() + } + + fun digestString( + data: ByteArray, + md: MessageDigest = MessageDigest.getInstance("MD5") + ): String { + return JWO.bytesToHex(digest(data, md)) + } + } } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheConfiguration.kt b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheConfiguration.kt new file mode 100644 index 0000000..5201716 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheConfiguration.kt @@ -0,0 +1,27 @@ +package net.woggioni.gbcs.cache + +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.base.GBCS +import net.woggioni.jwo.Application +import java.nio.file.Path +import java.time.Duration + +data class FileSystemCacheConfiguration( + val root: Path?, + val maxAge: Duration, + val digestAlgorithm : String?, + val compressionEnabled: Boolean, + val compressionLevel: Int, +) : Configuration.Cache { + override fun materialize() = FileSystemCache( + root ?: Application.builder("gbcs").build().computeCacheDirectory(), + maxAge, + digestAlgorithm, + compressionEnabled, + compressionLevel + ) + + override fun getNamespaceURI() = GBCS.GBCS_NAMESPACE_URI + + override fun getTypeName() = "fileSystemCacheType" +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheProvider.kt b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheProvider.kt new file mode 100644 index 0000000..26ff143 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/cache/FileSystemCacheProvider.kt @@ -0,0 +1,66 @@ +package net.woggioni.gbcs.cache + +import net.woggioni.gbcs.api.CacheProvider +import net.woggioni.gbcs.base.GBCS +import net.woggioni.gbcs.base.Xml +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.nio.file.Path +import java.time.Duration +import java.util.zip.Deflater + +class FileSystemCacheProvider : CacheProvider { + + override fun getXmlSchemaLocation() = "classpath:net/woggioni/gbcs/schema/gbcs.xsd" + + override fun getXmlType() = "fileSystemCacheType" + + override fun getXmlNamespace() = "urn:net.woggioni.gbcs" + + override fun deserialize(el: Element): FileSystemCacheConfiguration { + val path = el.getAttribute("path") + .takeIf(String::isNotEmpty) + ?.let(Path::of) + val maxAge = el.getAttribute("max-age") + .takeIf(String::isNotEmpty) + ?.let(Duration::parse) + ?: Duration.ofDays(1) + val enableCompression = el.getAttribute("enable-compression") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) + ?: true + val compressionLevel = el.getAttribute("compression-level") + .takeIf(String::isNotEmpty) + ?.let(String::toInt) + ?: Deflater.DEFAULT_COMPRESSION + val digestAlgorithm = el.getAttribute("digest").takeIf(String::isNotEmpty) ?: "MD5" + + return FileSystemCacheConfiguration( + path, + maxAge, + digestAlgorithm, + enableCompression, + compressionLevel + ) + } + + override fun serialize(doc: Document, cache : FileSystemCacheConfiguration) = cache.run { + val result = doc.createElement("cache") + Xml.of(doc, result) { + val prefix = doc.lookupPrefix(GBCS.GBCS_NAMESPACE_URI) + attr("xs:type", "${prefix}:fileSystemCacheType", GBCS.XML_SCHEMA_NAMESPACE_URI) + attr("path", root.toString()) + attr("max-age", maxAge.toString()) + digestAlgorithm?.let { digestAlgorithm -> + attr("digest", digestAlgorithm) + } + attr("enable-compression", compressionEnabled.toString()) + compressionLevel.takeIf { + it != Deflater.DEFAULT_COMPRESSION + }?.let { + attr("compression-level", it.toString()) + } + } + result + } +} diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/CacheSerializers.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/CacheSerializers.kt new file mode 100644 index 0000000..00d0012 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/CacheSerializers.kt @@ -0,0 +1,15 @@ +package net.woggioni.gbcs.configuration + +import net.woggioni.gbcs.api.CacheProvider +import net.woggioni.gbcs.api.Configuration +import java.util.ServiceLoader + +object CacheSerializers { + val index = (Configuration::class.java.module.layer?.let { layer -> + ServiceLoader.load(layer, CacheProvider::class.java) + } ?: ServiceLoader.load(CacheProvider::class.java)) + .asSequence() + .map { + (it.xmlNamespace to it.xmlType) to it + }.toMap() +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt index 0a55a83..2b2af28 100644 --- a/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/Configuration.kt @@ -1,14 +1,11 @@ package net.woggioni.gbcs.configuration -import net.woggioni.gbcs.Role -import net.woggioni.gbcs.Xml.Companion.asIterable -import org.w3c.dom.Document -import org.w3c.dom.Element +import net.woggioni.gbcs.api.Role import java.nio.file.Path -import java.nio.file.Paths import java.security.cert.X509Certificate import java.time.Duration +@ConsistentCopyVisibility data class Configuration private constructor( val host: String, val port: Int, @@ -36,10 +33,6 @@ data class Configuration private constructor( get() = groups.asSequence().flatMap { it.roles }.toSet() } - data class HostAndPort(val host: String, val port: Int) { - override fun toString() = "$host:$port" - } - fun interface UserExtractor { fun extract(cert :X509Certificate) : User } @@ -107,188 +100,210 @@ data class Configuration private constructor( useVirtualThread ) - fun parse(document: Document): Configuration { - val root = document.documentElement - var cache: Cache? = null - var host = "127.0.0.1" - var port = 11080 - var users = emptyMap() - var groups = emptyMap() - var tls: Tls? = null - val serverPath = root.getAttribute("path") - val useVirtualThread = root.getAttribute("useVirtualThreads") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) ?: false - var authentication : Authentication? = null - for (child in root.asIterable()) { - when (child.nodeName) { - "authorization" -> { - for (gchild in child.asIterable()) { - when (child.nodeName) { - "users" -> { - users = parseUsers(child) - } - - "groups" -> { - val pair = parseGroups(child, users) - users = pair.first - groups = pair.second - } - } - } - } - - "bind" -> { - host = child.getAttribute("host") - port = Integer.parseInt(child.getAttribute("port")) - } - - "cache" -> { - for (gchild in child.asIterable()) { - when (gchild.nodeName) { - "file-system-cache" -> { - val cacheFolder = gchild.getAttribute("path") - .takeIf(String::isNotEmpty) - ?.let(Paths::get) - ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") - val maxAge = gchild.getAttribute("max-age") - .takeIf(String::isNotEmpty) - ?.let(Duration::parse) - ?: Duration.ofDays(1) - cache = FileSystemCache(cacheFolder, maxAge) - } - } - } - } - - "authentication" -> { - for (gchild in child.asIterable()) { - when (gchild.nodeName) { - "basic" -> { - authentication = BasicAuthentication() - } - - "client-certificate" -> { - var tlsExtractorUser : TlsCertificateExtractor? = null - var tlsExtractorGroup : TlsCertificateExtractor? = null - for (gchild in child.asIterable()) { - when (gchild.nodeName) { - "group-extractor" -> { - val attrName = gchild.getAttribute("attribute-name") - val pattern = gchild.getAttribute("pattern") - tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) - } - - "user-extractor" -> { - val attrName = gchild.getAttribute("attribute-name") - val pattern = gchild.getAttribute("pattern") - tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) - } - } - } - authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) - } - } - } - } - - "tls" -> { - val verifyClients = child.getAttribute("verify-clients") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) ?: false - var keyStore: KeyStore? = null - var trustStore: TrustStore? = null - for (granChild in child.asIterable()) { - when (granChild.nodeName) { - "keystore" -> { - val keyStoreFile = Paths.get(granChild.getAttribute("file")) - val keyStorePassword = granChild.getAttribute("password") - .takeIf(String::isNotEmpty) - val keyAlias = granChild.getAttribute("key-alias") - val keyPassword = granChild.getAttribute("key-password") - .takeIf(String::isNotEmpty) - keyStore = KeyStore( - keyStoreFile, - keyStorePassword, - keyAlias, - keyPassword - ) - } - - "truststore" -> { - val trustStoreFile = Paths.get(granChild.getAttribute("file")) - val trustStorePassword = granChild.getAttribute("password") - .takeIf(String::isNotEmpty) - val checkCertificateStatus = granChild.getAttribute("check-certificate-status") - .takeIf(String::isNotEmpty) - ?.let(String::toBoolean) - ?: false - trustStore = TrustStore( - trustStoreFile, - trustStorePassword, - checkCertificateStatus - ) - } - } - } - tls = Tls(keyStore, trustStore, verifyClients) - } - } - } - return of(host, port, serverPath, users, groups, cache!!, authentication, tls, useVirtualThread) - } - - private fun parseRoles(root: Element) = root.asIterable().asSequence().map { - when (it.nodeName) { - "reader" -> Role.Reader - "writer" -> Role.Writer - else -> throw UnsupportedOperationException("Illegal node '${it.nodeName}'") - } - }.toSet() - - private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter { - it.nodeName == "user" - }.map { - it.getAttribute("ref") - }.toSet() - - private fun parseUsers(root: Element): Map { - return root.asIterable().asSequence().filter { - it.nodeName == "user" - }.map { el -> - val username = el.getAttribute("name") - val password = el.getAttribute("password").takeIf(String::isNotEmpty) - username to User(username, password, emptySet()) - }.toMap() - } - - private fun parseGroups(root: Element, knownUsers : Map): Pair, Map> { - val userGroups = mutableMapOf>() - val groups = root.asIterable().asSequence().filter { - it.nodeName == "group" - }.map { el -> - val groupName = el.getAttribute("name") - var roles = emptySet() - for (child in el.asIterable()) { - when (child.nodeName) { - "users" -> { - parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user -> - userGroups.computeIfAbsent(user.name) { - mutableSetOf() - }.add(groupName) - } - } - "roles" -> { - roles = parseRoles(child) - } - } - } - groupName to Group(groupName, roles) - }.toMap() - val users = knownUsers.map { (name, user) -> - name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet()) - }.toMap() - return users to groups - } +// fun parse(document: Document): Configuration { +// val cacheSerializers = ServiceLoader.load(Configuration::class.java.module.layer, CacheSerializer::class.java) +// .asSequence() +// .map { +// "${it.xmlType}:${it.xmlNamespace}" to it +// }.toMap() +// val root = document.documentElement +// var cache: Cache? = null +// var host = "127.0.0.1" +// var port = 11080 +// var users = emptyMap() +// var groups = emptyMap() +// var tls: Tls? = null +// val serverPath = root.getAttribute("path") +// val useVirtualThread = root.getAttribute("useVirtualThreads") +// .takeIf(String::isNotEmpty) +// ?.let(String::toBoolean) ?: false +// var authentication : Authentication? = null +// for (child in root.asIterable()) { +// when (child.nodeName) { +// "authorization" -> { +// for (gchild in child.asIterable()) { +// when (child.nodeName) { +// "users" -> { +// users = parseUsers(child) +// } +// +// "groups" -> { +// val pair = parseGroups(child, users) +// users = pair.first +// groups = pair.second +// } +// } +// } +// } +// +// "bind" -> { +// host = child.getAttribute("host") +// port = Integer.parseInt(child.getAttribute("port")) +// } +// +// "cache" -> { +// val type = child.getAttribute("xs:type") +// val serializer = cacheSerializers.get(type) ?: throw NotImplementedError() +// cache = serializer.deserialize(child) +// +// when(child.getAttribute("xs:type")) { +// "gbcs:fileSystemCacheType" -> { +// val cacheFolder = child.getAttribute("path") +// .takeIf(String::isNotEmpty) +// ?.let(Paths::get) +// ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") +// val maxAge = child.getAttribute("max-age") +// .takeIf(String::isNotEmpty) +// ?.let(Duration::parse) +// ?: Duration.ofDays(1) +// cache = FileSystemCache(cacheFolder, maxAge) +// } +// } +//// for (gchild in child.asIterable()) { +//// when (gchild.nodeName) { +//// "file-system-cache" -> { +//// val cacheFolder = gchild.getAttribute("path") +//// .takeIf(String::isNotEmpty) +//// ?.let(Paths::get) +//// ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") +//// val maxAge = gchild.getAttribute("max-age") +//// .takeIf(String::isNotEmpty) +//// ?.let(Duration::parse) +//// ?: Duration.ofDays(1) +//// cache = FileSystemCache(cacheFolder, maxAge) +//// } +//// } +//// } +// } +// +// "authentication" -> { +// for (gchild in child.asIterable()) { +// when (gchild.nodeName) { +// "basic" -> { +// authentication = BasicAuthentication() +// } +// +// "client-certificate" -> { +// var tlsExtractorUser : TlsCertificateExtractor? = null +// var tlsExtractorGroup : TlsCertificateExtractor? = null +// for (gchild in child.asIterable()) { +// when (gchild.nodeName) { +// "group-extractor" -> { +// val attrName = gchild.getAttribute("attribute-name") +// val pattern = gchild.getAttribute("pattern") +// tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) +// } +// +// "user-extractor" -> { +// val attrName = gchild.getAttribute("attribute-name") +// val pattern = gchild.getAttribute("pattern") +// tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) +// } +// } +// } +// authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) +// } +// } +// } +// } +// +// "tls" -> { +// val verifyClients = child.getAttribute("verify-clients") +// .takeIf(String::isNotEmpty) +// ?.let(String::toBoolean) ?: false +// var keyStore: KeyStore? = null +// var trustStore: TrustStore? = null +// for (granChild in child.asIterable()) { +// when (granChild.nodeName) { +// "keystore" -> { +// val keyStoreFile = Paths.get(granChild.getAttribute("file")) +// val keyStorePassword = granChild.getAttribute("password") +// .takeIf(String::isNotEmpty) +// val keyAlias = granChild.getAttribute("key-alias") +// val keyPassword = granChild.getAttribute("key-password") +// .takeIf(String::isNotEmpty) +// keyStore = KeyStore( +// keyStoreFile, +// keyStorePassword, +// keyAlias, +// keyPassword +// ) +// } +// +// "truststore" -> { +// val trustStoreFile = Paths.get(granChild.getAttribute("file")) +// val trustStorePassword = granChild.getAttribute("password") +// .takeIf(String::isNotEmpty) +// val checkCertificateStatus = granChild.getAttribute("check-certificate-status") +// .takeIf(String::isNotEmpty) +// ?.let(String::toBoolean) +// ?: false +// trustStore = TrustStore( +// trustStoreFile, +// trustStorePassword, +// checkCertificateStatus +// ) +// } +// } +// } +// tls = Tls(keyStore, trustStore, verifyClients) +// } +// } +// } +// return of(host, port, serverPath, users, groups, cache!!, authentication, tls, useVirtualThread) +// } +// +// private fun parseRoles(root: Element) = root.asIterable().asSequence().map { +// when (it.nodeName) { +// "reader" -> Role.Reader +// "writer" -> Role.Writer +// else -> throw UnsupportedOperationException("Illegal node '${it.nodeName}'") +// } +// }.toSet() +// +// private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter { +// it.nodeName == "user" +// }.map { +// it.getAttribute("ref") +// }.toSet() +// +// private fun parseUsers(root: Element): Map { +// return root.asIterable().asSequence().filter { +// it.nodeName == "user" +// }.map { el -> +// val username = el.getAttribute("name") +// val password = el.getAttribute("password").takeIf(String::isNotEmpty) +// username to User(username, password, emptySet()) +// }.toMap() +// } +// +// private fun parseGroups(root: Element, knownUsers : Map): Pair, Map> { +// val userGroups = mutableMapOf>() +// val groups = root.asIterable().asSequence().filter { +// it.nodeName == "group" +// }.map { el -> +// val groupName = el.getAttribute("name") +// var roles = emptySet() +// for (child in el.asIterable()) { +// when (child.nodeName) { +// "users" -> { +// parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user -> +// userGroups.computeIfAbsent(user.name) { +// mutableSetOf() +// }.add(groupName) +// } +// } +// "roles" -> { +// roles = parseRoles(child) +// } +// } +// } +// groupName to Group(groupName, roles) +// }.toMap() +// val users = knownUsers.map { (name, user) -> +// name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet()) +// }.toMap() +// return users to groups +// } } } diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/Parser.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/Parser.kt new file mode 100644 index 0000000..e8a3164 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/Parser.kt @@ -0,0 +1,231 @@ +package net.woggioni.gbcs.configuration + +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.api.Configuration.Authentication +import net.woggioni.gbcs.api.Configuration.BasicAuthentication +import net.woggioni.gbcs.api.Configuration.Cache +import net.woggioni.gbcs.api.Configuration.ClientCertificateAuthentication +import net.woggioni.gbcs.api.Configuration.Group +import net.woggioni.gbcs.api.Configuration.KeyStore +import net.woggioni.gbcs.api.Configuration.Tls +import net.woggioni.gbcs.api.Configuration.TlsCertificateExtractor +import net.woggioni.gbcs.api.Configuration.TrustStore +import net.woggioni.gbcs.api.Configuration.User +import net.woggioni.gbcs.api.Role +import net.woggioni.gbcs.base.Xml.Companion.asIterable +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.TypeInfo +import java.nio.file.Paths + +object Parser { + + fun parse(document: Document): Configuration { + val root = document.documentElement + var cache: Cache? = null + var host = "127.0.0.1" + var port = 11080 + var users = emptyMap() + var groups = emptyMap() + var tls: Tls? = null + val serverPath = root.getAttribute("path") + val useVirtualThread = root.getAttribute("useVirtualThreads") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) ?: false + var authentication: Authentication? = null + for (child in root.asIterable()) { + when (child.localName) { + "authorization" -> { + for (gchild in child.asIterable()) { + when (child.localName) { + "users" -> { + users = parseUsers(child) + } + + "groups" -> { + val pair = parseGroups(child, users) + users = pair.first + groups = pair.second + } + } + } + } + + "bind" -> { + host = child.getAttribute("host") + port = Integer.parseInt(child.getAttribute("port")) + } + + "cache" -> { +// val type = child.getAttribute("xs:type").split(":") +// val namespaceURI = child.lookupNamespaceURI(type[0]) +// val typeName = type[1] + cache = (child as? TypeInfo)?.let { tf -> + val typeNamespace = tf.typeNamespace + val typeName = tf.typeName + CacheSerializers.index[typeNamespace to typeName] + }?.deserialize(child) ?: throw NotImplementedError() + +// cache = serializer.deserialize(child) + +// when(child.getAttribute("xs:type")) { +// "gbcs:fileSystemCacheType" -> { +// val cacheFolder = child.getAttribute("path") +// .takeIf(String::isNotEmpty) +// ?.let(Paths::get) +// ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") +// val maxAge = child.getAttribute("max-age") +// .takeIf(String::isNotEmpty) +// ?.let(Duration::parse) +// ?: Duration.ofDays(1) +// cache = FileSystemCache(cacheFolder, maxAge) +// } +// } +// for (gchild in child.asIterable()) { +// when (gchild.localName) { +// "file-system-cache" -> { +// val cacheFolder = gchild.getAttribute("path") +// .takeIf(String::isNotEmpty) +// ?.let(Paths::get) +// ?: Paths.get(System.getProperty("user.home")).resolve(".gbcs") +// val maxAge = gchild.getAttribute("max-age") +// .takeIf(String::isNotEmpty) +// ?.let(Duration::parse) +// ?: Duration.ofDays(1) +// cache = FileSystemCache(cacheFolder, maxAge) +// } +// } +// } + } + + "authentication" -> { + for (gchild in child.asIterable()) { + when (gchild.localName) { + "basic" -> { + authentication = BasicAuthentication() + } + + "client-certificate" -> { + var tlsExtractorUser: TlsCertificateExtractor? = null + var tlsExtractorGroup: TlsCertificateExtractor? = null + for (gchild in child.asIterable()) { + when (gchild.localName) { + "group-extractor" -> { + val attrName = gchild.getAttribute("attribute-name") + val pattern = gchild.getAttribute("pattern") + tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern) + } + + "user-extractor" -> { + val attrName = gchild.getAttribute("attribute-name") + val pattern = gchild.getAttribute("pattern") + tlsExtractorUser = TlsCertificateExtractor(attrName, pattern) + } + } + } + authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) + } + } + } + } + + "tls" -> { + val verifyClients = child.getAttribute("verify-clients") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) ?: false + var keyStore: KeyStore? = null + var trustStore: TrustStore? = null + for (granChild in child.asIterable()) { + when (granChild.localName) { + "keystore" -> { + val keyStoreFile = Paths.get(granChild.getAttribute("file")) + val keyStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + val keyAlias = granChild.getAttribute("key-alias") + val keyPassword = granChild.getAttribute("key-password") + .takeIf(String::isNotEmpty) + keyStore = KeyStore( + keyStoreFile, + keyStorePassword, + keyAlias, + keyPassword + ) + } + + "truststore" -> { + val trustStoreFile = Paths.get(granChild.getAttribute("file")) + val trustStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + val checkCertificateStatus = granChild.getAttribute("check-certificate-status") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) + ?: false + trustStore = TrustStore( + trustStoreFile, + trustStorePassword, + checkCertificateStatus + ) + } + } + } + tls = Tls(keyStore, trustStore, verifyClients) + } + } + } + return Configuration(host, port, serverPath, users, groups, cache!!, authentication, tls, useVirtualThread) + } + + private fun parseRoles(root: Element) = root.asIterable().asSequence().map { + when (it.localName) { + "reader" -> Role.Reader + "writer" -> Role.Writer + else -> throw UnsupportedOperationException("Illegal node '${it.localName}'") + } + }.toSet() + + private fun parseUserRefs(root: Element) = root.asIterable().asSequence().filter { + it.localName == "user" + }.map { + it.getAttribute("ref") + }.toSet() + + private fun parseUsers(root: Element): Map { + return root.asIterable().asSequence().filter { + it.localName == "user" + }.map { el -> + val username = el.getAttribute("name") + val password = el.getAttribute("password").takeIf(String::isNotEmpty) + username to User(username, password, emptySet()) + }.toMap() + } + + private fun parseGroups(root: Element, knownUsers: Map): Pair, Map> { + val userGroups = mutableMapOf>() + val groups = root.asIterable().asSequence().filter { + it.localName == "group" + }.map { el -> + val groupName = el.getAttribute("name") + var roles = emptySet() + for (child in el.asIterable()) { + when (child.localName) { + "users" -> { + parseUserRefs(child).mapNotNull(knownUsers::get).forEach { user -> + userGroups.computeIfAbsent(user.name) { + mutableSetOf() + }.add(groupName) + } + } + + "roles" -> { + roles = parseRoles(child) + } + } + } + groupName to Group(groupName, roles) + }.toMap() + val users = knownUsers.map { (name, user) -> + name to User(name, user.password, userGroups[name]?.mapNotNull { groups[it] }?.toSet() ?: emptySet()) + }.toMap() + return users to groups + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt b/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt index 88485f9..07bde9d 100644 --- a/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/configuration/Serializer.kt @@ -1,34 +1,37 @@ package net.woggioni.gbcs.configuration -import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.api.CacheProvider +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.base.GBCS +import net.woggioni.gbcs.base.Xml import org.w3c.dom.Document object Serializer { - private const val GBCS_NAMESPACE: String = "urn:net.woggioni.gbcs" - private const val GBCS_PREFIX: String = "gbcs" - fun serialize(conf : Configuration) : Document { - return Xml.of(GBCS_NAMESPACE, GBCS_PREFIX + ":server") { - attr("userVirtualThreads", conf.useVirtualThread.toString()) - conf.serverPath?.let { serverPath -> + + val schemaLocations = CacheSerializers.index.values.asSequence().map { + it.xmlNamespace to it.xmlSchemaLocation + }.toMap() + return Xml.of(GBCS.GBCS_NAMESPACE_URI, GBCS.GBCS_PREFIX + ":server") { + attr("useVirtualThreads", conf.isUseVirtualThread.toString()) +// attr("xmlns:xs", GradleBuildCacheServer.XML_SCHEMA_NAMESPACE_URI) + val value = schemaLocations.asSequence().map { (k, v) -> "$k $v" }.joinToString(" ") + attr("xs:schemaLocation",value , namespaceURI = GBCS.XML_SCHEMA_NAMESPACE_URI) + + conf.serverPath + ?.takeIf(String::isNotEmpty) + ?.let { serverPath -> attr("path", serverPath) } node("bind") { attr("host", conf.host) attr("port", conf.port.toString()) } - node("cache") { - when(val cache = conf.cache) { - is Configuration.FileSystemCache -> { - node("file-system-cache") { - attr("path", cache.root.toString()) - attr("max-age", cache.maxAge.toString()) - } - } - else -> throw NotImplementedError() - } - } + val cache = conf.cache + val serializer : CacheProvider = + (CacheSerializers.index[cache.namespaceURI to cache.typeName] as? CacheProvider) ?: throw NotImplementedError() + element.appendChild(serializer.serialize(doc, cache)) node("authorization") { node("users") { for(user in conf.users.values) { @@ -112,7 +115,7 @@ object Serializer { trustStore.password?.let { password -> attr("password", password) } - attr("check-certificate-status", trustStore.checkCertificateStatus.toString()) + attr("check-certificate-status", trustStore.isCheckCertificateStatus.toString()) } } } diff --git a/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider b/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider new file mode 100644 index 0000000..c396440 --- /dev/null +++ b/src/main/resources/META-INF/services/net.woggioni.gbcs.api.CacheProvider @@ -0,0 +1 @@ +net.woggioni.gbcs.cache.FileSystemCacheProvider \ No newline at end of file diff --git a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml index 29746aa..369b9e3 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml +++ b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml @@ -1,9 +1,9 @@ - + - - - + diff --git a/src/main/resources/logback.xml b/src/main/resources/net/woggioni/gbcs/logback.xml similarity index 78% rename from src/main/resources/logback.xml rename to src/main/resources/net/woggioni/gbcs/logback.xml index 2861972..cc0ff2f 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/net/woggioni/gbcs/logback.xml @@ -15,5 +15,7 @@ - + + + \ No newline at end of file diff --git a/src/main/resources/net/woggioni/gbcs/gbcs.xsd b/src/main/resources/net/woggioni/gbcs/schema/gbcs.xsd similarity index 90% rename from src/main/resources/net/woggioni/gbcs/gbcs.xsd rename to src/main/resources/net/woggioni/gbcs/schema/gbcs.xsd index 1e7078a..f051e7c 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs.xsd +++ b/src/main/resources/net/woggioni/gbcs/schema/gbcs.xsd @@ -1,7 +1,9 @@ + xmlns:gbcs="urn:net.woggioni.gbcs" + elementFormDefault="unqualified" +> @@ -21,7 +23,7 @@ - + @@ -34,15 +36,18 @@ - - - - - + - - + + + + + + + + + diff --git a/src/test/java/module-info.java.backup b/src/test/java/module-info.java.backup deleted file mode 100644 index 4b1307c..0000000 --- a/src/test/java/module-info.java.backup +++ /dev/null @@ -1,7 +0,0 @@ -module net.woggioni.gbcs.test { - requires org.junit.jupiter.api; - requires net.woggioni.gbcs; - requires kotlin.stdlib; - requires java.xml; - requires java.naming; -} \ No newline at end of file diff --git a/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java b/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java index e89d315..5c27676 100644 --- a/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java +++ b/src/test/java/net/woggioni/gbcs/utils/CertificateUtils.java @@ -9,16 +9,13 @@ import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; -import org.bouncycastle.asn1.x509.SubjectAltPublicKeyInfo; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import java.io.FileOutputStream; import java.math.BigInteger; -import java.net.InetAddress; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PrivateKey; diff --git a/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt index d2829c9..0173f72 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/AbstractServerTest.kt @@ -1,7 +1,7 @@ package net.woggioni.gbcs.test import net.woggioni.gbcs.GradleBuildCacheServer -import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.api.Configuration import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.ClassOrderer @@ -14,7 +14,7 @@ import java.nio.file.Path @TestInstance(TestInstance.Lifecycle.PER_CLASS) @TestMethodOrder(MethodOrderer.OrderAnnotation::class) -abstract class AbstractServerTest { +abstract class AbstractServerTestKt { protected lateinit var cfg : Configuration diff --git a/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt index 8b85353..f55b678 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/BasicAuthServerTest.kt @@ -1,12 +1,11 @@ package net.woggioni.gbcs.test -import io.netty.handler.codec.Headers import io.netty.handler.codec.http.HttpResponseStatus import net.woggioni.gbcs.AbstractNettyHttpAuthenticator.Companion.hashPassword -import net.woggioni.gbcs.Authorizer -import net.woggioni.gbcs.Role -import net.woggioni.gbcs.Xml -import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.api.Role +import net.woggioni.gbcs.base.Xml +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.cache.FileSystemCacheConfiguration import net.woggioni.gbcs.configuration.Serializer import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Order @@ -20,10 +19,11 @@ 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 BasicAuthServerTestKt : AbstractServerTestKt() { companion object { private const val PASSWORD = "password" @@ -33,25 +33,31 @@ class BasicAuthServerTest : AbstractServerTest() { 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.of( - cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), - host = "127.0.0.1", - port = ServerSocket(0).localPort + 1, - users = listOf( + cfg = Configuration( + "127.0.0.1", + ServerSocket(0).localPort + 1, + serverPath, + listOf( Configuration.User("user1", hashPassword(PASSWORD), setOf(readersGroup)), Configuration.User("user2", hashPassword(PASSWORD), setOf(writersGroup)), Configuration.User("user3", hashPassword(PASSWORD), setOf(readersGroup, writersGroup)) ).asSequence().map { it.name to it}.toMap(), - groups = sequenceOf(writersGroup, readersGroup).map { it.name to it}.toMap(), - authentication = Configuration.BasicAuthentication(), - useVirtualThread = true, - tls = null, - serverPath = "/" + 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) } @@ -67,7 +73,7 @@ class BasicAuthServerTest : AbstractServerTest() { } fun newRequestBuilder(key : String) = HttpRequest.newBuilder() - .uri(URI.create("http://${cfg.host}:${cfg.port}/$key")) + .uri(URI.create("http://${cfg.host}:${cfg.port}/$serverPath/$key")) fun newEntry(random : Random) : Pair { diff --git a/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt index 049bb3e..54bfccd 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/ConfigurationTest.kt @@ -1,32 +1,44 @@ package net.woggioni.gbcs.test -import net.woggioni.gbcs.configuration.Configuration -import net.woggioni.gbcs.GradleBuildCacheServer -import net.woggioni.gbcs.Xml +import net.woggioni.gbcs.base.GBCS.toUrl +import net.woggioni.gbcs.base.Xml +import net.woggioni.gbcs.configuration.Parser import net.woggioni.gbcs.configuration.Serializer import net.woggioni.gbcs.url.ClasspathUrlStreamHandlerFactoryProvider import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import java.net.URL import java.nio.file.Files import java.nio.file.Path -class ConfigurationTest { +class ConfigurationTestKt { - @Test - fun test(@TempDir testDir : Path) { - URL.setURLStreamHandlerFactory(ClasspathUrlStreamHandlerFactoryProvider()) - val dbf = Xml.newDocumentBuilderFactory(GradleBuildCacheServer.CONFIGURATION_SCHEMA_URL) - val db = dbf.newDocumentBuilder() - val configurationUrl = GradleBuildCacheServer.DEFAULT_CONFIGURATION_URL - val doc = configurationUrl.openStream().use(db::parse) - val cfg = Configuration.parse(doc) +// companion object { +// init { +// URL.setURLStreamHandlerFactory(ClasspathUrlStreamHandlerFactoryProvider()) +// } +// } + + @ValueSource( + strings = [ + "classpath:net/woggioni/gbcs/test/gbcs-default.xml", + "classpath:net/woggioni/gbcs/test/gbcs-memcached.xml", + ] + ) + @ParameterizedTest + fun test(configurationUrl: String, @TempDir testDir: Path) { + ClasspathUrlStreamHandlerFactoryProvider.install() + val doc = Xml.parseXml(configurationUrl.toUrl()) + val cfg = Parser.parse(doc) val configFile = testDir.resolve("gbcs.xml") Files.newOutputStream(configFile).use { Xml.write(Serializer.serialize(cfg), it) } - val parsed = Configuration.parse(Xml.parseXml(configFile.toUri().toURL())) + Xml.write(Serializer.serialize(cfg), System.out) + + val parsed = Parser.parse(Xml.parseXml(configFile.toUri().toURL())) Assertions.assertEquals(cfg, parsed) } } \ No newline at end of file diff --git a/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt index 53ab69d..a99262c 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/NoAuthServerTest.kt @@ -1,8 +1,9 @@ package net.woggioni.gbcs.test import io.netty.handler.codec.http.HttpResponseStatus -import net.woggioni.gbcs.Xml -import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.base.Xml +import net.woggioni.gbcs.api.Configuration +import net.woggioni.gbcs.cache.FileSystemCacheConfiguration import net.woggioni.gbcs.configuration.Serializer import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Order @@ -15,28 +16,36 @@ import java.net.http.HttpResponse import java.nio.file.Path import java.time.Duration import java.util.Base64 +import java.util.zip.Deflater import kotlin.random.Random -class NoAuthServerTest : AbstractServerTest() { +class NoAuthServerTestKt : AbstractServerTestKt() { private lateinit var cacheDir : Path private val random = Random(101325) private val keyValuePair = newEntry(random) + private val serverPath = "/some/nested/path" override fun setUp() { this.cacheDir = testDir.resolve("cache") - cfg = Configuration.of( - cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), - host = "127.0.0.1", - port = ServerSocket(0).localPort + 1, - users = emptyMap(), - groups = emptyMap(), - authentication = null, - useVirtualThread = true, - tls = null, - serverPath = "/" + cfg = Configuration( + "127.0.0.1", + ServerSocket(0).localPort + 1, + serverPath, + emptyMap(), + emptyMap(), + FileSystemCacheConfiguration( + this.cacheDir, + maxAge = Duration.ofSeconds(3600 * 24), + compressionEnabled = true, + digestAlgorithm = "MD5", + compressionLevel = Deflater.DEFAULT_COMPRESSION + ), + null, + null, + true, ) Xml.write(Serializer.serialize(cfg), System.out) } @@ -45,7 +54,7 @@ class NoAuthServerTest : AbstractServerTest() { } fun newRequestBuilder(key : String) = HttpRequest.newBuilder() - .uri(URI.create("http://${cfg.host}:${cfg.port}/$key")) + .uri(URI.create("http://${cfg.host}:${cfg.port}/$serverPath/$key")) fun newEntry(random : Random) : Pair { val key = ByteArray(0x10).let { diff --git a/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt index 5579813..5725776 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/TlsServerTest.kt @@ -1,9 +1,10 @@ package net.woggioni.gbcs.test import io.netty.handler.codec.http.HttpResponseStatus -import net.woggioni.gbcs.Role -import net.woggioni.gbcs.Xml -import net.woggioni.gbcs.configuration.Configuration +import net.woggioni.gbcs.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 @@ -23,19 +24,23 @@ 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 TlsServerTestKt : AbstractServerTestKt() { 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 fun stripLeadingSlash(s : String) = Path.of("/").root.relativize(Path.of(s).normalize()).toString() + } private lateinit var cacheDir: Path @@ -51,6 +56,7 @@ class TlsServerTest : AbstractServerTest() { 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)), @@ -104,7 +110,7 @@ class TlsServerTest : AbstractServerTest() { } } - fun getClientKeyStore(ca : X509Credentials, subject: X500Name) = KeyStore.getInstance("PKCS12").apply { + fun getClientKeyStore(ca: X509Credentials, subject: X500Name) = KeyStore.getInstance("PKCS12").apply { val clientCert = CertificateUtils.createClientCertificate(ca, subject, 30) load(null, null) @@ -116,7 +122,7 @@ class TlsServerTest : AbstractServerTest() { ) } - fun getHttpClient(clientKeyStore : KeyStore?): HttpClient { + fun getHttpClient(clientKeyStore: KeyStore?): HttpClient { val kmf = clientKeyStore?.let { KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()).apply { init(it, PASSWORD.toCharArray()) @@ -141,23 +147,28 @@ class TlsServerTest : AbstractServerTest() { this.trustStoreFile = testDir.resolve("truststore.p12") this.cacheDir = testDir.resolve("cache") createKeyStoreAndTrustStore() - cfg = Configuration.of( - cache = Configuration.FileSystemCache(this.cacheDir, maxAge = Duration.ofSeconds(3600 * 24)), - host = "127.0.0.1", - port = ServerSocket(0).localPort + 1, - users = users.asSequence().map { it.name to it }.toMap(), - groups = sequenceOf(writersGroup, readersGroup).map { it.name to it }.toMap(), - authentication = Configuration.ClientCertificateAuthentication( - userExtractor = Configuration.TlsCertificateExtractor("CN", "(.*)"), - groupExtractor = null + 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" ), - useVirtualThread = true, - tls = Configuration.Tls( + Configuration.ClientCertificateAuthentication( + Configuration.TlsCertificateExtractor("CN", "(.*)"), + null + ), + Configuration.Tls( Configuration.KeyStore(this.serverKeyStoreFile, null, SERVER_CERTIFICATE_ENTRY, PASSWORD), Configuration.TrustStore(this.trustStoreFile, null, false), true ), - serverPath = "/" + false, ) Xml.write(Serializer.serialize(cfg), System.out) } @@ -166,7 +177,7 @@ class TlsServerTest : AbstractServerTest() { } fun newRequestBuilder(key: String) = HttpRequest.newBuilder() - .uri(URI.create("https://${cfg.host}:${cfg.port}/$key")) + .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 { diff --git a/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt b/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt index fa847a7..bc2ec6e 100644 --- a/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt +++ b/src/test/kotlin/net/woggioni/gbcs/test/X500NameTest.kt @@ -4,7 +4,7 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import javax.naming.ldap.LdapName -class X500NameTest { +class X500NameTestKt { @Test fun test() { diff --git a/src/test/resources/net/woggioni/gbcs/test/gbcs-default.xml b/src/test/resources/net/woggioni/gbcs/test/gbcs-default.xml new file mode 100644 index 0000000..369b9e3 --- /dev/null +++ b/src/test/resources/net/woggioni/gbcs/test/gbcs-default.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/net/woggioni/gbcs/test/gbcs-memcached.xml b/src/test/resources/net/woggioni/gbcs/test/gbcs-memcached.xml new file mode 100644 index 0000000..1533137 --- /dev/null +++ b/src/test/resources/net/woggioni/gbcs/test/gbcs-memcached.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file