Skip to content
Open
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
43 changes: 14 additions & 29 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
plugins {
id("com.github.johnrengelman.shadow") version "8.1.1" apply(false)
id("io.micronaut.application") version "${micronautGradlePluginVersion}" apply(false)
id("io.micronaut.aot") version "${micronautGradlePluginVersion}" apply(false)
id("io.micronaut.docker") version "${micronautGradlePluginVersion}" apply(false)
id("com.google.protobuf") version "0.9.4" apply(false)
id("maven-publish")
id("com.diffplug.spotless") version "7.0.3"
id("com.autonomousapps.dependency-analysis") version "2.17.0"
}



repositories {
mavenCentral()
}
Expand All @@ -19,7 +12,7 @@ allprojects {
apply plugin: 'java'
apply plugin: 'maven-publish'
apply plugin: 'com.diffplug.spotless'
apply plugin: 'com.autonomousapps.dependency-analysis'

repositories {
mavenCentral()
maven {
Expand All @@ -28,16 +21,24 @@ allprojects {
}
}

java {
sourceCompatibility = JavaVersion.toVersion("21")
targetCompatibility = JavaVersion.toVersion("21")
}

dependencies {
implementation 'ch.qos.logback:logback-classic:1.5.17'
implementation 'ch.qos.logback:logback-core:1.5.17'
implementation 'org.slf4j:slf4j-api:2.0.17'

testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
}

test {
useJUnitPlatform()
}

publishing {
publications {
maven(MavenPublication) {
Expand All @@ -56,40 +57,24 @@ allprojects {
}
}
}

jar {
manifest {
attributes 'Implementation-Version': project.version
}
}
spotless {

spotless {
format 'misc', {
// define the files to apply `misc` to
target '*.gradle', '.gitattributes', '.gitignore'

trimTrailingWhitespace()
leadingSpacesToTabs()
endWithNewline()
}
java {
googleJavaFormat()
formatAnnotations()
licenseHeader('''/*
* (C) 2017-$YEAR TCN Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/''')
targetExclude("build/**")
}
}
}
11 changes: 11 additions & 0 deletions config/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
plugins {
id("java-library")
id("maven-publish")
}

dependencies {
api(project(':core'))

// File watching — the only external dependency beyond core.
implementation("io.methvin:directory-watcher:0.18.0")
}
113 changes: 113 additions & 0 deletions config/src/main/java/com/tcn/exile/config/CertificateRotator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.tcn.exile.config;

import com.tcn.exile.ExileConfig;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Checks certificate expiration and rotates via the gate config service.
*
* <p>Intended to be called periodically (e.g., every hour). If the certificate expires within the
* configured threshold (default 10 days), it calls the gate service to rotate and writes the new
* certificate to the config file.
*/
public final class CertificateRotator {

private static final Logger log = LoggerFactory.getLogger(CertificateRotator.class);
private static final int DEFAULT_RENEWAL_DAYS = 10;

private final ExileClientManager manager;
private final int renewalDays;

public CertificateRotator(ExileClientManager manager) {
this(manager, DEFAULT_RENEWAL_DAYS);
}

public CertificateRotator(ExileClientManager manager, int renewalDays) {
this.manager = manager;
this.renewalDays = renewalDays;
}

/**
* Check and rotate if needed. Returns true if rotation was performed.
*
* <p>Call this periodically (e.g., hourly).
*/
public boolean checkAndRotate() {
var client = manager.client();
if (client == null) return false;

var config = client.config();
Instant expiration = getCertExpiration(config);
if (expiration == null) {
log.warn("Could not determine certificate expiration date");
return false;
}

var now = Instant.now();
if (expiration.isBefore(now)) {
log.error("Certificate has expired ({}). Manual renewal required.", expiration);
manager.stop();
return false;
}

if (expiration.isBefore(now.plus(renewalDays, ChronoUnit.DAYS))) {
log.info("Certificate expires at {}, attempting rotation", expiration);
try {
var hash = getCertFingerprint(config);
var newCert = client.config_().rotateCertificate(hash);
if (newCert != null && !newCert.isEmpty()) {
manager.configWatcher().writeConfig(newCert);
log.info("Certificate rotated successfully");
return true;
} else {
log.warn("Certificate rotation returned empty response");
}
} catch (Exception e) {
log.error("Certificate rotation failed: {}", e.getMessage());
}
} else {
log.debug(
"Certificate valid until {}, no rotation needed ({} day threshold)",
expiration,
renewalDays);
}
return false;
}

static Instant getCertExpiration(ExileConfig config) {
try {
var cf = CertificateFactory.getInstance("X.509");
var cert =
(X509Certificate)
cf.generateCertificate(
new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)));
return cert.getNotAfter().toInstant();
} catch (Exception e) {
return null;
}
}

static String getCertFingerprint(ExileConfig config) {
try {
var cf = CertificateFactory.getInstance("X.509");
var cert =
(X509Certificate)
cf.generateCertificate(
new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)));
var digest = MessageDigest.getInstance("SHA-256");
var hash = digest.digest(cert.getEncoded());
return HexFormat.of().withDelimiter(":").formatHex(hash);
} catch (Exception e) {
return "";
}
}
}
168 changes: 168 additions & 0 deletions config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.tcn.exile.config;

import com.tcn.exile.ExileConfig;
import io.methvin.watcher.DirectoryWatcher;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Watches the config directory for changes to the exile config file and invokes a callback.
*
* <p>Replaces the 400+ line ConfigChangeWatcher that was copy-pasted across all integrations.
*
* <p>The watcher monitors the standard config file ({@code com.tcn.exiles.sati.config.cfg}) in the
* standard directories ({@code /workdir/config} and {@code workdir/config}). On create/modify, it
* parses the file and calls the configured {@link Listener}. On delete, it calls {@link
* Listener#onConfigRemoved()}.
*/
public final class ConfigFileWatcher implements AutoCloseable {

private static final Logger log = LoggerFactory.getLogger(ConfigFileWatcher.class);
static final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg";
private static final List<Path> DEFAULT_WATCH_DIRS =
List.of(Path.of("/workdir/config"), Path.of("workdir/config"));

private final List<Path> watchDirs;
private final Listener listener;
private final AtomicBoolean started = new AtomicBoolean(false);
private volatile DirectoryWatcher watcher;
private volatile Path configDir;

/** Callback interface for config change events. */
public interface Listener {
/** Called when a new or updated config is detected. */
void onConfigChanged(ExileConfig config);

/** Called when the config file is deleted. */
void onConfigRemoved();
}

private ConfigFileWatcher(List<Path> watchDirs, Listener listener) {
this.watchDirs = watchDirs;
this.listener = listener;
}

public static ConfigFileWatcher create(Listener listener) {
return new ConfigFileWatcher(DEFAULT_WATCH_DIRS, listener);
}

public static ConfigFileWatcher create(List<Path> watchDirs, Listener listener) {
return new ConfigFileWatcher(watchDirs, listener);
}

/**
* Start watching. Reads any existing config file immediately, then watches for changes
* asynchronously. Call this once.
*/
public void start() throws IOException {
if (!started.compareAndSet(false, true)) {
throw new IllegalStateException("Already started");
}

// Find or create the config directory.
configDir = watchDirs.stream().filter(p -> p.toFile().exists()).findFirst().orElse(null);
if (configDir == null) {
var fallback = watchDirs.get(0);
if (fallback.toFile().mkdirs()) {
configDir = fallback;
log.info("Created config directory: {}", configDir);
} else {
log.warn("Could not find or create config directory from: {}", watchDirs);
}
} else {
log.info("Using config directory: {}", configDir);
}

// Read existing config if present.
loadExistingConfig();

// Start watching.
var existingDirs = watchDirs.stream().filter(p -> p.toFile().exists()).toList();
if (existingDirs.isEmpty()) {
log.warn("No config directories exist to watch");
return;
}

watcher =
DirectoryWatcher.builder()
.paths(existingDirs)
.fileHashing(false)
.listener(
event -> {
if (!event.path().getFileName().toString().equals(CONFIG_FILE_NAME)) return;
switch (event.eventType()) {
case CREATE, MODIFY -> handleConfigFileChange(event.path());
case DELETE -> {
log.info("Config file deleted");
listener.onConfigRemoved();
}
default -> log.debug("Ignoring event: {}", event.eventType());
}
})
.build();
watcher.watchAsync();
log.info("Config file watcher started");
}

/** Returns the directory where the config file was found or created. */
public Path configDir() {
return configDir;
}

/**
* Write a config string (e.g., a rotated certificate) to the config file. This triggers the
* watcher to reload.
*/
public void writeConfig(String content) throws IOException {
if (configDir == null) {
throw new IllegalStateException("No config directory available");
}
var file = configDir.resolve(CONFIG_FILE_NAME);
Files.writeString(file, content);
log.info("Wrote config file: {}", file);
}

private void loadExistingConfig() {
if (configDir == null) return;
var file = configDir.resolve(CONFIG_FILE_NAME);
if (file.toFile().exists()) {
ConfigParser.parse(file)
.ifPresent(
config -> {
log.info("Loaded existing config for org={}", config.org());
listener.onConfigChanged(config);
});
}
}

private void handleConfigFileChange(Path path) {
if (!path.toFile().canRead()) {
log.warn("Config file not readable: {}", path);
return;
}
ConfigParser.parse(path)
.ifPresentOrElse(
config -> {
log.info("Config changed for org={}", config.org());
listener.onConfigChanged(config);
},
() -> log.warn("Failed to parse config file: {}", path));
}

@Override
public void close() {
if (watcher != null) {
try {
watcher.close();
log.info("Config file watcher closed");
} catch (IOException e) {
log.warn("Error closing config file watcher", e);
}
}
}
}
Loading
Loading