Compare commits

...

25 Commits

Author SHA1 Message Date
1b1c6e54b1 Merge pull request 'update doc' (#9) from dev into main
Reviewed-on: #9
2025-12-25 07:56:44 +00:00
c4568a2f23 update doc 2025-12-25 07:56:03 +00:00
6000582d55 Merge pull request 'adding support for dokploy application' (#8) from dev into main
Reviewed-on: #8
2025-12-25 07:40:07 +00:00
0a2937ad55 adding support for dokploy application 2025-12-25 07:39:17 +00:00
22a244832e Merge pull request 'update doc' (#7) from dev into main
Reviewed-on: #7
2025-12-24 16:51:13 +00:00
ef2b9667fc update doc 2025-12-24 16:50:46 +00:00
c98900a9ff Merge pull request 'update doc' (#6) from dev into main
Reviewed-on: #6
2025-12-24 16:49:21 +00:00
1684099f74 update doc 2025-12-24 16:48:43 +00:00
32cb5b57d7 Merge pull request 'update README' (#5) from dev into main
Reviewed-on: #5
2025-12-24 16:45:40 +00:00
a65b2dbeb0 update README 2025-12-24 16:44:54 +00:00
6037788dbd Merge pull request 'add README.md' (#4) from dev into main
Reviewed-on: #4
2025-12-24 16:40:33 +00:00
aef24c809b add README.md 2025-12-24 16:39:16 +00:00
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
16 changed files with 402 additions and 58 deletions

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"]

143
README.md Normal file
View File

@@ -0,0 +1,143 @@
# Infisical ↔ Dokploy Bridge
A Spring Boot (Java 21) application acting as a secure bridge between Infisical and Dokploy, enabling automated synchronization and deployment of secrets through APIs and webhooks.
## Features
- Secure integration with Infisical
- Automated updates via Dokploy API
- Webhook-driven synchronization
- Docker and Docker Compose ready
## Architecture Overview
Infisical
↓ (Webhook / API)
InfisicalDokploy Bridge (Spring Boot)
↓ (Dokploy API)
Dokploy
## Requirements
- Java 21
- Docker and Docker Compose
- Infisical account
- Dokploy instance with API access
## Environment Variables
### Infisical
- INFISICAL_API_URL: Base URL of Infisical API
- INFISICAL_CLIENT_ID: Infisical service client ID
- INFISICAL_CLIENT_SECRET: Infisical service client secret
- INFISICAL_WEBHOOK_SECRET: Webhook signature validation secret
### Dokploy
- DOKPLOY_API_URL: Base URL of Dokploy API
- DOKPLOY_API_KEY: Dokploy API key
## Docker Compose
```txt
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}
```
## Running
With Docker Compose:
```sh
docker compose up -d --build
```
Local development:
```sh
./gradlew bootRun
```
Application runs on http://localhost:8080
Use a service like ngrok.
## Infisical Webhook Configuration
When creating a webhook in Infisical, the following rules must be respected.
### Webhook URL Formats
Infisical bridge supports two webhook URL formats, depending on the Dokploy resource you want to update.
#### Dokploy Compose Webhook
`${INFISICAL_API_URL}/webhook?dokployComposeId=${DOKPLOY_COMPOSE_ID}`
Parameters:
- dokployComposeId (required):
The identifier of the target Dokploy Compose.
This value is used to determine which Dokploy compose service should be updated when the webhook is triggered.
#### Dokploy Application Webhook
`${INFISICAL_API_URL}/webhook?dokployApplicationId=${DOKPLOY_APPLICATION_ID}`
Parameters:
- dokployApplicationId (required):
The identifier of the target Dokploy Application.
This value is used to determine which Dokploy application should be updated when the webhook is triggered.
#### Notes
- Exactly one identifier must be provided per webhook URL.
- If no identifier or multiple identifiers are provided, the webhook request will be rejected.
- Ensure the provided ID matches an existing Dokploy resource.
### Webhook Secret
The webhook secret **must exactly match**:
`${INFISICAL_WEBHOOK_SECRET}`
Requests with an invalid or missing secret will be rejected.
## Webhooks Behavior
- Incoming webhook signatures are validated
- Secrets are fetched from Infisical
- Dokploy is updated using its API
- Invalid or unsigned requests are ignored
## Security Notes
- Secrets are never persisted
- Configuration is environment-driven
- HTTPS is recommended in production
- Restrict network access to trusted sources only
## Testing
```sh
./gradlew test
```
## Tech Stack
- Java 21
- Spring Boot
- Gradle (Kotlin DSL)
- Docker / Docker Compose
## License
MIT License

View File

@@ -25,51 +25,22 @@ 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
@@ -78,7 +49,6 @@ dependencies {
dependencyManagement {
imports {
mavenBom("org.springframework.modulith:spring-modulith-bom:${property("springModulithVersion")}")
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}

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,18 @@
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.DokployApplicationUpdateRequest;
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);
@PostMapping("/application.update")
void updateApplication(@RequestBody DokployApplicationUpdateRequest 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 DokployApplicationUpdateRequest(
String applicationId,
String env) {
}

View File

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

View File

@@ -21,4 +21,7 @@ public class InfisicalProperties {
@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

@@ -1,11 +1,27 @@
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.DokployApplicationUpdateRequest;
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;
@@ -15,23 +31,77 @@ import lombok.extern.slf4j.Slf4j;
@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 void handleWebhook(
public ResponseEntity<Void> handleWebhook(
@RequestBody String payload,
@RequestHeader(value = "X-Infisical-Signature", required = false) String signature) {
// Check if signature header is present
@RequestParam(required = false) String dokployComposeId,
@RequestParam(required = false) String dokployApplicationId,
@RequestHeader(value = "X-Infisical-Signature", required = false) String signature)
throws InfisicalException {
if (signature == null || signature.isEmpty()) {
log.error("Missing signature header");
return;
log.warn("Missing X-Infisical-Signature header");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Verify the signature
if (!signatureVerifier.verifySignature(payload, signature, "demoa")) {
log.error("Invalid signature");
return;
if (!signatureVerifier.verifySignature(payload, signature, infisicalProperties.getWebhookSecret())) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
log.info("Webhook received and verified: {}", payload);
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"));
if (dokployComposeId != null) {
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();
}
}
if (dokployApplicationId != null) {
try {
dokployClient.updateApplication(
new DokployApplicationUpdateRequest(dokployApplicationId, envContent));
} catch (Exception e) {
log.error("Failed to update Dokploy application {}", dokployApplicationId, e);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
}
return ResponseEntity.ok().build();
}
}

View File

@@ -1,15 +0,0 @@
spring:
liquibase:
enabled: false
sql:
init:
mode: always
datasource:
initialization-mode: always
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop

View File

@@ -6,3 +6,8 @@ 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}