Compare commits

..

21 Commits

Author SHA1 Message Date
706631c57b Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2025-12-24 15:05:01 +00:00
1ee7979091 Merge pull request 'ci' (#2) from ci into dev
Reviewed-on: #2
2025-12-24 15:04:04 +00:00
12263fe0cb use java 21 instead of 17 2025-12-24 14:21:24 +00:00
668ebfcc84 adding ci 2025-12-24 14:12:03 +00:00
ab0f9ceb50 remove application-dev yaml 2025-12-24 14:01:48 +00:00
d4491a6c05 remove unecessary code 2025-12-24 13:59:54 +00:00
72a8501d37 updating webhook controller 2025-12-24 13:51:09 +00:00
66965e0ff2 adding infisical service 2025-12-24 13:50:56 +00:00
21d99f9923 adding dokploy client 2025-12-24 13:49:50 +00:00
816177c2ec enabling feign clients 2025-12-24 13:48:25 +00:00
dbdfea671e updating infisical properties 2025-12-24 13:47:59 +00:00
cde10a4b3e adding dokploy properties 2025-12-24 13:47:34 +00:00
bc9c2da372 adding app config 2025-12-24 11:26:25 +00:00
a63540a01b adding infisical properties 2025-12-24 11:26:17 +00:00
935793812b removing rabbitmq integration 2025-12-24 11:25:37 +00:00
19cad9a450 adding security config 2025-12-24 10:03:40 +00:00
2f8a561ffe adding webhook controller 2025-12-24 10:03:30 +00:00
3774473eac adding signature verifier 2025-12-24 10:03:03 +00:00
ce69b4ff6c adding dto 2025-12-24 10:02:46 +00:00
59a274567d adding application dev profile 2025-12-24 07:56:20 +00:00
01d5774349 ignoring env files 2025-12-24 07:56:04 +00:00
19 changed files with 501 additions and 45 deletions

3
.gitignore vendored
View File

@@ -35,3 +35,6 @@ out/
### VS Code ###
.vscode/
### Env ###
.env*

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM gradle:8.10.2-jdk21 AS build
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts gradlew ./
COPY gradle ./gradle
RUN ./gradlew dependencies --no-daemon || true
COPY . .
RUN ./gradlew clean bootJar --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

View File

@@ -25,55 +25,30 @@ repositories {
}
extra["springCloudVersion"] = "2025.1.0"
extra["springModulithVersion"] = "2.0.1"
extra["infisicalVersion"] = "3.0.5"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-liquibase")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-quartz")
implementation("org.springframework.boot:spring-boot-starter-restclient")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.modulith:spring-modulith-events-api")
implementation("org.springframework.modulith:spring-modulith-starter-core")
implementation("org.springframework.modulith:spring-modulith-starter-jpa")
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-docker-compose")
runtimeOnly("org.postgresql:postgresql")
runtimeOnly("org.springframework.modulith:spring-modulith-actuator")
runtimeOnly("org.springframework.modulith:spring-modulith-events-amqp")
runtimeOnly("org.springframework.modulith:spring-modulith-observability")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-actuator-test")
testImplementation("org.springframework.boot:spring-boot-starter-amqp-test")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testImplementation("org.springframework.boot:spring-boot-starter-liquibase-test")
testImplementation("org.springframework.boot:spring-boot-starter-mail-test")
testImplementation("org.springframework.boot:spring-boot-starter-quartz-test")
testImplementation("org.springframework.boot:spring-boot-starter-restclient-test")
testImplementation("org.springframework.boot:spring-boot-starter-security-test")
testImplementation("org.springframework.boot:spring-boot-starter-thymeleaf-test")
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.testcontainers:testcontainers-postgresql")
testImplementation("org.testcontainers:testcontainers-rabbitmq")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
// https://mvnrepository.com/artifact/com.infisical/sdk
implementation("com.infisical:sdk:${property("infisicalVersion")}")
}
dependencyManagement {
imports {
mavenBom("org.springframework.modulith:spring-modulith-bom:${property("springModulithVersion")}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}

View File

@@ -1,16 +1,16 @@
services:
postgres:
image: 'postgres:latest'
image: "postgres:latest"
environment:
- 'POSTGRES_DB=mydatabase'
- 'POSTGRES_PASSWORD=secret'
- 'POSTGRES_USER=myuser'
- "POSTGRES_DB=mydatabase"
- "POSTGRES_PASSWORD=secret"
- "POSTGRES_USER=myuser"
ports:
- '5432'
rabbitmq:
image: 'rabbitmq:latest'
environment:
- 'RABBITMQ_DEFAULT_PASS=secret'
- 'RABBITMQ_DEFAULT_USER=myuser'
ports:
- '5672'
- "5432"
# rabbitmq:
# image: 'rabbitmq:latest'
# environment:
# - 'RABBITMQ_DEFAULT_PASS=secret'
# - 'RABBITMQ_DEFAULT_USER=myuser'
# ports:
# - '5672'

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
infisical-bridge:
build: .
restart: always
environment:
INFISICAL_API_URL: ${INFISICAL_API_URL}
INFISICAL_CLIENT_ID: ${INFISICAL_CLIENT_ID}
INFISICAL_CLIENT_SECRET: ${INFISICAL_CLIENT_SECRET}
INFISICAL_WEBHOOK_SECRET: ${INFISICAL_WEBHOOK_SECRET}
DOKPLOY_API_URL: ${DOKPLOY_API_URL}
DOKPLOY_API_KEY: ${DOKPLOY_API_KEY}

View File

@@ -2,8 +2,10 @@ package com.abnov.infisicalbridge;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@SpringBootApplication
@EnableFeignClients
public class InfisicalBridgeApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,15 @@
package com.abnov.infisicalbridge.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.databind.ObjectMapper;
@Configuration
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}

View File

@@ -0,0 +1,22 @@
package com.abnov.infisicalbridge.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/webhook/**").permitAll()
.anyRequest().authenticated());
return http.build();
}
}

View File

@@ -0,0 +1,14 @@
package com.abnov.infisicalbridge.dokploy;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.abnov.infisicalbridge.dto.DokployComposeUpdateRequest;
@FeignClient(name = "dokployClient", url = "${dokploy.api-url}", configuration = DokployFeignConfig.class)
public interface DokployClient {
@PostMapping("/compose.update")
void updateCompose(@RequestBody DokployComposeUpdateRequest request);
}

View File

@@ -0,0 +1,24 @@
package com.abnov.infisicalbridge.dokploy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import feign.RequestInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class DokployFeignConfig {
private final DokployProperties properties;
@Bean
public RequestInterceptor dokployRequestInterceptor() {
return requestTemplate -> {
// Add API key to every request
requestTemplate.header("x-api-key", properties.getApiKey());
};
}
}

View File

@@ -0,0 +1,21 @@
package com.abnov.infisicalbridge.dokploy;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
@Data
@Configuration
@ConfigurationProperties(prefix = "dokploy")
@Validated
public class DokployProperties {
@NotBlank(message = "Dokploy API URL is required")
private String apiUrl;
@NotBlank(message = "Dokploy API KEY is required")
private String apiKey;
}

View File

@@ -0,0 +1,6 @@
package com.abnov.infisicalbridge.dto;
public record DokployComposeUpdateRequest(
String composeId,
String env) {
}

View File

@@ -0,0 +1,7 @@
package com.abnov.infisicalbridge.dto;
public record InfisicalWebhookEventResponse(
String event,
ProjectResponse project,
long timestamp) {
}

View File

@@ -0,0 +1,10 @@
package com.abnov.infisicalbridge.dto;
public record ProjectResponse(
String workspaceId,
String projectId,
String projectName,
String environment,
String secretPath) {
}

View File

@@ -0,0 +1,27 @@
package com.abnov.infisicalbridge.infisical;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
@Data
@Configuration
@ConfigurationProperties(prefix = "infisical")
@Validated
public class InfisicalProperties {
@NotBlank(message = "Infisical API URL is required")
private String apiUrl;
@NotBlank(message = "Infisical client ID is required")
private String clientId;
@NotBlank(message = "Infisical client secret is required")
private String clientSecret;
@NotBlank(message = "Infisical webhook secret is required")
private String webhookSecret;
}

View File

@@ -0,0 +1,45 @@
package com.abnov.infisicalbridge.infisical;
import org.springframework.stereotype.Service;
import com.infisical.sdk.InfisicalSdk;
import com.infisical.sdk.config.SdkConfig;
import com.infisical.sdk.util.InfisicalException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class InfisicalService {
private final InfisicalProperties properties;
@Getter
private final InfisicalSdk sdk;
public InfisicalService(InfisicalProperties properties) {
this.properties = properties;
this.sdk = initializeClient();
}
private InfisicalSdk initializeClient() {
try {
log.info("Initializing Infisical SDK");
var sdkInstance = new InfisicalSdk(
new SdkConfig.Builder()
.withSiteUrl(properties.getApiUrl())
.build());
sdkInstance.Auth().UniversalAuthLogin(
properties.getClientId(),
properties.getClientSecret());
log.info("Successfully authenticated with Infisical");
return sdkInstance;
} catch (InfisicalException e) {
log.error("Failed to initialize Infisical SDK", e);
throw new IllegalStateException("Failed to initialize Infisical SDK", e);
}
}
}

View File

@@ -0,0 +1,151 @@
package com.abnov.infisicalbridge.infisical;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
/**
* Service to verify Infisical webhook signatures
*/
@Component
@Slf4j
public class InfisicalSignatureVerifier {
private static final String HMAC_SHA256 = "HmacSHA256";
private static final int DEFAULT_TOLERANCE_SECONDS = 300; // 5 minutes
/**
* Verifies the signature of an Infisical webhook request
*
* @param payload The raw request body as string
* @param signatureHeader The value of x-infisical-signature header
* @param secretKey Your secret key from Infisical
* @return true if signature is valid, false otherwise
*/
public boolean verifySignature(String payload, String signatureHeader, String secretKey) {
return verifySignature(payload, signatureHeader, secretKey, DEFAULT_TOLERANCE_SECONDS);
}
/**
* Verifies the signature with custom tolerance
*
* @param payload The raw request body as string
* @param signatureHeader The value of x-infisical-signature header (format:
* t=<timestamp>;<signature>)
* @param secretKey Your secret key from Infisical
* @param toleranceInSeconds Time tolerance for replay attacks
* @return true if signature is valid, false otherwise
*/
public boolean verifySignature(String payload, String signatureHeader, String secretKey, int toleranceInSeconds) {
try {
// Parse the signature header: t=<timestamp>;<signature>
String[] parts = signatureHeader.split(";");
if (parts.length != 2) {
log.error("Invalid signature header format");
return false;
}
String timestamp = parts[0].substring(2); // Remove "t="
String receivedSignature = parts[1];
// Check timestamp to prevent replay attacks
long webhookTime = Long.parseLong(timestamp);
long currentTime;
// Detect if timestamp is in milliseconds or seconds
if (timestamp.length() > 10) {
// Timestamp is in milliseconds
currentTime = Instant.now().toEpochMilli();
long toleranceInMillis = (long) toleranceInSeconds * 1000;
if (currentTime - webhookTime > toleranceInMillis) {
log.error("Webhook timestamp is too old");
return false;
}
} else {
// Timestamp is in seconds
currentTime = Instant.now().getEpochSecond();
if (currentTime - webhookTime > toleranceInSeconds) {
log.error("Webhook timestamp is too old");
return false;
}
}
// Try different signature formats that Infisical might use
// Format 1: timestamp.payload (most common)
String signedPayload1 = timestamp + "." + payload;
String expectedSignature1 = generateHmacSHA256(signedPayload1, secretKey);
if (MessageDigest.isEqual(
receivedSignature.getBytes(StandardCharsets.UTF_8),
expectedSignature1.getBytes(StandardCharsets.UTF_8))) {
return true;
}
// Format 2: payload only
String expectedSignature2 = generateHmacSHA256(payload, secretKey);
if (MessageDigest.isEqual(
receivedSignature.getBytes(StandardCharsets.UTF_8),
expectedSignature2.getBytes(StandardCharsets.UTF_8))) {
return true;
}
// Format 3: t=timestamp.payload (with t= prefix)
String signedPayload3 = signatureHeader.split(";")[0] + "." + payload;
String expectedSignature3 = generateHmacSHA256(signedPayload3, secretKey);
if (MessageDigest.isEqual(
receivedSignature.getBytes(StandardCharsets.UTF_8),
expectedSignature3.getBytes(StandardCharsets.UTF_8))) {
return true;
}
// No format matched
log.error("Signature verification failed - no matching format found");
return false;
} catch (Exception e) {
log.error("Error verifying signature: {}" + e.getMessage());
return false;
}
}
/**
* Generates HMAC SHA256 signature
*/
private String generateHmacSHA256(String data, String key)
throws NoSuchAlgorithmException, InvalidKeyException {
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec secretKeySpec = new SecretKeySpec(
key.getBytes(StandardCharsets.UTF_8),
HMAC_SHA256);
mac.init(secretKeySpec);
byte[] hmacBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hmacBytes);
}
/**
* Converts byte array to hex string
*/
private String bytesToHex(byte[] bytes) {
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}

View File

@@ -0,0 +1,93 @@
package com.abnov.infisicalbridge.infisical;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.abnov.infisicalbridge.dokploy.DokployClient;
import com.abnov.infisicalbridge.dto.DokployComposeUpdateRequest;
import com.abnov.infisicalbridge.dto.InfisicalWebhookEventResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.infisical.sdk.InfisicalSdk;
import com.infisical.sdk.models.Secret;
import com.infisical.sdk.util.InfisicalException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@RestController
@RequestMapping("/webhook")
@RequiredArgsConstructor
@Slf4j
public class InfisicalWebhookController {
private final InfisicalSignatureVerifier signatureVerifier;
private final InfisicalService service;
private final InfisicalProperties infisicalProperties;
private final ObjectMapper objectMapper;
private final DokployClient dokployClient;
@PostMapping
public ResponseEntity<Void> handleWebhook(
@RequestBody String payload,
@RequestParam String dokployComposeId,
@RequestHeader(value = "X-Infisical-Signature", required = false) String signature)
throws InfisicalException {
if (signature == null || signature.isEmpty()) {
log.warn("Missing X-Infisical-Signature header");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!signatureVerifier.verifySignature(payload, signature, infisicalProperties.getWebhookSecret())) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
InfisicalWebhookEventResponse webhookEvent;
try {
webhookEvent = objectMapper.readValue(payload, InfisicalWebhookEventResponse.class);
} catch (JsonProcessingException e) {
log.error("Invalid webhook payload", e);
return ResponseEntity.badRequest().build();
}
InfisicalSdk sdk = service.getSdk();
List<Secret> secrets = sdk.Secrets().ListSecrets(
webhookEvent.project().projectId(),
webhookEvent.project().environment(),
webhookEvent.project().secretPath(),
false,
false,
false);
if (secrets.isEmpty()) {
log.warn("No secrets found for project={} env={}",
webhookEvent.project().projectName(),
webhookEvent.project().environment());
return ResponseEntity.noContent().build();
}
String envContent = secrets.stream()
.map(s -> s.getSecretKey() + "=" + s.getSecretValue())
.collect(Collectors.joining("\n"));
try {
dokployClient.updateCompose(
new DokployComposeUpdateRequest(dokployComposeId, envContent));
} catch (Exception e) {
log.error("Failed to update Dokploy compose {}", dokployComposeId, e);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
return ResponseEntity.ok().build();
}
}

View File

@@ -1,3 +1,13 @@
spring:
application:
name: infisical-bridge
infisical:
api-url: ${INFISICAL_API_URL}
client-id: ${INFISICAL_CLIENT_ID}
client-secret: ${INFISICAL_CLIENT_SECRET}
webhook-secret: ${INFISICAL_WEBHOOK_SECRET}
dokploy:
api-url: ${DOKPLOY_API_URL}
api-key: ${DOKPLOY_API_KEY}