diff --git a/build.gradle b/build.gradle index 5b5ce8c..6d8c209 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - alias catalog.plugins.kotlin + alias catalog.plugins.kotlin.jvm alias catalog.plugins.envelope id 'maven-publish' } @@ -55,11 +55,19 @@ Provider envelopeJarTaskProvider = tasks.named('envelopeJar', E mainClass = 'net.woggioni.gbcs.GradleBuildCacheServer' systemProperty 'java.util.logging.config.class', 'net.woggioni.gbcs.LoggingConfig' systemProperty 'log.config.source', 'logging.properties' + + manifest { + attributes([ + 'Add-Exports' : 'java.base/sun.security.x509' + ]) + } } -wrapper { - distributionType = Wrapper.DistributionType.BIN - gradleVersion = getProperty('gradle.version') +envelopeRun { + + mainModule = 'net.woggioni.envelope' + modularity.inferModulePath = true + jvmArgs('--add-exports=java.base/sun.security.x509=io.netty.handler') } def envelopeJarArtifact = artifacts.add('archives', envelopeJarTaskProvider.get().archiveFile.get().asFile) { diff --git a/gradle.properties b/gradle.properties index de0a211..0d3a954 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,3 @@ gbcs.version = 0.1-SNAPSHOT -gradle.version = 7.5.1 -lys.version = 0.1-SNAPSHOT +lys.version = 0.2-SNAPSHOT diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..070cb70 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 64f3239..e33858b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ pluginManagement { } gradlePluginPortal() } + includeBuild('../envelope') } dependencyResolutionManagement { @@ -30,3 +31,4 @@ dependencyResolutionManagement { } rootProject.name = 'gbcs' + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 3b9a4af..680563f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,3 +1,5 @@ +import java.net.URLStreamHandlerFactory; + module net.woggioni.gbcs { requires java.xml; requires java.logging; @@ -12,4 +14,6 @@ module net.woggioni.gbcs { requires net.woggioni.jwo; exports net.woggioni.gbcs; + opens net.woggioni.gbcs to net.woggioni.envelope; + uses java.net.URLStreamHandlerFactory; } \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/ClientCertificateValidator.kt b/src/main/kotlin/net/woggioni/gbcs/ClientCertificateValidator.kt new file mode 100644 index 0000000..faf2c72 --- /dev/null +++ b/src/main/kotlin/net/woggioni/gbcs/ClientCertificateValidator.kt @@ -0,0 +1,70 @@ +package net.woggioni.gbcs + +import java.security.KeyStore +import java.security.cert.CertPathValidator +import java.security.cert.CertificateFactory +import java.security.cert.PKIXParameters +import java.security.cert.PKIXRevocationChecker +import java.security.cert.X509Certificate +import java.util.EnumSet +import io.netty.channel.ChannelHandlerContext +import io.netty.channel.ChannelInboundHandlerAdapter +import io.netty.handler.ssl.SslHandler +import io.netty.handler.ssl.SslHandshakeCompletionEvent +import javax.net.ssl.SSLSession +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + + +class ClientCertificateValidator private constructor(private val sslHandler : SslHandler, private val x509TrustManager: X509TrustManager) : ChannelInboundHandlerAdapter() { + override fun userEventTriggered(ctx: ChannelHandlerContext, evt: Any) { + if (evt is SslHandshakeCompletionEvent) { + if (evt.isSuccess) { + val session: SSLSession = sslHandler.engine().session + val clientCertificateChain = session.peerCertificates as Array + val authType: String = clientCertificateChain[0].publicKey.algorithm + x509TrustManager.checkClientTrusted(clientCertificateChain, authType) + } else { + // Handle the failure, for example by closing the channel. + } + } + super.userEventTriggered(ctx, evt) + } + + companion object { + + fun of(sslHandler : SslHandler, trustStore : KeyStore?) : ClientCertificateValidator { + val certificateFactory = CertificateFactory.getInstance("X.509") + + val validator = CertPathValidator.getInstance("PKIX").apply { + val rc = revocationChecker as PKIXRevocationChecker + rc.options = EnumSet.of( + PKIXRevocationChecker.Option.NO_FALLBACK, + PKIXRevocationChecker.Option.SOFT_FAIL, + PKIXRevocationChecker.Option.PREFER_CRLS) + } + + val manager = if(trustStore != null) { + val params = PKIXParameters(trustStore) + object : X509TrustManager { + override fun checkClientTrusted(chain: Array, authType: String) { + val clientCertificateChain = certificateFactory.generateCertPath(chain.toList()) + validator.validate(clientCertificateChain, params) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + throw NotImplementedError() + } + + override fun getAcceptedIssuers(): Array { + throw NotImplementedError() + } + } + } else { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.trustManagers.asSequence().filter { it is X509TrustManager }.single() as X509TrustManager + } + return ClientCertificateValidator(sslHandler, manager) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/net/woggioni/gbcs/Configuration.kt b/src/main/kotlin/net/woggioni/gbcs/Configuration.kt index f888b85..b1f1e53 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Configuration.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Configuration.kt @@ -1,10 +1,95 @@ package net.woggioni.gbcs import java.nio.file.Path +import java.nio.file.Paths +import org.w3c.dom.Document +import net.woggioni.gbcs.Xml.asIterable +import org.w3c.dom.Element + +data class HostAndPort(val host: String, val port : Integer) { + override fun toString() = "$host:$port" +} + +data class TlsConfiguration(val keyStore: KeyStore?, val trustStore: TrustStore?, val verifyClients : Boolean) +data class KeyStore( + val file : Path, + val password : String?, + val keyAlias: String, + val keyPassword : String? +) + +data class TrustStore( + val file : Path, + val password : String?, +) data class Configuration( val cacheFolder : Path, val host : String, val port : Int, - val users : Map> -) \ No newline at end of file + val users : Map>, + val tlsConfiguration: TlsConfiguration?, + val serverPath : String +) { + companion object { + fun parse(document : Element) : Configuration { + + var cacheFolder = Paths.get(System.getProperty("user.home")).resolve(".gbcs") + var host : String = "127.0.0.1" + var port : Int = 11080 + var users = emptyMap>() + var tlsConfiguration : TlsConfiguration? = null + var serverPath = "/" + + for(child in document.asIterable()) { + when(child.nodeName) { + "bind" -> { + host = child.getAttribute("host") + port = Integer.parseInt(child.getAttribute("port")) + } + "cache" -> { + cacheFolder = Paths.get(child.textContent) + } + "tls" -> { + val verifyClients = child.getAttribute("verify-clients") + .takeIf(String::isNotEmpty) + ?.let(String::toBoolean) ?: false + var keyStore : KeyStore? = null + var trustStore : TrustStore? = null + for(granChild in child.asIterable()) { + when(granChild.nodeName) { + "keystore" -> { + val trustStoreFile = Paths.get(granChild.getAttribute("file")) + val trustStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + val keyAlias = granChild.getAttribute("server-key-alias") + val keyPasswordPassword = granChild.getAttribute("server-key-password") + .takeIf(String::isNotEmpty) + keyStore = KeyStore( + trustStoreFile, + trustStorePassword, + keyAlias, + keyPasswordPassword + ) + } + "truststore" -> { + val trustStoreFile = Paths.get(granChild.getAttribute("file")) + val trustStorePassword = granChild.getAttribute("password") + .takeIf(String::isNotEmpty) + trustStore = TrustStore( + trustStoreFile, + trustStorePassword + ) + } + } + } + tlsConfiguration = TlsConfiguration(keyStore, trustStore, verifyClients) + } + } + + } + + return Configuration(cacheFolder, host, port, users, tlsConfiguration, serverPath) + } + } +} diff --git a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt index 692b5ce..3027ef3 100644 --- a/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt +++ b/src/main/kotlin/net/woggioni/gbcs/GradleBuildCacheServer.kt @@ -1,5 +1,20 @@ package net.woggioni.gbcs +import java.net.InetSocketAddress +import java.net.URL +import java.net.URLStreamHandlerFactory +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.security.KeyStore +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.cert.X509Certificate +import java.util.AbstractMap.SimpleEntry +import java.util.Base64 +import java.util.ServiceLoader import io.netty.bootstrap.ServerBootstrap import io.netty.buffer.ByteBuf import io.netty.buffer.Unpooled @@ -30,25 +45,18 @@ import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpServerCodec import io.netty.handler.codec.http.HttpUtil import io.netty.handler.codec.http.LastHttpContent +import io.netty.handler.ssl.SslContextBuilder +import io.netty.handler.ssl.util.SelfSignedCertificate import io.netty.handler.stream.ChunkedNioFile import io.netty.handler.stream.ChunkedWriteHandler import io.netty.util.concurrent.DefaultEventExecutorGroup import io.netty.util.concurrent.EventExecutorGroup +import net.woggioni.jwo.Application import net.woggioni.jwo.JWO -import java.nio.channels.FileChannel -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.security.MessageDigest -import java.util.AbstractMap.SimpleEntry -import java.util.Base64 -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLEngine +import net.woggioni.jwo.Tuple2 -class GradleBuildCacheServer { +class GradleBuildCacheServer(private val cfg : Configuration) { internal class HttpChunkContentCompressor(threshold : Int, vararg compressionOptions: CompressionOptions = emptyArray()) : HttpContentCompressor(threshold, *compressionOptions) { @@ -115,25 +123,62 @@ class GradleBuildCacheServer { } } - private class ServerInitializer(private val cacheDir: Path) : ChannelInitializer() { + private class ServerInitializer(private val cfg : Configuration) : ChannelInitializer() { + + companion object { + val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()) + fun loadKeystore(file : Path, password : String?) : KeyStore { + val ext = JWO.splitExtension(file) + .map(Tuple2::get_2) + .orElseThrow { + IllegalArgumentException( + "Keystore file '${file}' must have .jks or p12 extension") + } + val keystore = when(ext.lowercase()) { + "jks" -> KeyStore.getInstance("JKS") + "p12", "pfx" -> KeyStore.getInstance("PKCS12") + else -> throw IllegalArgumentException( + "Keystore file '${file}' must have .jks or p12 extension") + } + Files.newInputStream(file).use { + keystore.load(it, password?.let(String::toCharArray)) + } + return keystore + } + } override fun initChannel(ch: Channel) { - val sslEngine: SSLEngine = SSLContext.getDefault().createSSLEngine() - sslEngine.useClientMode = false val pipeline = ch.pipeline() -// pipeline.addLast(SslHandler(sslEngine)) + val tlsConfiguration = cfg.tlsConfiguration + if(tlsConfiguration != null) { + val ssc = SelfSignedCertificate() + val keyStore = tlsConfiguration.keyStore + val sslCtx = if(keyStore == null) { + SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build() + } else { + val javaKeyStore = loadKeystore(keyStore.file, keyStore.password) + val serverKey = javaKeyStore.getKey( + keyStore.keyAlias, keyStore.keyPassword?.let(String::toCharArray)) as PrivateKey + val serverCert = javaKeyStore.getCertificateChain(keyStore.keyAlias) as Array + SslContextBuilder.forServer(serverKey, *serverCert).build() + } + val sslHandler = sslCtx.newHandler(ch.alloc()) + pipeline.addLast(sslHandler) + if(tlsConfiguration.verifyClients) { + val trustStore = tlsConfiguration.trustStore?.let { + loadKeystore(it.file, it.password) + } + pipeline.addLast(ClientCertificateValidator.of(sslHandler, trustStore)) + } + } pipeline.addLast(HttpServerCodec()) pipeline.addLast(HttpChunkContentCompressor(1024)) pipeline.addLast(ChunkedWriteHandler()) pipeline.addLast(HttpObjectAggregator(Int.MAX_VALUE)) - pipeline.addLast(NettyHttpBasicAuthenticator(mapOf("user" to "password")) { user, _ -> user == "user" }) - pipeline.addLast(group, ServerHandler(cacheDir, "/cache")) +// pipeline.addLast(NettyHttpBasicAuthenticator(mapOf("user" to "password")) { user, _ -> user == "user" }) + pipeline.addLast(group, ServerHandler(cfg.cacheFolder, cfg.serverPath)) pipeline.addLast(ExceptionHandler()) - Files.createDirectories(cacheDir) - } - - companion object { - val group: EventExecutorGroup = DefaultEventExecutorGroup(Runtime.getRuntime().availableProcessors()) + Files.createDirectories(cfg.cacheFolder) } } @@ -254,12 +299,13 @@ class GradleBuildCacheServer { // Configure the server httpBootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel::class.java) - .childHandler(ServerInitializer(Paths.get("/tmp/gbcs"))) // <-- Our handler created here + .childHandler(ServerInitializer(cfg)) .option(ChannelOption.SO_BACKLOG, 128) .childOption(ChannelOption.SO_KEEPALIVE, true) // Bind and start to accept incoming connections. - val httpChannel = httpBootstrap.bind(HTTP_PORT).sync() + val bindAddress = InetSocketAddress(cfg.host, cfg.port) + val httpChannel = httpBootstrap.bind(bindAddress).sync() // Wait until server socket is closed httpChannel.channel().closeFuture().sync() @@ -270,11 +316,69 @@ class GradleBuildCacheServer { } companion object { - private const val HTTP_PORT = 8080 + + private const val PROTOCOL_HANDLER = "java.protocol.handler.pkgs" + private const val HANDLERS_PACKAGE = "net.woggioni.gbcs.url" + + /** + * Reset any cached handlers just in case a jar protocol has already been used. We + * reset the handler by trying to set a null [URLStreamHandlerFactory] which + * should have no effect other than clearing the handlers cache. + */ + private fun resetCachedUrlHandlers() { + try { + URL.setURLStreamHandlerFactory(null) + } catch (ex: Error) { + // Ignore + } + } + fun registerUrlProtocolHandler() { + val handlers = System.getProperty(PROTOCOL_HANDLER, "") + System.setProperty( + PROTOCOL_HANDLER, + if (handlers == null || handlers.isEmpty()) HANDLERS_PACKAGE else "$handlers|$HANDLERS_PACKAGE" + ) + resetCachedUrlHandlers() + } + @JvmStatic fun main(args: Array) { + SelfSignedCertificate() + ServiceLoader.load(javaClass.module.layer, URLStreamHandlerFactory::class.java).stream().forEach { + println(it.type()) + } +// registerUrlProtocolHandler() Thread.currentThread().contextClassLoader = GradleBuildCacheServer::class.java.classLoader - GradleBuildCacheServer().run() + val app = Application.builder("gbcs") + .configurationDirectoryEnvVar("GBCS_CONFIGURATION_DIR") + .configurationDirectoryPropertyKey("net.woggioni.gbcs.conf.dir") + .build() + val confDir = app.computeConfigurationDirectory() + val configurationFile = confDir.resolve("gbcs.xml") + + if(!Files.exists(configurationFile)) { + Files.createDirectories(confDir) + val defaultConfigurationFileResourcePath = "net/woggioni/gbcs/gbcs-default.xml" + val defaultConfigurationFileResource = GradleBuildCacheServer.javaClass.classLoader + .getResource(defaultConfigurationFileResourcePath) + ?: throw IllegalStateException( + "Missing default configuration file 'classpath:$defaultConfigurationFileResourcePath'") + Files.newOutputStream(configurationFile).use { outputStream -> + defaultConfigurationFileResource.openStream().use { inputStream -> + JWO.copy(inputStream, outputStream) + } + } + } + val schemaResource = "net/woggioni/gbcs/gbcs.xsd" + val schemaUrl = URL("classpath:net/woggioni/gbcs/gbcs.xsd") +// val schemaUrl = GradleBuildCacheServer::class.java.classLoader.getResource(schemaResource) +// ?: throw IllegalStateException("Missing configuration schema '$schemaResource'") + val schemaUrl2 = URL(schemaUrl.toString()) + val dbf = Xml.newDocumentBuilderFactory() + dbf.schema = Xml.getSchema(schemaUrl) + val doc = Files.newInputStream(configurationFile) + .use(dbf.newDocumentBuilder()::parse) + GradleBuildCacheServer(Configuration.parse(doc.documentElement)).run() } fun digest(data : ByteArray, diff --git a/src/main/kotlin/net/woggioni/gbcs/Xml.kt b/src/main/kotlin/net/woggioni/gbcs/Xml.kt index cdc5c4e..7e3dec7 100644 --- a/src/main/kotlin/net/woggioni/gbcs/Xml.kt +++ b/src/main/kotlin/net/woggioni/gbcs/Xml.kt @@ -1,11 +1,5 @@ package net.woggioni.gbcs -import org.slf4j.LoggerFactory -import org.w3c.dom.Document -import org.xml.sax.ErrorHandler -import org.xml.sax.SAXNotRecognizedException -import org.xml.sax.SAXNotSupportedException -import org.xml.sax.SAXParseException import java.net.URL import javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD import javax.xml.XMLConstants.ACCESS_EXTERNAL_SCHEMA @@ -15,7 +9,60 @@ import javax.xml.parsers.DocumentBuilder import javax.xml.parsers.DocumentBuilderFactory import javax.xml.validation.Schema import javax.xml.validation.SchemaFactory +import org.slf4j.LoggerFactory +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import org.xml.sax.ErrorHandler +import org.xml.sax.SAXNotRecognizedException +import org.xml.sax.SAXNotSupportedException +import org.xml.sax.SAXParseException +class NodeListIterator(private val nodeList: NodeList) : Iterator { + private var cursor : Int = 0 + override fun hasNext(): Boolean { + return cursor < nodeList.length + } + + override fun next(): Node { + return if (hasNext()) nodeList.item(cursor++) else throw NoSuchElementException() + } +} + +class ElementIterator(parent: Element, name: String? = null) : Iterator { + private val it: NodeListIterator + private val name: String? + private var next: Element? + + init { + it = NodeListIterator(parent.childNodes) + this.name = name + next = getNext() + } + + override fun hasNext(): Boolean { + return next != null + } + + override fun next(): Element { + val result = next ?: throw NoSuchElementException() + next = getNext() + return result + } + + private fun getNext(): Element? { + var result: Element? = null + while (it.hasNext()) { + val node: Node = it.next() + if (node is Element && (name == null || name == node.tagName)) { + result = node + break + } + } + return result + } +} object Xml { @@ -66,17 +113,15 @@ object Xml { } } - private fun getSchema(schemaResourceURL: String): Schema { + fun getSchema(schema: URL): Schema { val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) sf.setFeature(FEATURE_SECURE_PROCESSING, true) - disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) - disableProperty(sf, ACCESS_EXTERNAL_DTD) - val schemaUrl: URL = Xml::class.java.classLoader.getResource(schemaResourceURL) - ?: throw IllegalStateException(String.format("Missing configuration schema '%s'", schemaResourceURL)) - return sf.newSchema(schemaUrl) +// disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) +// disableProperty(sf, ACCESS_EXTERNAL_DTD) + return sf.newSchema(schema) } - private fun newDocumentBuilderFactory(schemaResourceURL: String?): DocumentBuilderFactory { + fun newDocumentBuilderFactory(): DocumentBuilderFactory { val dbf = DocumentBuilderFactory.newInstance() dbf.setFeature(FEATURE_SECURE_PROCESSING, true) disableProperty(dbf, ACCESS_EXTERNAL_SCHEMA) @@ -84,35 +129,31 @@ object Xml { dbf.isExpandEntityReferences = false dbf.isIgnoringComments = true dbf.isNamespaceAware = true - val sf = SchemaFactory.newInstance(W3C_XML_SCHEMA_NS_URI) - sf.setFeature(FEATURE_SECURE_PROCESSING, true) - disableProperty(sf, ACCESS_EXTERNAL_SCHEMA) - disableProperty(sf, ACCESS_EXTERNAL_DTD) - if (schemaResourceURL != null) { - dbf.schema = getSchema(schemaResourceURL) - } return dbf } - fun newDocumentBuilder(resource: URL, schemaResourceURL: String?): DocumentBuilder { - val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder() - db.setErrorHandler(XmlErrorHandler(resource)) - return db - } +// fun newDocumentBuilder(resource: URL, schemaResourceURL: String?): DocumentBuilder { +// val db = newDocumentBuilderFactory(schemaResourceURL).newDocumentBuilder() +// db.setErrorHandler(XmlErrorHandler(resource)) +// return db +// } - fun parseXmlResource(resource: URL, schemaResourceURL: String?): Document { - val db = newDocumentBuilder(resource, schemaResourceURL) - return resource.openStream().use(db::parse) - } +// fun parseXmlResource(resource: URL, schemaResourceURL: String?): Document { +// val db = newDocumentBuilder(resource, schemaResourceURL) +// return resource.openStream().use(db::parse) +// } +// +// fun newDocumentBuilder(resource: URL): DocumentBuilder { +// val db = newDocumentBuilderFactory(null).newDocumentBuilder() +// db.setErrorHandler(XmlErrorHandler(resource)) +// return db +// } - fun newDocumentBuilder(resource: URL): DocumentBuilder { - val db = newDocumentBuilderFactory(null).newDocumentBuilder() - db.setErrorHandler(XmlErrorHandler(resource)) - return db - } +// fun parseXmlResource(resource: URL): Document { +// val db = newDocumentBuilder(resource, null) +// return resource.openStream().use(db::parse) +// } - fun parseXmlResource(resource: URL): Document { - val db = newDocumentBuilder(resource, null) - return resource.openStream().use(db::parse) - } + fun Element.asIterable() = Iterable { ElementIterator(this, null) } + fun NodeList.asIterable() = Iterable { NodeListIterator(this) } } diff --git a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml index bd636d6..d64444f 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs-default.xml +++ b/src/main/resources/net/woggioni/gbcs/gbcs-default.xml @@ -1,6 +1,6 @@ - - + + @@ -10,5 +10,5 @@ - + \ No newline at end of file diff --git a/src/main/resources/net/woggioni/gbcs/gbcs.xsd b/src/main/resources/net/woggioni/gbcs/gbcs.xsd index 258554c..4ca05dd 100644 --- a/src/main/resources/net/woggioni/gbcs/gbcs.xsd +++ b/src/main/resources/net/woggioni/gbcs/gbcs.xsd @@ -1,8 +1,9 @@ - + + @@ -10,6 +11,7 @@ + @@ -39,28 +41,28 @@ - - - - - - - - - - - - - - + + - + + + + + + + + + + + + + - + @@ -72,114 +74,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -