ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -0,0 +1,108 @@
package org.botstandards.apix.verification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationResult;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class O1DnsVerifier {
@JsonIgnoreProperties(ignoreUnknown = true)
record DohResponse(int Status, List<DohRecord> Answer) {
DohResponse() { this(0, List.of()); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
record DohRecord(String data) {
DohRecord() { this(null); }
}
private final VerificationConfig config;
private final HttpClient http;
private final ObjectMapper mapper;
public O1DnsVerifier(VerificationConfig config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
.build();
this.mapper = new ObjectMapper();
}
public VerificationResult verify(String domain, String expectedToken) {
VerificationResult txtResult = checkTxtRecord(domain, expectedToken);
if (!txtResult.succeeded()) {
return txtResult;
}
return checkMxRecord(domain);
}
private VerificationResult checkTxtRecord(String domain, String expectedToken) {
String url = config.dohUrl() + "?name=_apix-verification." + domain + "&type=TXT";
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/dns-json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
if (doh.Status() == 3) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
"NXDOMAIN for _apix-verification." + domain);
}
if (doh.Answer() == null || doh.Answer().isEmpty()) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
"No TXT record found at _apix-verification." + domain);
}
String expected = "apix-token=" + expectedToken;
boolean found = doh.Answer().stream()
.map(DohRecord::data)
.filter(d -> d != null)
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
.anyMatch(d -> d.equals(expected));
if (!found) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
"TXT record at _apix-verification." + domain + " does not contain " + expected);
}
return VerificationResult.success(OLevel.IDENTITY_VERIFIED);
} catch (Exception e) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_TXT",
"HTTP error: " + e.getMessage());
}
}
private VerificationResult checkMxRecord(String domain) {
String url = config.dohUrl() + "?name=" + domain + "&type=MX";
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/dns-json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
if (doh.Answer() == null || doh.Answer().isEmpty()) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_MX",
"No MX record found for " + domain);
}
return VerificationResult.success(OLevel.IDENTITY_VERIFIED);
} catch (Exception e) {
return VerificationResult.failure(OLevel.UNVERIFIED, "DNS_MX",
"HTTP error: " + e.getMessage());
}
}
}
@@ -0,0 +1,69 @@
package org.botstandards.apix.verification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationResult;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
public class O2GleifVerifier {
@JsonIgnoreProperties(ignoreUnknown = true)
record GleifResponse(List<GleifRecord> data) {
GleifResponse() { this(List.of()); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
record GleifRecord(String id) {
GleifRecord() { this(null); }
}
private final VerificationConfig config;
private final HttpClient http;
private final ObjectMapper mapper;
public O2GleifVerifier(VerificationConfig config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
.build();
this.mapper = new ObjectMapper();
}
public VerificationResult verify(String legalName, String jurisdiction) {
String encodedName = URLEncoder.encode(legalName, StandardCharsets.UTF_8);
String url = config.gleifApiUrl() + "/lei-records"
+ "?filter[entity.legalName]=" + encodedName
+ "&filter[entity.jurisdiction]=" + jurisdiction;
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
GleifResponse gleif = mapper.readValue(response.body(), GleifResponse.class);
if (gleif.data() != null && !gleif.data().isEmpty()) {
String lei = gleif.data().get(0).id();
return VerificationResult.success(OLevel.LEGAL_ENTITY_VERIFIED, lei);
}
}
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "GLEIF",
"No GLEIF record found");
} catch (Exception e) {
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "GLEIF",
"HTTP error: " + e.getMessage());
}
}
}
@@ -0,0 +1,71 @@
package org.botstandards.apix.verification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationResult;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
public class O2OpenCorporatesVerifier {
@JsonIgnoreProperties(ignoreUnknown = true)
record OcResponse(OcResults results) {
OcResponse() { this(null); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
record OcResults(List<Object> companies) {
OcResults() { this(List.of()); }
}
private final VerificationConfig config;
private final HttpClient http;
private final ObjectMapper mapper;
public O2OpenCorporatesVerifier(VerificationConfig config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
.build();
this.mapper = new ObjectMapper();
}
public VerificationResult verify(String legalName, String jurisdiction) {
String encodedName = URLEncoder.encode(legalName, StandardCharsets.UTF_8);
String url = config.openCorporatesApiUrl() + "/companies/search"
+ "?q=" + encodedName
+ "&jurisdiction_code=" + jurisdiction
+ "&api_token=" + config.openCorporatesApiKey();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
OcResponse oc = mapper.readValue(response.body(), OcResponse.class);
if (oc.results() != null
&& oc.results().companies() != null
&& !oc.results().companies().isEmpty()) {
return VerificationResult.success(OLevel.LEGAL_ENTITY_VERIFIED);
}
}
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "OPENCORPORATES",
"No company record found");
} catch (Exception e) {
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED, "OPENCORPORATES",
"HTTP error: " + e.getMessage());
}
}
}
@@ -0,0 +1,129 @@
package org.botstandards.apix.verification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationResult;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class O3HygieneVerifier {
@JsonIgnoreProperties(ignoreUnknown = true)
record DohResponse(int Status, List<DohRecord> Answer) {
DohResponse() { this(0, List.of()); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
record DohRecord(String data) {
DohRecord() { this(null); }
}
private final VerificationConfig config;
private final HttpClient http;
private final ObjectMapper mapper;
public O3HygieneVerifier(VerificationConfig config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
.build();
this.mapper = new ObjectMapper();
}
public VerificationResult verify(String domain) {
VerificationResult secResult = checkSecurityTxt(domain);
if (!secResult.succeeded()) {
return secResult;
}
VerificationResult dmarcResult = checkDmarc(domain);
if (!dmarcResult.succeeded()) {
return dmarcResult;
}
return checkSpf(domain);
}
private VerificationResult checkSecurityTxt(String domain) {
String url = config.securityTxtUrlTemplate().replace("{domain}", domain);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SECURITY_TXT",
"security.txt not found at " + url + " (HTTP " + response.statusCode() + ")");
}
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
} catch (Exception e) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SECURITY_TXT",
"HTTP error: " + e.getMessage());
}
}
private VerificationResult checkDmarc(String domain) {
String url = config.dohUrl() + "?name=_dmarc." + domain + "&type=TXT";
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/dns-json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
boolean found = doh.Answer() != null && doh.Answer().stream()
.map(DohRecord::data)
.filter(d -> d != null)
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
.anyMatch(d -> d.contains("v=DMARC1"));
if (!found) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "DMARC",
"No DMARC TXT record found for _dmarc." + domain);
}
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
} catch (Exception e) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "DMARC",
"HTTP error: " + e.getMessage());
}
}
private VerificationResult checkSpf(String domain) {
String url = config.dohUrl() + "?name=" + domain + "&type=TXT";
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/dns-json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
boolean found = doh.Answer() != null && doh.Answer().stream()
.map(DohRecord::data)
.filter(d -> d != null)
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
.anyMatch(d -> d.contains("v=spf1"));
if (!found) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SPF",
"No SPF TXT record found for " + domain);
}
return VerificationResult.success(OLevel.HYGIENE_VERIFIED);
} catch (Exception e) {
return VerificationResult.failure(OLevel.LEGAL_ENTITY_VERIFIED, "SPF",
"HTTP error: " + e.getMessage());
}
}
}
@@ -0,0 +1,76 @@
package org.botstandards.apix.verification;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
/**
* Verifies that an agent has published the expected rotation challenge token
* at the well-known DNS location: _apix-rotation.{domain} TXT "apix-rotate={token}"
*
* This is the machine-native key rotation path: rotation secret (first factor)
* + DNS control (second factor, analogous to ACME DNS-01).
* The challenge token is intentionally public — it is valueless without the rotation secret.
*/
public class RotationChallengeVerifier {
private static final String TXT_PREFIX = "apix-rotate=";
@JsonIgnoreProperties(ignoreUnknown = true)
record DohResponse(int Status, List<DohRecord> Answer) {
DohResponse() { this(0, List.of()); }
}
@JsonIgnoreProperties(ignoreUnknown = true)
record DohRecord(String data) {
DohRecord() { this(null); }
}
private final VerificationConfig config;
private final HttpClient http;
private final ObjectMapper mapper;
public RotationChallengeVerifier(VerificationConfig config) {
this.config = config;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(config.httpTimeoutMs()))
.build();
this.mapper = new ObjectMapper();
}
/**
* Returns true if _apix-rotation.{domain} TXT contains "apix-rotate={expectedToken}".
* Throws on HTTP/parse error so the caller can surface a meaningful error.
*/
public boolean verify(String domain, String expectedToken) {
String url = config.dohUrl() + "?name=_apix-rotation." + domain + "&type=TXT";
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMillis(config.httpTimeoutMs()))
.header("Accept", "application/dns-json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
DohResponse doh = mapper.readValue(response.body(), DohResponse.class);
if (doh.Answer() == null || doh.Answer().isEmpty()) {
return false;
}
String expected = TXT_PREFIX + expectedToken;
return doh.Answer().stream()
.map(DohRecord::data)
.filter(d -> d != null)
.map(d -> d.startsWith("\"") && d.endsWith("\"") ? d.substring(1, d.length() - 1) : d)
.anyMatch(d -> d.equals(expected));
} catch (Exception e) {
throw new RuntimeException("DoH query failed for _apix-rotation." + domain + ": " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,10 @@
package org.botstandards.apix.verification;
public record VerificationConfig(
String dohUrl,
String gleifApiUrl,
String openCorporatesApiKey,
String openCorporatesApiUrl,
String securityTxtUrlTemplate,
long httpTimeoutMs
) {}
@@ -0,0 +1,53 @@
package org.botstandards.apix.verification;
import org.botstandards.apix.common.OLevel;
import org.botstandards.apix.common.VerificationResult;
public class VerificationPipeline {
private final O1DnsVerifier o1;
private final O2GleifVerifier o2Gleif;
private final O2OpenCorporatesVerifier o2Oc;
private final O3HygieneVerifier o3;
public VerificationPipeline(VerificationConfig config) {
this.o1 = new O1DnsVerifier(config);
this.o2Gleif = new O2GleifVerifier(config);
this.o2Oc = new O2OpenCorporatesVerifier(config);
this.o3 = new O3HygieneVerifier(config);
}
public VerificationResult run(OLevel targetLevel, String domain, String dnsToken,
String legalName, String jurisdiction) {
if (targetLevel == OLevel.UNVERIFIED) {
return VerificationResult.success(OLevel.UNVERIFIED);
}
if (targetLevel == OLevel.OPERATIONALLY_VERIFIED || targetLevel == OLevel.AUDITED) {
return VerificationResult.failure(OLevel.UNVERIFIED, "MANUAL_REVIEW",
"requires BSF manual review");
}
VerificationResult o1Result = o1.verify(domain, dnsToken);
if (!o1Result.succeeded()) {
return o1Result;
}
if (targetLevel == OLevel.IDENTITY_VERIFIED) {
return o1Result;
}
VerificationResult o2Result = o2Gleif.verify(legalName, jurisdiction);
if (!o2Result.succeeded()) {
o2Result = o2Oc.verify(legalName, jurisdiction);
}
if (!o2Result.succeeded()) {
return VerificationResult.failure(OLevel.IDENTITY_VERIFIED,
o2Result.blockedAtStep(), o2Result.message());
}
if (targetLevel == OLevel.LEGAL_ENTITY_VERIFIED) {
return o2Result;
}
return o3.verify(domain);
}
}