Chat about this codebase

AI-powered code exploration

Online

Project Overview

Guardian Gateway provides a resilient, circuit-breaking API gateway built on Spring Cloud Gateway. It centralizes HTTP routing, load balancing and security for microservices, while automating SSL certificate issuance via ACME (Let’s Encrypt).

Problems It Solves

  • Unified entry point for downstream services
  • Fault isolation with Resilience4j circuit breakers
  • Automated TLS provisioning and renewal using Acme4j
  • Health monitoring and metrics via Spring Boot Actuator

When to Use

  • You need a centralized HTTP router and load balancer for microservices
  • You require built-in fault tolerance and automatic circuit breaking
  • You want hands-free SSL certificate management (Let’s Encrypt)
  • You must expose health checks and metrics for production monitoring

Core Components

  • pom.xml:
    • Spring Boot Starter WebFlux, Spring Cloud Gateway
    • Spring Cloud Circuit Breaker (Resilience4j)
    • Spring Boot Actuator
    • Acme4j for ACME protocol support
  • GuardianGatewayApplication.java:
    • Main entry point (@SpringBootApplication)
    • Launches the gateway service
  • application.yaml:
    • spring.application.name: guardian-gateway
    • server.port: 8080

Quick Start

Build and run the gateway locally:

# Build the JAR
mvn clean package

# Run on port 8080
java -jar target/guardian-gateway-0.0.1-SNAPSHOT.jar
## Getting Started

This guide walks you through running the Guardian Gateway locally or on a simple cloud VM. You’ll install the correct Java version, build the project with the Maven Wrapper, and launch the Spring Boot application secured by a self-signed SSL context.

### Prerequisites

- Git  
- SDKMAN (https://sdkman.io)  
- Linux/macOS or Windows Subsystem for Linux  

### 1. Clone the Repository

```bash
git clone https://github.com/nicholasM95/guardian-gateway.git
cd guardian-gateway

2. Install & Select Java

The project requires Java 24.0.1-librca. SDKMAN automates installation and switching:

# Install SDKMAN (if not already)
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

# Install and use the specified Java version
sdk install java 24.0.1-librca
sdk use java 24.0.1-librca

# Verify
java -version
# output: openjdk version "24.0.1" 2025-xx-xx

3. Build with Maven Wrapper

The Maven Wrapper (mvnw) ensures consistent Maven 3.3.2 usage without a global install.

# Grant execute permission (Linux/macOS)
chmod +x mvnw

# Clean, compile and package
./mvnw clean package -DskipTests

A runnable JAR appears in target/, e.g. guardian-gateway-0.0.1-SNAPSHOT.jar.

4. Run Locally

You can start the gateway either via Maven or by running the JAR directly.

a. Using Maven

./mvnw spring-boot:run

b. Using the Packaged JAR

java -jar target/guardian-gateway-*.jar

The application launches on HTTPS port 8443 with a self-signed certificate generated by DummySslContextGenerator. For local testing, bypass certificate checks:

curl -k https://localhost:8443/actuator/health

5. Running on a Cloud VM

  1. Provision a VM (e.g. Ubuntu 22.04).
  2. Install Git, curl, and SDKMAN as above.
  3. Clone repo and set Java version.
  4. Build with ./mvnw clean package -DskipTests.
  5. Open port 8443 in your firewall/SG.
  6. Start the gateway in the background:
nohup java -jar target/guardian-gateway-*.jar \
  > gateway.log 2>&1 &
  1. Verify health endpoint externally:
curl -k https://YOUR_VM_IP:8443/actuator/health

Next Steps

  • Review src/main/java/.../https/DummySslContextGenerator.java for customizing SSL.
  • Configure Spring Cloud settings in application.yml or environment variables.
  • Integrate real certificates via Acme4j for production environments.

Configuration Guide

This guide shows how to customize gateway routes, ports, SSL and certificate handling in guardian-gateway. You’ll learn to:

  • Define and reload routes without code changes
  • Hot-swap TLS certificates at runtime
  • Serve ACME HTTP-01 challenges for Let’s Encrypt integration

Dynamic Route Configuration via ApplicationProperties

Let guardian-gateway pick up new routes from application.yaml without recompiling.

1. Define your route properties in application.yaml

spring:
  application:
    name: guardian-gateway

server:
  port: 8080

application:
  config:
    - name: service1-route
      host: "*.service1.example.com"
      service: lb://SERVICE1
    - name: orders-route
      host: "orders.internal.local"
      service: http://localhost:9002

Fields per entry:

  • name: unique route ID
  • host: incoming request host predicate
  • service: target URI (lb://… for load-balanced, http://… or https://… for direct)

2. Property binding classes

ApplicationConfig holds each route:

package be.nicholasmeyers.guardiangateway.config;

public class ApplicationConfig {
    private String name;
    private String service;
    private String host;
    // getters & setters
}

ApplicationProperties collects the list:

package be.nicholasmeyers.guardiangateway.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "application")
public class ApplicationProperties {
    private List<ApplicationConfig> config;

    public List<ApplicationConfig> getConfig() {
        return config == null ? List.of() : config;
    }

    public void setConfig(List<ApplicationConfig> config) {
        this.config = config;
    }
}

3. Dynamic Route registration

RouteConfig converts properties into live routes:

package be.nicholasmeyers.guardiangateway.config;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RouteConfig {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder,
                               ApplicationProperties props) {
        var routes = builder.routes();

        props.getConfig().forEach(cfg ->
            routes.route(cfg.getName(), r -> r
                .host(cfg.getHost())
                .filters(f -> f.addRequestHeader("X-From-Gateway", "true"))
                .uri(cfg.getService())
            )
        );

        return routes.build();
    }
}

Key points:

  • Uses .host(...) to match incoming host
  • Adds X-From-Gateway header
  • Forwards to service URI

4. Practical guidance

  • To add a route, append under application.config in application.yaml.
  • Use lb://SERVICE_NAME for Spring Cloud services or full URLs for external targets.
  • Extend predicates by replacing .host(...) with .path(), .method(), etc., in RouteConfig.

Dynamic SSL Certificate Reloading

Enable on-the-fly TLS certificate updates in Reactor Netty without downtime.

1. ReloadingSslContextSupplier

A thread-safe Supplier<SslContext> that always hands out the latest context:

package be.nicholasmeyers.guardiangateway.https;

import io.netty.handler.ssl.SslContext;
import java.util.function.Supplier;

public class ReloadingSslContextSupplier implements Supplier<SslContext> {
    private volatile SslContext sslContext;

    public void updateSslContext(SslContext newContext) {
        this.sslContext = newContext;
    }

    @Override
    public SslContext get() {
        return sslContext != null
            ? sslContext
            : DummySslContextGenerator.create();
    }
}

2. Integrating with Reactor Netty

Register the supplier so each new handshake uses the latest context:

@Bean
@Primary
public NettyReactiveWebServerFactory webServerFactory(HttpHandler httpHandler,
                                                      ReloadingSslContextSupplier supplier) {
    var factory = new NettyReactiveWebServerFactory();
    factory.setPort(443);

    factory.addServerCustomizers(httpServer ->
        httpServer.secure(spec ->
            spec.sslContext(supplier.get())
        )
    );

    // HTTP on port 80
    HttpServer.create()
        .port(80)
        .handle(new ReactorHttpHandlerAdapter(httpHandler))
        .bindNow();

    return factory;
}

3. Listening for Certificate Updates

Rebuild SslContext when CertUpdateEvent fires:

@EventListener
public void handleCertificatesReloaded(CertUpdateEvent event) {
    var certs = certStore.getAll();
    if (certs.isEmpty()) return;

    var builder = SslContextBuilder.forServer(
        certs.get(0).keyPair().getPrivate(),
        certs.get(0).certificate()
    );
    for (int i = 1; i < certs.size(); i++) {
        builder.keyManager(
            certs.get(i).keyPair().getPrivate(),
            certs.get(i).certificate()
        );
    }

    try {
        var newContext = builder.build();
        supplier.updateSslContext(newContext);
    } catch (SSLException e) {
        throw new RuntimeException("Failed to build SSL context", e);
    }
}

4. Practical guidance

  • Ensure CertStore loads certificates on startup and emits CertUpdateEvent on change.
  • Replace DummySslContextGenerator with a valid fallback if needed.
  • Reactor Netty picks up new contexts for new connections; existing ones remain.
  • No restart required—certificate updates propagate immediately.

Serving ACME HTTP-01 Challenges

Expose /.well-known/acme-challenge/{token} so Let’s Encrypt can validate your domains.

1. ChallengeStore

Thread-safe in-memory store for pending challenges:

package be.nicholasmeyers.guardiangateway.acme;

import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Optional;

@Component
public class ChallengeStore {
    private final ConcurrentHashMap<String,String> pending = new ConcurrentHashMap<>();

    public void add(String token, String authorization) {
        pending.put(token, authorization);
    }

    public void remove(String token) {
        pending.remove(token);
    }

    public Optional<String> get(String token) {
        return Optional.ofNullable(pending.get(token));
    }
}

2. CertController

Serve the authorization value for incoming HTTP-01 requests:

package be.nicholasmeyers.guardiangateway.acme;

import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/.well-known/acme-challenge")
public class CertController {
    private final ChallengeStore store;

    public CertController(ChallengeStore store) {
        this.store = store;
    }

    @GetMapping("/{token}")
    public String getChallenge(@PathVariable String token) {
        return store.get(token)
            .orElseThrow(() -> new ResponseStatusException(
                HttpStatus.NOT_FOUND, "ACME token not found"
            ));
    }
}

3. Injecting challenges

During certificate issuance, add and remove tokens:

private void processAuthorization(Authorization auth) throws Exception {
    var challenge = auth.findChallenge(Http01Challenge.TYPE)
                        .orElseThrow(() -> new RuntimeException("No HTTP-01 challenge"));
    var token = challenge.getToken();
    var authz  = challenge.getAuthorization();

    challengeStore.add(token, authz);
    challenge.trigger();

    while (challenge.getStatus() != Status.VALID) {
        Thread.sleep(3000);
        challenge.update();
    }
    challengeStore.remove(token);
}

4. Practical guidance

  • Route /.well-known/acme-challenge/* to this service in your gateway or proxy.
  • For multi-instance setups, replace the in-memory store with a shared cache (Redis, etc.).
  • To test, GET /.well-known/acme-challenge/{token} immediately after add() and before trigger().
  • Challenges are short-lived and auto-removed once validated.

Architecture & Core Components

This section details Guardian Gateway’s internal building blocks. You’ll learn how incoming traffic is secured and routed, how certificates are managed in-memory, and how the gateway serves HTTP and HTTPS on multiple ports with live SSL reloads.

HTTPS Redirection Filter

Purpose
Ensure all HTTP traffic redirects to HTTPS (301), exempt ACME challenge requests, and add HSTS headers on HTTPS responses.

How It Works

  • Paths under /.well-known/acme-challenge/** pass through untouched.
  • Requests with scheme http receive a 301 redirect to the same URI under https.
  • HTTPS requests gain Strict-Transport-Security: max-age=31536000; includeSubDomains; preload.

Relevant Code

package be.nicholasmeyers.guardiangateway.https;

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.WebFilter;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.util.logging.Logger;

@Component
public class RedirectToHttps {
    private static final Logger logger = Logger.getLogger(RedirectToHttps.class.getName());

    @Bean
    public WebFilter httpsRedirectFilter() {
        return (exchange, chain) -> {
            ServerHttpRequest  request  = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();
            String             path     = request.getURI().getPath();

            if (path.startsWith("/.well-known/acme-challenge")) {
                logger.fine("ACME challenge – skipping redirect");
                return chain.filter(exchange);
            }

            if ("http".equals(request.getURI().getScheme())) {
                logger.info("Redirecting to HTTPS");
                String httpsUrl = UriComponentsBuilder.fromUri(request.getURI())
                    .scheme("https")
                    .build()
                    .toString();
                response.setStatusCode(HttpStatus.MOVED_PERMANENTLY);
                response.getHeaders().setLocation(URI.create(httpsUrl));
                return response.setComplete();
            }

            // Already HTTPS – add HSTS header
            response.getHeaders().add(
                "Strict-Transport-Security",
                "max-age=31536000; includeSubDomains; preload"
            );
            return chain.filter(exchange);
        };
    }
}

Usage

  1. Place this class under a scanned package. Spring Boot auto-detects @Component.
  2. No extra properties required.
  3. Certbot and other ACME clients can still renew via the exempt path.

Testing with cURL

  • HTTP → HTTPS redirect:
    curl -I http://your-domain/api/status
    Expect HTTP/1.1 301 Moved Permanently and
    Location: https://your-domain/api/status
  • HSTS on HTTPS:
    curl -I https://your-domain/api/status
    Expect header
    Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

Customization

  • Change the ACME challenge path in the path.startsWith(...) check.
  • Modify HSTS parameters by editing the header value.

CertStore: In-Memory Certificate Cache

Purpose
Store and retrieve CertificateInfo objects keyed by domain. Enables rapid SSL context lookup without repeated I/O or ACME calls.

Key API
Optional<CertificateInfo> get(String host)
void put(String host, CertificateInfo info)
boolean contains(String host)
boolean isEmpty()
List<CertificateInfo> getAll()

Example Usage

@Component
public class MyHttpsServer {
    private final CertStore certStore;
    private final CertManager certManager;

    public MyHttpsServer(CertStore certStore, CertManager certManager) {
        this.certStore = certStore;
        this.certManager = certManager;
    }

    public SslContext getSslContextFor(String host) {
        if (!certStore.contains(host)) {
            certManager.requestCertificateAsync(host);
        }
        return certStore.get(host)
            .map(CertificateInfo::sslContext)
            .map(NettySslContextConverter::toNetty)
            .orElse(DummySslContextGenerator.create());
    }

    public void printAllCerts() {
        certStore.getAll().forEach(info ->
            System.out.println(info.domain() + " expires at " + info.expiryDate())
        );
    }
}

Practical Tips

  • On startup, CertManager.loadExistingCertificates() populates the store from disk.
  • After issuance or renewal, certManager calls certStore.put(...) and fires CertUpdateEvent.
  • Other components listen for CertUpdateEvent to swap SSL contexts at runtime.
  • domainCertificates uses ConcurrentHashMap for lock-free, thread-safe access under high concurrency.

Multi-Port HTTP/HTTPS Configuration with Dynamic SSL Reloading

Purpose
Serve HTTPS on port 443 with live certificate updates while also listening on port 80 for ACME challenges or redirects.

1. Configure Netty for Port 443 & Port 80

@Bean
@Primary
public NettyReactiveWebServerFactory webServerFactory(
        HttpHandler httpHandler,
        ReloadingSslContextSupplier supplier) {

    NettyReactiveWebServerFactory factory = new NettyReactiveWebServerFactory();
    factory.setPort(443);

    factory.addServerCustomizers(httpServer ->
        httpServer.secure(sslSpec ->
            sslSpec.sslContext(supplier.get())
        )
    );

    // HTTP server on port 80 for ACME or redirect filter
    HttpServer.create()
        .port(80)
        .handle(new ReactorHttpHandlerAdapter(httpHandler))
        .bindNow();

    return factory;
}
  • supplier.get() returns the current Netty SslContext.
  • The manual HTTP server on port 80 handles ACME challenges and the redirect filter.

2. Handling Certificate Updates

Listen for CertUpdateEvent to rebuild and swap the SSL context:

@EventListener
public void onCertsUpdated(CertUpdateEvent event) {
    if (certStore.isEmpty()) {
        return;
    }
    List<CertificateInfo> certs = certStore.getAll();
    SslContextBuilder builder = SslContextBuilder.forServer(
        certs.get(0).keyPair().getPrivate(),
        certs.get(0).certificate()
    );
    for (int i = 1; i < certs.size(); i++) {
        builder.keyManager(
            certs.get(i).keyPair().getPrivate(),
            certs.get(i).certificate()
        );
    }
    try {
        SslContext newCtx = builder.build();
        supplier.updateSslContext(newCtx);
    } catch (SSLException e) {
        throw new RuntimeException("Failed to rebuild SSL context", e);
    }
}

Key Points

  • Rebuild with the full certificate chain (keyManager).
  • supplier.updateSslContext(...) swaps contexts for new connections without a restart.

3. Initial Dummy SSL Context (Local Development)

public class DummySslContextGenerator {
    public static SslContext create() throws SSLException {
        SelfSignedCertificate ssc = new SelfSignedCertificate("localhost");
        return SslContextBuilder.forServer(
            ssc.certificate(),
            ssc.privateKey()
        ).build();
    }
}

Initialize ReloadingSslContextSupplier with this dummy context to start before real certificates arrive.

4. Integrating HTTP→HTTPS Redirection

Reuse the httpsRedirectFilter() bean to redirect HTTP traffic on port 80 and add HSTS on HTTPS. The same filter applies to both servers thanks to shared HttpHandler.


With these components in place, Guardian Gateway routes, secures, and manages certificates dynamically—enabling seamless HTTPS even when certificates rotate at runtime.

Development & Release

This section outlines coding conventions, testing procedures, dependency updates, and automated release workflows for the nicholasM95/guardian-gateway repository.

1. Prerequisites

  • Java 17 (Temurin or OpenJDK)
  • Maven 3.6+
  • Node.js 14+
  • Git CLI

2. Coding & Commit Conventions

Follow Angular-style commits to drive semantic-release:

  • feat: New feature (MINOR bump)
  • fix: Bug fix (PATCH bump)
  • perf: Performance improvement (PATCH bump)
  • refactor: Code change without feature or fix (no release)
  • chore: Maintenance (no release)
  • BREAKING CHANGE: Any change requiring MAJOR bump

Example:

git commit -m "feat(gateway): add circuit breaker support"
git commit -m "fix(security): renew SSL certificate handling"
git commit -m "refactor(flow): simplify request filters"
git commit -m "feat!: migrate to Spring Cloud 2022.0 (BREAKING CHANGE)"

3. Running Tests

Java Tests

# Compile & run unit and integration tests
mvn clean verify

Node.js Tests

# Install dependencies and run JS tests (e.g., API mocks, scripts)
npm ci
npm test

4. Dependency Management

4.1 Automated Updates with Renovate

The renovate.json config assigns PRs to you on the develop branch:

{
  "extends": ["config:base"],
  "assignees": ["nicholasM95"],
  "baseBranches": ["develop"]
}

Merge Renovate PRs after validation and drop stale branches.

4.2 Manual Maven Updates

  1. Display available updates:

    mvn versions:display-dependency-updates
    
  2. Set new version for a dependency or Spring Cloud BOM:

    <!-- In pom.xml -->
    <dependencyManagement>
      <dependencies>
        <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-dependencies</artifactId>
          <version>2022.0.3</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
      </dependencies>
    </dependencyManagement>
    
  3. Commit changes and bump project version:

    mvn versions:set -DnewVersion=1.5.0-SNAPSHOT
    mvn versions:commit
    git commit -am "chore(deps): upgrade Spring Cloud to 2022.0.3"
    

5. Release Process

5.1 Semantic-Release Configuration

release.config.cjs drives automated versioning and changelog generation:

module.exports = {
  branches: [
    'main',
    { name: 'develop', prerelease: true }
  ],
  plugins: [
    ['@semantic-release/commit-analyzer', {
      releaseRules: [
        {type: 'feat', release: 'minor'},
        {type: 'fix', release: 'patch'},
        {breaking: true, release: 'major'}
      ]
    }],
    ['@semantic-release/release-notes-generator', {
      writerOpts: {commitsSort: ['scope', 'subject']}
    }],
    '@semantic-release/changelog',
    ['@semantic-release/git', {
      assets: ['CHANGELOG.md', 'pom.xml', 'package.json'],
      message: 'chore(release): ${nextRelease.version} [skip ci]'
    }],
    '@semantic-release/github'
  ]
};

5.2 GitHub Actions Workflow

.github/workflows/release.yaml triggers on pushes to main and develop:

name: Release

on:
  push:
    branches: [main, develop]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Set up Java
        uses: actions/setup-java@v3
        with:
          distribution: temurin
          java-version: '17'

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '14'

      - name: Install JS dependencies
        run: npm ci

      - name: Run tests
        run: |
          mvn clean verify
          npm test

      - name: Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npx semantic-release

This workflow:

  • Calculates the next version
  • Updates CHANGELOG.md
  • Commits version bump to main or develop
  • Creates GitHub release

5.3 Manual Version Bump Script

Use update-version.sh to override or preview version changes:

# Bump to version 1.6.0 and update pom.xml
./update-version.sh 1.6.0

# Create tag without altering pom.xml
./update-version.sh 1.6.0 true

Script behavior:

  1. Updates pom.xml (unless skipped)
  2. Commits chore(release): bump version to X.Y.Z
  3. Tags vX.Y.Z

6. Best Practices

  • Develop on develop; merge into main only via PR after CI passes.
  • Keep commit messages concise and follow conventions.
  • Let Renovate handle routine dependency bumps.
  • Review generated CHANGELOG and PR in develop after prerelease.
  • For urgent patches, push a fix: commit directly to main; GitHub Actions handles the rest.