Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,23 @@ interface TlsConfig {
*/
@WithDefault("true")
boolean selfSigned();

/**
* Additional port the TLS proxy binds for AWS-style HTTPS traffic, alongside the
* public Floci {@link EmulatorConfig#port()}.
*
* <p>CDK/CloudFormation custom resources send their {@code cfn-response} callback with
* bundled code that hardcodes {@code https://} and ignores the port in the ResponseURL,
* so the PUT lands on the conventional 443 regardless of Floci's configured port. Binding
* 443 here (with the same HTTP/HTTPS protocol detection used on the main port) lets those
* callbacks — and any other client that assumes AWS lives on 443 — reach Floci.
*
* <p>Default {@code 443}. Set to {@code 0} to disable the extra binding (e.g. when Floci
* runs unprivileged or another process owns 443). When equal to {@link EmulatorConfig#port()}
* only a single listener is started. Env: FLOCI_TLS_AWS_HTTPS_PORT
*/
@WithDefault("443")
int awsHttpsPort();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
Expand Down Expand Up @@ -80,9 +81,10 @@ public TlsConfigSource() {
"localhost.floci.io", "*.localhost.floci.io"));
currentHostnames.addAll(customHostnames);

// Check if hostname configuration has changed
if (hostnameConfigChanged(tlsDir, currentHostnames)) {
// Configuration changed or metadata missing - regenerate certificate
// Regenerate when the hostname config changed, or when the existing certificate
// is a legacy non-self-signed cert (issuer != subject) — those cannot serve as a
// trust anchor for clients that install them, so an upgrade must replace them.
if (hostnameConfigChanged(tlsDir, currentHostnames) || !isSelfSigned(certFile)) {
generateSelfSignedCert(tlsDir, certFile, keyFile);
} else {
// Configuration unchanged - reuse existing certificate
Expand Down Expand Up @@ -173,7 +175,7 @@ private void generateSelfSignedCert(Path tlsDir, Path certFile, Path keyFile) {
allSans.addAll(customHostnames);

CertificateGenerator gen = new CertificateGenerator();
CertificateGenerator.GeneratedCertificate generated = gen.generateCertificate(
CertificateGenerator.GeneratedCertificate generated = gen.generateSelfSignedCertificate(
"localhost",
allSans,
KeyAlgorithm.RSA_2048);
Expand All @@ -197,6 +199,21 @@ private static void validateFileExists(String path, String description) {
}
}

/**
* Returns {@code true} if the certificate at {@code certFile} is genuinely self-signed
* (issuer == subject) and therefore usable as a trust anchor. Legacy Floci certs carried a
* cosmetic Amazon issuer DN and return {@code false} here, triggering regeneration on upgrade.
*/
private boolean isSelfSigned(Path certFile) {
try {
X509Certificate cert = new CertificateGenerator().parseCertificate(Files.readString(certFile));
return cert.getIssuerX500Principal().equals(cert.getSubjectX500Principal());
} catch (Exception e) {
LOG.warnv("TLS: could not inspect existing certificate ({0}); regenerating", e.getMessage());
return false;
}
}

/**
* Extracts custom hostnames from FLOCI_HOSTNAME and FLOCI_BASE_URL configuration.
* Filters out default values like "localhost" and "127.0.0.1".
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.hectorvent.floci.config;

import io.quarkus.runtime.Startup;
import io.vertx.core.Handler;
import io.vertx.core.Vertx;
import io.vertx.core.net.NetClient;
import io.vertx.core.net.NetServer;
Expand All @@ -11,6 +12,11 @@
import jakarta.inject.Inject;
import org.jboss.logging.Logger;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

/**
* A TCP proxy server that enables HTTP and HTTPS on the same port (LocalStack parity).
*
Expand All @@ -26,6 +32,12 @@
* behavior where both {@code http://localhost:4566} and {@code https://localhost:4566}
* work simultaneously.
*
* <p>The same protocol-detecting handler is also bound on the configurable
* {@code floci.tls.aws-https-port} (443 by default). CDK/CloudFormation custom-resource
* {@code cfn-response} callbacks hardcode {@code https://} and ignore the ResponseURL port,
* so they PUT to 443 regardless of Floci's configured port; binding 443 lets those callbacks
* reach Floci. The extra binding is skipped when the port is {@code 0} or equals the public port.
*
* <p>This bean is only active when {@code floci.tls.enabled=true}. When TLS is disabled,
* Quarkus serves HTTP directly on port 4566 and this proxy is not started.
*/
Expand All @@ -43,13 +55,22 @@ public class TlsProxyServer {

private final Vertx vertx;
private final EmulatorConfig config;
private NetServer proxyServer;
private final int httpBackendPort;
private final int httpsBackendPort;
private final List<NetServer> proxyServers = new ArrayList<>();
private NetClient client;

@Inject
public TlsProxyServer(Vertx vertx, EmulatorConfig config) {
this(vertx, config, HTTP_BACKEND_PORT, HTTPS_BACKEND_PORT);
}

/** Visible for testing — lets tests point the proxy at backends on non-default ports. */
TlsProxyServer(Vertx vertx, EmulatorConfig config, int httpBackendPort, int httpsBackendPort) {
this.vertx = vertx;
this.config = config;
this.httpBackendPort = httpBackendPort;
this.httpsBackendPort = httpsBackendPort;
startIfTlsEnabled();
}

Expand All @@ -58,15 +79,57 @@ private void startIfTlsEnabled() {
return;
}

int publicPort = config.port();
NetServerOptions options = new NetServerOptions()
.setHost("0.0.0.0")
.setPort(publicPort);

proxyServer = vertx.createNetServer(options);
client = vertx.createNetClient();
Handler<NetSocket> connectHandler = buildConnectHandler();

for (int port : listenPorts()) {
NetServerOptions options = new NetServerOptions()
.setHost("0.0.0.0")
.setPort(port);
NetServer server = vertx.createNetServer(options);
server.connectHandler(connectHandler);
proxyServers.add(server);
server.listen().onComplete(ar -> {
if (ar.succeeded()) {
LOG.infov("TLS proxy: listening on port {0} (HTTP→{1}, HTTPS→{2})",
String.valueOf(port), String.valueOf(httpBackendPort), String.valueOf(httpsBackendPort));
} else if (port == config.port()) {
LOG.errorv("TLS proxy: failed to start on public port {0}: {1}",
String.valueOf(port), ar.cause().getMessage());
} else {
// The extra AWS-HTTPS port (443 by default) is privileged; binding it fails in
// unprivileged environments (e.g. CI/test). Non-fatal — HTTPS on that port is
// simply unavailable. Set floci.tls.aws-https-port=0 to skip the attempt.
LOG.warnv("TLS proxy: could not bind AWS-HTTPS port {0} ({1}); HTTPS on {0} unavailable. "
+ "Binding privileged ports needs elevated privileges — set floci.tls.aws-https-port=0 to disable.",
String.valueOf(port), ar.cause().getMessage());
}
});
}
}

/**
* The set of ports the proxy listens on: always the public Floci {@link EmulatorConfig#port()},
* plus {@code floci.tls.aws-https-port} (443 by default) so AWS-style HTTPS callbacks reach
* Floci. Deduplicated (a coinciding aws-https-port yields a single listener); a non-positive
* aws-https-port disables the extra binding.
*/
Set<Integer> listenPorts() {
Set<Integer> ports = new LinkedHashSet<>();
ports.add(config.port());
int awsHttpsPort = config.tls().awsHttpsPort();
if (awsHttpsPort > 0) {
ports.add(awsHttpsPort);
}
return ports;
}

proxyServer.connectHandler(frontSocket -> {
/**
* Builds the shared connect handler that peeks the first byte to detect TLS and pipes the
* connection to the matching backend. A single instance is reused across all listen ports.
*/
private Handler<NetSocket> buildConnectHandler() {
return frontSocket -> {
// Pause incoming data until we've peeked at the first byte
frontSocket.pause();

Expand All @@ -79,9 +142,9 @@ private void startIfTlsEnabled() {
// Inspect first byte to determine protocol
int backendPort;
if (buffer.length() > 0 && buffer.getByte(0) == TLS_HANDSHAKE) {
backendPort = HTTPS_BACKEND_PORT;
backendPort = httpsBackendPort;
} else {
backendPort = HTTP_BACKEND_PORT;
backendPort = httpBackendPort;
}

// Connect to the appropriate backend
Expand All @@ -108,23 +171,13 @@ private void startIfTlsEnabled() {

// Resume to receive the first buffer
frontSocket.resume();
});

proxyServer.listen().onComplete(ar -> {
if (ar.succeeded()) {
LOG.infov("TLS proxy: listening on port {0} (HTTP→{1}, HTTPS→{2})",
String.valueOf(publicPort), String.valueOf(HTTP_BACKEND_PORT), String.valueOf(HTTPS_BACKEND_PORT));
} else {
LOG.errorv("TLS proxy: failed to start on port {0}: {1}",
String.valueOf(publicPort), ar.cause().getMessage());
}
});
};
}

@PreDestroy
void stop() {
if (proxyServer != null) {
proxyServer.close();
for (NetServer server : proxyServers) {
server.close();
}
if (client != null) {
client.close();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,33 @@ public record GeneratedCertificate(
) {}

/**
* Generates a self-signed X.509 certificate for local emulation.
* Generates a certificate for local emulation that mimics an ACM-issued certificate:
* the subject is {@code CN=<domainName>} but the issuer is a cosmetic Amazon CA DN, matching
* what real ACM returns. It is signed by its own key, so it is <em>not</em> verifiable as a
* trust anchor (the issuer DN does not match any certificate a client could hold). Use this
* for ACM responses; use {@link #generateSelfSignedCertificate} for a cert clients must trust.
*
* <p>Note: RSA key generation (especially 4096-bit) can take 100-500ms.
* In production emulator usage, consider moving this to a worker thread
* or using virtual threads for concurrent certificate generation.</p>
*/
public GeneratedCertificate generateCertificate(String domainName, List<String> sans, KeyAlgorithm keyAlgorithm) {
return buildCertificate(domainName, sans, keyAlgorithm, ISSUER_DN, false);
}

/**
* Generates a genuinely self-signed certificate (issuer == subject, marked as a CA) suitable
* for use as a <em>trust anchor</em>: a client that adds this certificate to its CA store can
* verify a TLS connection that presents it. Used for Floci's own HTTPS server certificate so
* that containers (e.g. Lambdas making CDK {@code cfn-response} callbacks over HTTPS) can trust
* Floci once the certificate is installed in their CA bundle.
*/
public GeneratedCertificate generateSelfSignedCertificate(String domainName, List<String> sans, KeyAlgorithm keyAlgorithm) {
return buildCertificate(domainName, sans, keyAlgorithm, "CN=" + domainName, true);
}

private GeneratedCertificate buildCertificate(String domainName, List<String> sans, KeyAlgorithm keyAlgorithm,
String issuerDn, boolean asCa) {
try {
KeyPair keyPair = generateKeyPair(keyAlgorithm);

Expand All @@ -90,7 +110,7 @@ public GeneratedCertificate generateCertificate(String domainName, List<String>
BigInteger serial = new BigInteger(128, SECURE_RANDOM);
String subjectDn = "CN=" + domainName;

X500Name issuer = new X500Name(ISSUER_DN);
X500Name issuer = new X500Name(issuerDn);
X500Name subject = new X500Name(subjectDn);

String signatureAlgorithm = keyAlgorithm.getAlgorithm().equals("EC")
Expand Down Expand Up @@ -119,22 +139,20 @@ public GeneratedCertificate generateCertificate(String domainName, List<String>
GeneralNames generalNames = new GeneralNames(sanList.toArray(new GeneralName[0]));
certBuilder.addExtension(Extension.subjectAlternativeName, false, generalNames);

// Add Key Usage
certBuilder.addExtension(
Extension.keyUsage,
true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)
);
// Add Key Usage — a trust-anchor self-signed cert also needs keyCertSign so it can
// act as its own issuer; an ACM-style leaf only needs digitalSignature/keyEncipherment.
int keyUsageBits = KeyUsage.digitalSignature | KeyUsage.keyEncipherment;
if (asCa) {
keyUsageBits |= KeyUsage.keyCertSign;
}
certBuilder.addExtension(Extension.keyUsage, true, new KeyUsage(keyUsageBits));

// Add Basic Constraints (not a CA)
certBuilder.addExtension(
Extension.basicConstraints,
true,
new BasicConstraints(false)
);
// Add Basic Constraints — a trust anchor must be a CA so clients accept it as one.
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(asCa));

// Self-signed certificate for local emulation - signed with subject's own private key
// Real AWS ACM certificates are signed by Amazon's CA hierarchy
// Signed with the subject's own private key. For generateCertificate() the issuer DN is
// a cosmetic Amazon DN (mimicking ACM); for generateSelfSignedCertificate() issuer ==
// subject, so the cert is a valid self-signed trust anchor.
ContentSigner signer = new JcaContentSignerBuilder(signatureAlgorithm)
.setProvider(BouncyCastleProvider.PROVIDER_NAME)
.build(keyPair.getPrivate());
Expand All @@ -154,7 +172,7 @@ public GeneratedCertificate generateCertificate(String domainName, List<String>
notBefore,
notAfter,
subjectDn,
ISSUER_DN,
issuerDn,
signatureAlgorithm
);

Expand Down
Loading