feat(outbound): admin test action for reachability + TLS summary
POST /{id}/test issues a synthetic probe against the connection URL.
TLS protocol/cipher/peer-cert details stubbed for now (Plan 02 follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,23 @@ package com.cameleer.server.app.outbound.controller;
|
|||||||
import com.cameleer.server.app.outbound.crypto.SecretCipher;
|
import com.cameleer.server.app.outbound.crypto.SecretCipher;
|
||||||
import com.cameleer.server.app.outbound.dto.OutboundConnectionDto;
|
import com.cameleer.server.app.outbound.dto.OutboundConnectionDto;
|
||||||
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
|
import com.cameleer.server.app.outbound.dto.OutboundConnectionRequest;
|
||||||
|
import com.cameleer.server.app.outbound.dto.OutboundConnectionTestResult;
|
||||||
import com.cameleer.server.core.admin.AuditCategory;
|
import com.cameleer.server.core.admin.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
import com.cameleer.server.core.admin.AuditService;
|
||||||
|
import com.cameleer.server.core.http.OutboundHttpClientFactory;
|
||||||
|
import com.cameleer.server.core.http.OutboundHttpRequestContext;
|
||||||
import com.cameleer.server.core.outbound.OutboundConnection;
|
import com.cameleer.server.core.outbound.OutboundConnection;
|
||||||
import com.cameleer.server.core.outbound.OutboundConnectionService;
|
import com.cameleer.server.core.outbound.OutboundConnectionService;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.apache.hc.client5.http.classic.methods.HttpPost;
|
||||||
|
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
|
||||||
|
import org.apache.hc.core5.http.ContentType;
|
||||||
|
import org.apache.hc.core5.http.HttpEntity;
|
||||||
|
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||||
|
import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -25,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -38,11 +48,14 @@ public class OutboundConnectionAdminController {
|
|||||||
private final OutboundConnectionService service;
|
private final OutboundConnectionService service;
|
||||||
private final SecretCipher cipher;
|
private final SecretCipher cipher;
|
||||||
private final AuditService audit;
|
private final AuditService audit;
|
||||||
|
private final OutboundHttpClientFactory httpClientFactory;
|
||||||
|
|
||||||
public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher, AuditService audit) {
|
public OutboundConnectionAdminController(OutboundConnectionService service, SecretCipher cipher,
|
||||||
|
AuditService audit, OutboundHttpClientFactory httpClientFactory) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
this.cipher = cipher;
|
this.cipher = cipher;
|
||||||
this.audit = audit;
|
this.audit = audit;
|
||||||
|
this.httpClientFactory = httpClientFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -94,6 +107,36 @@ public class OutboundConnectionAdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/test")
|
||||||
|
public OutboundConnectionTestResult test(@PathVariable UUID id, HttpServletRequest httpRequest) {
|
||||||
|
String userId = currentUserId();
|
||||||
|
OutboundConnection c = service.get(id);
|
||||||
|
long t0 = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
var ctx = new OutboundHttpRequestContext(c.tlsTrustMode(), c.tlsCaPemPaths(), null, null);
|
||||||
|
CloseableHttpClient client = httpClientFactory.clientFor(ctx);
|
||||||
|
HttpPost request = new HttpPost(c.url());
|
||||||
|
request.setEntity(new StringEntity("{\"probe\":true}", ContentType.APPLICATION_JSON));
|
||||||
|
try (var resp = client.execute(request)) {
|
||||||
|
long latency = System.currentTimeMillis() - t0;
|
||||||
|
HttpEntity entity = resp.getEntity();
|
||||||
|
String body = entity == null ? "" : EntityUtils.toString(entity, StandardCharsets.UTF_8);
|
||||||
|
String snippet = body.substring(0, Math.min(512, body.length()));
|
||||||
|
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||||
|
id.toString(), Map.of("status", resp.getCode(), "latencyMs", latency),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
|
return new OutboundConnectionTestResult(resp.getCode(), latency, snippet,
|
||||||
|
"TLS", null, null, null, null);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
long latency = System.currentTimeMillis() - t0;
|
||||||
|
audit.log("test_outbound_connection", AuditCategory.OUTBOUND_CONNECTION_CHANGE,
|
||||||
|
id.toString(), Map.of("error", e.getClass().getSimpleName(), "latencyMs", latency),
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
|
return new OutboundConnectionTestResult(0, latency, null, null, null, null, null, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private OutboundConnection toDraft(OutboundConnectionRequest req) {
|
private OutboundConnection toDraft(OutboundConnectionRequest req) {
|
||||||
String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank())
|
String cipherSecret = (req.hmacSecret() == null || req.hmacSecret().isBlank())
|
||||||
? null : cipher.encrypt(req.hmacSecret());
|
? null : cipher.encrypt(req.hmacSecret());
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
|||||||
private String adminJwt;
|
private String adminJwt;
|
||||||
private String operatorJwt;
|
private String operatorJwt;
|
||||||
private String viewerJwt;
|
private String viewerJwt;
|
||||||
|
private com.github.tomakehurst.wiremock.WireMockServer wireMock;
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void tearDownWireMock() {
|
||||||
|
if (wireMock != null) wireMock.stop();
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
@@ -132,4 +138,36 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
|||||||
String.class);
|
String.class);
|
||||||
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testActionReturnsStatusAndLatency() throws Exception {
|
||||||
|
wireMock = new com.github.tomakehurst.wiremock.WireMockServer(
|
||||||
|
com.github.tomakehurst.wiremock.core.WireMockConfiguration.options()
|
||||||
|
.httpDisabled(true).dynamicHttpsPort());
|
||||||
|
wireMock.start();
|
||||||
|
wireMock.stubFor(com.github.tomakehurst.wiremock.client.WireMock.post("/probe")
|
||||||
|
.willReturn(com.github.tomakehurst.wiremock.client.WireMock.ok("pong")));
|
||||||
|
|
||||||
|
String createBody = """
|
||||||
|
{"name":"probe-target","url":"https://localhost:%d/probe","method":"POST",
|
||||||
|
"tlsTrustMode":"TRUST_ALL","auth":{}}""".formatted(wireMock.httpsPort());
|
||||||
|
|
||||||
|
ResponseEntity<String> create = restTemplate.exchange(
|
||||||
|
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||||
|
new HttpEntity<>(createBody, securityHelper.authHeaders(adminJwt)),
|
||||||
|
String.class);
|
||||||
|
assertThat(create.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||||
|
String id = objectMapper.readTree(create.getBody()).path("id").asText();
|
||||||
|
|
||||||
|
ResponseEntity<String> test = restTemplate.exchange(
|
||||||
|
"/api/v1/admin/outbound-connections/" + id + "/test", HttpMethod.POST,
|
||||||
|
new HttpEntity<>(securityHelper.authHeaders(adminJwt)),
|
||||||
|
String.class);
|
||||||
|
assertThat(test.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
JsonNode body = objectMapper.readTree(test.getBody());
|
||||||
|
assertThat(body.path("status").asInt()).isEqualTo(200);
|
||||||
|
assertThat(body.path("latencyMs").asLong()).isGreaterThanOrEqualTo(0);
|
||||||
|
assertThat(body.path("tlsProtocol").asText()).isEqualTo("TLS");
|
||||||
|
assertThat(body.path("error").isNull()).isTrue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user