diff --git a/compose/auth_proxy.yml b/compose/auth_proxy.yml index 4565e4248..649489867 100644 --- a/compose/auth_proxy.yml +++ b/compose/auth_proxy.yml @@ -4,11 +4,13 @@ services: expose: - "${CRYOSTAT_HTTP_PORT}" environment: + CRYOSTAT_HTTP_PROXY_HOST: auth + CRYOSTAT_HTTP_PROXY_PORT: '8080' QUARKUS_HTTP_PROXY_PROXY_ADDRESS_FORWARDING: 'true' QUARKUS_HTTP_PROXY_ALLOW_X_FORWARDED: 'true' QUARKUS_HTTP_PROXY_ENABLE_FORWARDED_HOST: 'true' QUARKUS_HTTP_PROXY_ENABLE_FORWARDED_PREFIX: 'true' - QUARKUS_HTTP_PROXY_TRUSTED_PROXIES: localhost:8080,auth:8080 + QUARKUS_HTTP_PROXY_TRUSTED_PROXIES: 127.0.0.1:${CRYOSTAT_HTTP_PORT} healthcheck: test: curl --fail http://cryostat:8181/health/liveness || exit 1 interval: 10s diff --git a/compose/sample-apps.yml b/compose/sample-apps.yml index 6166e2489..938a37f9d 100644 --- a/compose/sample-apps.yml +++ b/compose/sample-apps.yml @@ -4,7 +4,7 @@ services: depends_on: cryostat: condition: service_healthy - image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.0} + image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.1} hostname: vertx-fib-demo-1 environment: HTTP_PORT: 8081 @@ -15,11 +15,13 @@ services: CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-1" CRYOSTAT_AGENT_WEBSERVER_PORT: "8910" CRYOSTAT_AGENT_CALLBACK: "http://sample-app-1:8910/" - CRYOSTAT_AGENT_BASEURI: "http://cryostat:${CRYOSTAT_HTTP_PORT}/" + CRYOSTAT_AGENT_BASEURI: "http://${CRYOSTAT_HTTP_HOST}:8080/" CRYOSTAT_AGENT_TRUST_ALL: "true" CRYOSTAT_AGENT_AUTHORIZATION: Basic dXNlcjpwYXNz ports: - "8081:8081" + expose: + - "8910" labels: io.cryostat.discovery: "true" io.cryostat.jmxHost: "sample-app-1" @@ -35,7 +37,7 @@ services: depends_on: cryostat: condition: service_healthy - image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.0} + image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.1} hostname: vertx-fib-demo-2 environment: HTTP_PORT: 8082 @@ -47,11 +49,13 @@ services: CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-2" CRYOSTAT_AGENT_WEBSERVER_PORT: "8911" CRYOSTAT_AGENT_CALLBACK: "http://sample-app-2:8911/" - CRYOSTAT_AGENT_BASEURI: "http://cryostat:${CRYOSTAT_HTTP_PORT}/" + CRYOSTAT_AGENT_BASEURI: "http://${CRYOSTAT_HTTP_HOST}:8080/" CRYOSTAT_AGENT_TRUST_ALL: "true" CRYOSTAT_AGENT_AUTHORIZATION: "Basic dXNlcjpwYXNz" ports: - "8082:8082" + expose: + - "8911" restart: always healthcheck: test: curl --fail http://localhost:8081 || exit 1 @@ -63,7 +67,7 @@ services: depends_on: cryostat: condition: service_healthy - image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.0} + image: ${VERTX_FIB_DEMO_IMAGE:-quay.io/andrewazores/vertx-fib-demo:0.13.1} hostname: vertx-fib-demo-3 environment: HTTP_PORT: 8083 @@ -74,13 +78,15 @@ services: CRYOSTAT_AGENT_WEBCLIENT_SSL_TRUST_ALL: "true" CRYOSTAT_AGENT_WEBCLIENT_SSL_VERIFY_HOSTNAME: "false" CRYOSTAT_AGENT_WEBSERVER_HOST: "sample-app-3" - CRYOSTAT_AGENT_WEBSERVER_PORT: "8910" + CRYOSTAT_AGENT_WEBSERVER_PORT: "8912" CRYOSTAT_AGENT_CALLBACK: "http://sample-app-3:8912/" - CRYOSTAT_AGENT_BASEURI: "http://cryostat:${CRYOSTAT_HTTP_PORT}/" + CRYOSTAT_AGENT_BASEURI: "http://${CRYOSTAT_HTTP_HOST}:8080/" CRYOSTAT_AGENT_TRUST_ALL: "true" CRYOSTAT_AGENT_AUTHORIZATION: "Basic dXNlcjpwYXNz" ports: - "8083:8083" + expose: + - "8912" restart: always healthcheck: test: curl --fail http://localhost:8081 || exit 1 @@ -104,7 +110,7 @@ services: CRYOSTAT_AGENT_WEBSERVER_HOST: quarkus-test-agent CRYOSTAT_AGENT_WEBSERVER_PORT: 9977 CRYOSTAT_AGENT_CALLBACK: http://quarkus-test-agent:9977/ - CRYOSTAT_AGENT_BASEURI: http://cryostat:${CRYOSTAT_HTTP_PORT}/ + CRYOSTAT_AGENT_BASEURI: http://${CRYOSTAT_HTTP_HOST}:8080/ CRYOSTAT_AGENT_BASEURI_RANGE: public CRYOSTAT_AGENT_SSL_TRUST_ALL: "true" CRYOSTAT_AGENT_SSL_VERIFY_HOSTNAME: "false" diff --git a/pom.xml b/pom.xml index d70cb38f7..a1316b6ae 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ 3.13.0 1.7 0.3.21 + 9.31 1.19.7 quarkus-bom io.quarkus.platform @@ -165,6 +166,11 @@ commons-validator ${org.apache.commons.validator.version} + + com.nimbusds + nimbus-jose-jwt + ${com.nimbusds.jose.jwt.version} + io.quarkus quarkus-rest-client-reactive-jackson diff --git a/schema/openapi.yaml b/schema/openapi.yaml index 95010a66e..b7db9ef33 100644 --- a/schema/openapi.yaml +++ b/schema/openapi.yaml @@ -1155,11 +1155,6 @@ paths: $ref: '#/components/schemas/JsonObject' responses: "200": - content: - application/json: - schema: - additionalProperties: {} - type: object description: OK "401": description: Not Authorized diff --git a/smoketest.bash b/smoketest.bash index a9701add4..6e88e733a 100755 --- a/smoketest.bash +++ b/smoketest.bash @@ -18,6 +18,7 @@ OPEN_TABS=${OPEN_TABS:-false} PRECREATE_BUCKETS=${PRECREATE_BUCKETS:-archivedrecordings,archivedreports,eventtemplates} +CRYOSTAT_HTTP_HOST=${CRYOSTAT_HTTP_HOST:-cryostat} CRYOSTAT_HTTP_PORT=${CRYOSTAT_HTTP_PORT:-8080} USE_PROXY=${USE_PROXY:-true} DEPLOY_GRAFANA=${DEPLOY_GRAFANA:-true} @@ -97,6 +98,7 @@ fi if [ "${USE_PROXY}" = "true" ]; then FILES+=("${DIR}/compose/auth_proxy.yml") + CRYOSTAT_HTTP_HOST=auth CRYOSTAT_HTTP_PORT=8181 GRAFANA_DASHBOARD_EXT_URL=http://localhost:8080/grafana/ else @@ -106,6 +108,7 @@ else fi GRAFANA_DASHBOARD_EXT_URL=http://grafana:3000/ fi +export CRYOSTAT_HTTP_HOST export CRYOSTAT_HTTP_PORT export GRAFANA_DASHBOARD_EXT_URL diff --git a/src/main/java/io/cryostat/ExceptionMappers.java b/src/main/java/io/cryostat/ExceptionMappers.java index 4ca58ab88..acb7c439a 100644 --- a/src/main/java/io/cryostat/ExceptionMappers.java +++ b/src/main/java/io/cryostat/ExceptionMappers.java @@ -24,6 +24,7 @@ import io.cryostat.targets.TargetConnectionManager; import io.cryostat.util.EntityExistsException; +import com.nimbusds.jwt.proc.BadJWTException; import io.netty.handler.codec.http.HttpResponseStatus; import io.smallrye.mutiny.TimeoutException; import jakarta.inject.Inject; @@ -118,6 +119,12 @@ public RestResponse mapEntityExistsException(EntityExistsException ex) { .build(); } + @ServerExceptionMapper + public RestResponse mapBadJwtException(BadJWTException ex) { + logger.warn(ex); + return RestResponse.status(HttpResponseStatus.UNAUTHORIZED.code()); + } + @ServerExceptionMapper public RestResponse mapCompletionException(CompletionException ex) throws Throwable { logger.warn(ex); diff --git a/src/main/java/io/cryostat/discovery/Discovery.java b/src/main/java/io/cryostat/discovery/Discovery.java index afac28aa5..d4c6e4ced 100644 --- a/src/main/java/io/cryostat/discovery/Discovery.java +++ b/src/main/java/io/cryostat/discovery/Discovery.java @@ -15,74 +15,134 @@ */ package io.cryostat.discovery; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.SocketException; import java.net.URI; import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.text.ParseException; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.UUID; -import java.util.regex.Pattern; +import io.cryostat.credentials.Credential; import io.cryostat.discovery.DiscoveryPlugin.PluginCallback; import io.cryostat.targets.TargetConnectionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jwt.proc.BadJWTException; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; +import io.smallrye.common.annotation.Blocking; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; import io.vertx.mutiny.core.eventbus.EventBus; import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.event.Observes; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.ForbiddenException; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.apache.commons.lang3.StringUtils; +import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.RestPath; import org.jboss.resteasy.reactive.RestQuery; import org.jboss.resteasy.reactive.RestResponse; import org.jboss.resteasy.reactive.RestResponse.ResponseBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; +import org.quartz.JobDetail; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.SimpleScheduleBuilder; +import org.quartz.TriggerBuilder; +import org.quartz.impl.matchers.GroupMatcher; @Path("") public class Discovery { - public static final Pattern HOST_PORT_PAIR_PATTERN = - Pattern.compile("^([^:\\s]+)(?::(\\d{1,5}))$"); + static final String X_FORWARDED_FOR = "X-Forwarded-For"; + + private static final String JOB_PERIODIC = "periodic"; + private static final String JOB_STARTUP = "startup"; + private static final String PLUGIN_ID_MAP_KEY = "pluginId"; + private static final String REFRESH_MAP_KEY = "refresh"; + + @ConfigProperty(name = "cryostat.discovery.plugins.ping-period") + Duration discoveryPingPeriod; @Inject Logger logger; @Inject ObjectMapper mapper; @Inject EventBus bus; @Inject TargetConnectionManager connectionManager; + @Inject DiscoveryJwtFactory jwtFactory; + @Inject DiscoveryJwtValidator jwtValidator; + @Inject Scheduler scheduler; @Transactional void onStart(@Observes StartupEvent evt) { + // ensure lazily initialized entries are created DiscoveryNode.getUniverse(); DiscoveryPlugin.findAll().list().stream() .filter(p -> !p.builtin) .forEach( plugin -> { + var dataMap = new JobDataMap(); + dataMap.put(PLUGIN_ID_MAP_KEY, plugin.id); + dataMap.put(REFRESH_MAP_KEY, true); + JobDetail jobDetail = + JobBuilder.newJob(RefreshPluginJob.class) + .withIdentity(plugin.id.toString(), JOB_STARTUP) + .usingJobData(dataMap) + .build(); + var trigger = + TriggerBuilder.newTrigger() + .usingJobData(jobDetail.getJobDataMap()) + .startNow() + .withSchedule( + SimpleScheduleBuilder.simpleSchedule() + .withRepeatCount(0)) + .build(); try { - PluginCallback.create(plugin).ping(); - logger.infov( - "Retained discovery plugin: {0} @ {1}", - plugin.realm, plugin.callback); - } catch (Exception e) { - logger.infov( - "Pruned discovery plugin: {0} @ {1}", - plugin.realm, plugin.callback); - plugin.delete(); + scheduler.scheduleJob(jobDetail, trigger); + } catch (SchedulerException e) { + logger.warn("Failed to schedule plugin prune job", e); } }); } + void onStop(@Observes ShutdownEvent evt) throws SchedulerException { + scheduler.shutdown(); + } + @GET @Path("/api/v2.1/discovery") @RolesAllowed("read") @@ -102,49 +162,118 @@ public DiscoveryNode get() { @GET @Path("/api/v2.2/discovery/{id}") @RolesAllowed("read") - public RestResponse checkRegistration(@RestPath UUID id, @RestQuery String token) { - DiscoveryPlugin.find("id", id).singleResult(); + public RestResponse checkRegistration( + @Context RoutingContext ctx, @RestPath UUID id, @RestQuery String token) + throws SocketException, + UnknownHostException, + MalformedURLException, + ParseException, + JOSEException, + URISyntaxException { + DiscoveryPlugin plugin = DiscoveryPlugin.find("id", id).singleResult(); + jwtValidator.validateJwt(ctx, plugin, token, true); return ResponseBuilder.ok().build(); } @Transactional @POST @Path("/api/v2.2/discovery") + @Produces(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON) @RolesAllowed("write") - public Map register(JsonObject body) throws URISyntaxException { - String id = body.getString("id"); + public Response register(@Context RoutingContext ctx, JsonObject body) + throws URISyntaxException, + JOSEException, + UnknownHostException, + SocketException, + ParseException, + BadJWTException, + SchedulerException { + String pluginId = body.getString("id"); String priorToken = body.getString("token"); - - if (StringUtils.isNotBlank(id) && StringUtils.isNotBlank(priorToken)) { - // TODO refresh the JWT - return Map.of("id", id, "token", priorToken); - } - String realmName = body.getString("realm"); URI callbackUri = new URI(body.getString("callback")); - DiscoveryPlugin plugin = new DiscoveryPlugin(); - plugin.callback = callbackUri; - plugin.realm = DiscoveryNode.environment(realmName, DiscoveryNode.REALM); - plugin.builtin = false; - plugin.persist(); + // TODO apply URI range validation to the remote address + InetAddress remoteAddress = getRemoteAddress(ctx); - DiscoveryNode.getUniverse().children.add(plugin.realm); + URI location; + DiscoveryPlugin plugin; + if (StringUtils.isNotBlank(pluginId) && StringUtils.isNotBlank(priorToken)) { + // refresh the JWT for existing registration + plugin = + DiscoveryPlugin.find("id", UUID.fromString(pluginId)) + .singleResult(); + if (!Objects.equals(plugin.realm.name, realmName)) { + throw new ForbiddenException(); + } + if (!Objects.equals(plugin.callback, callbackUri)) { + throw new BadRequestException(); + } + location = jwtFactory.getPluginLocation(plugin); + jwtFactory.parseDiscoveryPluginJwt(plugin, priorToken, location, remoteAddress, false); + } else { + // new plugin registration + plugin = new DiscoveryPlugin(); + plugin.callback = callbackUri; + plugin.realm = + DiscoveryNode.environment( + requireNonBlank(realmName, "realm"), DiscoveryNode.REALM); + plugin.builtin = false; + plugin.persist(); - return Map.of( - "meta", - Map.of( - "mimeType", "JSON", - "status", "OK"), - "data", + DiscoveryNode.getUniverse().children.add(plugin.realm); + + location = jwtFactory.getPluginLocation(plugin); + + var dataMap = new JobDataMap(); + dataMap.put(PLUGIN_ID_MAP_KEY, plugin.id); + dataMap.put(REFRESH_MAP_KEY, true); + JobDetail jobDetail = + JobBuilder.newJob(RefreshPluginJob.class) + .withIdentity(plugin.id.toString(), JOB_PERIODIC) + .usingJobData(dataMap) + .build(); + var trigger = + TriggerBuilder.newTrigger() + .usingJobData(jobDetail.getJobDataMap()) + .startAt(Date.from(Instant.now().plus(discoveryPingPeriod))) + .withSchedule( + SimpleScheduleBuilder.simpleSchedule() + .repeatForever() + .withIntervalInSeconds( + (int) discoveryPingPeriod.toSeconds())) + .build(); + scheduler.scheduleJob(jobDetail, trigger); + } + + String token = jwtFactory.createDiscoveryPluginJwt(plugin, remoteAddress, location); + + // TODO implement more generic env map passing by some platform detection strategy or + // generalized config properties + var envMap = new HashMap(); + String insightsProxy = System.getenv("INSIGHTS_PROXY"); + if (StringUtils.isNotBlank(insightsProxy)) { + envMap.put("INSIGHTS_SVC", "INSIGHTS_PROXY"); + } + return Response.created(location) + .entity( Map.of( - "result", - Map.of( - "id", - plugin.id.toString(), - "token", - UUID.randomUUID().toString()))); + "meta", + Map.of( + "mimeType", "JSON", + "status", "OK"), + "data", + Map.of( + "result", + Map.of( + "id", + plugin.id.toString(), + "token", + token, + "env", + envMap)))) + .build(); } @Transactional @@ -153,8 +282,18 @@ public Map register(JsonObject body) throws URISyntaxException { @Consumes(MediaType.APPLICATION_JSON) @PermitAll public Map> publish( - @RestPath UUID id, @RestQuery String token, List body) { + @Context RoutingContext ctx, + @RestPath UUID id, + @RestQuery String token, + List body) + throws SocketException, + UnknownHostException, + MalformedURLException, + ParseException, + JOSEException, + URISyntaxException { DiscoveryPlugin plugin = DiscoveryPlugin.find("id", id).singleResult(); + jwtValidator.validateJwt(ctx, plugin, token, true); plugin.realm.children.clear(); plugin.persist(); plugin.realm.children.addAll(body); @@ -179,12 +318,31 @@ public Map> publish( @DELETE @Path("/api/v2.2/discovery/{id}") @PermitAll - public Map> deregister(@RestPath UUID id, @RestQuery String token) { + public Map> deregister( + @Context RoutingContext ctx, @RestPath UUID id, @RestQuery String token) + throws SocketException, + UnknownHostException, + MalformedURLException, + ParseException, + JOSEException, + URISyntaxException, + SchedulerException { DiscoveryPlugin plugin = DiscoveryPlugin.find("id", id).singleResult(); + jwtValidator.validateJwt(ctx, plugin, token, false); if (plugin.builtin) { throw new ForbiddenException(); } + + Set jobKeys = new HashSet<>(); + jobKeys.addAll(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(JOB_PERIODIC))); + jobKeys.addAll(scheduler.getJobKeys(GroupMatcher.jobGroupEquals(JOB_STARTUP))); + for (var key : jobKeys) { + scheduler.deleteJob(key); + } + + plugin.realm.delete(); plugin.delete(); + getStoredCredential(plugin).ifPresent(Credential::delete); DiscoveryNode.getUniverse().children.remove(plugin.realm); return Map.of( "meta", @@ -217,4 +375,71 @@ public Response getPlugins(@RestQuery String realm) throws JsonProcessingExcepti public DiscoveryPlugin getPlugin(@RestPath UUID id) throws JsonProcessingException { return DiscoveryPlugin.find("id", id).singleResult(); } + + Optional getStoredCredential(DiscoveryPlugin plugin) { + return new DiscoveryPlugin.PluginCallback.DiscoveryPluginAuthorizationHeaderFactory(plugin) + .getCredential(); + } + + static class RefreshPluginJob implements Job { + @Inject Logger logger; + + @Override + @Transactional + @Blocking + @SuppressFBWarnings("RCN_REDUNDANT_NULLCHECK_OF_NULL_VALUE") + public void execute(JobExecutionContext context) throws JobExecutionException { + DiscoveryPlugin plugin = null; + try { + boolean refresh = context.getMergedJobDataMap().getBoolean(REFRESH_MAP_KEY); + plugin = + DiscoveryPlugin.find( + "id", context.getMergedJobDataMap().get(PLUGIN_ID_MAP_KEY)) + .singleResult(); + var cb = PluginCallback.create(plugin); + if (refresh) { + cb.refresh(); + logger.infov( + "Refreshed discovery plugin: {0} @ {1}", plugin.realm, plugin.callback); + } else { + cb.ping(); + logger.infov( + "Retained discovery plugin: {0} @ {1}", plugin.realm, plugin.callback); + } + } catch (Exception e) { + if (plugin != null) { + logger.infov( + "Pruned discovery plugin: {0} @ {1}", plugin.realm, plugin.callback); + plugin.realm.delete(); + plugin.delete(); + new DiscoveryPlugin.PluginCallback.DiscoveryPluginAuthorizationHeaderFactory( + plugin) + .getCredential() + .ifPresent(Credential::delete); + } + throw new JobExecutionException(e); + } + } + } + + static String requireNonBlank(String in, String name) { + if (StringUtils.isBlank(in)) { + throw new IllegalArgumentException( + String.format("Parameter \"%s\" may not be blank", name)); + } + return in; + } + + private InetAddress getRemoteAddress(RoutingContext ctx) { + InetAddress addr = null; + if (ctx.request() != null && ctx.request().remoteAddress() != null) { + addr = jwtValidator.tryResolveAddress(addr, ctx.request().remoteAddress().host()); + } + if (ctx.request() != null && ctx.request().headers() != null) { + addr = + jwtValidator.tryResolveAddress( + addr, ctx.request().headers().get(X_FORWARDED_FOR)); + } + return addr; + } } diff --git a/src/main/java/io/cryostat/discovery/DiscoveryJwtFactory.java b/src/main/java/io/cryostat/discovery/DiscoveryJwtFactory.java new file mode 100644 index 000000000..e033a1410 --- /dev/null +++ b/src/main/java/io/cryostat/discovery/DiscoveryJwtFactory.java @@ -0,0 +1,204 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.discovery; + +import java.net.InetAddress; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.text.ParseException; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEDecrypter; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWEHeader; +import com.nimbusds.jose.JWEObject; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class DiscoveryJwtFactory { + + public static final String RESOURCE_CLAIM = "resource"; + public static final String REALM_CLAIM = "realm"; + static final String DISCOVERY_API_PATH = "/api/v2.2/discovery/"; + + @Inject JWSSigner signer; + @Inject JWSVerifier verifier; + @Inject JWEEncrypter encrypter; + @Inject JWEDecrypter decrypter; + + @ConfigProperty(name = "cryostat.discovery.plugins.ping-period") + Duration discoveryPingPeriod; + + @ConfigProperty(name = "cryostat.discovery.plugins.jwt.signature.algorithm") + String signatureAlgorithm; + + @ConfigProperty(name = "cryostat.discovery.plugins.jwt.encryption.algorithm") + String encryptionAlgorithm; + + @ConfigProperty(name = "cryostat.discovery.plugins.jwt.encryption.method") + String encryptionMethod; + + @ConfigProperty(name = "cryostat.http.proxy.tls-enabled") + boolean tlsEnabled; + + @ConfigProperty(name = "cryostat.http.proxy.host") + String httpHost; + + @ConfigProperty(name = "cryostat.http.proxy.port") + int httpPort; + + @ConfigProperty(name = "cryostat.http.proxy.path") + String httpPath; + + public String createDiscoveryPluginJwt( + DiscoveryPlugin plugin, InetAddress requestAddr, URI resource) + throws SocketException, UnknownHostException, URISyntaxException, JOSEException { + URI hostUri = + new URI( + String.format( + "%s://%s:%d%s", + tlsEnabled ? "https" : "http", httpHost, httpPort, httpPath)); + String issuer = hostUri.toString(); + Date now = Date.from(Instant.now()); + Date expiry = Date.from(now.toInstant().plus(discoveryPingPeriod.multipliedBy(2))); + JWTClaimsSet claims = + new JWTClaimsSet.Builder() + .issuer(issuer) + .audience(List.of(issuer, requestAddr.getHostAddress())) + .issueTime(now) + .notBeforeTime(now) + .expirationTime(expiry) + .subject(plugin.id.toString()) + .claim(RESOURCE_CLAIM, resource.toASCIIString()) + .claim(REALM_CLAIM, plugin.realm.name) + .build(); + + SignedJWT jwt = + new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.parse(signatureAlgorithm)).build(), + claims); + jwt.sign(signer); + + JWEHeader header = + new JWEHeader.Builder( + JWEAlgorithm.parse(encryptionAlgorithm), + EncryptionMethod.parse(encryptionMethod)) + .contentType("JWT") + .build(); + JWEObject jwe = new JWEObject(header, new Payload(jwt)); + jwe.encrypt(encrypter); + + return jwe.serialize(); + } + + public JWT parseDiscoveryPluginJwt( + DiscoveryPlugin plugin, String rawToken, URI resource, InetAddress requestAddr) + throws ParseException, + JOSEException, + BadJWTException, + SocketException, + UnknownHostException, + URISyntaxException { + return parseDiscoveryPluginJwt(plugin, rawToken, resource, requestAddr, true); + } + + public JWT parseDiscoveryPluginJwt( + DiscoveryPlugin plugin, + String rawToken, + URI resource, + InetAddress requestAddr, + boolean checkTimeClaims) + throws ParseException, + JOSEException, + BadJWTException, + SocketException, + UnknownHostException, + URISyntaxException { + JWEObject jwe = JWEObject.parse(rawToken); + jwe.decrypt(decrypter); + + SignedJWT jwt = jwe.getPayload().toSignedJWT(); + jwt.verify(verifier); + + URI hostUri = + new URI( + String.format( + "%s://%s:%d/%s", + tlsEnabled ? "https" : "http", + httpHost, + httpPort, + httpPath)) + .normalize(); + String issuer = hostUri.toString(); + JWTClaimsSet exactMatchClaims = + new JWTClaimsSet.Builder() + .issuer(issuer) + .audience(List.of(issuer, requestAddr.getHostAddress())) + .subject(plugin.id.toString()) + .claim(RESOURCE_CLAIM, resource.toASCIIString()) + .claim(REALM_CLAIM, plugin.realm.name) + .build(); + Set requiredClaimNames = + new HashSet<>(Set.of("iat", "iss", "aud", "sub", REALM_CLAIM)); + if (checkTimeClaims) { + requiredClaimNames.add("exp"); + requiredClaimNames.add("nbf"); + } + DefaultJWTClaimsVerifier verifier = + new DefaultJWTClaimsVerifier<>(issuer, exactMatchClaims, requiredClaimNames); + if (checkTimeClaims) { + verifier.setMaxClockSkew(5); + } + verifier.verify(jwt.getJWTClaimsSet(), null); + + return jwt; + } + + public URI getPluginLocation(DiscoveryPlugin plugin) throws URISyntaxException { + URI hostUri = + new URI( + String.format( + "%s://%s:%d/", + tlsEnabled ? "https" : "http", httpHost, httpPort)) + .resolve(httpPath) + .resolve(DISCOVERY_API_PATH) + .normalize(); + return hostUri.resolve(plugin.id.toString()).normalize(); + } +} diff --git a/src/main/java/io/cryostat/discovery/DiscoveryJwtValidator.java b/src/main/java/io/cryostat/discovery/DiscoveryJwtValidator.java new file mode 100644 index 000000000..777614b64 --- /dev/null +++ b/src/main/java/io/cryostat/discovery/DiscoveryJwtValidator.java @@ -0,0 +1,142 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.discovery; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.SocketException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +import java.text.ParseException; +import java.util.Objects; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.proc.BadJWTException; +import io.quarkus.security.UnauthorizedException; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class DiscoveryJwtValidator { + + @ConfigProperty(name = "cryostat.http.proxy.tls-enabled") + boolean tlsEnabled; + + @ConfigProperty(name = "cryostat.http.proxy.host") + String httpHost; + + @ConfigProperty(name = "cryostat.http.proxy.port") + int httpPort; + + @ConfigProperty(name = "cryostat.http.proxy.path") + String httpPath; + + @Inject DiscoveryJwtFactory jwtFactory; + @Inject Logger logger; + + public JWT validateJwt( + RoutingContext ctx, DiscoveryPlugin plugin, String token, boolean validateTimeClaims) + throws ParseException, + JOSEException, + SocketException, + UnknownHostException, + URISyntaxException, + MalformedURLException { + if (StringUtils.isBlank(token)) { + throw new UnauthorizedException("Token cannot be blank"); + } + InetAddress addr = null; + HttpServerRequest req = ctx.request(); + if (req.remoteAddress() != null) { + addr = tryResolveAddress(addr, req.remoteAddress().host()); + } + MultiMap headers = req.headers(); + addr = tryResolveAddress(addr, headers.get(Discovery.X_FORWARDED_FOR)); + + URI hostUri = + new URI( + String.format( + "%s://%s:%d/%s", + tlsEnabled ? "https" : "http", + httpHost, + httpPort, + httpPath)) + .normalize(); + + JWT parsed; + try { + parsed = + jwtFactory.parseDiscoveryPluginJwt( + plugin, + token, + jwtFactory.getPluginLocation(plugin), + addr, + validateTimeClaims); + } catch (BadJWTException e) { + throw new UnauthorizedException("Provided JWT was invalid", e); + } + + URI requestUri = new URI(req.absoluteURI()); + URI fullRequestUri = + new URI(hostUri.getScheme(), hostUri.getAuthority(), null, null, null) + .resolve(requestUri.getRawPath()); + URI relativeRequestUri = new URI(requestUri.getRawPath()); + URI resourceClaim; + try { + resourceClaim = + new URI( + parsed.getJWTClaimsSet() + .getStringClaim(DiscoveryJwtFactory.RESOURCE_CLAIM)); + } catch (URISyntaxException use) { + throw new UnauthorizedException("JWT resource claim was invalid", use); + } + boolean matchesAbsoluteRequestUri = + resourceClaim.isAbsolute() && Objects.equals(fullRequestUri, resourceClaim); + boolean matchesRelativeRequestUri = Objects.equals(relativeRequestUri, resourceClaim); + if (!matchesAbsoluteRequestUri && !matchesRelativeRequestUri) { + throw new UnauthorizedException( + "Token resource claim does not match requested resource"); + } + + String subject = parsed.getJWTClaimsSet().getSubject(); + if (!Objects.equals(subject, plugin.id.toString())) { + throw new UnauthorizedException( + "Token subject claim does not match the original subject"); + } + + return parsed; + } + + public InetAddress tryResolveAddress(InetAddress addr, String host) { + if (StringUtils.isBlank(host)) { + return addr; + } + try { + return InetAddress.getByName(host); + } catch (UnknownHostException e) { + logger.error("Address resolution exception", e); + } + return addr; + } +} diff --git a/src/main/java/io/cryostat/discovery/DiscoveryPlugin.java b/src/main/java/io/cryostat/discovery/DiscoveryPlugin.java index b893ebd9e..75eaf20b7 100644 --- a/src/main/java/io/cryostat/discovery/DiscoveryPlugin.java +++ b/src/main/java/io/cryostat/discovery/DiscoveryPlugin.java @@ -15,17 +15,19 @@ */ package io.cryostat.discovery; -import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.Optional; import java.util.UUID; import io.cryostat.credentials.Credential; import com.fasterxml.jackson.annotation.JsonProperty; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.CascadeType; @@ -39,14 +41,14 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.PrePersist; import jakarta.validation.constraints.NotNull; -import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import jakarta.ws.rs.client.ClientRequestContext; -import jakarta.ws.rs.client.ClientRequestFilter; import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; import org.apache.commons.lang3.StringUtils; -import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory; import org.hibernate.annotations.GenericGenerator; import org.jboss.logging.Logger; @@ -94,8 +96,11 @@ public void prePersist(DiscoveryPlugin plugin) { logger.infov( "Registered discovery plugin: {0} @ {1}", plugin.realm.name, plugin.callback); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); } catch (Exception e) { logger.error("Discovery Plugin ping failed", e); + throw e; } } } @@ -103,62 +108,85 @@ public void prePersist(DiscoveryPlugin plugin) { @Path("") interface PluginCallback { + final Logger logger = Logger.getLogger(PluginCallback.class); + @GET public void ping(); + @POST + public void refresh(); + public static PluginCallback create(DiscoveryPlugin plugin) throws URISyntaxException { + if (StringUtils.isBlank(plugin.callback.getUserInfo())) { + logger.warnv( + "Plugin with id:{0} realm:{1} callback:{2} did not supply userinfo", + plugin.id, plugin.realm, plugin.callback); + } + PluginCallback client = - RestClientBuilder.newBuilder() + QuarkusRestClientBuilder.newBuilder() .baseUri(plugin.callback) - .register(AuthorizationFilter.class) + .clientHeadersFactory( + new DiscoveryPluginAuthorizationHeaderFactory(plugin)) .build(PluginCallback.class); return client; } - public static class AuthorizationFilter implements ClientRequestFilter { + public static class DiscoveryPluginAuthorizationHeaderFactory + implements ClientHeadersFactory { - final Logger logger = Logger.getLogger(PluginCallback.class); + private final DiscoveryPlugin plugin; - @Override - public void filter(ClientRequestContext requestContext) throws IOException { - String userInfo = requestContext.getUri().getUserInfo(); + @SuppressFBWarnings("EI_EXPOSE_REP2") + public DiscoveryPluginAuthorizationHeaderFactory(DiscoveryPlugin plugin) { + this.plugin = plugin; + } + + public Optional getCredential() { + String userInfo = plugin.callback.getUserInfo(); if (StringUtils.isBlank(userInfo)) { - return; + logger.error("No stored credentials specified"); + return Optional.empty(); } - if (StringUtils.isNotBlank(userInfo) && userInfo.contains(":")) { - String[] parts = userInfo.split(":"); - if (parts.length == 2 && "storedcredentials".equals(parts[0])) { - logger.infov( - "Using stored credentials id:{0} referenced in ping callback" - + " userinfo", - parts[1]); - - Credential credential = - Credential.find("id", Long.parseLong(parts[1])).singleResult(); - - requestContext - .getHeaders() - .add( - HttpHeaders.AUTHORIZATION, - "Basic " - + Base64.getEncoder() - .encodeToString( - (credential.username - + ":" - + credential - .password) - .getBytes( - StandardCharsets - .UTF_8))); - } else { - throw new IllegalStateException("Unexpected credential format"); - } - } else { - throw new IOException( - new BadRequestException( - "No credentials provided and none found in storage")); + if (!userInfo.contains(":")) { + logger.errorv("Unexpected non-basic credential format, found: {0}", userInfo); + return Optional.empty(); } + + String[] parts = userInfo.split(":"); + if (parts.length != 2) { + logger.errorv("Unexpected basic credential format, found: {0}", userInfo); + return Optional.empty(); + } + + if (!"storedcredentials".equals(parts[0])) { + logger.errorv( + "Unexpected credential format, expected \"storedcredentials\" but" + + " found: {0}", + parts[0]); + return Optional.empty(); + } + + return Credential.find("id", Long.parseLong(parts[1])).singleResultOptional(); + } + + @Override + public MultivaluedMap update( + MultivaluedMap incomingHeaders, + MultivaluedMap clientOutgoingHeaders) { + var result = new MultivaluedHashMap(); + Optional opt = getCredential(); + opt.ifPresent( + credential -> { + String basicAuth = credential.username + ":" + credential.password; + byte[] authBytes = basicAuth.getBytes(StandardCharsets.UTF_8); + String base64Auth = Base64.getEncoder().encodeToString(authBytes); + result.add( + HttpHeaders.AUTHORIZATION, + String.format("Basic %s", base64Auth)); + }); + return result; } } } diff --git a/src/main/java/io/cryostat/discovery/DiscoveryProducers.java b/src/main/java/io/cryostat/discovery/DiscoveryProducers.java new file mode 100644 index 000000000..3d5a5ce4a --- /dev/null +++ b/src/main/java/io/cryostat/discovery/DiscoveryProducers.java @@ -0,0 +1,73 @@ +/* + * Copyright The Cryostat Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cryostat.discovery; + +import java.security.NoSuchAlgorithmException; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWEDecrypter; +import com.nimbusds.jose.JWEEncrypter; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.KeyLengthException; +import com.nimbusds.jose.crypto.DirectDecrypter; +import com.nimbusds.jose.crypto.DirectEncrypter; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.MACVerifier; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +public class DiscoveryProducers { + + @Produces + @ApplicationScoped + static SecretKey provideSecretKey( + @ConfigProperty(name = "cryostat.discovery.plugins.jwt.secret.algorithm") String alg, + @ConfigProperty(name = "cryostat.discovery.plugins.jwt.secret.keysize") int keysize) + throws NoSuchAlgorithmException { + KeyGenerator generator = KeyGenerator.getInstance(alg); + generator.init(keysize); + return generator.generateKey(); + } + + @Produces + @ApplicationScoped + static JWSSigner provideJwsSigner(SecretKey key) throws KeyLengthException { + return new MACSigner(key); + } + + @Produces + @ApplicationScoped + static JWSVerifier provideJwsVerifier(SecretKey key) throws JOSEException { + return new MACVerifier(key); + } + + @Produces + @ApplicationScoped + static JWEEncrypter provideJweEncrypter(SecretKey key) throws KeyLengthException { + return new DirectEncrypter(key); + } + + @Produces + @ApplicationScoped + static JWEDecrypter provideJweDecrypter(SecretKey key) throws KeyLengthException { + return new DirectDecrypter(key); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9589478fa..6d6ab697b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,6 +4,12 @@ cryostat.discovery.containers.poll-period=10s cryostat.discovery.containers.request-timeout=2s cryostat.discovery.podman.enabled=false cryostat.discovery.docker.enabled=false +cryostat.discovery.plugins.ping-period=5m +cryostat.discovery.plugins.jwt.secret.algorithm=AES +cryostat.discovery.plugins.jwt.secret.keysize=256 +cryostat.discovery.plugins.jwt.signature.algorithm=HS256 +cryostat.discovery.plugins.jwt.encryption.algorithm=dir +cryostat.discovery.plugins.jwt.encryption.method=A256GCM quarkus.test.integration-test-profile=test cryostat.connections.max-open=0 @@ -21,6 +27,11 @@ cryostat.services.reports.storage-cache.enabled=true cryostat.services.reports.storage-cache.name=archivedreports cryostat.services.reports.storage-cache.expiry-duration=24h +cryostat.http.proxy.tls-enabled=false +cryostat.http.proxy.host=${quarkus.http.host} +cryostat.http.proxy.port=${quarkus.http.port} +cryostat.http.proxy.path=/ + quarkus.http.auth.proactive=false quarkus.http.host=0.0.0.0 quarkus.http.port=8181 diff --git a/src/test/java/io/cryostat/JsonRequestFilterTest.java b/src/test/java/io/cryostat/JsonRequestFilterTest.java index 8616f1db8..3b5f7c1c9 100644 --- a/src/test/java/io/cryostat/JsonRequestFilterTest.java +++ b/src/test/java/io/cryostat/JsonRequestFilterTest.java @@ -79,7 +79,7 @@ private void simulateRequest(String jsonPayload) throws Exception { when(requestContext.getEntityStream()).thenReturn(payloadStream); when(requestContext.getMediaType()).thenReturn(MediaType.APPLICATION_JSON_TYPE); UriInfo uriInfo = Mockito.mock(UriInfo.class); - Mockito.when(uriInfo.getPath()).thenReturn("/some/path"); + when(uriInfo.getPath()).thenReturn("/some/path"); when(requestContext.getUriInfo()).thenReturn(uriInfo); filter.filter(requestContext); }