Skip to content

Commit a7e185a

Browse files
author
wlanboy
committed
Added TlsInspectorService
1 parent d3bbe67 commit a7e185a

File tree

4 files changed

+238
-1
lines changed

4 files changed

+238
-1
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.wlanboy.javahttpclient.client;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.stereotype.Service;
6+
7+
import javax.net.ssl.*;
8+
import java.net.URI;
9+
import java.security.SecureRandom;
10+
import java.security.cert.CertificateParsingException;
11+
import java.security.cert.X509Certificate;
12+
import java.time.Instant;
13+
import java.time.ZoneOffset;
14+
import java.time.format.DateTimeFormatter;
15+
import java.time.temporal.ChronoUnit;
16+
import java.util.*;
17+
import java.util.concurrent.atomic.AtomicReference;
18+
19+
@Service
20+
public class TlsInspectorService {
21+
22+
private static final Logger logger = LoggerFactory.getLogger(TlsInspectorService.class);
23+
private static final DateTimeFormatter ISO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC);
24+
25+
public Map<String, Object> inspect(String url) {
26+
Map<String, Object> result = new LinkedHashMap<>();
27+
try {
28+
URI uri = URI.create(url);
29+
if (!"https".equalsIgnoreCase(uri.getScheme())) {
30+
result.put("error", "Kein HTTPS – TLS-Inspektion nicht möglich.");
31+
return result;
32+
}
33+
34+
String host = uri.getHost();
35+
int port = uri.getPort() != -1 ? uri.getPort() : 443;
36+
result.put("host", host);
37+
result.put("port", port);
38+
39+
AtomicReference<X509Certificate[]> chainRef = new AtomicReference<>();
40+
41+
// TrustManager der alles akzeptiert, aber die Chain immer captured
42+
X509ExtendedTrustManager capturingTM = new X509ExtendedTrustManager() {
43+
@Override public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine e) { chainRef.set(chain); }
44+
@Override public void checkServerTrusted(X509Certificate[] chain, String authType, java.net.Socket s) { chainRef.set(chain); }
45+
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) { chainRef.set(chain); }
46+
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) {}
47+
@Override public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine e) {}
48+
@Override public void checkClientTrusted(X509Certificate[] chain, String authType, java.net.Socket s) {}
49+
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
50+
};
51+
52+
SSLContext ctx = SSLContext.getInstance("TLS");
53+
ctx.init(null, new TrustManager[]{capturingTM}, new SecureRandom());
54+
SSLSocketFactory factory = ctx.getSocketFactory();
55+
56+
try (SSLSocket socket = (SSLSocket) factory.createSocket(host, port)) {
57+
socket.setSoTimeout(5000);
58+
59+
// SNI setzen
60+
SSLParameters params = socket.getSSLParameters();
61+
params.setServerNames(List.of(new SNIHostName(host)));
62+
socket.setSSLParameters(params);
63+
64+
SSLSession session = socket.getSession(); // löst Handshake aus
65+
66+
result.put("tlsVersion", session.getProtocol());
67+
result.put("cipherSuite", session.getCipherSuite());
68+
69+
X509Certificate[] chain = chainRef.get();
70+
if (chain != null && chain.length > 0) {
71+
String spiffe = extractSpiffe(chain[0]);
72+
result.put("isMtls", spiffe != null);
73+
if (spiffe != null) result.put("spiffeId", spiffe);
74+
result.put("chain", serializeChain(chain));
75+
}
76+
}
77+
78+
} catch (Exception e) {
79+
logger.warn("TLS-Inspektion fehlgeschlagen für {}: {}", url, e.getMessage());
80+
result.put("error", e.getMessage());
81+
}
82+
return result;
83+
}
84+
85+
private List<Map<String, Object>> serializeChain(X509Certificate[] chain) {
86+
List<Map<String, Object>> list = new ArrayList<>();
87+
for (int i = 0; i < chain.length; i++) {
88+
X509Certificate cert = chain[i];
89+
Map<String, Object> entry = new LinkedHashMap<>();
90+
entry.put("index", i);
91+
entry.put("type", i == 0 ? "leaf" : (i == chain.length - 1 ? "root" : "intermediate"));
92+
entry.put("subject", cert.getSubjectX500Principal().getName());
93+
entry.put("issuer", cert.getIssuerX500Principal().getName());
94+
entry.put("serial", cert.getSerialNumber().toString(16).toUpperCase());
95+
entry.put("validFrom", ISO.format(cert.getNotBefore().toInstant()));
96+
entry.put("validTo", ISO.format(cert.getNotAfter().toInstant()));
97+
98+
long daysLeft = ChronoUnit.DAYS.between(Instant.now(), cert.getNotAfter().toInstant());
99+
entry.put("daysUntilExpiry", daysLeft);
100+
entry.put("expired", daysLeft < 0);
101+
102+
List<String> sans = extractSans(cert);
103+
if (!sans.isEmpty()) entry.put("subjectAltNames", sans);
104+
105+
list.add(entry);
106+
}
107+
return list;
108+
}
109+
110+
private String extractSpiffe(X509Certificate cert) {
111+
List<String> sans = extractSans(cert);
112+
return sans.stream()
113+
.filter(s -> s.startsWith("URI:spiffe://"))
114+
.findFirst()
115+
.map(s -> s.substring(4)) // "URI:" prefix entfernen
116+
.orElse(null);
117+
}
118+
119+
private List<String> extractSans(X509Certificate cert) {
120+
List<String> result = new ArrayList<>();
121+
try {
122+
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
123+
if (sans == null) return result;
124+
for (List<?> san : sans) {
125+
int type = (Integer) san.get(0);
126+
String value = san.get(1).toString();
127+
String prefix = switch (type) {
128+
case 0 -> "OtherName";
129+
case 1 -> "Email";
130+
case 2 -> "DNS";
131+
case 4 -> "DirName";
132+
case 6 -> "URI";
133+
case 7 -> "IP";
134+
default -> "Type" + type;
135+
};
136+
result.add(prefix + ":" + value);
137+
}
138+
} catch (CertificateParsingException ignored) {}
139+
return result;
140+
}
141+
}

src/main/java/com/wlanboy/javahttpclient/controller/DiagnosticController.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.wlanboy.javahttpclient.controller;
22

33
import com.wlanboy.javahttpclient.client.K8sDiagnosticService;
4+
import com.wlanboy.javahttpclient.client.TlsInspectorService;
45
import io.swagger.v3.oas.annotations.Operation;
56
import io.swagger.v3.oas.annotations.Parameter;
67
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -12,6 +13,7 @@
1213
import org.springframework.http.ResponseEntity;
1314
import org.springframework.web.bind.annotation.*;
1415

16+
import java.util.List;
1517
import java.util.Map;
1618

1719
@RestController
@@ -20,9 +22,11 @@
2022
public class DiagnosticController {
2123

2224
private final K8sDiagnosticService k8sService;
25+
private final TlsInspectorService tlsService;
2326

24-
public DiagnosticController(K8sDiagnosticService k8sService) {
27+
public DiagnosticController(K8sDiagnosticService k8sService, TlsInspectorService tlsService) {
2528
this.k8sService = k8sService;
29+
this.tlsService = tlsService;
2630
}
2731

2832
/**
@@ -93,4 +97,15 @@ public ResponseEntity<?> getIstioResources(
9397
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
9498
}
9599
}
100+
101+
@Operation(
102+
summary = "TLS-Zertifikat inspizieren",
103+
description = "Baut eine separate TLS-Verbindung zum Ziel auf und gibt Protokoll, Cipher Suite, Zertifikatskette und SPIFFE/mTLS-Informationen zurück."
104+
)
105+
@GetMapping("/tls")
106+
public ResponseEntity<Map<String, Object>> inspectTls(
107+
@Parameter(description = "Ziel-URL (muss https:// sein)", example = "https://example.com")
108+
@RequestParam String url) {
109+
return ResponseEntity.ok(tlsService.inspect(url));
110+
}
96111
}

src/main/resources/public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
</div>
9595
</div>
9696
<div id="redirectChain" style="display:none;" class="mb-3"></div>
97+
<div id="tlsPanel" style="display:none;" class="mb-3"></div>
9798

9899
<div id="errorBox" class="alert alert-danger shadow-sm border-0" style="display:none;">
99100
<div class="d-flex">

src/main/resources/public/js/http-client.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
1111
const protocolBadge = document.getElementById('protocolBadge');
1212
const responseTimeText = document.getElementById('responseTime');
1313
const redirectChainDiv = document.getElementById('redirectChain');
14+
const tlsPanelDiv = document.getElementById('tlsPanel');
1415
const stacktraceArea = document.getElementById('stacktraceArea');
1516
const toggleStackBtn = document.getElementById('toggleStackBtn');
1617

@@ -83,6 +84,7 @@ document.addEventListener('DOMContentLoaded', () => {
8384

8485
updateResponseMetadata(response.status, duration, protocol, resolvedIp);
8586
renderRedirectChain(redirectChainHeader);
87+
if (payload.url.startsWith('https://')) fetchAndRenderTls(payload.url);
8688
addToHistory(payload, response.status, duration, data);
8789

8890
if (response.status === 502 && data.includes("---STACKTRACE---")) {
@@ -132,6 +134,7 @@ document.addEventListener('DOMContentLoaded', () => {
132134
stacktraceArea.style.display = 'none';
133135
if (toggleStackBtn) toggleStackBtn.textContent = 'Stacktrace Details';
134136
if (redirectChainDiv) redirectChainDiv.style.display = 'none';
137+
if (tlsPanelDiv) tlsPanelDiv.style.display = 'none';
135138
}
136139

137140
function renderRedirectChain(chainHeader) {
@@ -197,6 +200,83 @@ document.addEventListener('DOMContentLoaded', () => {
197200
stacktraceArea.style.display = 'none';
198201
}
199202

203+
async function fetchAndRenderTls(url) {
204+
if (!tlsPanelDiv) return;
205+
tlsPanelDiv.innerHTML = `<div class="border rounded p-2 bg-light x-small text-muted"><div class="spinner-border spinner-border-sm me-2"></div>TLS wird inspiziert...</div>`;
206+
tlsPanelDiv.style.display = 'block';
207+
try {
208+
const res = await fetch(`/api/k8s/tls?url=${encodeURIComponent(url)}`);
209+
const tls = await res.json();
210+
tlsPanelDiv.innerHTML = renderTlsPanel(tls);
211+
} catch (e) {
212+
tlsPanelDiv.innerHTML = `<div class="border rounded p-2 bg-light x-small text-danger">TLS-Inspektion fehlgeschlagen: ${e.message}</div>`;
213+
}
214+
}
215+
216+
function renderTlsPanel(tls) {
217+
if (tls.error) return `
218+
<div class="border rounded p-2 bg-light">
219+
<span class="x-small fw-bold text-uppercase text-muted"><i class="bi bi-shield-x me-1"></i>TLS</span>
220+
<span class="ms-2 x-small text-danger">${tls.error}</span>
221+
</div>`;
222+
223+
const mtlsBadge = tls.isMtls
224+
? `<span class="badge bg-success ms-1">mTLS / SPIFFE</span>`
225+
: `<span class="badge bg-secondary ms-1">TLS</span>`;
226+
227+
const spiffeRow = tls.spiffeId ? `
228+
<div class="mt-1 x-small">
229+
<i class="bi bi-person-badge me-1 text-success"></i>
230+
<code class="text-success">${tls.spiffeId}</code>
231+
</div>` : '';
232+
233+
const chainHtml = (tls.chain ?? []).map((cert, i) => {
234+
const expiredClass = cert.expired ? 'text-danger' : cert.daysUntilExpiry < 30 ? 'text-warning' : 'text-success';
235+
const expiredIcon = cert.expired ? 'bi-x-circle-fill text-danger' : cert.daysUntilExpiry < 30 ? 'bi-exclamation-triangle-fill text-warning' : 'bi-check-circle-fill text-success';
236+
const typeBadge = { leaf: 'bg-primary', intermediate: 'bg-secondary', root: 'bg-dark' }[cert.type] ?? 'bg-secondary';
237+
const sans = (cert.subjectAltNames ?? []).map(s => {
238+
const isSpiffe = s.startsWith('URI:spiffe://');
239+
return `<span class="badge ${isSpiffe ? 'bg-success' : 'bg-light text-dark border'} font-monospace me-1 mb-1" style="font-size:0.65rem;">${s}</span>`;
240+
}).join('');
241+
const id = `tls-cert-${i}`;
242+
return `
243+
<div class="border rounded mb-1">
244+
<button class="btn btn-sm w-100 text-start x-small d-flex justify-content-between align-items-center px-2 py-1"
245+
onclick="document.getElementById('${id}').classList.toggle('d-none')">
246+
<span>
247+
<span class="badge ${typeBadge} me-1">${cert.type}</span>
248+
<span class="font-monospace">${cert.subject.split(',')[0]}</span>
249+
</span>
250+
<span class="${expiredClass} x-small"><i class="bi ${expiredIcon} me-1"></i>${cert.expired ? 'ABGELAUFEN' : cert.daysUntilExpiry + 'd'}</span>
251+
</button>
252+
<div id="${id}" class="d-none px-2 pb-2 x-small">
253+
<table class="table table-sm table-borderless mb-1" style="font-size:0.7rem;">
254+
<tbody>
255+
<tr><td class="text-muted w-25">Subject</td><td class="font-monospace">${cert.subject}</td></tr>
256+
<tr><td class="text-muted">Issuer</td><td class="font-monospace">${cert.issuer}</td></tr>
257+
<tr><td class="text-muted">Serial</td><td class="font-monospace">${cert.serial}</td></tr>
258+
<tr><td class="text-muted">Gültig von</td><td>${cert.validFrom}</td></tr>
259+
<tr><td class="text-muted">Gültig bis</td><td class="${expiredClass}">${cert.validTo}</td></tr>
260+
</tbody>
261+
</table>
262+
${sans ? `<div class="mb-1">${sans}</div>` : ''}
263+
</div>
264+
</div>`;
265+
}).join('');
266+
267+
return `
268+
<div class="border rounded p-2 bg-light">
269+
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
270+
<span class="x-small fw-bold text-uppercase text-muted"><i class="bi bi-shield-lock me-1"></i>TLS</span>
271+
<span class="badge bg-info text-dark">${tls.tlsVersion ?? ''}</span>
272+
<span class="badge bg-light text-dark border font-monospace" style="font-size:0.65rem;">${tls.cipherSuite ?? ''}</span>
273+
${mtlsBadge}
274+
</div>
275+
${spiffeRow}
276+
<div class="mt-2">${chainHtml}</div>
277+
</div>`;
278+
}
279+
200280
function handleSuccess(data) {
201281
errorBox.style.display = 'none';
202282
responseOutput.style.display = 'block';

0 commit comments

Comments
 (0)