diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorIngestInputService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorIngestInputService.java new file mode 100644 index 000000000000..94976740bd72 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorIngestInputService.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors; + +import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import org.apache.shiro.subject.Subject; +import org.graylog.collectors.input.CollectorIngestHttpInput; +import org.graylog2.Configuration; +import org.graylog2.inputs.Input; +import org.graylog2.inputs.InputService; +import org.graylog2.plugin.configuration.ConfigurationException; +import org.graylog2.plugin.database.ValidationException; +import org.graylog2.rest.models.system.inputs.requests.InputCreateRequest; +import org.graylog2.shared.inputs.MessageInputFactory; +import org.graylog2.shared.inputs.NoSuchInputTypeException; +import org.graylog2.shared.security.RestPermissions; + +import java.util.List; +import java.util.Map; + +import static org.graylog2.shared.utilities.StringUtils.f; + +public class CollectorIngestInputService { + private final InputService inputService; + private final MessageInputFactory messageInputFactory; + private final Configuration configuration; + + @Inject + public CollectorIngestInputService(InputService inputService, + MessageInputFactory messageInputFactory, + Configuration configuration) { + this.inputService = inputService; + this.messageInputFactory = messageInputFactory; + this.configuration = configuration; + } + + public List getInputIds() { + return inputService.allByType(CollectorIngestHttpInput.class.getCanonicalName()) + .stream() + .map(Input::getId) + .toList(); + } + + public void createInput(Subject subject, String userName, int port) throws ValidationException { + if (configuration.isCloud()) { + throw new BadRequestException("Creating collector ingest inputs is not supported in cloud environments"); + } + + if (!subject.isPermitted(RestPermissions.INPUTS_CREATE)) { + throw new ForbiddenException("Not permitted to create inputs"); + } + if (!subject.isPermitted(f("%s:%s", RestPermissions.INPUT_TYPES_CREATE, CollectorIngestHttpInput.class.getCanonicalName()))) { + throw new ForbiddenException(f("Not permitted to create input type %s", CollectorIngestHttpInput.class.getCanonicalName())); + } + + try { + final var inputCreateRequest = InputCreateRequest.create( + CollectorIngestHttpInput.NAME, + CollectorIngestHttpInput.class.getCanonicalName(), + true, + Map.of( + "bind_address", "0.0.0.0", + "port", port + ), + null + ); + final var messageInput = messageInputFactory.create( + inputCreateRequest, userName, inputCreateRequest.node(), false); + messageInput.checkConfiguration(); + final var input = inputService.create(messageInput.asMap()); + inputService.save(input); + } catch (NoSuchInputTypeException e) { + throw new NotFoundException("No such input type registered", e); + } catch (ConfigurationException e) { + throw new BadRequestException("Invalid input configuration", e); + } + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorInputService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorInputService.java deleted file mode 100644 index b47fcecc11ce..000000000000 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorInputService.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.collectors; - -import jakarta.annotation.Nullable; -import jakarta.inject.Inject; -import jakarta.ws.rs.InternalServerErrorException; -import org.graylog.collectors.rest.CollectorsConfigRequest; -import org.graylog2.database.NotFoundException; -import org.graylog2.inputs.Input; -import org.graylog2.inputs.InputService; -import org.graylog2.plugin.IOState; -import org.graylog2.plugin.database.ValidationException; -import org.graylog2.plugin.inputs.MessageInput; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -import static org.graylog2.shared.utilities.StringUtils.f; - -public class CollectorInputService { - private static final Logger LOG = LoggerFactory.getLogger(CollectorInputService.class); - - private final InputService inputService; - - @Inject - public CollectorInputService(InputService inputService) { - this.inputService = inputService; - } - - @Nullable - public String reconcile(CollectorsConfigRequest.IngestEndpointRequest requested, - @Nullable IngestEndpointConfig existing, - String inputType, - String title, - String creatorUserId) { - final String existingInputId = existing != null ? existing.inputId() : null; - final boolean inputExists = existingInputId != null && inputExistsInDb(existingInputId); - - if (requested.enabled()) { - if (!inputExists) { - return createManagedInput(inputType, title, creatorUserId); - } - if (existing.port() != requested.port()) { - restartInput(existingInputId); - } - return existingInputId; - } else { - if (inputExists) { - deleteManagedInput(existingInputId); - } - return null; - } - } - - private boolean inputExistsInDb(String inputId) { - try { - inputService.find(inputId); - return true; - } catch (NotFoundException e) { - return false; - } - } - - private String createManagedInput(String inputType, String title, String creatorUserId) { - final var input = inputService.create(Map.of( - MessageInput.FIELD_TYPE, inputType, - MessageInput.FIELD_TITLE, title, - MessageInput.FIELD_CREATOR_USER_ID, creatorUserId, - MessageInput.FIELD_GLOBAL, true, - MessageInput.FIELD_CONFIGURATION, Map.of(), - MessageInput.FIELD_DESIRED_STATE, IOState.Type.RUNNING.name() - )); - try { - return inputService.save(input); - } catch (ValidationException e) { - throw new InternalServerErrorException(f("Failed to create managed input: %s", title), e); - } - } - - private void restartInput(String inputId) { - try { - final Input input = inputService.find(inputId); - inputService.update(input); - } catch (NotFoundException e) { - LOG.warn("Input {} not found during restart attempt", inputId); - } catch (ValidationException e) { - throw new InternalServerErrorException(f("Failed to restart input %s", inputId), e); - } - } - - private void deleteManagedInput(String inputId) { - try { - final Input input = inputService.find(inputId); - inputService.destroy(input); - } catch (NotFoundException e) { - LOG.warn("Input {} not found during delete attempt", inputId); - } - } -} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java index 6b01fe04dfcc..2c719b54404f 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java @@ -95,7 +95,7 @@ public static Builder createDefaultBuilder(String hostname) { requireNonBlank(hostname, "hostname can't be blank"); return CollectorsConfig.builder() - .http(new IngestEndpointConfig(true, hostname, DEFAULT_HTTP_PORT, null)); + .http(new IngestEndpointConfig(hostname, DEFAULT_HTTP_PORT)); } public static CollectorsConfig createDefault(String hostname) { diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java index 9676cbe7aa8a..785edc8d9585 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java @@ -19,9 +19,11 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog2.configuration.HttpConfiguration; import org.graylog2.events.ClusterEventBus; import org.graylog2.plugin.cluster.ClusterConfigService; +import java.net.URI; import java.util.Objects; import java.util.Optional; @@ -34,11 +36,15 @@ public class CollectorsConfigService { private final ClusterConfigService clusterConfigService; private final ClusterEventBus clusterEventBus; + private final URI httpExternalUri; @Inject - public CollectorsConfigService(ClusterConfigService clusterConfigService, ClusterEventBus clusterEventBus) { + public CollectorsConfigService(ClusterConfigService clusterConfigService, + ClusterEventBus clusterEventBus, + HttpConfiguration httpConfiguration) { this.clusterConfigService = clusterConfigService; this.clusterEventBus = clusterEventBus; + this.httpExternalUri = httpConfiguration.getHttpExternalUri(); } /** @@ -56,7 +62,7 @@ public Optional get() { * @return the current config or a default config */ public CollectorsConfig getOrDefault() { - return get().orElse(CollectorsConfig.createDefault("localhost")); + return get().orElse(CollectorsConfig.createDefault(httpExternalUri.getHost())); } /** diff --git a/graylog2-server/src/main/java/org/graylog/collectors/IngestEndpointConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/IngestEndpointConfig.java index 943baa4be8d8..6df707f2ffcb 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/IngestEndpointConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/IngestEndpointConfig.java @@ -17,11 +17,8 @@ package org.graylog.collectors; import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.annotation.Nullable; public record IngestEndpointConfig( - @JsonProperty("enabled") boolean enabled, @JsonProperty("hostname") String hostname, - @JsonProperty("port") int port, - @JsonProperty("input_id") @Nullable String inputId + @JsonProperty("port") int port ) {} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/CollectorIngestHttpInput.java b/graylog2-server/src/main/java/org/graylog/collectors/input/CollectorIngestHttpInput.java index 4e80a0ee5da9..b7b11741a54e 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/input/CollectorIngestHttpInput.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/CollectorIngestHttpInput.java @@ -70,6 +70,17 @@ public static class Descriptor extends MessageInput.Descriptor { public Descriptor() { super(NAME, false, ""); } + + @Override + public String getDescription() { + return "This input receives data from managed collectors over mTLS-secured HTTP. " + + "Managed collectors are configured to send their data to the external address " + + "specified in the Collectors Settings page. The port configured on this input must " + + "either match the external port from the Collectors Settings, or the external port " + + "must be routed to this input's port (e.g. via a load balancer or port mapping). " + + "Changing this input's port without updating the routing will prevent collectors " + + "from delivering data."; + } } @ConfigClass diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java b/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java index 90935881388e..7d9e86d5d468 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java @@ -22,12 +22,11 @@ import io.netty.channel.ChannelHandler; import io.netty.channel.EventLoopGroup; import io.netty.handler.ssl.SslContext; +import jakarta.inject.Inject; import jakarta.inject.Named; import org.graylog.collectors.CollectorCaService; import org.graylog.collectors.CollectorTLSUtils; -import org.graylog.collectors.CollectorsConfig; import org.graylog.collectors.CollectorsConfigService; -import org.graylog.collectors.IngestEndpointConfig; import org.graylog.inputs.otel.transport.OtlpHttpUtils; import org.graylog2.configuration.TLSProtocolsConfiguration; import org.graylog2.inputs.transports.AbstractHttpTransport; @@ -36,6 +35,8 @@ import org.graylog2.plugin.LocalMetricRegistry; import org.graylog2.plugin.configuration.Configuration; import org.graylog2.plugin.configuration.ConfigurationRequest; +import org.graylog2.plugin.configuration.fields.ConfigurationField; +import org.graylog2.plugin.configuration.fields.NumberField; import org.graylog2.plugin.inputs.MessageInput; import org.graylog2.plugin.inputs.annotations.ConfigClass; import org.graylog2.plugin.inputs.annotations.FactoryClass; @@ -43,8 +44,9 @@ import org.graylog2.plugin.inputs.util.ThroughputCounter; import org.graylog2.utilities.IpSubnet; +import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; @@ -72,30 +74,18 @@ public CollectorIngestHttpTransport(@Assisted Configuration configuration, LocalMetricRegistry localMetricRegistry, TLSProtocolsConfiguration tlsConfiguration, @Named("trusted_proxies") Set trustedProxies, - CollectorTLSUtils tlsUtils, - CollectorsConfigService collectorsConfigService) { - super(buildTransportConfig(collectorsConfigService), eventLoopGroup, eventLoopGroupFactory, + CollectorTLSUtils tlsUtils) { + super(withTlsDefaults(configuration), eventLoopGroup, eventLoopGroupFactory, nettyTransportConfiguration, throughputCounter, localMetricRegistry, tlsConfiguration, trustedProxies, OtlpHttpUtils.LOGS_PATH); this.tlsUtils = tlsUtils; } - private static Configuration buildTransportConfig(CollectorsConfigService collectorsConfigService) { - final var port = collectorsConfigService.get() - .map(CollectorsConfig::http) - .map(IngestEndpointConfig::port) - .orElse(DEFAULT_HTTP_PORT); - return new Configuration(Map.of( - CK_BIND_ADDRESS, "0.0.0.0", - CK_PORT, port, - CK_TLS_ENABLE, true, - CK_TLS_CLIENT_AUTH, TLS_CLIENT_AUTH_REQUIRED, - CK_MAX_CHUNK_SIZE, 4 * 1024 * 1024, - CK_RECV_BUFFER_SIZE, 1048576, - CK_NUMBER_WORKER_THREADS, 4, - CK_TCP_KEEPALIVE, true, - CK_IDLE_WRITER_TIMEOUT, 60 - )); + private static Configuration withTlsDefaults(Configuration userConfig) { + final var merged = Optional.ofNullable(userConfig.getSource()).map(HashMap::new).orElse(new HashMap<>()); + merged.put(CK_TLS_ENABLE, true); + merged.put(CK_TLS_CLIENT_AUTH, TLS_CLIENT_AUTH_REQUIRED); + return new Configuration(merged); } @Override @@ -137,9 +127,56 @@ public interface Factory extends Transport.Factory @ConfigClass public static class Config extends AbstractHttpTransport.Config { + private static final int DEFAULT_MAX_CHUNK_SIZE = 4 * 1024 * 1024; + + private static final Set ALLOWED_FIELDS = Set.of( + CK_BIND_ADDRESS, + CK_PORT, + CK_RECV_BUFFER_SIZE, + CK_NUMBER_WORKER_THREADS, + CK_MAX_CHUNK_SIZE, + CK_IDLE_WRITER_TIMEOUT, + CK_TCP_KEEPALIVE + ); + + private final CollectorsConfigService collectorsConfigService; + + @Inject + public Config(CollectorsConfigService collectorsConfigService) { + this.collectorsConfigService = collectorsConfigService; + } + @Override public ConfigurationRequest getRequestedConfiguration() { - return new ConfigurationRequest(); + final var config = new ConfigurationRequest(); + + super.getRequestedConfiguration().getFields().values().stream() + .filter(field -> ALLOWED_FIELDS.contains(field.getName())) + .forEach(config::addField); + + config.addField(new NumberField( + CK_PORT, + "Port", + DEFAULT_HTTP_PORT, + portDescription(), + NumberField.Attribute.IS_PORT_NUMBER)); + config.addField(new NumberField( + CK_MAX_CHUNK_SIZE, + "Max. HTTP chunk size", + DEFAULT_MAX_CHUNK_SIZE, + "The maximum HTTP chunk size in bytes (e.g. length of HTTP request body)", + ConfigurationField.Optional.OPTIONAL)); + + return config; + } + + private String portDescription() { + return collectorsConfigService.get() + .map(c -> "Port to listen on. The collectors settings currently direct collectors to port " + + c.http().port() + ". If you use a different port, ensure the external port " + + "is routed correctly to this port.") + .orElse("Port to listen on. If this port differs from the port configured in the " + + "collectors settings, ensure the external port is routed correctly to this port."); } } } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/migrations/V20260303120000_CollectorDEVMigrations.java b/graylog2-server/src/main/java/org/graylog/collectors/migrations/V20260303120000_CollectorDEVMigrations.java index cf50faef149b..8d13d680b74c 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/migrations/V20260303120000_CollectorDEVMigrations.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/migrations/V20260303120000_CollectorDEVMigrations.java @@ -16,13 +16,24 @@ */ package org.graylog.collectors.migrations; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Updates; import jakarta.inject.Inject; +import org.bson.Document; +import org.bson.conversions.Bson; import org.graylog2.database.MongoConnection; import org.graylog2.migrations.Migration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.graylog.collectors.CollectorsConfig.DEFAULT_HTTP_PORT; +import static org.graylog2.database.utils.MongoUtils.idEq; /** * Migration for Collector changes during the 7.1 development. @@ -30,6 +41,10 @@ */ public class V20260303120000_CollectorDEVMigrations extends Migration { private static final Logger LOG = LoggerFactory.getLogger(V20260303120000_CollectorDEVMigrations.class); + private static final String CLUSTER_CONFIG_COLLECTION = "cluster_config"; + private static final String INPUTS_COLLECTION = "inputs"; + private static final String CONFIG_TYPE = "org.graylog.collectors.CollectorsConfig"; + private static final String INPUT_TYPE = "org.graylog.collectors.input.CollectorIngestHttpInput"; private final MongoConnection mongoConnection; @@ -49,5 +64,78 @@ public void upgrade() { final var db = mongoConnection.getMongoDatabase(); + migrateCollectorIngestConfig(db); + } + + /** + * Migrates the collector ingest configuration: + *
    + *
  1. Strips the removed {@code enabled} and {@code input_id} fields from the persisted + * {@link org.graylog.collectors.CollectorsConfig} cluster config document.
  2. + *
  3. If {@code input_id} was present, updates that input with {@code bind_address} and {@code port}.
  4. + *
  5. Backfills missing transport config defaults ({@code recv_buffer_size}, {@code number_worker_threads}, + * {@code idle_writer_timeout}, {@code tcp_keepalive}) on all persisted collector ingest inputs.
  6. + *
+ */ + private void migrateCollectorIngestConfig(MongoDatabase db) { + final MongoCollection clusterConfig = db.getCollection(CLUSTER_CONFIG_COLLECTION); + final MongoCollection inputs = db.getCollection(INPUTS_COLLECTION); + + // 1. Clean the http sub-object in cluster config and migrate the linked input + final Document configDoc = clusterConfig.find(Filters.eq("type", CONFIG_TYPE)).first(); + if (configDoc != null) { + final Document payload = configDoc.get("payload", Document.class); + final Document http = payload != null ? payload.get("http", Document.class) : null; + + if (http != null && (http.containsKey("input_id") || http.containsKey("enabled"))) { + if (http.containsKey("input_id")) { + final String inputId = http.getString("input_id"); + final int port = http.getInteger("port", DEFAULT_HTTP_PORT); + if (inputId != null && !inputId.isBlank()) { + final long modified = inputs.updateOne( + idEq(inputId), + Updates.combine( + Updates.set("configuration.bind_address", "0.0.0.0"), + Updates.set("configuration.port", port) + ) + ).getModifiedCount(); + if (modified > 0) { + LOG.info("Updated input <{}> configuration with bind_address=0.0.0.0 and port={}.", inputId, port); + } + } + } + + final Document cleanHttp = new Document(); + cleanHttp.put("hostname", http.getString("hostname")); + cleanHttp.put("port", http.getInteger("port", DEFAULT_HTTP_PORT)); + + clusterConfig.updateOne( + Filters.eq("type", CONFIG_TYPE), + Updates.set("payload.http", cleanHttp) + ); + LOG.info("Cleaned CollectorsConfig http sub-object: removed deprecated fields (enabled, input_id)."); + } + } + + // 2. Backfill bind_address and port on all collector ingest inputs (previously runtime-injected, never persisted) + for (final Document doc : inputs.find(Filters.eq("type", INPUT_TYPE))) { + final Document config = doc.get("configuration", Document.class); + if (config == null) { + continue; + } + + final List needed = new ArrayList<>(); + if (!config.containsKey("bind_address")) { + needed.add(Updates.set("configuration.bind_address", "0.0.0.0")); + } + if (!config.containsKey("port")) { + needed.add(Updates.set("configuration.port", DEFAULT_HTTP_PORT)); + } + + if (!needed.isEmpty()) { + inputs.updateOne(Filters.eq("_id", doc.getObjectId("_id")), Updates.combine(needed)); + LOG.info("Backfilled bind_address/port on collector ingest input <{}>.", doc.getObjectId("_id")); + } + } } } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java b/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java index 7212556a3858..8140b212ec40 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/opamp/OpAmpService.java @@ -32,7 +32,6 @@ import com.google.protobuf.util.JsonFormat; import io.opentelemetry.instrumentation.annotations.WithSpan; import jakarta.annotation.Nonnull; -import jakarta.annotation.Nullable; import jakarta.inject.Inject; import jakarta.inject.Singleton; import okhttp3.HttpUrl; @@ -284,14 +283,7 @@ private Optional getCsr(AgentToServer message) { return Optional.of(csrBytes); } - static void buildConnectionSettings(ServerToAgent.Builder builder, @Nullable OtlpExporterConfig exporterConfig) { - if (exporterConfig == null) { - builder.setConnectionSettings(Opamp.ConnectionSettingsOffers.newBuilder() - .setOwnLogs(Opamp.TelemetryConnectionSettings.newBuilder() - .setDestinationEndpoint(""))); // Empty signals: don't send logs - return; - } - + static void buildConnectionSettings(ServerToAgent.Builder builder, OtlpExporterConfig exporterConfig) { final var caCert = exporterConfig.tls().caPem().orElseThrow(); final var minVersion = exporterConfig.tls().minVersion(); final var maxVersion = exporterConfig.tls().maxVersion().orElse(""); @@ -395,7 +387,7 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo final ServerToAgent.Builder responseBuilder = serverToAgentBuilder(message); // Don't fetch the config, we might not need it. If we need it, only get it once. - final var configSupplier = Suppliers.memoize(collectorsConfigService::get); + final Supplier configSupplier = Suppliers.memoize(collectorsConfigService::getOrDefault); LOG.debug("[{}/{}] Previously seen state {} - consecutive: {}", instanceUid, sequenceNum, previousState, seqConsecutive); if (!seqConsecutive) { @@ -417,20 +409,18 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo LOG.debug("[{}/{}] {} unprocessed markers for this collector (last processed tnx id {}) coalesced to {}", instanceUid, sequenceNum, unprocessedMarkers.size(), lastProcessedTxnSeq, coalesced); - // do this first. in case there's no configured endpoint we don't have to perform the more expensive stuff - final Optional exporterConfig = getExporterConfig(configSupplier); + final OtlpExporterConfig exporterConfig = getExporterConfig(configSupplier); if (coalesced.recomputeIngestConfig()) { // The connection settings should only be sent when they change. Not having a config is a change, too. if (agentCapabilities.contains(Opamp.AgentCapabilities.AgentCapabilities_ReportsOwnLogs)) { // The "own_logs" are always transmitted via HTTP according to OpAMP. - buildConnectionSettings(responseBuilder, exporterConfig.orElse(null)); + buildConnectionSettings(responseBuilder, exporterConfig); } } final var configBuilder = CollectorConfig.builder(); if (coalesced.recomputeConfig() || coalesced.recomputeIngestConfig()) { - final var effectiveEndpoint = exporterConfig.orElseThrow(); final var effectiveFleetId = (coalesced.newFleetId() == null) ? fleetId : coalesced.newFleetId(); LOG.debug("[{}/{}] Computing new collector config for fleet id {}", instanceUid, sequenceNum, effectiveFleetId); @@ -451,7 +441,7 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo .collect(Collectors.groupingBy(CollectorReceiverConfig::type)); configBuilder.receivers(receiverConfigs); - configBuilder.exporters(Map.of(effectiveEndpoint.getName(), effectiveEndpoint)); + configBuilder.exporters(Map.of(exporterConfig.getName(), exporterConfig)); final Map receiverProcessors = receiverGroups.keySet().stream() .map(component -> ResourceProcessorConfig.builder(component) @@ -464,7 +454,7 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo final var pipelines = receiverGroups.entrySet().stream() .collect(Collectors.toMap(e -> f("logs/%s", e.getKey()), e -> CollectorPipelineConfig.builder() .receivers(e.getValue().stream().map(CollectorReceiverConfig::name).collect(Collectors.toSet())) - .exporters(Set.of(effectiveEndpoint.getName())) + .exporters(Set.of(exporterConfig.getName())) .processors(receiverProcessors.values().stream() .filter(config -> e.getKey().equals(config.id())) .map(CollectorProcessorConfig::name) @@ -514,10 +504,10 @@ private ServerToAgent handleIdentifiedMessage(AgentToServer message, OpAmpAuthCo private void handleRenewal(ServerToAgent.Builder responseBuilder, String instanceUid, ByteString csr, - Supplier> configSupplier) { + Supplier configSupplier) { LOG.info("Received CSR for certificate renewal from instance: {}", instanceUid); try { - final var config = configSupplier.get().orElse(CollectorsConfig.createDefault("localhost")); + final var config = configSupplier.get(); final var issuer = collectorCaService.getSigningCert(); final var cert = certificateService.builder().signCsr(csr.toByteArray(), issuer, instanceUid, config.collectorCertLifetime()); @@ -586,27 +576,18 @@ private OptionalLong handleRemoteConfig(String instanceUid, return OptionalLong.empty(); } - @Nonnull - private Optional getExporterConfig(Supplier> configSupplier) { - final CollectorsConfig collectorsConfig = configSupplier.get() - .orElseThrow(() -> new IllegalStateException("Unable to determine collector input config, cannot send remote config.")); + private OtlpExporterConfig getExporterConfig(Supplier configSupplier) { + final CollectorsConfig collectorsConfig = configSupplier.get(); final var httpEndpoint = collectorsConfig.http(); - if (httpEndpoint == null) { - throw new IllegalStateException("No collector input configured, cannot send remote config."); - } // We use the long-lived CA cert so intermediate cert rotation is not an issue for Collector mTLS connections. final var caCert = collectorCaService.getCaCert().certificate(); final var tlsSettings = TLSConfigurationSettings.withCACert(clusterIdService.getString(), caCert); - if (httpEndpoint.enabled()) { - return Optional.of(OtlpHttpExporterConfig.builder() - .endpoint(f("https://%s:%s", httpEndpoint.hostname(), httpEndpoint.port())) - .tls(tlsSettings) - .build()); - } - - return Optional.empty(); + return OtlpHttpExporterConfig.builder() + .endpoint(f("https://%s:%s", httpEndpoint.hostname(), httpEndpoint.port())) + .tls(tlsSettings) + .build(); } @Nonnull diff --git a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInputIdsResponse.java b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInputIdsResponse.java new file mode 100644 index 000000000000..32843feca54d --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorInputIdsResponse.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.rest; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record CollectorInputIdsResponse( + @JsonProperty("collector_input_ids") List collectorInputIds +) {} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigRequest.java b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigRequest.java index 9a5740bee923..b37773694151 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigRequest.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigRequest.java @@ -18,23 +18,30 @@ import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; import org.graylog.collectors.IngestEndpointConfig; import java.time.Duration; +import static java.util.Objects.requireNonNull; + public record CollectorsConfigRequest( - @JsonProperty("http") IngestEndpointRequest http, + @JsonProperty("http") @NotNull IngestEndpointRequest http, @JsonProperty("collector_offline_threshold") @Nullable Duration collectorOfflineThreshold, @JsonProperty("collector_default_visibility_threshold") @Nullable Duration collectorDefaultVisibilityThreshold, - @JsonProperty("collector_expiration_threshold") @Nullable Duration collectorExpirationThreshold + @JsonProperty("collector_expiration_threshold") @Nullable Duration collectorExpirationThreshold, + @JsonProperty("create_input") boolean createInput ) { + public CollectorsConfigRequest { + requireNonNull(http, "http must not be null"); + } + public record IngestEndpointRequest( - @JsonProperty("enabled") boolean enabled, @JsonProperty("hostname") String hostname, @JsonProperty("port") int port ) { - public IngestEndpointConfig toConfig(String inputId) { - return new IngestEndpointConfig(enabled(), hostname(), port(), inputId); + public IngestEndpointConfig toConfig() { + return new IngestEndpointConfig(hostname(), port()); } } } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigResource.java b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigResource.java index 4b5e91921200..999123281a9c 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigResource.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/rest/CollectorsConfigResource.java @@ -31,11 +31,10 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; -import org.apache.shiro.SecurityUtils; import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.graylog.collectors.CollectorCaService; -import org.graylog.collectors.CollectorInputService; +import org.graylog.collectors.CollectorIngestInputService; import org.graylog.collectors.CollectorLogsDestinationService; import org.graylog.collectors.CollectorsConfig; import org.graylog.collectors.CollectorsConfigService; @@ -44,7 +43,6 @@ import org.graylog.collectors.FleetTransactionLogService; import org.graylog.collectors.TokenSigningKey; import org.graylog.collectors.db.MarkerType; -import org.graylog.collectors.input.CollectorIngestHttpInput; import org.graylog.collectors.opamp.auth.EnrollmentTokenService; import org.graylog2.audit.AuditEventTypes; import org.graylog2.audit.jersey.AuditEvent; @@ -71,7 +69,7 @@ @RequiresAuthentication public class CollectorsConfigResource extends RestResource { private final CollectorsConfigService collectorsConfigService; - private final CollectorInputService collectorInputService; + private final CollectorIngestInputService collectorIngestInputService; private final CollectorLogsDestinationService collectorLogsDestinationService; private final URI httpExternalUri; private final FleetService fleetService; @@ -81,7 +79,7 @@ public class CollectorsConfigResource extends RestResource { @Inject public CollectorsConfigResource(CollectorsConfigService collectorsConfigService, - CollectorInputService collectorInputService, + CollectorIngestInputService collectorIngestInputService, CollectorLogsDestinationService collectorLogsDestinationService, HttpConfiguration httpConfiguration, FleetService fleetService, @@ -89,7 +87,7 @@ public CollectorsConfigResource(CollectorsConfigService collectorsConfigService, EnrollmentTokenService enrollmentTokenService, CollectorCaService collectorCaService) { this.collectorsConfigService = collectorsConfigService; - this.collectorInputService = collectorInputService; + this.collectorIngestInputService = collectorIngestInputService; this.collectorLogsDestinationService = collectorLogsDestinationService; this.httpExternalUri = httpConfiguration.getHttpExternalUri(); this.fleetService = fleetService; @@ -108,6 +106,18 @@ public CollectorsConfig get(@Context ContainerRequestContext requestContext) { }); } + // Separate endpoint so the UI can check input existence without needing read permission on each input. + // Currently, all users with Reader role have wildcard inputs:read, so per-input filtering is not needed in + // practice. If more fine-grained read permissions become common, the UI can use these IDs to determine the + // presence of collector inputs, regardless of the user's read permissions. + @GET + @Path("/inputs") + @Operation(summary = "Get collector ingest input IDs") + @RequiresPermissions(CollectorsPermissions.CONFIGURATION_READ) + public CollectorInputIdsResponse getInputIds() { + return new CollectorInputIdsResponse(collectorIngestInputService.getInputIds()); + } + @AuditEvent(type = AuditEventTypes.COLLECTORS_CONFIG_UPDATE) @PUT @Operation(summary = "Update collectors configuration") @@ -117,14 +127,7 @@ public CollectorsConfig put(@Valid @NotNull @RequestBody(required = true, usePar collectorLogsDestinationService.ensureExists(); final var existing = collectorsConfigService.get(); - final String creatorUserId = SecurityUtils.getSubject().getPrincipal().toString(); - - final String httpInputId = collectorInputService.reconcile( - request.http(), - existing.map(CollectorsConfig::http).orElse(null), - CollectorIngestHttpInput.class.getCanonicalName(), - CollectorIngestHttpInput.NAME, - creatorUserId); + final Duration effectiveOffline = request.collectorOfflineThreshold() != null ? request.collectorOfflineThreshold() : CollectorsConfig.DEFAULT_OFFLINE_THRESHOLD; final Duration effectiveVisibility = request.collectorDefaultVisibilityThreshold() != null @@ -151,12 +154,16 @@ public CollectorsConfig put(@Valid @NotNull @RequestBody(required = true, usePar .signingCertId(caHierarchy.signingCert().id()) .tokenSigningKey(tokenSigningKey) .otlpServerCertId(caHierarchy.otlpServerCert().id()) - .http(request.http().toConfig(httpInputId)) + .http(request.http().toConfig()) .collectorOfflineThreshold(effectiveOffline) .collectorDefaultVisibilityThreshold(effectiveVisibility) .collectorExpirationThreshold(effectiveExpiration) .build(); + if (request.createInput()) { + collectorIngestInputService.createInput(getSubject(), getCurrentUser().getName(), request.http().port()); + } + collectorsConfigService.save(config); // TODO: We should probably compare the existing and new config to avoid the marker for unrelated changes. diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java index 9c9e79e4136b..53e5cf0dc77b 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java @@ -34,6 +34,7 @@ import org.graylog.testing.mongodb.MongoDBExtension; import org.graylog.testing.mongodb.MongoDBTestService; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; +import org.graylog2.configuration.HttpConfiguration; import org.graylog2.database.MongoCollections; import org.graylog2.events.ClusterEventBus; import org.graylog2.jackson.InputConfigurationBeanDeserializerModifier; @@ -91,7 +92,9 @@ void setUp(MongoDBTestService mongodb, ClusterConfigService clusterConfigService certificateService = new CertificateService(mongoCollections, encryptedValueService, CustomizationConfig.empty(), clock); clusterIdService = mock(ClusterIdService.class); when(clusterIdService.getString()).thenReturn("cluster-id"); - collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class)); + final var httpConfiguration = mock(HttpConfiguration.class); + when(httpConfiguration.getHttpExternalUri()).thenReturn(java.net.URI.create("https://localhost:443/")); + collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class), httpConfiguration); collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, clock); } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorIngestInputServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorIngestInputServiceTest.java new file mode 100644 index 000000000000..f7f03143a4ea --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorIngestInputServiceTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors; + +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.ForbiddenException; +import org.apache.shiro.subject.Subject; +import org.graylog.collectors.input.CollectorIngestHttpInput; +import org.graylog2.Configuration; +import org.graylog2.inputs.Input; +import org.graylog2.inputs.InputService; +import org.graylog2.plugin.configuration.ConfigurationException; +import org.graylog2.plugin.inputs.MessageInput; +import org.graylog2.rest.models.system.inputs.requests.InputCreateRequest; +import org.graylog2.shared.inputs.MessageInputFactory; +import org.graylog2.shared.security.RestPermissions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CollectorIngestInputServiceTest { + + @Mock + private InputService inputService; + @Mock + private MessageInputFactory messageInputFactory; + @Mock + private Configuration configuration; + @Mock + private Subject subject; + + private CollectorIngestInputService service; + + @BeforeEach + void setUp() { + service = new CollectorIngestInputService(inputService, messageInputFactory, configuration); + } + + @Test + void getInputIdsReturnsIdsForMatchingInputs() { + final var input1 = mock(Input.class); + final var input2 = mock(Input.class); + when(input1.getId()).thenReturn("id-1"); + when(input2.getId()).thenReturn("id-2"); + when(inputService.allByType(CollectorIngestHttpInput.class.getCanonicalName())) + .thenReturn(List.of(input1, input2)); + + assertThat(service.getInputIds()).containsExactly("id-1", "id-2"); + } + + @Test + void getInputIdsReturnsEmptyListWhenNoInputs() { + when(inputService.allByType(CollectorIngestHttpInput.class.getCanonicalName())) + .thenReturn(List.of()); + + assertThat(service.getInputIds()).isEmpty(); + } + + @Test + void createInputRejectsInCloud() { + when(configuration.isCloud()).thenReturn(true); + + assertThatThrownBy(() -> service.createInput(subject, "admin", 14401)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("not supported in cloud"); + } + + @Test + void createInputRejectsWithoutInputsCreatePermission() { + when(subject.isPermitted(RestPermissions.INPUTS_CREATE)).thenReturn(false); + + assertThatThrownBy(() -> service.createInput(subject, "admin", 14401)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void createInputRejectsWithoutInputTypePermission() { + when(subject.isPermitted(RestPermissions.INPUTS_CREATE)).thenReturn(true); + when(subject.isPermitted(RestPermissions.INPUT_TYPES_CREATE + ":" + CollectorIngestHttpInput.class.getCanonicalName())) + .thenReturn(false); + + assertThatThrownBy(() -> service.createInput(subject, "admin", 14401)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void createInputSucceedsWithPermissions() throws Exception { + when(subject.isPermitted(RestPermissions.INPUTS_CREATE)).thenReturn(true); + when(subject.isPermitted(RestPermissions.INPUT_TYPES_CREATE + ":" + CollectorIngestHttpInput.class.getCanonicalName())) + .thenReturn(true); + + final var messageInput = mock(MessageInput.class); + when(messageInput.asMap()).thenReturn(Map.of()); + when(messageInputFactory.create(any(InputCreateRequest.class), anyString(), isNull(), anyBoolean())) + .thenReturn(messageInput); + final var input = mock(Input.class); + when(inputService.create(any(Map.class))).thenReturn(input); + + service.createInput(subject, "admin", 14401); + + verify(messageInput).checkConfiguration(); + verify(inputService).save(input); + } + + @Test + void createInputMapsConfigurationExceptionToBadRequest() throws Exception { + when(subject.isPermitted(RestPermissions.INPUTS_CREATE)).thenReturn(true); + when(subject.isPermitted(RestPermissions.INPUT_TYPES_CREATE + ":" + CollectorIngestHttpInput.class.getCanonicalName())) + .thenReturn(true); + + final var messageInput = mock(MessageInput.class); + when(messageInputFactory.create(any(InputCreateRequest.class), anyString(), isNull(), anyBoolean())) + .thenReturn(messageInput); + org.mockito.Mockito.doThrow(new ConfigurationException("bad config")).when(messageInput).checkConfiguration(); + + assertThatThrownBy(() -> service.createInput(subject, "admin", 14401)) + .isInstanceOf(BadRequestException.class); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorInputServiceIT.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorInputServiceIT.java deleted file mode 100644 index 74dc129dcc70..000000000000 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorInputServiceIT.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.collectors; - -import org.graylog.collectors.rest.CollectorsConfigRequest; -import org.graylog.testing.mongodb.MongoDBExtension; -import org.graylog2.database.MongoCollections; -import org.graylog2.database.NotFoundException; -import org.graylog2.events.ClusterEventBus; -import org.graylog2.inputs.InputServiceImpl; -import org.graylog2.inputs.converters.ConverterFactory; -import org.graylog2.inputs.extractors.ExtractorFactory; -import org.graylog2.shared.SuppressForbidden; -import org.graylog2.shared.bindings.providers.ObjectMapperProvider; -import org.graylog2.shared.inputs.MessageInputFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import java.util.concurrent.Executors; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -@ExtendWith(MongoDBExtension.class) -@MockitoSettings(strictness = Strictness.WARN) -class CollectorInputServiceIT { - - @Mock - private ExtractorFactory extractorFactory; - @Mock - private ConverterFactory converterFactory; - @Mock - private MessageInputFactory messageInputFactory; - - private InputServiceImpl inputService; - private CollectorInputService collectorInputService; - - @BeforeEach - @SuppressForbidden("Executors#newSingleThreadExecutor() is okay for tests") - void setUp(MongoCollections mongoCollections) { - final var objectMapper = new ObjectMapperProvider().get(); - final var clusterEventBus = new ClusterEventBus("collectors-test", Executors.newSingleThreadExecutor()); - - inputService = new InputServiceImpl( - mongoCollections, - extractorFactory, - converterFactory, - messageInputFactory, - clusterEventBus, - objectMapper); - - collectorInputService = new CollectorInputService(inputService); - } - - @Test - void fullLifecycleCreateAndDelete() throws NotFoundException { - final var createRequest = new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14401); - - // 1. Create input - final String inputId = collectorInputService.reconcile( - createRequest, null, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - assertThat(inputId).isNotNull(); - final var httpInput = inputService.find(inputId); - assertThat(httpInput.getTitle()).isEqualTo("Collector Ingest (HTTP)"); - assertThat(httpInput.isGlobal()).isTrue(); - - // 2. Delete input - final var existing = new IngestEndpointConfig(true, "host", 14401, inputId); - final var disableRequest = new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401); - - final String deletedId = collectorInputService.reconcile( - disableRequest, existing, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - assertThat(deletedId).isNull(); - assertThat(inputService.all()).isEmpty(); - } - - @Test - void reconcileKeepsExistingInput() throws NotFoundException { - final var request = new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14401); - - // 1. Create - final String firstId = collectorInputService.reconcile( - request, null, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - // 2. Reconcile again with same settings - final var existing = new IngestEndpointConfig(true, "host", 14401, firstId); - final String secondId = collectorInputService.reconcile( - request, existing, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - assertThat(secondId).isEqualTo(firstId); - assertThat(inputService.all()).hasSize(1); - } - - @Test - void reconcileCreatesNewInputWhenStaleReference() { - final var existing = new IngestEndpointConfig(true, "host", 14401, "nonexistent-input-id"); - final var request = new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14401); - - final String newId = collectorInputService.reconcile( - request, existing, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - assertThat(newId).isNotNull(); - assertThat(newId).isNotEqualTo("nonexistent-input-id"); - assertThat(inputService.all()).hasSize(1); - } - - @Test - void reconcileRestartsExistingInputWhenPortChanges() { - final String firstId = collectorInputService.reconcile( - new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14401), - null, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - final var existing = new IngestEndpointConfig(true, "host", 14401, firstId); - final String secondId = collectorInputService.reconcile( - new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14402), - existing, - "org.graylog.collectors.input.CollectorIngestHttpInput", - "Collector Ingest (HTTP)", "admin"); - - assertThat(secondId).isEqualTo(firstId); - assertThat(inputService.all()).hasSize(1); - } -} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java index 4ae23cfa0983..55039a01fed5 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java @@ -47,6 +47,7 @@ import org.graylog.security.pki.PemUtils; import org.graylog.testing.cluster.ClusterConfigServiceExtension; import org.graylog.testing.mongodb.MongoDBExtension; +import org.graylog2.configuration.HttpConfiguration; import org.graylog2.database.MongoCollections; import org.graylog2.events.ClusterEventBus; import org.graylog2.plugin.cluster.ClusterConfigService; @@ -117,7 +118,9 @@ void setUp(MongoCollections mongoCollections, ClusterConfigService clusterConfig final var certService = new CertificateService(mongoCollections, encryptedValueService, CustomizationConfig.empty(), Clock.systemUTC()); final var clusterIdService = mock(ClusterIdService.class); - final var collectorsConfigService = new CollectorsConfigService(clusterConfigService, new ClusterEventBus()); + final var httpConfiguration = mock(HttpConfiguration.class); + when(httpConfiguration.getHttpExternalUri()).thenReturn(java.net.URI.create("https://localhost:443/")); + final var collectorsConfigService = new CollectorsConfigService(clusterConfigService, new ClusterEventBus(), httpConfiguration); final var caService = new CollectorCaService(certService, clusterIdService, collectorsConfigService, Clock.systemUTC()); when(clusterIdService.getString()).thenReturn(UUID.randomUUID().toString()); diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java index 5e415e0d538d..da4518328349 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java @@ -17,6 +17,7 @@ package org.graylog.collectors; import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog2.configuration.HttpConfiguration; import org.graylog2.events.ClusterEventBus; import org.graylog2.plugin.cluster.ClusterConfigService; import org.junit.jupiter.api.BeforeEach; @@ -38,7 +39,9 @@ class CollectorsConfigServiceTest { void setUp() { clusterConfigService = mock(ClusterConfigService.class); clusterEventBus = mock(ClusterEventBus.class); - service = new CollectorsConfigService(clusterConfigService, clusterEventBus); + final var httpConfiguration = mock(HttpConfiguration.class); + when(httpConfiguration.getHttpExternalUri()).thenReturn(java.net.URI.create("https://localhost:443/")); + service = new CollectorsConfigService(clusterConfigService, clusterEventBus, httpConfiguration); } private CollectorsConfig configWithCerts(String caCertId, String signingCertId, String serverCertId) { @@ -96,7 +99,7 @@ void save_doesNotFireEventWhenNonCertFieldChanges() { // Change only the HTTP port, not cert IDs final var updated = existing.toBuilder() - .http(new IngestEndpointConfig(true, "localhost", 9999, null)) + .http(new IngestEndpointConfig("localhost", 9999)) .build(); service.save(updated); diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigTest.java index 4772cb8c3d75..16c7b467c32c 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigTest.java @@ -29,7 +29,7 @@ class CollectorsConfigTest { @Test void serializesAndDeserializes() throws Exception { - final var http = new IngestEndpointConfig(true, "graylog.example.com", 14401, "input-1"); + final var http = new IngestEndpointConfig("graylog.example.com", 14401); final var config = CollectorsConfig.builder() .caCertId("ca-cert-id") .signingCertId("signing-cert-id") @@ -47,18 +47,6 @@ void serializesAndDeserializes() throws Exception { assertThat(json).contains("\"otlp_server_cert_id\""); } - @Test - void ingestEndpointConfigWithNullInputId() throws Exception { - final var endpoint = new IngestEndpointConfig(true, "host.example.com", 14401, null); - final var json = objectMapper.writeValueAsString(endpoint); - final var deserialized = objectMapper.readValue(json, IngestEndpointConfig.class); - - assertThat(deserialized.inputId()).isNull(); - assertThat(deserialized.enabled()).isTrue(); - assertThat(deserialized.hostname()).isEqualTo("host.example.com"); - assertThat(deserialized.port()).isEqualTo(14401); - } - @Test void nullableCertIds() throws Exception { final var config = CollectorsConfig.createDefaultBuilder("host") diff --git a/graylog2-server/src/test/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransportConfigTest.java b/graylog2-server/src/test/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransportConfigTest.java new file mode 100644 index 000000000000..e2df1be8d57f --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransportConfigTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog.collectors.input.transport; + +import org.graylog.collectors.CollectorsConfig; +import org.graylog.collectors.CollectorsConfigService; +import org.graylog.collectors.IngestEndpointConfig; +import org.graylog2.plugin.inputs.transports.NettyTransport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CollectorIngestHttpTransportConfigTest { + + @Mock + CollectorsConfigService collectorsConfigService; + + @Test + void portDescriptionIncludesConfiguredPort() { + final var config = CollectorsConfig.builder() + .http(new IngestEndpointConfig("host", 14401)) + .build(); + when(collectorsConfigService.get()).thenReturn(Optional.of(config)); + + final var transportConfig = new CollectorIngestHttpTransport.Config(collectorsConfigService); + final var portField = transportConfig.getRequestedConfiguration().getField(NettyTransport.CK_PORT); + + assertThat(portField.getDescription()).contains("port 14401"); + } + + @Test + void portDescriptionFallsBackWhenNoConfig() { + when(collectorsConfigService.get()).thenReturn(Optional.empty()); + + final var transportConfig = new CollectorIngestHttpTransport.Config(collectorsConfigService); + final var portField = transportConfig.getRequestedConfiguration().getField(NettyTransport.CK_PORT); + + assertThat(portField.getDescription()) + .contains("collectors settings") + .doesNotContain("port 14401"); + } + + @Test + void portFieldHasPortNumberAttribute() { + when(collectorsConfigService.get()).thenReturn(Optional.empty()); + + final var transportConfig = new CollectorIngestHttpTransport.Config(collectorsConfigService); + final var portField = transportConfig.getRequestedConfiguration().getField(NettyTransport.CK_PORT); + + assertThat(portField.getAttributes()).contains("is_port_number"); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceConnectionSettingsTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceConnectionSettingsTest.java index 469bb07cf713..01e574ca3737 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceConnectionSettingsTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/OpAmpServiceConnectionSettingsTest.java @@ -80,13 +80,4 @@ void setsCorrectDestinationEndpoint() { .isEqualTo("https://otlp.example.com:14401/?tls_server_name=" + CLUSTER_ID + "&log_level=info"); } - @Test - void returnsEmptyWhenExporterConfigIsNull() { - final var builder = Opamp.ServerToAgent.newBuilder(); - OpAmpService.buildConnectionSettings(builder, null); - - final var httpSettings = builder.getConnectionSettings().getOwnLogs(); - - assertThat(httpSettings.getDestinationEndpoint()).isEmpty(); - } } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java index b128b7492d1c..74a3facacaa2 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java @@ -28,6 +28,7 @@ import org.graylog.collectors.CollectorInstanceService; import org.graylog.collectors.CollectorsConfig; import org.graylog.collectors.CollectorsConfigService; +import org.graylog2.configuration.HttpConfiguration; import org.graylog.collectors.db.CollectorInstanceDTO; import org.graylog.collectors.opamp.transport.OpAmpAuthContext; import org.graylog.grn.GRNRegistry; @@ -99,7 +100,9 @@ void setUp(MongoDBTestService mongodb, ClusterConfigService clusterConfigService final var clusterIdService = mock(ClusterIdService.class); when(clusterIdService.getString()).thenReturn(TEST_CLUSTER_ID); collectorInstanceService = new CollectorInstanceService(mongoCollections); - collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class)); + final var httpConfiguration = mock(HttpConfiguration.class); + when(httpConfiguration.getHttpExternalUri()).thenReturn(java.net.URI.create("https://localhost:443/")); + collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class), httpConfiguration); collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, clock); agentTokenService = new AgentTokenService(collectorInstanceService, clock); } diff --git a/graylog2-server/src/test/java/org/graylog/collectors/rest/CollectorsConfigResourceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/rest/CollectorsConfigResourceTest.java index 2bd49e11a9ed..cd97e87be387 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/rest/CollectorsConfigResourceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/rest/CollectorsConfigResourceTest.java @@ -20,24 +20,25 @@ import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.MultivaluedHashMap; import org.apache.shiro.subject.Subject; -import org.apache.shiro.util.ThreadContext; import org.graylog.collectors.CollectorCaService; -import org.graylog.collectors.CollectorInputService; +import org.graylog.collectors.CollectorIngestInputService; import org.graylog.collectors.CollectorLogsDestinationService; import org.graylog.collectors.CollectorsConfig; import org.graylog.collectors.CollectorsConfigService; +import org.graylog.collectors.CollectorsPermissions; import org.graylog.collectors.FleetService; import org.graylog.collectors.FleetTransactionLogService; import org.graylog.collectors.TokenSigningKey; import org.graylog.collectors.db.MarkerType; -import org.graylog.collectors.input.CollectorIngestHttpInput; import org.graylog.collectors.opamp.auth.EnrollmentTokenService; import org.graylog.security.pki.CertificateEntry; import org.graylog2.configuration.HttpConfiguration; import org.graylog2.plugin.database.ValidationException; import org.graylog2.plugin.database.validators.ValidationResult; +import org.graylog2.security.WithAuthorization; +import org.graylog2.security.WithAuthorizationExtension; import org.graylog2.security.encryption.EncryptedValue; -import org.junit.jupiter.api.AfterEach; +import org.graylog2.shared.security.RestPermissions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -54,21 +55,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) +@ExtendWith(WithAuthorizationExtension.class) +@WithAuthorization(username = "admin", permissions = "*") class CollectorsConfigResourceTest { @Mock private CollectorsConfigService collectorsConfigService; @Mock - private CollectorInputService collectorInputService; + private CollectorIngestInputService collectorIngestInputService; @Mock private CollectorLogsDestinationService collectorLogsDestinationService; @Mock @@ -91,7 +93,7 @@ void setUp() { when(httpConfiguration.getHttpExternalUri()).thenReturn(URI.create("https://graylog.example.com:443/")); resource = new CollectorsConfigResource( collectorsConfigService, - collectorInputService, + collectorIngestInputService, collectorLogsDestinationService, httpConfiguration, fleetService, @@ -99,15 +101,6 @@ void setUp() { enrollmentTokenService, collectorCaService ); - - final var subject = mock(Subject.class); - lenient().when(subject.getPrincipal()).thenReturn("admin"); - ThreadContext.bind(subject); - } - - @AfterEach - void tearDown() { - ThreadContext.unbindSubject(); } @Test @@ -127,10 +120,8 @@ void getReturnsDefaultWhenNoConfigExists() { final var result = resource.get(requestContext); - assertThat(result.http().enabled()).isTrue(); assertThat(result.http().hostname()).isEqualTo("graylog.example.com"); assertThat(result.http().port()).isEqualTo(14401); - assertThat(result.http().inputId()).isNull(); assertThat(result.collectorOfflineThreshold()).isEqualTo(CollectorsConfig.DEFAULT_OFFLINE_THRESHOLD); assertThat(result.collectorDefaultVisibilityThreshold()).isEqualTo(CollectorsConfig.DEFAULT_VISIBILITY_THRESHOLD); assertThat(result.collectorExpirationThreshold()).isEqualTo(CollectorsConfig.DEFAULT_EXPIRATION_THRESHOLD); @@ -141,8 +132,8 @@ void putInitializesCaAndDestination() throws ValidationException { stubCaService(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); resource.put(request); @@ -151,29 +142,13 @@ void putInitializesCaAndDestination() throws ValidationException { verify(collectorLogsDestinationService).ensureExists(); } - @Test - void putDelegatesInputReconciliation() throws ValidationException { - stubCaService(); - when(collectorInputService.reconcile(any(), isNull(), eq(CollectorIngestHttpInput.class.getCanonicalName()), - eq(CollectorIngestHttpInput.NAME), anyString())).thenReturn("new-input-id"); - - final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(true, "host", 14401), - null, null, null - ); - - final var result = resource.put(request); - - assertThat(result.http().inputId()).isEqualTo("new-input-id"); - } - @Test void putPersistsConfig() throws ValidationException { stubCaService(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); final var fleetIds = Set.of("fleet-1", "fleet-2"); @@ -188,8 +163,8 @@ void putPersistsConfig() throws ValidationException { @Test void putRejectsZeroVisibilityThreshold() { final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, Duration.ZERO, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, Duration.ZERO, null, false ); assertThatThrownBy(() -> resource.put(request)) @@ -205,8 +180,8 @@ void putRejectsZeroVisibilityThreshold() { @Test void putRejectsVisibilityThresholdBelowOfflineThreshold() { final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, Duration.ofMinutes(3), null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, Duration.ofMinutes(3), null, false ); assertThatThrownBy(() -> resource.put(request)) @@ -222,8 +197,8 @@ void putRejectsVisibilityThresholdBelowOfflineThreshold() { @Test void putRejectsExpirationNotGreaterThanVisibility() { final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, Duration.ofDays(2), Duration.ofDays(1) + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, Duration.ofDays(2), Duration.ofDays(1), false ); assertThatThrownBy(() -> resource.put(request)) @@ -239,8 +214,8 @@ void putRejectsExpirationNotGreaterThanVisibility() { @Test void putRejectsMultipleInvalidThresholds() { final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, Duration.ofMinutes(-5), Duration.ofMinutes(-10) + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, Duration.ofMinutes(-5), Duration.ofMinutes(-10), false ); assertThatThrownBy(() -> resource.put(request)) @@ -257,8 +232,8 @@ void putAcceptsValidThresholds() throws ValidationException { stubCaService(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - Duration.ofMinutes(10), Duration.ofHours(12), Duration.ofDays(3) + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + Duration.ofMinutes(10), Duration.ofHours(12), Duration.ofDays(3), false ); final var result = resource.put(request); @@ -273,8 +248,8 @@ void putAcceptsNullThresholds() throws ValidationException { stubCaService(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); final var result = resource.put(request); @@ -287,8 +262,8 @@ void putAcceptsNullThresholds() throws ValidationException { @Test void putRejectsOfflineThresholdBelowOneMinute() { final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - Duration.ofSeconds(30), null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + Duration.ofSeconds(30), null, null, false ); assertThatThrownBy(() -> resource.put(request)) @@ -306,8 +281,8 @@ void putCreatesNewTokenSigningKeyWhenNoExistingConfig() throws Exception { stubCaService(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); final var result = resource.put(request); @@ -323,7 +298,7 @@ void putReusesTokenSigningKeyFromExistingConfig() throws Exception { .caCertId("ca-id") .otlpServerCertId("otlp-id") .tokenSigningKey(existingKey) - .http(new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401).toConfig(null)) + .http(new CollectorsConfigRequest.IngestEndpointRequest("host", 14401).toConfig()) .collectorOfflineThreshold(CollectorsConfig.DEFAULT_OFFLINE_THRESHOLD) .collectorDefaultVisibilityThreshold(CollectorsConfig.DEFAULT_VISIBILITY_THRESHOLD) .collectorExpirationThreshold(CollectorsConfig.DEFAULT_EXPIRATION_THRESHOLD) @@ -333,8 +308,8 @@ void putReusesTokenSigningKeyFromExistingConfig() throws Exception { stubInitAndLoad(); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); final var result = resource.put(request); @@ -348,8 +323,8 @@ void putThrowsInternalServerErrorWhenTokenSigningKeyCreationFails() throws Excep when(enrollmentTokenService.createTokenSigningKey()).thenThrow(new NoSuchAlgorithmException("test error")); final var request = new CollectorsConfigRequest( - new CollectorsConfigRequest.IngestEndpointRequest(false, "host", 14401), - null, null, null + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false ); assertThatThrownBy(() -> resource.put(request)) @@ -357,6 +332,36 @@ void putThrowsInternalServerErrorWhenTokenSigningKeyCreationFails() throws Excep .hasMessageContaining("Could not create token signing key"); } + @Test + void putWithCreateInputDelegatesToService() throws Exception { + stubCaService(); + + final var request = new CollectorsConfigRequest( + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, true + ); + + resource.put(request); + + verify(collectorIngestInputService).createInput(any(Subject.class), eq("admin"), eq(14401)); + verify(collectorsConfigService).save(any(CollectorsConfig.class)); + } + + @Test + void putWithCreateInputFalseDoesNotCallService() throws Exception { + stubCaService(); + + final var request = new CollectorsConfigRequest( + new CollectorsConfigRequest.IngestEndpointRequest("host", 14401), + null, null, null, false + ); + + resource.put(request); + + verify(collectorIngestInputService, never()).createInput(any(), any(), any(int.class)); + verify(collectorsConfigService).save(any(CollectorsConfig.class)); + } + private void stubCaService() { lenient().when(collectorsConfigService.get()).thenReturn(Optional.empty()); stubInitAndLoad(); diff --git a/graylog2-web-interface/src/components/collectors/hooks/index.ts b/graylog2-web-interface/src/components/collectors/hooks/index.ts index bac2f07843f7..44dbd83a9d56 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/index.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/index.ts @@ -30,6 +30,8 @@ export { useSources, fetchPaginatedSources, sourcesKeyFn, SOURCES_KEY_PREFIX } f export { useCollectorStats } from './useCollectorStats'; export { useCollectorsConfig } from './useCollectorsConfig'; +export { useCollectorInputIds } from './useCollectorInputIds'; +export { useCollectorInputDetails } from './useCollectorInputDetails'; export { fetchPaginatedEnrollmentTokens, diff --git a/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputDetails.ts b/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputDetails.ts new file mode 100644 index 000000000000..2886031743dd --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputDetails.ts @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; + +import { SystemInputs } from '@graylog/server-api'; + +import useCurrentUser from 'hooks/useCurrentUser'; +import { isPermitted } from 'util/PermissionsMixin'; +import { onError } from 'util/conditional/onError'; +import FetchError from 'logic/errors/FetchError'; +import UserNotification from 'util/UserNotification'; + +import { useCollectorInputIds } from './useCollectorInputIds'; + +export const useCollectorInputDetails = () => { + const currentUser = useCurrentUser(); + const { data: collectorInputIds = [], isLoading: isLoadingIds } = useCollectorInputIds(); + + const readableInputIds = useMemo( + () => collectorInputIds.filter((id) => isPermitted(currentUser?.permissions, `inputs:read:${id}`)), + [collectorInputIds, currentUser?.permissions], + ); + + const inputQueries = useQueries({ + queries: readableInputIds.map((id) => ({ + queryKey: ['inputs', id], + queryFn: () => onError( + SystemInputs.get(id), + (error) => { + if (error instanceof FetchError && error.status === 404) return; + + UserNotification.error( + `Loading collector input details failed with status: ${error}`, + 'Could not load collector input details.', + ); + }, + ), + retry: false, + refetchOnWindowFocus: true, // override global false — refresh input data when user returns to this tab + })), + }); + + const allQueriesSettled = inputQueries.every((q) => !q.isLoading); + + const loadedInputs = inputQueries + .filter((q) => q.isSuccess && q.data) + .map((q) => q.data); + + const unreadableCount = collectorInputIds.length - readableInputIds.length; + + return { + collectorInputIds, + readableInputIds, + loadedInputs, + unreadableCount, + isLoading: isLoadingIds || !allQueriesSettled, + }; +}; diff --git a/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputIds.ts b/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputIds.ts new file mode 100644 index 000000000000..314b7352bbb4 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/hooks/useCollectorInputIds.ts @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useQuery } from '@tanstack/react-query'; + +import { CollectorsConfig as CollectorsConfigApi } from '@graylog/server-api'; + +import { defaultOnError } from 'util/conditional/onError'; + +import type { CollectorInputIdsResponse } from '../types'; + +export const COLLECTOR_INPUT_IDS_KEY_PREFIX = ['collectors', 'config', 'inputs']; + +export const useCollectorInputIds = (): { data: string[] | undefined; isLoading: boolean } => { + const { data, isLoading } = useQuery({ + queryKey: COLLECTOR_INPUT_IDS_KEY_PREFIX, + queryFn: () => + defaultOnError( + CollectorsConfigApi.getInputIds(), + 'Loading collector input IDs failed with status', + 'Could not load collector input IDs.', + ), + refetchOnWindowFocus: true, // override global false — refresh input data when user returns to this tab + }); + + return { data: data?.collector_input_ids, isLoading }; +}; diff --git a/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.test.tsx b/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.test.tsx index 3008c2775c83..f9bf1a82b04c 100644 --- a/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.test.tsx +++ b/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.test.tsx @@ -19,18 +19,35 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from 'wrappedTestingLibrary'; import { asMock } from 'helpers/mocking'; -import useInput from 'hooks/useInput'; import useInputsStates from 'hooks/useInputsStates'; import CollectorsSettings from './CollectorsSettings'; -import { useCollectorsConfig, useCollectorsMutations } from '../hooks'; +import { useCollectorsConfig, useCollectorInputIds, useCollectorsMutations, useCollectorInputDetails } from '../hooks'; import type { CollectorsConfig } from '../types'; import { mockCollectorsMutations } from '../testing/mockMutations'; jest.mock('../hooks'); -jest.mock('hooks/useInput'); jest.mock('hooks/useInputsStates'); + +const mockInput = (port: number) => ({ + id: 'input-1', + creator_user_id: 'admin', + node: 'node-1', + name: 'CollectorIngestHttpInput', + created_at: '2026-01-01T00:00:00Z', + global: true, + attributes: { port, bind_address: '0.0.0.0' }, + title: 'Collector Ingest (HTTP)', + type: 'org.graylog.collectors.input.CollectorIngestHttpInput', + content_pack: '', + static_fields: {}, +}); +jest.mock('hooks/useInputMutations', () => () => ({ + createInput: jest.fn(), + updateInput: jest.fn(), + deleteInput: jest.fn(), +})); jest.mock('components/inputs/InputStateBadge', () => () => Running); const updateConfig = jest.fn(); @@ -46,10 +63,8 @@ const config: CollectorsConfig = { }, otlp_server_cert_id: 'otlp-id', http: { - enabled: true, hostname: 'otlp.example.com', port: 14401, - input_id: 'input-1', }, collector_offline_threshold: 'PT5M', collector_default_visibility_threshold: 'P1D', @@ -70,33 +85,40 @@ describe('CollectorsSettings', () => { isUpdatingConfig: false, }), ); - asMock(useInput).mockReturnValue({ - data: { id: 'input-1' }, - } as ReturnType); + asMock(useCollectorInputIds).mockReturnValue({ + data: [], + isLoading: false, + }); asMock(useInputsStates).mockReturnValue({ data: {}, refetch: jest.fn(), isLoading: false, }); + asMock(useCollectorInputDetails).mockReturnValue({ + collectorInputIds: [], + readableInputIds: [], + loadedInputs: [], + unreadableCount: 0, + isLoading: false, + }); updateConfig.mockResolvedValue(undefined); }); - it('renders only the HTTP ingest endpoint', async () => { + it('renders the ingest endpoint section', async () => { render(); - expect(await screen.findByRole('heading', { name: 'HTTP' })).toBeInTheDocument(); + await screen.findByRole('heading', { name: 'Ingest Endpoint' }); + expect(screen.queryByRole('heading', { name: 'gRPC' })).not.toBeInTheDocument(); - expect(screen.getByText('HTTP:')).toBeInTheDocument(); - expect(screen.queryByText('gRPC:')).not.toBeInTheDocument(); }); - it('saves a request payload without grpc settings', async () => { + it('saves config with create_input false when already configured', async () => { const user = userEvent.setup(); render(); - const hostnameInput = await screen.findByLabelText('Hostname'); - const portInput = screen.getByLabelText('Port'); + const hostnameInput = await screen.findByLabelText('External hostname'); + const portInput = screen.getByLabelText('External port'); await user.clear(hostnameInput); await user.type(hostnameInput, 'ingest.example.com'); @@ -107,16 +129,67 @@ describe('CollectorsSettings', () => { await waitFor(() => expect(updateConfig).toHaveBeenCalledWith({ http: { - enabled: true, hostname: 'ingest.example.com', port: 14411, }, + create_input: false, collector_offline_threshold: 'PT5M', collector_default_visibility_threshold: 'P1D', collector_expiration_threshold: 'P7D', }), ); + }); + + it('does not show enabled checkbox', async () => { + render(); + + await screen.findByLabelText('External hostname'); + + expect(screen.queryByLabelText('Enabled')).not.toBeInTheDocument(); + }); + + it('shows port mismatch info when input port differs from config port', async () => { + asMock(useCollectorInputDetails).mockReturnValue({ + collectorInputIds: ['input-1'], + readableInputIds: ['input-1'], + loadedInputs: [mockInput(14402)], + unreadableCount: 0, + isLoading: false, + }); + + render(); + + await screen.findByText(/different port/i); + expect(screen.getAllByText(/14402/).length).toBeGreaterThanOrEqual(1); + }); + + it('does not show port mismatch info when ports match', async () => { + asMock(useCollectorInputDetails).mockReturnValue({ + collectorInputIds: ['input-1'], + readableInputIds: ['input-1'], + loadedInputs: [mockInput(14401)], + unreadableCount: 0, + isLoading: false, + }); + + render(); + + await screen.findByLabelText('External hostname'); + expect(screen.queryByText(/different port/i)).not.toBeInTheDocument(); + }); + + it('does not show port mismatch info while loading', async () => { + asMock(useCollectorInputDetails).mockReturnValue({ + collectorInputIds: [], + readableInputIds: [], + loadedInputs: [], + unreadableCount: 0, + isLoading: true, + }); + + render(); - expect(updateConfig).not.toHaveBeenCalledWith(expect.objectContaining({ grpc: expect.anything() })); + await screen.findByLabelText('External hostname'); + expect(screen.queryByText(/different port/i)).not.toBeInTheDocument(); }); }); diff --git a/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.tsx b/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.tsx index d2cf13740d18..7c7b2c63d3dc 100644 --- a/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.tsx +++ b/graylog2-web-interface/src/components/collectors/settings/CollectorsSettings.tsx @@ -18,23 +18,42 @@ import * as React from 'react'; import { useCallback, useMemo } from 'react'; import { Formik, Form } from 'formik'; import moment from 'moment'; +import styled, { css } from 'styled-components'; import { Alert, Row, Col } from 'components/bootstrap'; -import { FormikInput, Link, Spinner } from 'components/common'; +import { FormikInput, Spinner } from 'components/common'; import TimeUnitInput, { extractDurationAndUnit } from 'components/common/TimeUnitInput'; -import Routes from 'routing/Routes'; -import InputStateBadge from 'components/inputs/InputStateBadge'; -import useInput from 'hooks/useInput'; -import useInputsStates from 'hooks/useInputsStates'; import FormSubmit from 'components/common/FormSubmit'; +import useCurrentUser from 'hooks/useCurrentUser'; +import { isPermitted } from 'util/PermissionsMixin'; -import { useCollectorsConfig, useCollectorsMutations } from '../hooks'; +import IngestEndpointStatus from './IngestEndpointStatus'; +import PortMismatchAlert from './PortMismatchAlert'; + +import { useCollectorsConfig, useCollectorInputIds, useCollectorsMutations, useCollectorInputDetails } from '../hooks'; import type { CollectorsConfigRequest } from '../types'; + +const SectionTitle = styled.h3( + ({ theme }) => css` + margin-bottom: ${theme.spacings.sm}; + border-bottom: 1px solid ${theme.colors.gray[80]}; + padding-bottom: ${theme.spacings.xs}; + `, +); + +const HelpText = styled.p( + ({ theme }) => css` + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.gray[60]}; + margin-bottom: ${theme.spacings.md}; + `, +); + type FormValues = { - http_enabled: boolean; http_hostname: string; http_port: number; + create_input: boolean; offline_value: number; offline_unit: string; visibility_value: number; @@ -48,16 +67,24 @@ const THRESHOLD_UNITS = ['DAYS', 'HOURS', 'MINUTES']; const CollectorsSettings = () => { const { data: config, isLoading: isLoadingConfig } = useCollectorsConfig(); const { updateConfig } = useCollectorsMutations(); + const currentUser = useCurrentUser(); const isConfigured = !!config?.signing_cert_id; - const { data: inputStates } = useInputsStates({ enabled: isConfigured }); - const { data: httpInput } = useInput(config?.http?.input_id); + const { data: collectorInputIds = [], isLoading: isLoadingInputIds } = useCollectorInputIds(); + const { loadedInputs: collectorInputs, isLoading: isLoadingInputDetails } = useCollectorInputDetails(); + + const canCreateInputs = isPermitted(currentUser?.permissions, [ + 'inputs:create', + 'input_types:create:org.graylog.collectors.input.CollectorIngestHttpInput', + ]); + + const showCreateInputCheckbox = !isConfigured && !isLoadingInputIds && collectorInputIds.length === 0 && canCreateInputs; const initialValues: FormValues = useMemo(() => { if (!config) { return { - http_enabled: false, http_hostname: '', http_port: 14401, + create_input: true, offline_value: 5, offline_unit: 'MINUTES', visibility_value: 1, @@ -72,9 +99,9 @@ const CollectorsSettings = () => { const expiration = extractDurationAndUnit(config.collector_expiration_threshold, THRESHOLD_UNITS); return { - http_enabled: config.http.enabled, http_hostname: config.http.hostname, http_port: config.http.port, + create_input: true, offline_value: offline.duration, offline_unit: offline.unit, visibility_value: visibility.duration, @@ -87,7 +114,7 @@ const CollectorsSettings = () => { const handleSubmit = useCallback( async (values: FormValues, { setErrors }: { setErrors: (errors: Record) => void }) => { const request: CollectorsConfigRequest = { - http: { enabled: values.http_enabled, hostname: values.http_hostname, port: values.http_port }, + http: { hostname: values.http_hostname, port: values.http_port }, collector_offline_threshold: moment .duration(values.offline_value, values.offline_unit as moment.unitOfTime.DurationConstructor) .toISOString(), @@ -97,6 +124,7 @@ const CollectorsSettings = () => { collector_expiration_threshold: moment .duration(values.expiration_value, values.expiration_unit as moment.unitOfTime.DurationConstructor) .toISOString(), + create_input: showCreateInputCheckbox && values.create_input, }; try { @@ -107,19 +135,24 @@ const CollectorsSettings = () => { )?.additional?.body?.validation_errors; if (validationErrors) { - const extracted: Record = {}; + const fieldMapping: Record = { + collector_offline_threshold: 'offline_value', + collector_default_visibility_threshold: 'visibility_value', + collector_expiration_threshold: 'expiration_value', + }; + const mapped: Record = {}; Object.entries(validationErrors).forEach(([field, errors]) => { if (errors?.[0]?.error) { - extracted[field] = errors[0].error; + mapped[fieldMapping[field] ?? field] = errors[0].error; } }); - setErrors(extracted); + setErrors(mapped); } } }, - [updateConfig], + [updateConfig, showCreateInputCheckbox], ); if (isLoadingConfig) { @@ -129,36 +162,56 @@ const CollectorsSettings = () => { return ( <> - - {!isConfigured && ( + {!isConfigured && ( + - Collectors have not been set up yet. Configure the ingest endpoints below and save to get started. + Collectors have not been set up yet. Configure the ingest endpoint and basic settings below to get started. - )} - initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize> - {({ isSubmitting, setFieldValue, values }) => ( -
-

Ingest Endpoints

+ + )} + initialValues={initialValues} onSubmit={handleSubmit} enableReinitialize> + {({ isSubmitting, setFieldValue, values, errors }) => ( + + + Ingest Endpoint + + The external address that is pushed to managed collectors as their data destination. + It must route to a running collector ingest input. + This is typically the address of a load balancer or the server itself. + -

HTTP

- -

Collector Lifecycle

+ + + {showCreateInputCheckbox && ( + + )} + + + + Collector Lifecycle { units={THRESHOLD_UNITS} required hideCheckbox - help="Collectors that haven't reported within this time are shown as offline." + help={errors.offline_value + ? {errors.offline_value} + : "Collectors that haven't reported within this time are shown as offline."} /> { units={THRESHOLD_UNITS} required hideCheckbox - help="Collectors that haven't reported within this time are hidden from the default view. Users can adjust or remove this filter in the instances table." + help={errors.visibility_value + ? {errors.visibility_value} + : "Collectors that haven't reported within this time are hidden from the default view. Users can adjust or remove this filter in the instances table."} /> { units={THRESHOLD_UNITS} required hideCheckbox - help="Collectors that haven't reported within this time are permanently removed." + help={errors.expiration_value + ? {errors.expiration_value} + : "Collectors that haven't reported within this time are permanently removed."} /> + + + { isSubmitting={isSubmitting} displayCancel={false} /> - - )} - - + + + )} +
- {isConfigured && ( - - -

Ingest Endpoint Status

- {httpInput && ( -

- HTTP: {' '} - View Diagnostics -

- )} - {!httpInput &&

No ingest endpoints are running.

} - -
- )} + ); }; diff --git a/graylog2-web-interface/src/components/collectors/settings/IngestEndpointStatus.tsx b/graylog2-web-interface/src/components/collectors/settings/IngestEndpointStatus.tsx new file mode 100644 index 000000000000..6afad20db331 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/settings/IngestEndpointStatus.tsx @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import styled, { css } from 'styled-components'; + +import { Button, Alert, Row, Col, Table } from 'components/bootstrap'; +import { Link } from 'components/common'; +import Routes from 'routing/Routes'; +import InputStateBadge from 'components/inputs/InputStateBadge'; +import useCurrentUser from 'hooks/useCurrentUser'; +import useInputMutations from 'hooks/useInputMutations'; +import { isPermitted } from 'util/PermissionsMixin'; +import useInputsStates from 'hooks/useInputsStates'; + +import { useCollectorInputDetails } from '../hooks'; +import { COLLECTOR_INPUT_IDS_KEY_PREFIX } from '../hooks/useCollectorInputIds'; + +const SectionTitle = styled.h3( + ({ theme }) => css` + margin-bottom: ${theme.spacings.sm}; + `, +); + +type Props = { + defaultPort: number; + isInitialSetup: boolean; +}; + +const IngestEndpointStatus = ({ defaultPort, isInitialSetup }: Props) => { + const queryClient = useQueryClient(); + const currentUser = useCurrentUser(); + const { createInput } = useInputMutations(); + const { collectorInputIds, loadedInputs, unreadableCount, isLoading } = useCollectorInputDetails(); + const { data: inputStates, isLoading: isLoadingInputStates } = useInputsStates({ enabled: collectorInputIds.length > 0 }); + + const canCreateInputs = isPermitted(currentUser?.permissions, [ + 'inputs:create', + 'input_types:create:org.graylog.collectors.input.CollectorIngestHttpInput', + ]); + + const hasInputs = collectorInputIds.length > 0; + + const hasRunningInput = loadedInputs.some((input) => { + const nodeStates = inputStates?.[input.id]; + if (!nodeStates) return false; + + return Object.values(nodeStates).some((entry) => entry.state === 'RUNNING'); + }); + + const handleCreateInput = async () => { + await createInput({ + input: { + title: 'Collector Ingest (HTTP)', + type: 'org.graylog.collectors.input.CollectorIngestHttpInput', + global: true, + configuration: { + bind_address: '0.0.0.0', + port: defaultPort, + }, + }, + }); + await queryClient.invalidateQueries({ queryKey: COLLECTOR_INPUT_IDS_KEY_PREFIX }); + }; + + if (isLoading) { + return null; + } + + if (isInitialSetup && !hasInputs) { + return null; + } + + return ( + + + Ingest Inputs + {!isInitialSetup && !hasInputs && ( + + No collector ingest input exists. Collectors will not be able to send data until an input is created. + {canCreateInputs && ( + <> + {' '} + + + )} + + )} + {loadedInputs.length === 0 && unreadableCount > 0 && ( +

+ {unreadableCount} collector ingest {unreadableCount === 1 ? 'input exists' : 'inputs exist'} but you do + not have permission to view {unreadableCount === 1 ? 'it' : 'them'}. +

+ )} + {!isLoadingInputStates && !hasRunningInput && loadedInputs.length > 0 && ( + + Collector ingest inputs exist but none are currently running. Collectors will not be able to send data. + + )} + {loadedInputs.length > 0 && ( + + + + + + + + + + + {loadedInputs.map((input) => ( + + + + + + + + ))} + +
NameStatusBind AddressPort +
{input.title}{String(input.attributes?.bind_address ?? '')}{String(input.attributes?.port ?? '')}Manage
+ )} + {unreadableCount > 0 && loadedInputs.length > 0 && ( +

+ {unreadableCount} additional {unreadableCount === 1 ? 'input' : 'inputs'} not visible due to + permissions. +

+ )} + +
+ ); +}; + +export default IngestEndpointStatus; diff --git a/graylog2-web-interface/src/components/collectors/settings/PortMismatchAlert.tsx b/graylog2-web-interface/src/components/collectors/settings/PortMismatchAlert.tsx new file mode 100644 index 000000000000..a7906b81da76 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/settings/PortMismatchAlert.tsx @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useState, useEffect } from 'react'; + +import { Alert } from 'components/bootstrap'; + +const DEBOUNCE_MS = 500; + +type Props = { + formPort: number; + collectorInputs: Array<{ attributes?: { port?: number } }>; + isLoading: boolean; +}; + +const PortMismatchAlert = ({ formPort, collectorInputs, isLoading }: Props) => { + const [debouncedPort, setDebouncedPort] = useState(formPort); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedPort(formPort), DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [formPort]); + + if (isLoading || collectorInputs.length === 0) { + return null; + } + + const mismatchedPorts = [ + ...new Set( + collectorInputs + .map((input) => input.attributes?.port) + .filter((port): port is number => port !== undefined && port !== debouncedPort), + ), + ].sort((a, b) => a - b); + + if (mismatchedPorts.length === 0) { + return null; + } + + return ( + + Collector ingest inputs exist on different {mismatchedPorts.length === 1 ? 'port' : 'ports'}: {mismatchedPorts.join(', ')}. + If the external port differs from an input port, ensure your network routes traffic correctly. + + ); +}; + +export default PortMismatchAlert; diff --git a/graylog2-web-interface/src/components/collectors/types.ts b/graylog2-web-interface/src/components/collectors/types.ts index 5923caf3374f..595f5ef45c15 100644 --- a/graylog2-web-interface/src/components/collectors/types.ts +++ b/graylog2-web-interface/src/components/collectors/types.ts @@ -108,10 +108,8 @@ export type CollectorStats = { }; export type IngestEndpointConfig = { - enabled: boolean; hostname: string; port: number; - input_id: string | null; }; export type TokenSigningKey = { @@ -132,15 +130,19 @@ export type CollectorsConfig = { collector_expiration_threshold: string; }; +export type CollectorInputIdsResponse = { + collector_input_ids: string[]; +}; + export type CollectorsConfigRequest = { http: { - enabled: boolean; hostname: string; port: number; }; collector_offline_threshold: string; collector_default_visibility_threshold: string; collector_expiration_threshold: string; + create_input: boolean; }; export type FleetStatsSummary = { diff --git a/graylog2-web-interface/src/pages/CollectorsSettingsPage.tsx b/graylog2-web-interface/src/pages/CollectorsSettingsPage.tsx index 47d427e57e4f..480827184ddc 100644 --- a/graylog2-web-interface/src/pages/CollectorsSettingsPage.tsx +++ b/graylog2-web-interface/src/pages/CollectorsSettingsPage.tsx @@ -30,7 +30,7 @@ const CollectorsSettingsPage = () => ( Collectors Settings }> - Configure ingest endpoints for managed collectors. + Configure settings for managed collectors.