422 lines
17 KiB
Java
422 lines
17 KiB
Java
package com.hithomelabs.cftunnels.Controllers;
|
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.hithomelabs.cftunnels.Config.AuthoritiesToGroupMapping;
|
|
import com.hithomelabs.cftunnels.Config.CloudflareConfig;
|
|
import com.hithomelabs.common.config.RestTemplateConfig;
|
|
import com.hithomelabs.cftunnels.Entity.Request;
|
|
import com.hithomelabs.cftunnels.Entity.Tunnel;
|
|
import com.hithomelabs.cftunnels.Entity.User;
|
|
import com.hithomelabs.cftunnels.Headers.AuthKeyEmailHeader;
|
|
import com.hithomelabs.cftunnels.Models.Config;
|
|
import com.hithomelabs.cftunnels.Models.Ingress;
|
|
import com.hithomelabs.cftunnels.Models.TunnelResponse;
|
|
import com.hithomelabs.cftunnels.Models.TunnelsResponse;
|
|
import com.hithomelabs.cftunnels.Repositories.UserRepository;
|
|
import com.hithomelabs.cftunnels.Services.CloudflareAPIService;
|
|
import com.hithomelabs.cftunnels.Services.MappingRequestService;
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.boot.web.servlet.error.ErrorController;
|
|
import org.springframework.dao.DataAccessException;
|
|
import org.springframework.http.*;
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
import org.springframework.security.core.GrantedAuthority;
|
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
|
import org.springframework.web.bind.annotation.*;
|
|
import org.springframework.web.client.RestTemplate;
|
|
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.NoSuchElementException;
|
|
import java.util.UUID;
|
|
|
|
/**
|
|
* REST Controller for managing Cloudflare Tunnels.
|
|
*
|
|
* <p>This controller provides the public API for managing Cloudflare Tunnels
|
|
* and their ingress mappings. All endpoints require authentication via OIDC
|
|
* and are protected by role-based access control.</p>
|
|
*
|
|
* <p><b>Base URL:</b> {@code /cloudflare}</p>
|
|
*
|
|
* <p><b>Authentication:</b> OIDC-based with role-based access</p>
|
|
*
|
|
* <p><b>Available Roles:</b></p>
|
|
* <ul>
|
|
* <li>USER - View tunnels and requests</li>
|
|
* <li>DEVELOPER - Create/modify/delete mappings</li>
|
|
* <li>APPROVER - Approve/reject requests</li>
|
|
* <li>ADMIN - Full tunnel configuration access</li>
|
|
* </ul>
|
|
*
|
|
* <p><b>Example Usage:</b></p>
|
|
* <pre>
|
|
* # Get all tunnels (requires USER role)
|
|
* curl -H "Authorization: Bearer <token>" \
|
|
* https://api.example.com/cloudflare/tunnels
|
|
*
|
|
* # Add a mapping (requires ADMIN role)
|
|
* curl -X POST -H "Authorization: Bearer <token>" \
|
|
* -H "Content-Type: application/json" \
|
|
* -d '{"hostname":"api.example.com","service":"http://localhost:8080"}' \
|
|
* https://api.example.com/cloudflare/tunnels/{tunnelId}/mappings
|
|
* </pre>
|
|
*
|
|
* @see CloudflareAPIService
|
|
* @see MappingRequestService
|
|
*/
|
|
@RestController
|
|
@RequestMapping("/cloudflare")
|
|
public class TunnelController implements ErrorController {
|
|
|
|
private final RestTemplate restTemplate = new RestTemplate();
|
|
private static final String ERROR_PATH = "/error";
|
|
|
|
@Autowired
|
|
private AuthoritiesToGroupMapping authoritiesToGroupMapping;
|
|
@Autowired
|
|
private CloudflareConfig cloudflareConfig;
|
|
|
|
@Autowired
|
|
private AuthKeyEmailHeader authKeyEmailHeader;
|
|
|
|
@Autowired
|
|
private RestTemplateConfig restTemplateConfig;
|
|
|
|
@Autowired
|
|
CloudflareAPIService cloudflareAPIService;
|
|
|
|
@Autowired
|
|
MappingRequestService mappingRequestService;
|
|
|
|
@Autowired
|
|
private UserRepository userRepository;
|
|
|
|
/**
|
|
* Current environment (loaded from spring.profiles.active).
|
|
*/
|
|
@Value("${spring.profiles.active}")
|
|
private String environment;
|
|
|
|
/**
|
|
* Get current user information.
|
|
*
|
|
* <p>Returns the authenticated user's username and roles.</p>
|
|
*
|
|
* @param oidcUser The authenticated OIDC user
|
|
* @return Map containing username and roles
|
|
* @throws SecurityException if authentication fails
|
|
*/
|
|
@PreAuthorize("hasAnyRole('USER')")
|
|
@GetMapping("/whoami")
|
|
public Map<String,Object> whoAmI(@AuthenticationPrincipal OidcUser oidcUser) {
|
|
|
|
List<String> authorities = oidcUser.getAuthorities().stream()
|
|
.map(GrantedAuthority::getAuthority)
|
|
.toList();
|
|
return Map.of(
|
|
"username", oidcUser.getPreferredUsername(),
|
|
"roles", authorities
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all tunnels from Cloudflare API.
|
|
*
|
|
* <p>Fetches the complete list of tunnels from Cloudflare,
|
|
* including their status and configuration from the Cloudflare API.</p>
|
|
*
|
|
* @return Map containing list of all tunnels
|
|
* @throws SecurityException if user lacks required role
|
|
* @see <a href="https://api.cloudflare.com/#cfd_tunnel-get-tunnels">Cloudflare API</a>
|
|
*/
|
|
@PreAuthorize("hasAnyRole('USER')")
|
|
@GetMapping("/tunnels")
|
|
@Operation( security = { @SecurityRequirement(name = "oidcAuth") } )
|
|
public ResponseEntity<Map<String,Object>> getTunnels(){
|
|
|
|
ResponseEntity<TunnelsResponse> responseEntity = cloudflareAPIService.getCloudflareTunnels();
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
jsonResponse.put("status", "success");
|
|
jsonResponse.put("data", responseEntity.getBody());
|
|
|
|
return ResponseEntity.ok(jsonResponse);
|
|
}
|
|
|
|
/**
|
|
* Get locally configured tunnels.
|
|
*
|
|
* <p>Returns the tunnels that have been configured locally
|
|
* with environment associations.</p>
|
|
*
|
|
* @return Map containing list of configured tunnels
|
|
* @throws SecurityException if user lacks required role
|
|
* @see CloudflareAPIService#getAllConfiguredTunnels()
|
|
*/
|
|
@PreAuthorize("hasAnyRole('USER')")
|
|
@GetMapping("/configured/tunnels")
|
|
public ResponseEntity<Map<String,Object>> getConfiguredTunnels(){
|
|
try {
|
|
List<Tunnel> tunnels = cloudflareAPIService.getAllConfiguredTunnels();
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
jsonResponse.put("status", "success");
|
|
jsonResponse.put("data", tunnels);
|
|
return ResponseEntity.ok(jsonResponse);
|
|
} catch (DataAccessException e) {
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all mapping requests.
|
|
*
|
|
* <p>Returns all pending, approved, and rejected mapping requests.</p>
|
|
*
|
|
* @return Map containing list of all requests
|
|
* @throws SecurityException if user lacks required role
|
|
*/
|
|
@PreAuthorize("hasAnyRole('USER')")
|
|
@GetMapping("/requests")
|
|
public ResponseEntity<Map<String,Object>> getAllRequests() {
|
|
try {
|
|
List<Request> requests = mappingRequestService.getAllRequests();
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
jsonResponse.put("status", "success");
|
|
jsonResponse.put("data", requests);
|
|
return ResponseEntity.ok(jsonResponse);
|
|
} catch (DataAccessException e) {
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get tunnel configuration from Cloudflare.
|
|
*
|
|
* <p>Fetches the complete configuration for a specific tunnel,
|
|
* including all ingress rules.</p>
|
|
*
|
|
* @param tunnelId The Cloudflare tunnel ID (UUID)
|
|
* @return Map containing tunnel configuration
|
|
* @throws SecurityException if user lacks required role
|
|
* @see <a href="https://api.cloudflare.com/#cfd_tunnel-get-tunnel-config">Cloudflare API</a>
|
|
*/
|
|
@PreAuthorize("hasAnyRole('DEVELOPER')")
|
|
@GetMapping("/tunnels/{tunnelId}/mappings")
|
|
public ResponseEntity<Map<String,Object>> getTunnelConfigurations(@PathVariable String tunnelId) {
|
|
|
|
ResponseEntity<Map> responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplate, Map.class);
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
jsonResponse.put("status", "success");
|
|
jsonResponse.put("data", responseEntity.getBody());
|
|
|
|
return ResponseEntity.ok(jsonResponse);
|
|
}
|
|
|
|
/**
|
|
* Add an ingress mapping to a tunnel.
|
|
*
|
|
* <p>Adds a new ingress rule to the tunnel configuration.
|
|
* The new rule is inserted at the second-to-last position,
|
|
* before any catch-all rule.</p>
|
|
*
|
|
* @param tunnelId The Cloudflare tunnel ID (UUID)
|
|
* @param ingress The ingress rule to add
|
|
* @return Map containing the updated configuration
|
|
* @throws SecurityException if user lacks required role
|
|
* @throws JsonProcessingException if JSON processing fails
|
|
*
|
|
* @example
|
|
* {
|
|
* "hostname": "api.example.com",
|
|
* "service": "http://localhost:8080",
|
|
* "originRequest": {"noTLSVerify": true}
|
|
* }
|
|
*/
|
|
@PreAuthorize("hasAnyRole('ADMIN')")
|
|
@PostMapping("/tunnels/{tunnelId}/mappings")
|
|
public ResponseEntity<Map<String, Object>> addTunnelconfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException {
|
|
|
|
ResponseEntity<TunnelResponse> responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class);
|
|
|
|
// Inserting new ingress value at second-to last position in list
|
|
Config config = responseEntity.getBody().getResult().getConfig();
|
|
List<Ingress> response_ingress = config.getIngress();
|
|
response_ingress.add(response_ingress.size()-1, ingress);
|
|
|
|
// Hitting put endpoint
|
|
ResponseEntity<TunnelResponse> response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config);
|
|
|
|
// Displaying response
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
jsonResponse.put("status", response.getStatusCode().toString());
|
|
jsonResponse.put("data", response.getBody());
|
|
|
|
return ResponseEntity.ok(jsonResponse);
|
|
}
|
|
|
|
/**
|
|
* Delete an ingress mapping from a tunnel.
|
|
*
|
|
* <p>Removes an ingress rule by hostname from the tunnel configuration.</p>
|
|
*
|
|
* @param tunnelId The Cloudflare tunnel ID (UUID)
|
|
* @param ingress Ingress containing hostname to delete (only hostname field is used)
|
|
* @return Map containing the result
|
|
* @throws SecurityException if user lacks required role
|
|
* @throws JsonProcessingException if JSON processing fails
|
|
*/
|
|
@PreAuthorize("hasAnyRole('DEVELOPER')")
|
|
@DeleteMapping("/tunnels/{tunnelId}/mappings")
|
|
public ResponseEntity<Map<String, Object>> deleteTunnelConfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException {
|
|
|
|
ResponseEntity<TunnelResponse> responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class);
|
|
|
|
// Deleting the selected ingress value
|
|
Config config = responseEntity.getBody().getResult().getConfig();
|
|
List<Ingress> response_ingress = config.getIngress();
|
|
Boolean result = Ingress.deleteByHostName(response_ingress, ingress.getHostname());
|
|
|
|
// Hitting put endpoint
|
|
ResponseEntity<TunnelResponse> response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config);
|
|
|
|
// Displaying response
|
|
Map<String, Object> jsonResponse = new HashMap<>();
|
|
|
|
if (result){
|
|
jsonResponse.put("status", response.getStatusCode().toString());
|
|
jsonResponse.put("data", response.getBody());
|
|
}
|
|
else{
|
|
jsonResponse.put("status", HttpStatus.CONFLICT);
|
|
jsonResponse.put("data", "Conflict: the resource to delete, does not exist");
|
|
}
|
|
|
|
return ResponseEntity.ok(jsonResponse);
|
|
}
|
|
|
|
/**
|
|
* Create a mapping change request.
|
|
*
|
|
* <p>Creates a new request for changing tunnel ingress mappings.
|
|
* The request starts in PENDING status and must be approved
|
|
* before the changes are applied.</p>
|
|
*
|
|
* @param tunnelId The Cloudflare tunnel ID (UUID)
|
|
* @param oidcUser The authenticated user
|
|
* @param ingess The ingress configuration to request
|
|
* @return The created request with PENDING status
|
|
* @throws SecurityException if user lacks required role
|
|
* @see MappingRequestService#createMappingRequest(String, Ingress, OidcUser)
|
|
*/
|
|
@PreAuthorize("hasAnyRole('DEVELOPER')")
|
|
@PostMapping("/tunnels/configure/{tunnelId}/requests")
|
|
public ResponseEntity<Request> createTunnelMappingRequest(@PathVariable String tunnelId, @AuthenticationPrincipal OidcUser oidcUser, @RequestBody Ingress ingess){
|
|
Request request = mappingRequestService.createMappingRequest(tunnelId, ingess, oidcUser);
|
|
if(request.getId() != null)
|
|
return ResponseEntity.status(HttpStatus.CREATED).body(request);
|
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
|
}
|
|
|
|
/**
|
|
* Approve a mapping request.
|
|
*
|
|
* <p>Approves a pending mapping request. If approved, the
|
|
* mapping will be applied to the Cloudflare tunnel.</p>
|
|
*
|
|
* @param requestId The ID of the request to approve
|
|
* @param oidcUser The approver (must have APPROVER role)
|
|
* @return The updated request with APPROVED status
|
|
* @throws SecurityException if user lacks required role
|
|
*/
|
|
@PreAuthorize("hasAnyRole('APPROVER')")
|
|
@PutMapping("/requests/{requestId}/approve")
|
|
public ResponseEntity<Request> approveMappingRequest(@PathVariable UUID requestId, @AuthenticationPrincipal OidcUser oidcUser) {
|
|
try {
|
|
User approver = userRepository.findByEmail(oidcUser.getEmail())
|
|
.orElseThrow(() -> new RuntimeException("Approver not found"));
|
|
Request request = mappingRequestService.approveRequest(requestId, approver);
|
|
return ResponseEntity.ok(request);
|
|
} catch (NoSuchElementException e) {
|
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
|
} catch (IllegalStateException e) {
|
|
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
|
} catch (RuntimeException e) {
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reject a mapping request.
|
|
*
|
|
* <p>Rejects a pending mapping request. No changes
|
|
* will be made to the tunnel.</p>
|
|
*
|
|
* @param requestId The ID of the request to reject
|
|
* @param oidcUser The rejecter (must have APPROVER role)
|
|
* @return The updated request with REJECTED status
|
|
* @throws SecurityException if user lacks required role
|
|
*/
|
|
@PreAuthorize("hasAnyRole('APPROVER')")
|
|
@PutMapping("/requests/{requestId}/reject")
|
|
public ResponseEntity<Request> rejectMappingRequest(@PathVariable UUID requestId, @AuthenticationPrincipal OidcUser oidcUser) {
|
|
try {
|
|
User rejecter = userRepository.findByEmail(oidcUser.getEmail())
|
|
.orElseThrow(() -> new RuntimeException("Rejecter not found"));
|
|
Request request = mappingRequestService.rejectRequest(requestId, rejecter);
|
|
return ResponseEntity.ok(request);
|
|
} catch (NoSuchElementException e) {
|
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
|
} catch (IllegalStateException e) {
|
|
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
|
} catch (RuntimeException e) {
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure a tunnel for the current environment.
|
|
*
|
|
* <p>Creates a local configuration entry for a tunnel,
|
|
* associating it with the current environment (from spring.profiles.active).</p>
|
|
*
|
|
* <p><b>Response Codes:</b></p>
|
|
* <ul>
|
|
* <li>200 - Created/updated with new tunnel</li>
|
|
* <li>204 - No changes needed</li>
|
|
* <li>404 - Tunnel not found in Cloudflare</li>
|
|
* </ul>
|
|
*
|
|
* @param tunnelId The Cloudflare tunnel ID (UUID)
|
|
* @param user The authenticated user
|
|
* @return The tunnel configuration
|
|
* @throws SecurityException if user lacks required role
|
|
*/
|
|
@PreAuthorize("hasAnyRole('ADMIN')")
|
|
@PutMapping("/tunnels/configure/{tunnelId}")
|
|
public ResponseEntity<Tunnel> configureTunnelForEnvironment(@PathVariable String tunnelId, @AuthenticationPrincipal OidcUser user) {
|
|
/*
|
|
* Returns 200 if an object is created or updated with a new representation of the object
|
|
* Returns 204 if the object state did not need any changing.
|
|
* Returns 404 if the tunnelId is not valid
|
|
*/
|
|
|
|
try {
|
|
Tunnel tunnel = cloudflareAPIService.createOrUpdateTunnel(tunnelId, environment);
|
|
if (tunnel == null)
|
|
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
|
|
else
|
|
return ResponseEntity.ok(tunnel);
|
|
} catch (NoSuchElementException e) {
|
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
|
} catch (RuntimeException e) {
|
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
|
}
|
|
}
|
|
|
|
}
|