Compare commits

...

5 Commits
master ... dev

Author SHA1 Message Date
OpenCode
ffe84fd331 Add optional OpenTelemetry Netty server instrumentation
- Update lys.version to 2026.04.14

- Add optional compileOnly dependency on opentelemetry-netty-4.1 in rbcs-server

- Add runtime guard to only activate instrumentation when OTel classes are on classpath

- Insert OTel combined handler after HttpServerCodec in the Netty pipeline

- Add requires-static JPMS directives for optional module support
2026-04-28 17:39:57 +00:00
5d190d81ab version bump to 0.5.0
All checks were successful
CI / build (push) Successful in 2m54s
2026-04-13 22:28:33 +08:00
e6f35f4340 Added support for client certificate forwarding
Some checks failed
CI / build (push) Has been cancelled
2026-04-13 22:19:12 +08:00
6d214eb066 uniformed Docker images 2026-04-13 22:19:12 +08:00
0a50ae0643 improved error handling 2026-04-13 22:19:12 +08:00
18 changed files with 363 additions and 37 deletions

View File

@@ -32,7 +32,7 @@ jobs:
push: true push: true
pull: true pull: true
tags: | tags: |
gitea.woggioni.net/woggioni/rbcs:vanilla-dev gitea.woggioni.net/woggioni/rbcs:dev-vanilla
target: release-vanilla target: release-vanilla
- -
name: Build rbcs memcache Docker image name: Build rbcs memcache Docker image
@@ -44,7 +44,7 @@ jobs:
push: true push: true
pull: true pull: true
tags: | tags: |
gitea.woggioni.net/woggioni/rbcs:memcache-dev gitea.woggioni.net/woggioni/rbcs:dev-memcache
target: release-memcache target: release-memcache
- -
name: Build rbcs redis Docker image name: Build rbcs redis Docker image
@@ -56,7 +56,7 @@ jobs:
push: true push: true
pull: true pull: true
tags: | tags: |
gitea.woggioni.net/woggioni/rbcs:redis-dev gitea.woggioni.net/woggioni/rbcs:dev-redis
target: release-redis target: release-redis
- -
name: Build rbcs native Docker image name: Build rbcs native Docker image
@@ -68,7 +68,7 @@ jobs:
push: true push: true
pull: true pull: true
tags: | tags: |
gitea.woggioni.net/woggioni/rbcs:native-dev gitea.woggioni.net/woggioni/rbcs:dev-native
target: release-native target: release-native
- -
name: Build rbcs jlink Docker image name: Build rbcs jlink Docker image
@@ -80,6 +80,6 @@ jobs:
push: true push: true
pull: true pull: true
tags: | tags: |
gitea.woggioni.net/woggioni/rbcs:jlink-dev gitea.woggioni.net/woggioni/rbcs:dev-jlink
target: release-jlink target: release-jlink

View File

@@ -1,33 +1,38 @@
FROM eclipse-temurin:25-jre-alpine AS base-release FROM eclipse-temurin:25-jre-alpine AS base-release
RUN adduser -D luser RUN adduser -D rbcs
USER luser USER rbcs
WORKDIR /home/luser WORKDIR /var/lib/rbcs
FROM base-release AS release-vanilla FROM base-release AS release-vanilla
ADD rbcs-cli-envelope-*.jar rbcs.jar ADD rbcs-cli-envelope-*.jar rbcs.jar
ENTRYPOINT ["java", "-Dlogback.configurationFile=logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-XX:+ZGenerational", "-jar", "/home/luser/rbcs.jar"] ADD logback.xml /etc/rbcs/logback.xml
ENV RBCS_CONFIGURATION_DIR="/etc/rbcs"
ENTRYPOINT ["java", "-Dlogback.configurationFile=/etc/rbcs/logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-jar", "/var/lib/rbcs/rbcs.jar"]
FROM base-release AS release-memcache FROM base-release AS release-memcache
ADD --chown=luser:luser rbcs-cli-envelope-*.jar rbcs.jar ADD --chown=rbcs:rbcs rbcs-cli-envelope-*.jar rbcs.jar
RUN mkdir plugins RUN mkdir plugins
WORKDIR /home/luser/plugins WORKDIR /var/lib/rbcs/plugins
RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-server-memcache*.tar RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-server-memcache*.tar
WORKDIR /home/luser WORKDIR /var/lib/rbcs
ADD logback.xml . ADD logback.xml /etc/rbcs/logback.xml
ENTRYPOINT ["java", "-Dlogback.configurationFile=logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-XX:+ZGenerational", "-jar", "/home/luser/rbcs.jar"] ENV RBCS_CONFIGURATION_DIR="/etc/rbcs"
ENTRYPOINT ["java", "-Dlogback.configurationFile=/etc/rbcs/logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-jar", "/var/lib/rbcs/rbcs.jar"]
FROM base-release AS release-redis FROM base-release AS release-redis
ADD --chown=luser:luser rbcs-cli-envelope-*.jar rbcs.jar ADD --chown=rbcs:rbcs rbcs-cli-envelope-*.jar rbcs.jar
RUN mkdir plugins RUN mkdir plugins
WORKDIR /home/luser/plugins WORKDIR /var/lib/rbcs/plugins
RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-server-redis*.tar RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-server-redis*.tar
WORKDIR /home/luser WORKDIR /var/lib/rbcs
ADD logback.xml . ADD logback.xml /etc/rbcs/logback.xml
ENTRYPOINT ["java", "-Dlogback.configurationFile=logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-XX:+ZGenerational", "-jar", "/home/luser/rbcs.jar"] ENV RBCS_CONFIGURATION_DIR="/etc/rbcs"
ENTRYPOINT ["java", "-Dlogback.configurationFile=/etc/rbcs/logback.xml", "-XX:MaxRAMPercentage=70", "-XX:GCTimeRatio=24", "-XX:+UseZGC", "-jar", "/var/lib/rbcs/rbcs.jar"]
FROM busybox:musl AS base-native FROM busybox:musl AS base-native
RUN mkdir -p /var/lib/rbcs /etc/rbcs RUN mkdir -p /var/lib/rbcs /var/tmp/rbcs /etc/rbcs
RUN adduser -D -u 1000 rbcs -h /var/lib/rbcs RUN adduser -D -u 1000 rbcs -h /var/lib/rbcs
RUN chown rbcs:rbcs /var/tmp/rbcs
FROM scratch AS release-native FROM scratch AS release-native
COPY --from=base-native /etc/passwd /etc/passwd COPY --from=base-native /etc/passwd /etc/passwd
@@ -37,16 +42,17 @@ ADD rbcs-cli.upx /usr/bin/rbcs-cli
ENV RBCS_CONFIGURATION_DIR="/etc/rbcs" ENV RBCS_CONFIGURATION_DIR="/etc/rbcs"
USER rbcs USER rbcs
WORKDIR /var/lib/rbcs WORKDIR /var/lib/rbcs
ENTRYPOINT ["/usr/bin/rbcs-cli", "-XX:MaximumHeapSizePercent=70"] ENTRYPOINT ["/usr/bin/rbcs-cli", "-XX:MaximumHeapSizePercent=70", "-Dio.netty.tmpdir=/var/tmp/rbcs", "-Dlogback.configurationFile=/etc/rbcs/logback.xml"]
FROM debian:12-slim AS release-jlink FROM debian:12-slim AS release-jlink
RUN mkdir -p /usr/share/java/rbcs RUN mkdir -p /usr/share/java/rbcs
RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-cli*.tar -C /usr/share/java/rbcs RUN --mount=type=bind,source=.,target=/build/distributions tar -xf /build/distributions/rbcs-cli*.tar -C /usr/share/java/rbcs
RUN chmod 755 /usr/share/java/rbcs/bin/* RUN chmod 755 /usr/share/java/rbcs/bin/*
ADD --chmod=755 rbcs-cli.sh /usr/local/bin/rbcs-cli ADD --chmod=755 rbcs-cli.sh /usr/local/bin/rbcs-cli
RUN adduser -u 1000 luser RUN adduser -u 1000 rbcs
USER luser USER rbcs
WORKDIR /home/luser WORKDIR /var/lib/rbcs
ADD logback.xml . ADD logback.xml /etc/rbcs/logback.xml
ENV JAVA_OPTS=-XX:-UseJVMCICompiler\ -Dlogback.configurationFile=logback.xml\ -XX:MaxRAMPercentage=70\ -XX:GCTimeRatio=24\ -XX:+UseZGC\ -XX:+ZGenerational ENV RBCS_CONFIGURATION_DIR="/etc/rbcs"
ENV JAVA_OPTS=-XX:-UseJVMCICompiler\ -Dlogback.configurationFile=/etc/rbcs/logback.xml\ -XX:MaxRAMPercentage=70\ -XX:GCTimeRatio=24\ -XX:+UseZGC
ENTRYPOINT ["/usr/local/bin/rbcs-cli"] ENTRYPOINT ["/usr/local/bin/rbcs-cli"]

View File

@@ -2,9 +2,9 @@ org.gradle.configuration-cache=false
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
rbcs.version = 0.4.0 rbcs.version = 0.5.0
lys.version = 2026.03.26 lys.version = 2026.04.28
gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven
docker.registry.url=gitea.woggioni.net docker.registry.url=gitea.woggioni.net

View File

@@ -16,6 +16,7 @@ import java.util.stream.Collectors;
@Value @Value
public class Configuration { public class Configuration {
boolean enableTelemetry;
String host; String host;
int port; int port;
boolean proxyProtocolEnabled; boolean proxyProtocolEnabled;
@@ -136,6 +137,13 @@ public class Configuration {
TlsCertificateExtractor groupExtractor; TlsCertificateExtractor groupExtractor;
} }
@Value
public static class ForwardedClientCertificateAuthentication implements Authentication {
String headerName;
TlsCertificateExtractor userExtractor;
TlsCertificateExtractor groupExtractor;
}
public interface Cache { public interface Cache {
CacheHandlerFactory materialize(); CacheHandlerFactory materialize();
String getNamespaceURI(); String getNamespaceURI();
@@ -143,6 +151,7 @@ public class Configuration {
} }
public static Configuration of( public static Configuration of(
boolean enableTelemetry,
String host, String host,
int port, int port,
boolean proxyProtocolEnabled, boolean proxyProtocolEnabled,
@@ -159,6 +168,7 @@ public class Configuration {
Tls tls Tls tls
) { ) {
return new Configuration( return new Configuration(
enableTelemetry,
host, host,
port, port,
proxyProtocolEnabled, proxyProtocolEnabled,

View File

@@ -13,6 +13,11 @@ dependencies {
implementation catalog.netty.buffer implementation catalog.netty.buffer
implementation catalog.netty.transport implementation catalog.netty.transport
implementation catalog.netty.codec.haproxy implementation catalog.netty.codec.haproxy
compileOnly catalog.opentelemetry.netty['4']['1']
compileOnly catalog.opentelemetry.sdk.extension.autoconfigure
compileOnly catalog.opentelemetry.logback.appender['1']['0']
compileOnly catalog.opentelemetry.extension.trace.propagators
compileOnly catalog.logback.classic
api project(':rbcs-common') api project(':rbcs-common')
api project(':rbcs-api') api project(':rbcs-api')

View File

@@ -17,6 +17,11 @@ module net.woggioni.rbcs.server {
requires io.netty.common; requires io.netty.common;
requires io.netty.codec; requires io.netty.codec;
requires io.netty.codec.haproxy; requires io.netty.codec.haproxy;
requires static io.opentelemetry.api;
requires static io.opentelemetry.instrumentation.netty_4_1;
requires static io.opentelemetry.sdk.autoconfigure;
requires static io.opentelemetry.instrumentation.logback_appender_1_0;
requires static io.opentelemetry.extension.trace.propagation;
requires org.slf4j; requires org.slf4j;
exports net.woggioni.rbcs.server; exports net.woggioni.rbcs.server;

View File

@@ -68,6 +68,8 @@ import net.woggioni.rbcs.common.Xml
import net.woggioni.rbcs.common.createLogger import net.woggioni.rbcs.common.createLogger
import net.woggioni.rbcs.common.debug import net.woggioni.rbcs.common.debug
import net.woggioni.rbcs.common.info import net.woggioni.rbcs.common.info
import net.woggioni.rbcs.server.otel.OtelIntegration
import net.woggioni.rbcs.server.otel.OtelSdkIntegration
import net.woggioni.rbcs.server.auth.AbstractNettyHttpAuthenticator import net.woggioni.rbcs.server.auth.AbstractNettyHttpAuthenticator
import net.woggioni.rbcs.server.auth.Authorizer import net.woggioni.rbcs.server.auth.Authorizer
import net.woggioni.rbcs.server.auth.RoleAuthorizer import net.woggioni.rbcs.server.auth.RoleAuthorizer
@@ -149,12 +151,68 @@ class RemoteBuildCacheServer(private val cfg: Configuration) {
((user?.groups ?: emptySet()).asSequence() + sequenceOf(group).filterNotNull()).toSet() ((user?.groups ?: emptySet()).asSequence() + sequenceOf(group).filterNotNull()).toSet()
AuthenticationResult(user, allGroups) AuthenticationResult(user, allGroups)
} ?: anonymousUserGroups?.let { AuthenticationResult(null, it) } } ?: anonymousUserGroups?.let { AuthenticationResult(null, it) }
} catch (es: SSLPeerUnverifiedException) { } catch (ex: SSLPeerUnverifiedException) {
log.debug(ctx) {
ex.message ?: "Error witch client certificate authentication"
}
anonymousUserGroups?.let { AuthenticationResult(null, it) } anonymousUserGroups?.let { AuthenticationResult(null, it) }
} }
} }
} }
@Sharable
private class ForwardedClientCertificateAuthenticator(
authorizer: Authorizer,
private val anonymousUserGroups: Set<Configuration.Group>?,
private val subjectDnUserExtractor: SubjectDnExtractor?,
private val subjectDnGroupExtractor: SubjectDnExtractor?,
private val headerName: String,
private val trustedProxyIPs: List<Cidr>,
private val users: Map<String, Configuration.User>,
private val groups: Map<String, Configuration.Group>,
) : AbstractNettyHttpAuthenticator(authorizer) {
companion object {
private val log = createLogger<ForwardedClientCertificateAuthenticator>()
}
override fun authenticate(ctx: ChannelHandlerContext, req: HttpRequest): AuthenticationResult? {
val clientIp = ctx.channel().attr(clientIp).get()
if (clientIp == null || trustedProxyIPs.none { it.contains(clientIp.address) }) {
log.debug(ctx) {
"Rejecting forwarded client certificate authentication from untrusted address: $clientIp"
}
return null
}
val subjectDn = req.headers()[headerName]
?: return anonymousUserGroups?.let { AuthenticationResult(null, it) }
val ldapName = try {
LdapName(subjectDn)
} catch (e: Exception) {
log.debug(ctx) {
"Invalid subject DN in header $headerName: $subjectDn"
}
return anonymousUserGroups?.let { AuthenticationResult(null, it) }
}
val user = subjectDnUserExtractor?.extract(ldapName)?.let { userName ->
users[userName] ?: throw RuntimeException("Failed to extract user '$userName'")
}
val group = subjectDnGroupExtractor?.extract(ldapName)?.let { groupName ->
groups[groupName] ?: throw RuntimeException("Failed to extract group '$groupName'")
}
val allGroups = ((user?.groups ?: emptySet()).asSequence() + sequenceOf(group).filterNotNull()).toSet()
return AuthenticationResult(user, allGroups)
}
}
private data class SubjectDnExtractor(val rdnType: String, val pattern: Pattern) {
fun extract(ldapName: LdapName): String? {
return ldapName.rdns.find { it.type == rdnType }
?.let { pattern.matcher(it.value.toString()) }
?.takeIf(Matcher::matches)?.group(1)
}
}
@Sharable @Sharable
private class NettyHttpBasicAuthenticator( private class NettyHttpBasicAuthenticator(
private val users: Map<String, Configuration.User>, authorizer: Authorizer private val users: Map<String, Configuration.User>, authorizer: Authorizer
@@ -261,6 +319,23 @@ class RemoteBuildCacheServer(private val cfg: Configuration) {
) )
} }
is Configuration.ForwardedClientCertificateAuthentication -> {
ForwardedClientCertificateAuthenticator(
RoleAuthorizer(),
cfg.users[""]?.groups,
auth.userExtractor?.let { extractor ->
SubjectDnExtractor(extractor.rdnType, Pattern.compile(extractor.pattern))
},
auth.groupExtractor?.let { extractor ->
SubjectDnExtractor(extractor.rdnType, Pattern.compile(extractor.pattern))
},
auth.headerName,
cfg.trustedProxyIPs,
cfg.users,
cfg.groups,
)
}
else -> null else -> null
} }
@@ -358,6 +433,7 @@ class RemoteBuildCacheServer(private val cfg: Configuration) {
maxChunkSize = cfg.connection.chunkSize maxChunkSize = cfg.connection.chunkSize
} }
pipeline.addLast(HttpServerCodec(httpDecoderConfig)) pipeline.addLast(HttpServerCodec(httpDecoderConfig))
OtelIntegration.createHandler(cfg.isEnableTelemetry)?.let { pipeline.addLast(it) }
pipeline.addLast(ReadTriggerDuplexHandler.NAME, ReadTriggerDuplexHandler()) pipeline.addLast(ReadTriggerDuplexHandler.NAME, ReadTriggerDuplexHandler())
pipeline.addLast(MaxRequestSizeHandler.NAME, MaxRequestSizeHandler(cfg.connection.maxRequestSize)) pipeline.addLast(MaxRequestSizeHandler.NAME, MaxRequestSizeHandler(cfg.connection.maxRequestSize))
pipeline.addLast(HttpChunkContentCompressor(1024)) pipeline.addLast(HttpChunkContentCompressor(1024))
@@ -452,6 +528,7 @@ class RemoteBuildCacheServer(private val cfg: Configuration) {
} }
fun run(): ServerHandle { fun run(): ServerHandle {
OtelSdkIntegration.initialize(cfg.isEnableTelemetry)
// Create the multithreaded event loops for the server // Create the multithreaded event loops for the server
val bossGroup = MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()) val bossGroup = MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory())
val channelFactory = ChannelFactory<SocketChannel> { NioSocketChannel() } val channelFactory = ChannelFactory<SocketChannel> { NioSocketChannel() }

View File

@@ -12,15 +12,18 @@ import io.netty.handler.codec.http.HttpRequest
import io.netty.handler.codec.http.HttpResponseStatus import io.netty.handler.codec.http.HttpResponseStatus
import io.netty.handler.codec.http.HttpVersion import io.netty.handler.codec.http.HttpVersion
import io.netty.util.ReferenceCountUtil import io.netty.util.ReferenceCountUtil
import java.net.InetSocketAddress
import net.woggioni.rbcs.api.Configuration import net.woggioni.rbcs.api.Configuration
import net.woggioni.rbcs.api.Configuration.Group import net.woggioni.rbcs.api.Configuration.Group
import net.woggioni.rbcs.api.Role import net.woggioni.rbcs.api.Role
import net.woggioni.rbcs.common.createLogger
import net.woggioni.rbcs.server.RemoteBuildCacheServer import net.woggioni.rbcs.server.RemoteBuildCacheServer
abstract class AbstractNettyHttpAuthenticator(private val authorizer: Authorizer) : ChannelInboundHandlerAdapter() { abstract class AbstractNettyHttpAuthenticator(private val authorizer: Authorizer) : ChannelInboundHandlerAdapter() {
companion object { companion object {
private val log = createLogger<AbstractNettyHttpAuthenticator>()
private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse( private val AUTHENTICATION_FAILED: FullHttpResponse = DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER HttpVersion.HTTP_1_1, HttpResponseStatus.UNAUTHORIZED, Unpooled.EMPTY_BUFFER
).apply { ).apply {
@@ -53,6 +56,16 @@ abstract class AbstractNettyHttpAuthenticator(private val authorizer: Authorizer
result.groups.asSequence().flatMap { it.roles.asSequence() } result.groups.asSequence().flatMap { it.roles.asSequence() }
).toSet() ).toSet()
val authorized = authorizer.authorize(roles, msg) val authorized = authorizer.authorize(roles, msg)
if(log.isDebugEnabled) {
val authorizedMessage = if(authorized) { "Authorized" } else { "Forbidden" }
val clientAddress = ctx.channel().attr<InetSocketAddress>(RemoteBuildCacheServer.clientIp).get()
val roleString = "[" + roles.asSequence().map { "\"" + it + "\""}.joinToString(", ") + "]"
result.user?.name?.takeUnless(String::isEmpty)?.let { username ->
log.debug("$authorizedMessage ${msg.method()} request from user $username with address $clientAddress, granted roles $roleString")
} ?: {
log.debug("$authorizedMessage anonymous ${msg.method()} request with address $clientAddress, granted roles $roleString")
}
}
if (authorized) { if (authorized) {
super.channelRead(ctx, msg) super.channelRead(ctx, msg)
} else { } else {

View File

@@ -8,6 +8,7 @@ import net.woggioni.rbcs.api.Configuration.Authentication
import net.woggioni.rbcs.api.Configuration.BasicAuthentication import net.woggioni.rbcs.api.Configuration.BasicAuthentication
import net.woggioni.rbcs.api.Configuration.Cache import net.woggioni.rbcs.api.Configuration.Cache
import net.woggioni.rbcs.api.Configuration.ClientCertificateAuthentication import net.woggioni.rbcs.api.Configuration.ClientCertificateAuthentication
import net.woggioni.rbcs.api.Configuration.ForwardedClientCertificateAuthentication
import net.woggioni.rbcs.api.Configuration.Group import net.woggioni.rbcs.api.Configuration.Group
import net.woggioni.rbcs.api.Configuration.KeyStore import net.woggioni.rbcs.api.Configuration.KeyStore
import net.woggioni.rbcs.api.Configuration.Tls import net.woggioni.rbcs.api.Configuration.Tls
@@ -45,6 +46,8 @@ object Parser {
var groups = emptyMap<String, Group>() var groups = emptyMap<String, Group>()
var tls: Tls? = null var tls: Tls? = null
val serverPath = root.renderAttribute("path") val serverPath = root.renderAttribute("path")
var enableTelemetry = root.renderAttribute("enable-telemetry")
?.let(String::toBoolean) ?: false
var incomingConnectionsBacklogSize = 1024 var incomingConnectionsBacklogSize = 1024
var authentication: Authentication? = null var authentication: Authentication? = null
for (child in root.asIterable()) { for (child in root.asIterable()) {
@@ -77,6 +80,28 @@ object Parser {
} }
authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup) authentication = ClientCertificateAuthentication(tlsExtractorUser, tlsExtractorGroup)
} }
"forwarded-client-certificate" -> {
val headerName = gchild.renderAttribute("header-name") ?: "X-Client-Cert-Subject-DN"
var tlsExtractorUser: TlsCertificateExtractor? = null
var tlsExtractorGroup: TlsCertificateExtractor? = null
for (ggchild in gchild.asIterable()) {
when (ggchild.localName) {
"group-extractor" -> {
val attrName = ggchild.renderAttribute("attribute-name")
val pattern = ggchild.renderAttribute("pattern")
tlsExtractorGroup = TlsCertificateExtractor(attrName, pattern)
}
"user-extractor" -> {
val attrName = ggchild.renderAttribute("attribute-name")
val pattern = ggchild.renderAttribute("pattern")
tlsExtractorUser = TlsCertificateExtractor(attrName, pattern)
}
}
}
authentication = ForwardedClientCertificateAuthentication(headerName, tlsExtractorUser, tlsExtractorGroup)
}
} }
} }
} }
@@ -210,6 +235,7 @@ object Parser {
} }
} }
return Configuration.of( return Configuration.of(
enableTelemetry,
host, host,
port, port,
proxyProtocolEnabled, proxyProtocolEnabled,

View File

@@ -29,6 +29,7 @@ object Serializer {
?.let { serverPath -> ?.let { serverPath ->
attr("path", serverPath) attr("path", serverPath)
} }
attr("enable-telemetry", conf.isEnableTelemetry.toString())
node("bind") { node("bind") {
attr("host", conf.host) attr("host", conf.host)
attr("port", conf.port.toString()) attr("port", conf.port.toString())
@@ -165,6 +166,23 @@ object Serializer {
} }
} }
} }
is Configuration.ForwardedClientCertificateAuthentication -> {
node("forwarded-client-certificate") {
attr("header-name", authentication.headerName)
authentication.groupExtractor?.let { extractor ->
node("group-extractor") {
attr("attribute-name", extractor.rdnType)
attr("pattern", extractor.pattern)
}
}
authentication.userExtractor?.let { extractor ->
node("user-extractor") {
attr("attribute-name", extractor.rdnType)
attr("pattern", extractor.pattern)
}
}
}
}
} }
} }
} }

View File

@@ -22,6 +22,7 @@ import net.woggioni.rbcs.api.exception.ContentTooLargeException
import net.woggioni.rbcs.common.contextLogger import net.woggioni.rbcs.common.contextLogger
import net.woggioni.rbcs.common.debug import net.woggioni.rbcs.common.debug
import net.woggioni.rbcs.common.log import net.woggioni.rbcs.common.log
import net.woggioni.rbcs.server.RemoteBuildCacheServer
import org.slf4j.event.Level import org.slf4j.event.Level
import org.slf4j.spi.LoggingEventBuilder import org.slf4j.spi.LoggingEventBuilder
@@ -59,27 +60,38 @@ object ExceptionHandler : ChannelDuplexHandler() {
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
when (cause) { when (cause) {
is DecoderException -> { is DecoderException -> {
if(log.isDebugEnabled) {
log.debug(cause.message, cause) log.debug(cause.message, cause)
}
ctx.close() ctx.close()
} }
is ConnectException -> { is ConnectException -> {
if(log.isErrorEnabled) {
log.error(cause.message, cause) log.error(cause.message, cause)
}
ctx.writeAndFlush(SERVER_ERROR.retainedDuplicate()) ctx.writeAndFlush(SERVER_ERROR.retainedDuplicate())
} }
is SocketException -> { is SocketException -> {
if(log.isDebugEnabled) {
log.debug(cause.message, cause) log.debug(cause.message, cause)
}
ctx.close() ctx.close()
} }
is SSLPeerUnverifiedException -> { is SSLPeerUnverifiedException -> {
if(log.isDebugEnabled) {
log.debug(cause.message, cause)
}
ctx.writeAndFlush(NOT_AUTHORIZED.retainedDuplicate()) ctx.writeAndFlush(NOT_AUTHORIZED.retainedDuplicate())
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE) .addListener(ChannelFutureListener.CLOSE_ON_FAILURE)
} }
is SSLException -> { is SSLException -> {
if(log.isDebugEnabled) {
log.debug(cause.message, cause) log.debug(cause.message, cause)
}
ctx.close() ctx.close()
} }
@@ -108,13 +120,17 @@ object ExceptionHandler : ChannelDuplexHandler() {
} }
is CacheException -> { is CacheException -> {
if(log.isErrorEnabled) {
log.error(cause.message, cause) log.error(cause.message, cause)
}
ctx.writeAndFlush(NOT_AVAILABLE.retainedDuplicate()) ctx.writeAndFlush(NOT_AVAILABLE.retainedDuplicate())
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE) .addListener(ChannelFutureListener.CLOSE_ON_FAILURE)
} }
else -> { else -> {
if(log.isErrorEnabled) {
log.error(cause.message, cause) log.error(cause.message, cause)
}
ctx.writeAndFlush(SERVER_ERROR.retainedDuplicate()) ctx.writeAndFlush(SERVER_ERROR.retainedDuplicate())
.addListener(ChannelFutureListener.CLOSE_ON_FAILURE) .addListener(ChannelFutureListener.CLOSE_ON_FAILURE)
} }

View File

@@ -0,0 +1,32 @@
package net.woggioni.rbcs.server.otel
import io.netty.channel.ChannelHandler
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.instrumentation.netty.v4_1.NettyServerTelemetry
import net.woggioni.rbcs.common.createLogger
import net.woggioni.rbcs.common.warn
object OtelIntegration {
private val log = createLogger<OtelIntegration>()
val isAvailable: Boolean by lazy {
runCatching {
Class.forName("io.opentelemetry.api.OpenTelemetry")
}.fold(
onSuccess = { true },
onFailure = {
log.warn { "OpenTelemetry classes not on classpath, instrumentation disabled" }
false
},
)
}
fun createHandler(enabled: Boolean): ChannelHandler? {
return if (enabled && isAvailable) createHandlerInternal() else null
}
private fun createHandlerInternal(): ChannelHandler {
return NettyServerTelemetry.create(GlobalOpenTelemetry.get()).createCombinedHandler()
}
}

View File

@@ -0,0 +1,58 @@
package net.woggioni.rbcs.server.otel
import net.woggioni.rbcs.common.createLogger
import net.woggioni.rbcs.common.info
object OtelSdkIntegration {
private val log = createLogger<OtelSdkIntegration>()
private val isAvailable: Boolean by lazy {
runCatching {
Class.forName("io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk")
}.fold(
onSuccess = { true },
onFailure = {
log.info { "OpenTelemetry SDK autoconfigure not on classpath" }
false
},
)
}
private val appenderAvailable: Boolean by lazy {
runCatching {
Class.forName("io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender")
}.fold(
onSuccess = { true },
onFailure = {
log.info { "OpenTelemetry logback appender not on classpath" }
false
},
)
}
fun initialize(enabled: Boolean) {
if (!enabled || !isAvailable) return
log.info { "Initializing OpenTelemetry SDK with auto-configuration" }
val sdk = io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk.builder()
.setResultAsGlobal()
.build()
.openTelemetrySdk
if (appenderAvailable) {
runCatching {
val clazz = Class.forName("io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender")
clazz.getMethod("install", Class.forName("io.opentelemetry.api.OpenTelemetry"))
.invoke(null, sdk)
log.info { "OpenTelemetry logback appender installed" }
}.onFailure { ex ->
val msg = ex.localizedMessage ?: ex.javaClass.name
log.info { "Failed to install OpenTelemetry logback appender: $msg" }
}
}
log.info { "OpenTelemetry SDK initialized successfully" }
}
}

View File

@@ -2,7 +2,8 @@
<rbcs:server <rbcs:server
xmlns:xs="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema-instance"
xmlns:rbcs="urn:net.woggioni.rbcs.server" xmlns:rbcs="urn:net.woggioni.rbcs.server"
xs:schemaLocation="urn:net.woggioni.rbcs.server jpms://net.woggioni.rbcs.server/net/woggioni/rbcs/server/schema/rbcs-server.xsd"> xs:schemaLocation="urn:net.woggioni.rbcs.server jpms://net.woggioni.rbcs.server/net/woggioni/rbcs/server/schema/rbcs-server.xsd"
enable-telemetry="false">
<bind host="127.0.0.1" port="8080" incoming-connections-backlog-size="1024"/> <bind host="127.0.0.1" port="8080" incoming-connections-backlog-size="1024"/>
<cache xs:type="rbcs:fileSystemCacheType" path="${sys:java.io.tmpdir}/rbcs" max-age="P7D"/> <cache xs:type="rbcs:fileSystemCacheType" path="${sys:java.io.tmpdir}/rbcs" max-age="P7D"/>
</rbcs:server> </rbcs:server>

View File

@@ -59,6 +59,14 @@
</xs:documentation> </xs:documentation>
</xs:annotation> </xs:annotation>
</xs:attribute> </xs:attribute>
<xs:attribute name="enable-telemetry" type="xs:boolean" use="optional" default="false">
<xs:annotation>
<xs:documentation>
Enable OpenTelemetry distributed tracing for the server.
Even when enabled, telemetry only activates if OpenTelemetry classes are present on the classpath.
</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType> </xs:complexType>
<xs:complexType name="bindType"> <xs:complexType name="bindType">
@@ -311,6 +319,45 @@
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
<xs:complexType name="forwardedClientCertificateAuthorizationType">
<xs:annotation>
<xs:documentation>
Authenticate clients based on a custom HTTP header containing the client TLS certificate
subject DN, forwarded by a reverse proxy that performs TLS termination. The proxy must be
listed in the trusted-proxies configuration for the header to be accepted.
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="group-extractor" type="rbcs:X500NameExtractorType" minOccurs="0">
<xs:annotation>
<xs:documentation>
A regex based extractor that will be used to determine which group the client belongs to,
based on the X.500 name of the subject DN forwarded by the reverse proxy.
When this is set RBAC works even if the user isn't listed in the &lt;users/&gt; section as
the client will be assigned role solely based on the group he is found to belong to.
Note that this does not allow for a client to be part of multiple groups.
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="user-extractor" type="rbcs:X500NameExtractorType" minOccurs="0">
<xs:annotation>
<xs:documentation>
A regex based extractor that will be used to assign a user to a connected client,
based on the X.500 name of the subject DN forwarded by the reverse proxy.
</xs:documentation>
</xs:annotation>
</xs:element>
</xs:sequence>
<xs:attribute name="header-name" type="xs:token">
<xs:annotation>
<xs:documentation>
Name of the HTTP header containing the client certificate subject DN
forwarded by the reverse proxy. Defaults to "X-Client-Cert-Subject-DN".
</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
<xs:complexType name="X500NameExtractorType"> <xs:complexType name="X500NameExtractorType">
<xs:annotation> <xs:annotation>
<xs:documentation> <xs:documentation>
@@ -380,6 +427,15 @@
</xs:documentation> </xs:documentation>
</xs:annotation> </xs:annotation>
</xs:element> </xs:element>
<xs:element name="forwarded-client-certificate" type="rbcs:forwardedClientCertificateAuthorizationType">
<xs:annotation>
<xs:documentation>
Enable forwarded client certificate authentication. Authenticates clients based on
a custom HTTP header containing the client certificate subject DN, forwarded by a
reverse proxy that performs TLS termination. Requires trusted-proxies to be configured.
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="none"> <xs:element name="none">
<xs:annotation> <xs:annotation>
<xs:documentation> <xs:documentation>

View File

@@ -32,6 +32,7 @@ abstract class AbstractBasicAuthServerTest : AbstractServerTest() {
override fun setUp() { override fun setUp() {
this.cacheDir = testDir.resolve("cache") this.cacheDir = testDir.resolve("cache")
cfg = Configuration.of( cfg = Configuration.of(
false,
"127.0.0.1", "127.0.0.1",
getFreePort(), getFreePort(),
false, false,

View File

@@ -138,6 +138,7 @@ abstract class AbstractTlsServerTest : AbstractServerTest() {
this.cacheDir = testDir.resolve("cache") this.cacheDir = testDir.resolve("cache")
createKeyStoreAndTrustStore() createKeyStoreAndTrustStore()
cfg = Configuration( cfg = Configuration(
false,
"127.0.0.1", "127.0.0.1",
getFreePort(), getFreePort(),
false, false,

View File

@@ -32,6 +32,7 @@ class NoAuthServerTest : AbstractServerTest() {
override fun setUp() { override fun setUp() {
this.cacheDir = testDir.resolve("cache") this.cacheDir = testDir.resolve("cache")
cfg = Configuration( cfg = Configuration(
false,
"127.0.0.1", "127.0.0.1",
getFreePort(), getFreePort(),
false, false,