This is an automated email from the ASF dual-hosted git repository. dmsolr pushed a commit to branch feat/gateway-diagnostic-bundles in repository https://gitbox.apache.org/repos/asf/shenyu.git
commit c30696a691d6ee2de1d0eec1a9095e317f124c07 Author: Luke Haochao Zhuang <[email protected]> AuthorDate: Wed Oct 21 20:35:57 2026 +0800 feat: add bounded and redacted gateway diagnostic bundles Introduce an opt-in diagnostics subsystem in shenyu-web that exports a bounded, redacted ZIP bundle of gateway runtime state for troubleshooting. - DiagnosticProperties: shenyu.diagnostics.* config (disabled by default), total/section byte budgets, section/total timeouts, max concurrent jobs - SectionCollector framework with RuntimeInfoCollector, PluginInventoryCollector and EffectiveConfigDigestCollector - DiagnosticRedactor: secret-key detection, JSON/text redaction, URL stripping and SHA-256 digests; effective config is emitted as digests only, never raw - DiagnosticBundleService: per-section isolation, timeout, budget enforcement and concurrency limiting with an overload fallback bundle - DiagnosticBundleWriter + DiagnosticManifest: path-traversal-safe ZIP archive with a manifest of section statuses and sizes - ShenyuDiagnosticsEndpoint: /actuator/diagnostics/bundle, guarded by @ConditionalOnProperty(shenyu.diagnostics.enabled=true) - Unit tests covering redaction, writer, service budget/timeout/concurrency, a seeded-secret leak fixture, and the endpoint --- .../shenyu/web/diagnostics/DiagnosticBudget.java | 92 +++++++++ .../web/diagnostics/DiagnosticBundleService.java | 211 +++++++++++++++++++ .../web/diagnostics/DiagnosticBundleWriter.java | 129 ++++++++++++ .../shenyu/web/diagnostics/DiagnosticManifest.java | 105 ++++++++++ .../web/diagnostics/DiagnosticProperties.java | 155 ++++++++++++++ .../shenyu/web/diagnostics/DiagnosticRedactor.java | 196 ++++++++++++++++++ .../shenyu/web/diagnostics/DiagnosticSection.java | 172 ++++++++++++++++ .../web/diagnostics/DiagnosticsConfiguration.java | 79 +++++++ .../EffectiveConfigDigestCollector.java | 139 +++++++++++++ .../web/diagnostics/PluginInventoryCollector.java | 98 +++++++++ .../web/diagnostics/RuntimeInfoCollector.java | 106 ++++++++++ .../shenyu/web/diagnostics/SectionCollector.java | 45 ++++ .../shenyu/web/diagnostics/SectionStatus.java | 59 ++++++ .../web/diagnostics/ShenyuDiagnosticsEndpoint.java | 75 +++++++ .../diagnostics/DiagnosticBundleServiceTest.java | 226 +++++++++++++++++++++ .../diagnostics/DiagnosticBundleWriterTest.java | 120 +++++++++++ .../web/diagnostics/DiagnosticRedactorTest.java | 94 +++++++++ .../EffectiveConfigDigestCollectorTest.java | 91 +++++++++ .../diagnostics/ShenyuDiagnosticsEndpointTest.java | 58 ++++++ 19 files changed, 2250 insertions(+) diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java new file mode 100644 index 0000000000..1dddaec444 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBudget.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * An independent time and size budget handed to a single collector. + * + * <p>Collectors are expected to poll {@link #shouldStop()} between bounded work units and + * to respect {@link #maxBytes()} so a slow or greedy section cannot consume the whole + * bundle allowance or delay other sections. Cancellation is cooperative: the orchestrator + * sets it when the section deadline passes or the caller goes away.</p> + */ +public final class DiagnosticBudget { + + private final long deadlineNanos; + + private final int maxBytes; + + private final AtomicBoolean cancelled = new AtomicBoolean(false); + + /** + * Instantiates a new budget. + * + * @param timeoutMs the wall-clock budget in milliseconds + * @param maxBytes the maximum content size in bytes + */ + public DiagnosticBudget(final long timeoutMs, final int maxBytes) { + this.deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); + this.maxBytes = maxBytes; + } + + /** + * Whether the time budget is exhausted. + * + * @return true if expired + */ + public boolean expired() { + return System.nanoTime() - deadlineNanos >= 0; + } + + /** + * Whether the orchestrator asked this collector to stop. + * + * @return true if cancelled + */ + public boolean isCancelled() { + return cancelled.get(); + } + + /** + * Request cooperative cancellation. + */ + public void cancel() { + cancelled.set(true); + } + + /** + * Whether the collector should stop, either because it was cancelled or ran out of time. + * + * @return true if the collector should stop producing content + */ + public boolean shouldStop() { + return isCancelled() || expired(); + } + + /** + * Get the maximum content size in bytes. + * + * @return the max bytes + */ + public int maxBytes() { + return maxBytes; + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java new file mode 100644 index 0000000000..29a3bc5d7e --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleService.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Orchestrates bounded, partial-tolerant diagnostic collection and archive assembly. + * + * <p>Each collector runs on a worker thread with its own time and size budget. A collector that + * times out, is cancelled or throws becomes a {@link SectionStatus#FAILED} section instead of + * failing the whole bundle, and exhausting the total budget marks the remaining sections + * {@link SectionStatus#SKIPPED}. Simultaneous bundle jobs are limited; when the limit is reached + * the request degrades to an overload bundle that says so honestly rather than queueing behind + * gateway traffic.</p> + */ +public final class DiagnosticBundleService { + + private static final Logger LOG = LoggerFactory.getLogger(DiagnosticBundleService.class); + + private static final String UNKNOWN = "unknown"; + + private final List<SectionCollector> collectors; + + private final DiagnosticProperties properties; + + private final DiagnosticBundleWriter writer; + + private final Semaphore jobs; + + /** + * Instantiates a new bundle service. + * + * @param collectors the section collectors + * @param properties the diagnostic bounds + * @param writer the archive writer + */ + public DiagnosticBundleService(final List<SectionCollector> collectors, final DiagnosticProperties properties, + final DiagnosticBundleWriter writer) { + this.collectors = List.copyOf(collectors); + this.properties = properties; + this.writer = writer; + this.jobs = new Semaphore(Math.max(1, properties.getMaxConcurrentJobs())); + } + + /** + * Collect all sections and assemble the archive. + * + * @return the bundle archive bytes + */ + public byte[] bundle() { + if (!jobs.tryAcquire()) { + return writeOverloadBundle(); + } + try { + return writer.write(collect()); + } catch (IOException ex) { + throw new UncheckedIOException("failed to assemble diagnostic bundle", ex); + } finally { + jobs.release(); + } + } + + /** + * Collect all sections into a manifest without archiving. + * + * @return the manifest carrying every section outcome + */ + public DiagnosticManifest collect() { + long generatedAt = System.currentTimeMillis(); + long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(properties.getTotalTimeoutMs()); + List<DiagnosticSection> sections = new ArrayList<>(collectors.size()); + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "shenyu-diagnostics"); + thread.setDaemon(true); + return thread; + }); + try { + for (SectionCollector collector : collectors) { + sections.add(runCollector(executor, collector, deadlineNanos)); + } + } finally { + executor.shutdownNow(); + } + return new DiagnosticManifest(generatedAt, instanceIdentity(), softwareVersions(), sections); + } + + private DiagnosticSection runCollector(final ExecutorService executor, final SectionCollector collector, final long deadlineNanos) { + String name = collector.name(); + long observedAt = System.currentTimeMillis(); + if (System.nanoTime() - deadlineNanos >= 0) { + return DiagnosticSection.of(name, SectionStatus.SKIPPED, observedAt, 0L, "total budget exhausted"); + } + DiagnosticBudget budget = new DiagnosticBudget(properties.getSectionTimeoutMs(), properties.getMaxSectionBytes()); + long startNanos = System.nanoTime(); + Future<DiagnosticSection> future = executor.submit(() -> collector.collect(budget)); + try { + return capSection(future.get(properties.getSectionTimeoutMs(), TimeUnit.MILLISECONDS), startNanos); + } catch (TimeoutException ex) { + budget.cancel(); + future.cancel(true); + return DiagnosticSection.of(name, SectionStatus.FAILED, observedAt, elapsedMs(startNanos), "section timeout"); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + budget.cancel(); + future.cancel(true); + return DiagnosticSection.of(name, SectionStatus.FAILED, observedAt, elapsedMs(startNanos), "interrupted"); + } catch (ExecutionException ex) { + return DiagnosticSection.of(name, SectionStatus.FAILED, observedAt, elapsedMs(startNanos), failureReason(ex.getCause())); + } + } + + private DiagnosticSection capSection(final DiagnosticSection section, final long startNanos) { + long durationMs = elapsedMs(startNanos); + if (Objects.isNull(section)) { + return DiagnosticSection.of(UNKNOWN, SectionStatus.FAILED, System.currentTimeMillis(), durationMs, "collector returned null"); + } + byte[] content = section.getContent(); + if (content.length > properties.getMaxSectionBytes()) { + byte[] capped = Arrays.copyOf(content, properties.getMaxSectionBytes()); + return DiagnosticSection.truncated(section.getName(), section.getObservedAtEpochMs(), durationMs, capped, "section size cap"); + } + return section; + } + + private byte[] writeOverloadBundle() { + long now = System.currentTimeMillis(); + List<DiagnosticSection> sections = new ArrayList<>(collectors.size()); + for (SectionCollector collector : collectors) { + sections.add(DiagnosticSection.of(collector.name(), SectionStatus.SKIPPED, now, 0L, "concurrent collection limit reached")); + } + DiagnosticManifest manifest = new DiagnosticManifest(now, instanceIdentity(), softwareVersions(), sections); + try { + return writer.write(manifest); + } catch (IOException ex) { + throw new UncheckedIOException("failed to assemble overload diagnostic bundle", ex); + } + } + + private Map<String, Object> instanceIdentity() { + Map<String, Object> instance = new LinkedHashMap<>(); + RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean(); + instance.put("bootTimeEpochMs", runtime.getStartTime()); + instance.put("uptimeMs", runtime.getUptime()); + instance.put("hostAddress", hostAddress()); + return instance; + } + + private Map<String, String> softwareVersions() { + Map<String, String> versions = new LinkedHashMap<>(); + String shenyuVersion = DiagnosticBundleService.class.getPackage().getImplementationVersion(); + versions.put("shenyu", Objects.isNull(shenyuVersion) ? UNKNOWN : shenyuVersion); + versions.put("java", System.getProperty("java.version", UNKNOWN)); + versions.put("osName", System.getProperty("os.name", UNKNOWN)); + versions.put("osArch", System.getProperty("os.arch", UNKNOWN)); + return versions; + } + + private String hostAddress() { + try { + return InetAddress.getLocalHost().getHostAddress(); + } catch (UnknownHostException ex) { + LOG.debug("unable to resolve local host address for diagnostic manifest", ex); + return UNKNOWN; + } + } + + private long elapsedMs(final long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } + + private String failureReason(final Throwable cause) { + return Objects.isNull(cause) ? "collector failure" : cause.getClass().getSimpleName(); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java new file mode 100644 index 0000000000..4f1db59e4c --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriter.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.common.utils.GsonUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Packages a manifest and its sections into an in-memory ZIP archive. + * + * <p>Entry names are sanitised so a hostile section name cannot introduce path traversal or an + * absolute path, and both per-entry and total sizes are hard-capped so a bundle can never grow + * without bound. The archive is built entirely in memory and returned as a byte array; no + * temporary file touches the disk on this path.</p> + */ +public final class DiagnosticBundleWriter { + + private static final String MANIFEST_ENTRY = "manifest.json"; + + private static final String SECTION_PREFIX = "sections/"; + + private static final String SECTION_SUFFIX = ".json"; + + private static final int MAX_NAME_LENGTH = 64; + + private final DiagnosticProperties properties; + + /** + * Instantiates a new writer. + * + * @param properties the diagnostic bounds + */ + public DiagnosticBundleWriter(final DiagnosticProperties properties) { + this.properties = properties; + } + + /** + * Write the bundle archive. + * + * @param manifest the manifest, which also carries the sections + * @return the archive bytes + * @throws IOException if the in-memory archive cannot be produced + */ + public byte[] write(final DiagnosticManifest manifest) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + Set<String> used = new HashSet<>(); + try (ZipOutputStream zip = new ZipOutputStream(buffer, StandardCharsets.UTF_8)) { + putEntry(zip, used, MANIFEST_ENTRY, GsonUtils.getGson().toJson(manifest.toMap()).getBytes(StandardCharsets.UTF_8)); + List<DiagnosticSection> sections = manifest.getSections(); + int remaining = properties.getMaxTotalBytes(); + for (DiagnosticSection section : sections) { + byte[] content = cap(section.getContent(), properties.getMaxSectionBytes()); + if (content.length > remaining) { + content = Arrays.copyOf(content, Math.max(0, remaining)); + } + remaining -= content.length; + putEntry(zip, used, SECTION_PREFIX + safeEntryName(section.getName()) + SECTION_SUFFIX, content); + } + } + return buffer.toByteArray(); + } + + /** + * Sanitise a section name into a single, relative, traversal-safe archive entry component. + * + * @param name the raw section name + * @return a safe entry component + */ + public static String safeEntryName(final String name) { + if (Objects.isNull(name)) { + return "section"; + } + String cleaned = name.replaceAll("[^A-Za-z0-9._-]", "_"); + cleaned = cleaned.replaceAll("\\.{2,}", "."); + while (cleaned.startsWith(".")) { + cleaned = cleaned.substring(1); + } + if (cleaned.length() > MAX_NAME_LENGTH) { + cleaned = cleaned.substring(0, MAX_NAME_LENGTH); + } + if (cleaned.isEmpty() || ".".equals(cleaned) || "..".equals(cleaned)) { + return "section"; + } + return cleaned; + } + + private void putEntry(final ZipOutputStream zip, final Set<String> used, final String name, final byte[] content) throws IOException { + String unique = name; + int suffix = 1; + while (!used.add(unique)) { + unique = name + "-" + suffix++; + } + zip.putNextEntry(new ZipEntry(unique)); + zip.write(content); + zip.closeEntry(); + } + + private byte[] cap(final byte[] content, final int maxBytes) { + if (content.length <= maxBytes) { + return content; + } + return Arrays.copyOf(content, maxBytes); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java new file mode 100644 index 0000000000..056641c2ef --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticManifest.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The bundle manifest describing provenance and the per-section outcome. + * + * <p>The manifest is explicit that a bundle is not an atomic cross-component snapshot: every + * section records its own observation time and status, and the manifest records the source + * instance boot identity and software versions so a consumer can reason about skew.</p> + */ +public final class DiagnosticManifest { + + /** + * The bundle format version, bumped on any structural change to the archive layout. + */ + public static final int FORMAT_VERSION = 1; + + private final long generatedAtEpochMs; + + private final Map<String, Object> instance; + + private final Map<String, String> softwareVersions; + + private final List<DiagnosticSection> sections; + + /** + * Instantiates a new manifest. + * + * @param generatedAtEpochMs the bundle generation timestamp in epoch milliseconds + * @param instance the source instance identity + * @param softwareVersions the observed software versions + * @param sections the collected sections + */ + public DiagnosticManifest(final long generatedAtEpochMs, final Map<String, Object> instance, + final Map<String, String> softwareVersions, final List<DiagnosticSection> sections) { + this.generatedAtEpochMs = generatedAtEpochMs; + this.instance = new LinkedHashMap<>(instance); + this.softwareVersions = new LinkedHashMap<>(softwareVersions); + this.sections = new ArrayList<>(sections); + } + + /** + * Render the manifest as an ordered map suitable for JSON serialisation. + * + * @return the manifest map + */ + public Map<String, Object> toMap() { + Map<String, Object> map = new LinkedHashMap<>(); + map.put("formatVersion", FORMAT_VERSION); + map.put("generatedAtEpochMs", generatedAtEpochMs); + map.put("instance", instance); + map.put("softwareVersions", softwareVersions); + map.put("atomicSnapshot", Boolean.FALSE); + List<Map<String, Object>> summaries = new ArrayList<>(sections.size()); + for (DiagnosticSection section : sections) { + summaries.add(summarize(section)); + } + map.put("sections", summaries); + return map; + } + + /** + * Get the collected sections. + * + * @return the sections + */ + public List<DiagnosticSection> getSections() { + return new ArrayList<>(sections); + } + + private Map<String, Object> summarize(final DiagnosticSection section) { + Map<String, Object> summary = new LinkedHashMap<>(); + summary.put("name", section.getName()); + summary.put("status", section.getStatus().name()); + summary.put("observedAtEpochMs", section.getObservedAtEpochMs()); + summary.put("durationMs", section.getDurationMs()); + summary.put("sizeBytes", section.getSizeBytes()); + if (Objects.nonNull(section.getReason())) { + summary.put("reason", section.getReason()); + } + return summary; + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java new file mode 100644 index 0000000000..d5be4e263a --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticProperties.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration for the opt-in gateway diagnostic bundle facility. + * + * <p>The feature is disabled by default. Every bound is a hard ceiling that keeps a + * support bundle small and cheap: an oversized or slow collection degrades to a partial + * bundle rather than growing without limit or blocking gateway traffic.</p> + */ +@ConfigurationProperties(prefix = "shenyu.diagnostics") +public class DiagnosticProperties { + + private static final int DEFAULT_MAX_TOTAL_BYTES = 4 * 1024 * 1024; + + private static final int DEFAULT_MAX_SECTION_BYTES = 512 * 1024; + + private boolean enabled; + + private int maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES; + + private int maxSectionBytes = DEFAULT_MAX_SECTION_BYTES; + + private long sectionTimeoutMs = 2000L; + + private long totalTimeoutMs = 8000L; + + private int maxConcurrentJobs = 2; + + /** + * Whether the diagnostic bundle endpoint is exposed. Disabled by default. + * + * @return true if enabled + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Set whether the diagnostic bundle endpoint is exposed. + * + * @param enabled the enabled flag + */ + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + /** + * Get the maximum total uncompressed bundle size in bytes. + * + * @return the max total bytes + */ + public int getMaxTotalBytes() { + return maxTotalBytes; + } + + /** + * Set the maximum total uncompressed bundle size in bytes. + * + * @param maxTotalBytes the max total bytes + */ + public void setMaxTotalBytes(final int maxTotalBytes) { + this.maxTotalBytes = maxTotalBytes; + } + + /** + * Get the maximum size of a single section in bytes. + * + * @return the max section bytes + */ + public int getMaxSectionBytes() { + return maxSectionBytes; + } + + /** + * Set the maximum size of a single section in bytes. + * + * @param maxSectionBytes the max section bytes + */ + public void setMaxSectionBytes(final int maxSectionBytes) { + this.maxSectionBytes = maxSectionBytes; + } + + /** + * Get the per-section collection timeout in milliseconds. + * + * @return the section timeout ms + */ + public long getSectionTimeoutMs() { + return sectionTimeoutMs; + } + + /** + * Set the per-section collection timeout in milliseconds. + * + * @param sectionTimeoutMs the section timeout ms + */ + public void setSectionTimeoutMs(final long sectionTimeoutMs) { + this.sectionTimeoutMs = sectionTimeoutMs; + } + + /** + * Get the total collection timeout across all sections in milliseconds. + * + * @return the total timeout ms + */ + public long getTotalTimeoutMs() { + return totalTimeoutMs; + } + + /** + * Set the total collection timeout across all sections in milliseconds. + * + * @param totalTimeoutMs the total timeout ms + */ + public void setTotalTimeoutMs(final long totalTimeoutMs) { + this.totalTimeoutMs = totalTimeoutMs; + } + + /** + * Get the maximum number of simultaneous bundle collection jobs. + * + * @return the max concurrent jobs + */ + public int getMaxConcurrentJobs() { + return maxConcurrentJobs; + } + + /** + * Set the maximum number of simultaneous bundle collection jobs. + * + * @param maxConcurrentJobs the max concurrent jobs + */ + public void setMaxConcurrentJobs(final int maxConcurrentJobs) { + this.maxConcurrentJobs = maxConcurrentJobs; + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java new file mode 100644 index 0000000000..8a603c1b30 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactor.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.shenyu.common.utils.GsonUtils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * The shared allowlist and redaction policy applied before anything enters a bundle. + * + * <p>Redaction is deliberately conservative: it is safer to hide a non-secret value than to + * leak a credential. Secret material is recognised by key name and by a small set of value + * shapes (bearer tokens, JWTs and {@code key=value} assignments). Free-form text and JSON are + * both supported; unparseable JSON falls back to text scrubbing so a malformed payload can + * never bypass the policy. Where a value must be correlatable but not revealed, use + * {@link #digest(String)} to publish a stable SHA-256 fingerprint instead of the raw value.</p> + */ +public final class DiagnosticRedactor { + + /** + * The replacement for any redacted value. + */ + public static final String REDACTED = "******"; + + private static final String SHA_256 = "SHA-256"; + + private static final Set<String> SECRET_KEY_TOKENS = Set.of( + "password", "passwd", "pwd", "secret", "token", "apikey", "accesskey", + "secretkey", "privatekey", "credential", "authorization", "cookie", + "bearer", "signature", "jwt", "sessionid"); + + private static final Pattern SECRET_ASSIGN = Pattern.compile( + "(?i)(\\b(?:password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key|secret[-_]?key|" + + "private[-_]?key|credential|authorization|cookie|session[-_]?id)\\b\\s*[:=]\\s*)(\"?)([^\",}\\s]+)"); + + private static final Pattern BEARER = Pattern.compile("(?i)(bearer\\s+)([A-Za-z0-9._~+/=-]+)"); + + private static final Pattern JWT = Pattern.compile("\\beyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+"); + + /** + * Whether a configuration key names secret material. + * + * @param key the key to test + * @return true if the key must have its value redacted + */ + public boolean isSecretKey(final String key) { + if (Objects.isNull(key)) { + return false; + } + String normalized = key.toLowerCase().replaceAll("[^a-z0-9]", ""); + return SECRET_KEY_TOKENS.stream().anyMatch(normalized::contains); + } + + /** + * Redact a JSON document, hiding the value of every secret-named member at any depth. + * + * <p>Unparseable input is scrubbed as free text so a malformed payload cannot bypass the + * policy.</p> + * + * @param json the JSON document + * @return the redacted JSON, or redacted text when the input is not valid JSON + */ + public String redactJson(final String json) { + if (Objects.isNull(json) || json.isEmpty()) { + return json; + } + try { + JsonElement root = JsonParser.parseString(json); + JsonElement redacted = redactElement(root); + return GsonUtils.getGson().toJson(redacted); + } catch (RuntimeException ex) { + return redactText(json); + } + } + + /** + * Scrub secret value shapes from free-form text such as thread dumps or error messages. + * + * @param text the text to scrub + * @return the scrubbed text + */ + public String redactText(final String text) { + if (Objects.isNull(text) || text.isEmpty()) { + return text; + } + String result = BEARER.matcher(text).replaceAll("$1" + REDACTED); + result = SECRET_ASSIGN.matcher(result).replaceAll("$1$2" + REDACTED); + return JWT.matcher(result).replaceAll(REDACTED); + } + + /** + * Reduce a URL to its non-sensitive parts by dropping credentials and the query string. + * + * @param url the url to strip + * @return the stripped url + */ + public String stripUrl(final String url) { + if (Objects.isNull(url) || url.isEmpty()) { + return url; + } + String result = url.replaceAll("(?i)(://)[^/@?#]+@", "$1" + REDACTED + "@"); + int query = result.indexOf('?'); + if (query >= 0) { + result = result.substring(0, query) + "?" + REDACTED; + } + int fragment = result.indexOf('#'); + if (fragment >= 0) { + result = result.substring(0, fragment); + } + return result; + } + + /** + * Produce a stable, non-reversible SHA-256 fingerprint of a value. + * + * <p>Use this to keep configuration correlatable across bundles without ever publishing + * the raw value. A {@code null} or empty value yields an empty fingerprint so callers do + * not mistake "no value" for a real digest.</p> + * + * @param value the value to fingerprint + * @return the lowercase hex digest, or an empty string for null/empty input + */ + public String digest(final String value) { + if (Objects.isNull(value) || value.isEmpty()) { + return ""; + } + try { + MessageDigest messageDigest = MessageDigest.getInstance(SHA_256); + byte[] hash = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException ex) { + return REDACTED; + } + } + + private JsonElement redactElement(final JsonElement element) { + if (element.isJsonObject()) { + return redactObject(element.getAsJsonObject()); + } + if (element.isJsonArray()) { + return redactArray(element.getAsJsonArray()); + } + return element; + } + + private JsonElement redactObject(final JsonObject source) { + JsonObject target = new JsonObject(); + for (Map.Entry<String, JsonElement> entry : source.entrySet()) { + if (isSecretKey(entry.getKey())) { + target.addProperty(entry.getKey(), REDACTED); + } else { + target.add(entry.getKey(), redactElement(entry.getValue())); + } + } + return target; + } + + private JsonElement redactArray(final JsonArray source) { + List<JsonElement> items = new ArrayList<>(source.size()); + for (JsonElement item : source) { + items.add(redactElement(item)); + } + JsonArray target = new JsonArray(); + items.forEach(target::add); + return target; + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java new file mode 100644 index 0000000000..c6d86579ca --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticSection.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * An immutable, already-redacted diagnostic section. + * + * <p>Content is stored as bytes so the writer never has to re-encode it. The byte + * array is defensively copied on the way in and out; callers cannot mutate the state + * of a published section.</p> + */ +public final class DiagnosticSection { + + private final String name; + + private final SectionStatus status; + + private final long observedAtEpochMs; + + private final long durationMs; + + private final byte[] content; + + private final String reason; + + private DiagnosticSection(final String name, final SectionStatus status, final long observedAtEpochMs, + final long durationMs, final byte[] content, final String reason) { + this.name = name; + this.status = status; + this.observedAtEpochMs = observedAtEpochMs; + this.durationMs = durationMs; + this.content = Arrays.copyOf(content, content.length); + this.reason = reason; + } + + /** + * Create a complete section. + * + * @param name the section name + * @param observedAtEpochMs the observation timestamp in epoch milliseconds + * @param durationMs the collection duration in milliseconds + * @param content the already-redacted UTF-8 content + * @return the section + */ + public static DiagnosticSection ok(final String name, final long observedAtEpochMs, final long durationMs, final byte[] content) { + return new DiagnosticSection(name, SectionStatus.OK, observedAtEpochMs, durationMs, content, null); + } + + /** + * Create a complete section from a UTF-8 string. + * + * @param name the section name + * @param observedAtEpochMs the observation timestamp in epoch milliseconds + * @param durationMs the collection duration in milliseconds + * @param content the already-redacted content + * @return the section + */ + public static DiagnosticSection ok(final String name, final long observedAtEpochMs, final long durationMs, final String content) { + return ok(name, observedAtEpochMs, durationMs, content.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Create a section that carries an explicit status and reason with no content. + * + * @param name the section name + * @param status the section status + * @param observedAtEpochMs the observation timestamp in epoch milliseconds + * @param durationMs the collection duration in milliseconds + * @param reason a short, non-secret reason + * @return the section + */ + public static DiagnosticSection of(final String name, final SectionStatus status, final long observedAtEpochMs, + final long durationMs, final String reason) { + return new DiagnosticSection(name, status, observedAtEpochMs, durationMs, new byte[0], reason); + } + + /** + * Create a section whose content was cut to satisfy the size budget. + * + * @param name the section name + * @param observedAtEpochMs the observation timestamp in epoch milliseconds + * @param durationMs the collection duration in milliseconds + * @param content the truncated, already-redacted content + * @param reason a short, non-secret reason + * @return the section + */ + public static DiagnosticSection truncated(final String name, final long observedAtEpochMs, final long durationMs, + final byte[] content, final String reason) { + return new DiagnosticSection(name, SectionStatus.TRUNCATED, observedAtEpochMs, durationMs, content, reason); + } + + /** + * Get the section name. + * + * @return the name + */ + public String getName() { + return name; + } + + /** + * Get the section status. + * + * @return the status + */ + public SectionStatus getStatus() { + return status; + } + + /** + * Get the observation timestamp in epoch milliseconds. + * + * @return the observed at epoch ms + */ + public long getObservedAtEpochMs() { + return observedAtEpochMs; + } + + /** + * Get the collection duration in milliseconds. + * + * @return the duration ms + */ + public long getDurationMs() { + return durationMs; + } + + /** + * Get a copy of the section content. + * + * @return the content bytes + */ + public byte[] getContent() { + return Arrays.copyOf(content, content.length); + } + + /** + * Get the content size in bytes. + * + * @return the size bytes + */ + public int getSizeBytes() { + return content.length; + } + + /** + * Get the non-secret reason, may be {@code null} for {@link SectionStatus#OK}. + * + * @return the reason + */ + public String getReason() { + return reason; + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java new file mode 100644 index 0000000000..e7f2755733 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/DiagnosticsConfiguration.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.web.handler.ShenyuWebHandler; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Wires the opt-in gateway diagnostic bundle facility. + * + * <p>The whole configuration is gated on {@code shenyu.diagnostics.enabled=true}; a default + * deployment creates none of these beans and the endpoint is not registered. Collector order is + * fixed here so the manifest lists sections deterministically.</p> + */ +@Configuration +@ConditionalOnProperty(name = "shenyu.diagnostics.enabled", havingValue = "true") +@EnableConfigurationProperties(DiagnosticProperties.class) +public class DiagnosticsConfiguration { + + /** + * The shared redaction policy. + * + * @return the redactor + */ + @Bean + public DiagnosticRedactor diagnosticRedactor() { + return new DiagnosticRedactor(); + } + + /** + * The archive writer. + * + * @param properties the diagnostic bounds + * @return the writer + */ + @Bean + public DiagnosticBundleWriter diagnosticBundleWriter(final DiagnosticProperties properties) { + return new DiagnosticBundleWriter(properties); + } + + /** + * The bundle service composing the fixed collector set. + * + * @param properties the diagnostic bounds + * @param redactor the shared redaction policy + * @param writer the archive writer + * @param webHandler the gateway web handler + * @return the bundle service + */ + @Bean + public DiagnosticBundleService diagnosticBundleService(final DiagnosticProperties properties, final DiagnosticRedactor redactor, + final DiagnosticBundleWriter writer, final ShenyuWebHandler webHandler) { + List<SectionCollector> collectors = List.of( + new RuntimeInfoCollector(), + new PluginInventoryCollector(webHandler), + new EffectiveConfigDigestCollector(redactor)); + return new DiagnosticBundleService(collectors, properties, writer); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java new file mode 100644 index 0000000000..9c44b65fe2 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollector.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.common.dto.PluginData; +import org.apache.shenyu.common.dto.RuleData; +import org.apache.shenyu.common.dto.SelectorData; +import org.apache.shenyu.common.utils.GsonUtils; +import org.apache.shenyu.plugin.base.cache.BaseDataCache; +import org.apache.shenyu.plugin.base.cache.MetaDataCache; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +/** + * Summarises the effective plugin, selector and rule configuration as redacted digests. + * + * <p>Selector and rule handles routinely carry credentials, upstream secrets and tokens. This + * collector never publishes a raw handle or plugin config: it emits a stable SHA-256 digest so + * two bundles can be compared for "did the config change" without ever revealing the value, plus + * non-secret structural counters. The rendered document is passed through the shared redaction + * policy again as defense in depth.</p> + */ +public final class EffectiveConfigDigestCollector implements SectionCollector { + + private static final String NAME = "config-digest"; + + private final DiagnosticRedactor redactor; + + /** + * Instantiates a new effective config digest collector. + * + * @param redactor the shared redaction policy + */ + public EffectiveConfigDigestCollector(final DiagnosticRedactor redactor) { + this.redactor = redactor; + } + + @Override + public String name() { + return NAME; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + final long observedAt = System.currentTimeMillis(); + final long startNanos = System.nanoTime(); + if (budget.shouldStop()) { + return DiagnosticSection.of(NAME, SectionStatus.SKIPPED, observedAt, 0L, "budget exhausted before start"); + } + BaseDataCache base = BaseDataCache.getInstance(); + Map<String, Object> root = new LinkedHashMap<>(); + root.put("plugins", summarizePlugins(base.getPluginMap(), budget)); + root.put("selectors", summarizeSelectors(base.getSelectorMap(), budget)); + root.put("rules", summarizeRules(base.getRuleMap(), budget)); + root.put("metadataCount", MetaDataCache.getInstance().getMetaDataMap().size()); + String redacted = redactor.redactJson(GsonUtils.getInstance().toJson(root)); + return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos), redacted); + } + + private List<Map<String, Object>> summarizePlugins(final ConcurrentMap<String, PluginData> plugins, final DiagnosticBudget budget) { + List<Map<String, Object>> list = new ArrayList<>(plugins.size()); + for (Map.Entry<String, PluginData> entry : plugins.entrySet()) { + if (budget.shouldStop()) { + break; + } + PluginData data = entry.getValue(); + Map<String, Object> item = new LinkedHashMap<>(); + item.put("name", entry.getKey()); + item.put("role", data.getRole()); + item.put("configDigest", redactor.digest(data.getConfig())); + list.add(item); + } + return list; + } + + private List<Map<String, Object>> summarizeSelectors(final ConcurrentMap<String, List<SelectorData>> selectors, final DiagnosticBudget budget) { + List<Map<String, Object>> list = new ArrayList<>(); + for (Map.Entry<String, List<SelectorData>> entry : selectors.entrySet()) { + for (SelectorData data : entry.getValue()) { + if (budget.shouldStop()) { + return list; + } + Map<String, Object> item = new LinkedHashMap<>(); + item.put("pluginName", entry.getKey()); + item.put("handleDigest", redactor.digest(data.getHandle())); + item.put("conditionCount", size(data.getConditionList())); + list.add(item); + } + } + return list; + } + + private List<Map<String, Object>> summarizeRules(final ConcurrentMap<String, List<RuleData>> rules, final DiagnosticBudget budget) { + List<Map<String, Object>> list = new ArrayList<>(); + for (Map.Entry<String, List<RuleData>> entry : rules.entrySet()) { + for (RuleData data : entry.getValue()) { + if (budget.shouldStop()) { + return list; + } + Map<String, Object> item = new LinkedHashMap<>(); + item.put("selectorId", entry.getKey()); + item.put("pluginName", data.getPluginName()); + item.put("handleDigest", redactor.digest(data.getHandle())); + item.put("conditionCount", size(data.getConditionDataList())); + list.add(item); + } + } + return list; + } + + private int size(final List<?> list) { + return Objects.isNull(list) ? 0 : list.size(); + } + + private long elapsedMs(final long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java new file mode 100644 index 0000000000..489b62130a --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/PluginInventoryCollector.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.common.utils.GsonUtils; +import org.apache.shenyu.plugin.api.ShenyuPlugin; +import org.apache.shenyu.web.handler.ShenyuWebHandler; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Collects the ordered inventory of plugins actually composed into this gateway. + * + * <p>Only the plugin identity and order are published; no plugin configuration or rule handle is + * included here. Configuration is summarised separately as redacted digests by + * {@link EffectiveConfigDigestCollector}.</p> + */ +public final class PluginInventoryCollector implements SectionCollector { + + private static final String NAME = "plugins"; + + private final ShenyuWebHandler webHandler; + + /** + * Instantiates a new plugin inventory collector. + * + * @param webHandler the gateway web handler exposing the live plugin chain + */ + public PluginInventoryCollector(final ShenyuWebHandler webHandler) { + this.webHandler = webHandler; + } + + @Override + public String name() { + return NAME; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + final long observedAt = System.currentTimeMillis(); + final long startNanos = System.nanoTime(); + if (budget.shouldStop()) { + return DiagnosticSection.of(NAME, SectionStatus.SKIPPED, observedAt, 0L, "budget exhausted before start"); + } + List<ShenyuPlugin> plugins = webHandler.getPlugins(); + List<Map<String, Object>> inventory = new ArrayList<>(plugins.size()); + boolean complete = true; + for (ShenyuPlugin plugin : plugins) { + if (budget.shouldStop()) { + complete = false; + break; + } + inventory.add(describe(plugin)); + } + Map<String, Object> root = new LinkedHashMap<>(); + root.put("count", plugins.size()); + root.put("collected", inventory.size()); + root.put("plugins", inventory); + String json = GsonUtils.getInstance().toJson(root); + if (!complete) { + return DiagnosticSection.truncated(NAME, observedAt, elapsedMs(startNanos), json.getBytes(StandardCharsets.UTF_8), "budget exhausted during inventory"); + } + return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos), json); + } + + private Map<String, Object> describe(final ShenyuPlugin plugin) { + Map<String, Object> entry = new LinkedHashMap<>(); + String named = plugin.named(); + entry.put("name", named.isEmpty() ? plugin.getClass().getSimpleName() : named); + entry.put("class", plugin.getClass().getName()); + entry.put("order", plugin.getOrder()); + return entry; + } + + private long elapsedMs(final long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java new file mode 100644 index 0000000000..3580a596a4 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/RuntimeInfoCollector.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.common.utils.GsonUtils; + +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryUsage; +import java.lang.management.OperatingSystemMXBean; +import java.lang.management.RuntimeMXBean; +import java.lang.management.ThreadMXBean; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Collects bounded JVM, OS, memory and thread counters for the local gateway. + * + * <p>Only aggregate counters are published. No heap dump, thread dump or environment variable + * is collected, matching the issue's explicit exclusion of high-volume or secret-bearing data + * from the default bundle.</p> + */ +public final class RuntimeInfoCollector implements SectionCollector { + + private static final String NAME = "runtime"; + + @Override + public String name() { + return NAME; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + final long observedAt = System.currentTimeMillis(); + final long startNanos = System.nanoTime(); + if (budget.shouldStop()) { + return DiagnosticSection.of(NAME, SectionStatus.SKIPPED, observedAt, 0L, "budget exhausted before start"); + } + Map<String, Object> runtime = new LinkedHashMap<>(); + runtime.put("jvm", jvm()); + runtime.put("os", os()); + runtime.put("memory", memory()); + runtime.put("threads", threads()); + String json = GsonUtils.getInstance().toJson(runtime); + return DiagnosticSection.ok(NAME, observedAt, elapsedMs(startNanos), json); + } + + private Map<String, Object> jvm() { + RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean(); + Map<String, Object> jvm = new LinkedHashMap<>(); + jvm.put("vmName", runtime.getVmName()); + jvm.put("vmVersion", runtime.getVmVersion()); + jvm.put("specVersion", runtime.getSpecVersion()); + jvm.put("bootTimeEpochMs", runtime.getStartTime()); + jvm.put("uptimeMs", runtime.getUptime()); + return jvm; + } + + private Map<String, Object> os() { + OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); + Map<String, Object> info = new LinkedHashMap<>(); + info.put("name", os.getName()); + info.put("arch", os.getArch()); + info.put("availableProcessors", os.getAvailableProcessors()); + return info; + } + + private Map<String, Object> memory() { + MemoryUsage heap = ManagementFactory.getMemoryMXBean().getHeapMemoryUsage(); + MemoryUsage nonHeap = ManagementFactory.getMemoryMXBean().getNonHeapMemoryUsage(); + Map<String, Object> memory = new LinkedHashMap<>(); + memory.put("heapUsedBytes", heap.getUsed()); + memory.put("heapCommittedBytes", heap.getCommitted()); + memory.put("heapMaxBytes", heap.getMax()); + memory.put("nonHeapUsedBytes", nonHeap.getUsed()); + return memory; + } + + private Map<String, Object> threads() { + ThreadMXBean threads = ManagementFactory.getThreadMXBean(); + Map<String, Object> info = new LinkedHashMap<>(); + info.put("count", threads.getThreadCount()); + info.put("peak", threads.getPeakThreadCount()); + info.put("daemon", threads.getDaemonThreadCount()); + return info; + } + + private long elapsedMs(final long startNanos) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + } +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java new file mode 100644 index 0000000000..6fb4cbb353 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionCollector.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +/** + * A single, independently-budgeted contributor to a diagnostic bundle. + * + * <p>Implementations must return already-redacted content and must never throw for an + * expected unsupported or empty condition; they should return a section with the matching + * {@link SectionStatus} instead. Unexpected failures are captured by the orchestrator and + * turned into a {@link SectionStatus#FAILED} section so one bad collector cannot sink the + * whole bundle.</p> + */ +public interface SectionCollector { + + /** + * The stable, filesystem-safe section name used as the archive entry key. + * + * @return the section name + */ + String name(); + + /** + * Collect this section within the supplied budget. + * + * @param budget the independent time and size budget + * @return the redacted section, never {@code null} + */ + DiagnosticSection collect(DiagnosticBudget budget); +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java new file mode 100644 index 0000000000..bbffd3b2d9 --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/SectionStatus.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +/** + * Outcome of a single diagnostic section collection. + * + * <p>A bundle is deliberately partial-tolerant: every section carries an explicit + * status so a consumer can tell a real observation from a failed, stale, truncated + * or unsupported one instead of silently treating missing data as empty.</p> + */ +public enum SectionStatus { + + /** + * The section was collected within its budget and is complete. + */ + OK, + + /** + * The section was collected but its content exceeded the size budget and was cut. + */ + TRUNCATED, + + /** + * The collector threw, timed out or was cancelled; no trustworthy content is present. + */ + FAILED, + + /** + * The observation exists but is older than the collection window. + */ + STALE, + + /** + * This gateway build or configuration does not support the section. + */ + UNSUPPORTED, + + /** + * The section was not attempted, for example because the total budget was exhausted + * or the concurrent collection limit was reached. + */ + SKIPPED +} diff --git a/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java new file mode 100644 index 0000000000..a770fdb2af --- /dev/null +++ b/shenyu-web/src/main/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpoint.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Gateway management endpoint that exports a bounded, redacted diagnostic bundle. + * + * <p>The endpoint is registered only when {@code shenyu.diagnostics.enabled} is {@code true}, so a + * default deployment does not expose it. Collection runs on the bounded-elastic scheduler and is + * assembled in memory; the download filename is generated from a timestamp so no caller-supplied + * value can influence it. Exposing this path behind the deployment's own authentication is the + * operator's responsibility, exactly like the other gateway actuator endpoints.</p> + */ +@RestController +@RequestMapping("/actuator/diagnostics") +@ConditionalOnProperty(name = "shenyu.diagnostics.enabled", havingValue = "true") +public class ShenyuDiagnosticsEndpoint { + + private final DiagnosticBundleService bundleService; + + /** + * Instantiates a new diagnostics endpoint. + * + * @param bundleService the bundle service + */ + public ShenyuDiagnosticsEndpoint(final DiagnosticBundleService bundleService) { + this.bundleService = bundleService; + } + + /** + * Export the diagnostic bundle as a ZIP archive download. + * + * @return the archive bytes wrapped in an attachment response + */ + @GetMapping(value = "/bundle", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public Mono<ResponseEntity<byte[]>> bundle() { + return Mono.fromCallable(bundleService::bundle) + .subscribeOn(Schedulers.boundedElastic()) + .map(this::toAttachment); + } + + private ResponseEntity<byte[]> toAttachment(final byte[] body) { + String filename = "shenyu-diagnostics-" + System.currentTimeMillis() + ".zip"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(body.length) + .body(body); + } +} diff --git a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java new file mode 100644 index 0000000000..20c3920a5e --- /dev/null +++ b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleServiceTest.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link DiagnosticBundleService}. + */ +public final class DiagnosticBundleServiceTest { + + @Test + public void failingCollectorIsIsolatedAndDoesNotLeakItsMessage() { + DiagnosticProperties properties = new DiagnosticProperties(); + DiagnosticBundleService service = service(properties, new FixedCollector("runtime", "{}"), new ThrowingCollector("bad")); + List<DiagnosticSection> sections = service.collect().getSections(); + assertEquals(SectionStatus.OK, sections.get(0).getStatus()); + assertEquals(SectionStatus.FAILED, sections.get(1).getStatus()); + assertEquals("IllegalStateException", sections.get(1).getReason()); + assertFalse(sections.get(1).getReason().contains("boom-secret")); + } + + @Test + public void slowCollectorTimesOutIndependently() { + DiagnosticProperties properties = new DiagnosticProperties(); + properties.setSectionTimeoutMs(50L); + DiagnosticBundleService service = service(properties, new FixedCollector("runtime", "{}"), new SleepingCollector("slow", 5000L)); + List<DiagnosticSection> sections = service.collect().getSections(); + assertEquals(SectionStatus.OK, sections.get(0).getStatus()); + assertEquals(SectionStatus.FAILED, sections.get(1).getStatus()); + assertEquals("section timeout", sections.get(1).getReason()); + } + + @Test + public void exhaustedTotalBudgetSkipsEverySection() { + DiagnosticProperties properties = new DiagnosticProperties(); + properties.setTotalTimeoutMs(0L); + DiagnosticBundleService service = service(properties, new FixedCollector("runtime", "{}"), new FixedCollector("plugins", "[]")); + List<DiagnosticSection> sections = service.collect().getSections(); + assertEquals(2, sections.size()); + assertEquals(SectionStatus.SKIPPED, sections.get(0).getStatus()); + assertEquals(SectionStatus.SKIPPED, sections.get(1).getStatus()); + assertEquals("total budget exhausted", sections.get(0).getReason()); + } + + @Test + public void oversizedSectionIsTruncated() { + DiagnosticProperties properties = new DiagnosticProperties(); + properties.setMaxSectionBytes(8); + DiagnosticBundleService service = service(properties, new FixedCollector("runtime", "x".repeat(200))); + DiagnosticSection section = service.collect().getSections().get(0); + assertEquals(SectionStatus.TRUNCATED, section.getStatus()); + assertTrue(section.getSizeBytes() <= 8); + } + + @Test + public void healthyBundleProducesArchive() throws IOException { + DiagnosticProperties properties = new DiagnosticProperties(); + DiagnosticBundleService service = service(properties, new FixedCollector("runtime", "{}"), new FixedCollector("plugins", "[]")); + byte[] archive = service.bundle(); + assertTrue(archive.length > 0); + assertTrue(readEntry(archive, "manifest.json").contains("formatVersion")); + } + + @Test + public void concurrentLimitDegradesToOverloadBundle() throws Exception { + DiagnosticProperties properties = new DiagnosticProperties(); + properties.setMaxConcurrentJobs(1); + properties.setSectionTimeoutMs(10000L); + BlockingCollector blocking = new BlockingCollector("runtime"); + DiagnosticBundleService service = service(properties, blocking); + AtomicReference<byte[]> first = new AtomicReference<>(); + Thread worker = new Thread(() -> first.set(service.bundle())); + worker.start(); + assertTrue(blocking.started.await(5L, TimeUnit.SECONDS)); + byte[] overload = service.bundle(); + assertTrue(readEntry(overload, "manifest.json").contains("concurrent collection limit reached")); + blocking.release.countDown(); + worker.join(TimeUnit.SECONDS.toMillis(10L)); + assertTrue(Objects.nonNull(first.get()) && first.get().length > 0); + } + + private DiagnosticBundleService service(final DiagnosticProperties properties, final SectionCollector... collectors) { + return new DiagnosticBundleService(List.of(collectors), properties, new DiagnosticBundleWriter(properties)); + } + + private String readEntry(final byte[] archive, final String target) throws IOException { + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive), StandardCharsets.UTF_8)) { + ZipEntry entry = zip.getNextEntry(); + while (Objects.nonNull(entry)) { + if (entry.getName().equals(target)) { + return new String(zip.readAllBytes(), StandardCharsets.UTF_8); + } + entry = zip.getNextEntry(); + } + } + return ""; + } + + private static final class FixedCollector implements SectionCollector { + + private final String name; + + private final String content; + + FixedCollector(final String name, final String content) { + this.name = name; + this.content = content; + } + + @Override + public String name() { + return name; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L, content); + } + } + + private static final class ThrowingCollector implements SectionCollector { + + private final String name; + + ThrowingCollector(final String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + throw new IllegalStateException("boom-secret"); + } + } + + private static final class SleepingCollector implements SectionCollector { + + private final String name; + + private final long sleepMs; + + SleepingCollector(final String name, final long sleepMs) { + this.name = name; + this.sleepMs = sleepMs; + } + + @Override + public String name() { + return name; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L, "{}"); + } + } + + private static final class BlockingCollector implements SectionCollector { + + private final CountDownLatch started = new CountDownLatch(1); + + private final CountDownLatch release = new CountDownLatch(1); + + private final String name; + + BlockingCollector(final String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public DiagnosticSection collect(final DiagnosticBudget budget) { + started.countDown(); + try { + release.await(10L, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return DiagnosticSection.ok(name, System.currentTimeMillis(), 0L, "{}"); + } + } +} diff --git a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java new file mode 100644 index 0000000000..52796accf9 --- /dev/null +++ b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticBundleWriterTest.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link DiagnosticBundleWriter}. + */ +public final class DiagnosticBundleWriterTest { + + @Test + public void archiveContainsManifestAndSections() throws IOException { + DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new DiagnosticProperties()); + DiagnosticManifest manifest = manifest( + DiagnosticSection.ok("runtime", 1L, 1L, "{\"a\":1}"), + DiagnosticSection.ok("plugins", 1L, 1L, "[]")); + Map<String, String> entries = read(writer.write(manifest)); + assertTrue(entries.containsKey("manifest.json")); + assertTrue(entries.containsKey("sections/runtime.json")); + assertTrue(entries.containsKey("sections/plugins.json")); + assertTrue(entries.get("manifest.json").contains("formatVersion")); + } + + @Test + public void entryNamesAreTraversalSafe() throws IOException { + DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new DiagnosticProperties()); + DiagnosticManifest manifest = manifest( + DiagnosticSection.ok("../../etc/passwd", 1L, 1L, "x"), + DiagnosticSection.ok("/absolute/win\\path", 1L, 1L, "y")); + Map<String, String> entries = read(writer.write(manifest)); + for (String name : entries.keySet()) { + if (name.startsWith("sections/")) { + String component = name.substring("sections/".length(), name.length() - ".json".length()); + assertFalse(component.contains("..")); + assertFalse(component.contains("/")); + assertFalse(component.contains("\\")); + assertFalse(component.isEmpty()); + } + } + assertEquals(3, entries.size()); + } + + @Test + public void safeEntryNameNeutralisesHostileNames() { + assertEquals("section", DiagnosticBundleWriter.safeEntryName("..")); + assertEquals("section", DiagnosticBundleWriter.safeEntryName("")); + assertEquals("section", DiagnosticBundleWriter.safeEntryName(null)); + assertFalse(DiagnosticBundleWriter.safeEntryName("../../x").contains("..")); + assertEquals("a_b", DiagnosticBundleWriter.safeEntryName("a/b")); + } + + @Test + public void perEntrySizeIsCapped() throws IOException { + DiagnosticProperties properties = new DiagnosticProperties(); + properties.setMaxSectionBytes(16); + DiagnosticBundleWriter writer = new DiagnosticBundleWriter(properties); + String big = "x".repeat(200); + DiagnosticManifest manifest = manifest(DiagnosticSection.ok("runtime", 1L, 1L, big)); + Map<String, String> entries = read(writer.write(manifest)); + assertTrue(entries.get("sections/runtime.json").length() <= 16); + } + + @Test + public void duplicateSectionNamesProduceUniqueEntries() throws IOException { + DiagnosticBundleWriter writer = new DiagnosticBundleWriter(new DiagnosticProperties()); + DiagnosticManifest manifest = manifest( + DiagnosticSection.ok("dup", 1L, 1L, "a"), + DiagnosticSection.ok("dup", 1L, 1L, "b")); + Map<String, String> entries = read(writer.write(manifest)); + assertTrue(entries.containsKey("sections/dup.json")); + assertEquals(3, entries.size()); + } + + private DiagnosticManifest manifest(final DiagnosticSection... sections) { + return new DiagnosticManifest(System.currentTimeMillis(), Map.of("hostAddress", "127.0.0.1"), + Map.of("shenyu", "test"), List.of(sections)); + } + + private Map<String, String> read(final byte[] archive) throws IOException { + Map<String, String> entries = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive), StandardCharsets.UTF_8)) { + ZipEntry entry = zip.getNextEntry(); + while (Objects.nonNull(entry)) { + entries.put(entry.getName(), new String(zip.readAllBytes(), StandardCharsets.UTF_8)); + entry = zip.getNextEntry(); + } + } + return entries; + } +} diff --git a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java new file mode 100644 index 0000000000..addbdec501 --- /dev/null +++ b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/DiagnosticRedactorTest.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link DiagnosticRedactor}. + */ +public final class DiagnosticRedactorTest { + + private final DiagnosticRedactor redactor = new DiagnosticRedactor(); + + @Test + public void secretKeyDetection() { + assertTrue(redactor.isSecretKey("password")); + assertTrue(redactor.isSecretKey("api-key")); + assertTrue(redactor.isSecretKey("API_KEY")); + assertTrue(redactor.isSecretKey("authorization")); + assertTrue(redactor.isSecretKey("clientSecret")); + assertFalse(redactor.isSecretKey("author")); + assertFalse(redactor.isSecretKey("name")); + assertFalse(redactor.isSecretKey("sortKey")); + assertFalse(redactor.isSecretKey(null)); + } + + @Test + public void redactJsonHidesSecretValuesAtAnyDepth() { + String json = "{\"username\":\"admin\",\"password\":\"hunter2\"," + + "\"nested\":{\"apiKey\":\"sk-123\",\"safe\":\"v\"}," + + "\"list\":[{\"token\":\"t1\"},{\"ok\":\"yes\"}]}"; + String result = redactor.redactJson(json); + assertTrue(result.contains("admin")); + assertTrue(result.contains("\"safe\":\"v\"")); + assertTrue(result.contains("\"ok\":\"yes\"")); + assertFalse(result.contains("hunter2")); + assertFalse(result.contains("sk-123")); + assertFalse(result.contains("t1")); + assertTrue(result.contains(DiagnosticRedactor.REDACTED)); + } + + @Test + public void malformedJsonFallsBackToTextScrubbing() { + String result = redactor.redactJson("{not-json password=abc123"); + assertFalse(result.contains("abc123")); + } + + @Test + public void redactTextScrubsBearerJwtAndAssignments() { + String text = "Authorization: Bearer abc.def.ghi password=secret123 eyJhbGciOi.eyJzdWI.SflKxw"; + String result = redactor.redactText(text); + assertFalse(result.contains("secret123")); + assertFalse(result.contains("abc.def.ghi")); + assertFalse(result.contains("eyJhbGciOi")); + } + + @Test + public void stripUrlRemovesCredentialsQueryAndFragment() { + String result = redactor.stripUrl("https://user:pass@host:8080/path?token=abc&x=1#frag"); + assertFalse(result.contains("pass")); + assertFalse(result.contains("token=abc")); + assertFalse(result.contains("frag")); + assertTrue(result.contains("host:8080/path")); + assertTrue(result.contains(DiagnosticRedactor.REDACTED)); + } + + @Test + public void digestIsStableAndEmptyForBlankInput() { + assertEquals(64, redactor.digest("abc").length()); + assertEquals(redactor.digest("abc"), redactor.digest("abc")); + assertFalse(redactor.digest("abc").equals(redactor.digest("abd"))); + assertEquals("", redactor.digest(null)); + assertEquals("", redactor.digest("")); + } +} diff --git a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java new file mode 100644 index 0000000000..d764bc8734 --- /dev/null +++ b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/EffectiveConfigDigestCollectorTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.apache.shenyu.common.dto.PluginData; +import org.apache.shenyu.common.dto.RuleData; +import org.apache.shenyu.common.dto.SelectorData; +import org.apache.shenyu.plugin.base.cache.BaseDataCache; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link EffectiveConfigDigestCollector}, focused on the seeded-secret leak fixture. + */ +public final class EffectiveConfigDigestCollectorTest { + + private static final String SELECTOR_HANDLE = "[{\"password\":\"hunter2\",\"upstream\":\"10.0.0.1:8080\"}]"; + + private static final String RULE_HANDLE = "{\"apiKey\":\"sk-secret\",\"path\":\"/order\"}"; + + private static final String PLUGIN_CONFIG = "{\"clientSecret\":\"cfg-secret\"}"; + + private final DiagnosticRedactor redactor = new DiagnosticRedactor(); + + private final EffectiveConfigDigestCollector collector = new EffectiveConfigDigestCollector(redactor); + + @BeforeEach + public void setUp() { + cleanCaches(); + BaseDataCache base = BaseDataCache.getInstance(); + base.cachePluginData(PluginData.builder().id("p1").name("divide").config(PLUGIN_CONFIG).role("1").enabled(true).build()); + base.cacheSelectData(SelectorData.builder().id("s1").name("sel").pluginName("divide").handle(SELECTOR_HANDLE).enabled(true).build()); + base.cacheRuleData(RuleData.builder().id("r1").name("rule").pluginName("divide").selectorId("s1").handle(RULE_HANDLE).build()); + } + + @AfterEach + public void tearDown() { + cleanCaches(); + } + + @Test + public void seededSecretsNeverAppearInTheSection() { + DiagnosticSection section = collector.collect(new DiagnosticBudget(2000L, 524288)); + String content = new String(section.getContent(), StandardCharsets.UTF_8); + assertEquals(SectionStatus.OK, section.getStatus()); + assertFalse(content.contains("hunter2")); + assertFalse(content.contains("sk-secret")); + assertFalse(content.contains("cfg-secret")); + assertFalse(content.contains("password")); + assertFalse(content.contains("apiKey")); + } + + @Test + public void structuralIdentityAndDigestsArePreserved() { + DiagnosticSection section = collector.collect(new DiagnosticBudget(2000L, 524288)); + String content = new String(section.getContent(), StandardCharsets.UTF_8); + assertTrue(content.contains("divide")); + assertTrue(content.contains(redactor.digest(SELECTOR_HANDLE))); + assertTrue(content.contains(redactor.digest(RULE_HANDLE))); + assertTrue(content.contains(redactor.digest(PLUGIN_CONFIG))); + } + + private void cleanCaches() { + BaseDataCache base = BaseDataCache.getInstance(); + base.cleanPluginData(); + base.cleanSelectorData(); + base.cleanRuleData(); + } +} diff --git a/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java new file mode 100644 index 0000000000..1cc36d6b04 --- /dev/null +++ b/shenyu-web/src/test/java/org/apache/shenyu/web/diagnostics/ShenyuDiagnosticsEndpointTest.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.shenyu.web.diagnostics; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test cases for {@link ShenyuDiagnosticsEndpoint}. + */ +public final class ShenyuDiagnosticsEndpointTest { + + @Test + public void bundleReturnsAZipAttachment() { + DiagnosticProperties properties = new DiagnosticProperties(); + DiagnosticBundleService service = new DiagnosticBundleService( + List.of(new RuntimeInfoCollector()), properties, new DiagnosticBundleWriter(properties)); + ShenyuDiagnosticsEndpoint endpoint = new ShenyuDiagnosticsEndpoint(service); + + ResponseEntity<byte[]> response = endpoint.bundle().block(Duration.ofSeconds(10)); + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.APPLICATION_OCTET_STREAM, response.getHeaders().getContentType()); + String disposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION); + assertTrue(disposition.startsWith("attachment; filename=\"shenyu-diagnostics-")); + byte[] body = response.getBody(); + assertNotNull(body); + assertTrue(body.length > 0); + assertEquals((byte) 'P', body[0]); + assertEquals((byte) 'K', body[1]); + } +}
