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.cftunnels.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. * *

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.

* *

Base URL: {@code /cloudflare}

* *

Authentication: OIDC-based with role-based access

* *

Available Roles:

* * *

Example Usage:

*
 * # 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
 * 
* * @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. * *

Returns the authenticated user's username and roles.

* * @param oidcUser The authenticated OIDC user * @return Map containing username and roles * @throws SecurityException if authentication fails */ @PreAuthorize("hasAnyRole('USER')") @GetMapping("/whoami") public Map whoAmI(@AuthenticationPrincipal OidcUser oidcUser) { List authorities = oidcUser.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .toList(); return Map.of( "username", oidcUser.getPreferredUsername(), "roles", authorities ); } /** * Get all tunnels from Cloudflare API. * *

Fetches the complete list of tunnels from Cloudflare, * including their status and configuration from the Cloudflare API.

* * @return Map containing list of all tunnels * @throws SecurityException if user lacks required role * @see Cloudflare API */ @PreAuthorize("hasAnyRole('USER')") @GetMapping("/tunnels") @Operation( security = { @SecurityRequirement(name = "oidcAuth") } ) public ResponseEntity> getTunnels(){ ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnels(); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); return ResponseEntity.ok(jsonResponse); } /** * Get locally configured tunnels. * *

Returns the tunnels that have been configured locally * with environment associations.

* * @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> getConfiguredTunnels(){ try { List tunnels = cloudflareAPIService.getAllConfiguredTunnels(); Map 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. * *

Returns all pending, approved, and rejected mapping requests.

* * @return Map containing list of all requests * @throws SecurityException if user lacks required role */ @PreAuthorize("hasAnyRole('USER')") @GetMapping("/requests") public ResponseEntity> getAllRequests() { try { List requests = mappingRequestService.getAllRequests(); Map 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. * *

Fetches the complete configuration for a specific tunnel, * including all ingress rules.

* * @param tunnelId The Cloudflare tunnel ID (UUID) * @return Map containing tunnel configuration * @throws SecurityException if user lacks required role * @see Cloudflare API */ @PreAuthorize("hasAnyRole('DEVELOPER')") @GetMapping("/tunnels/{tunnelId}/mappings") public ResponseEntity> getTunnelConfigurations(@PathVariable String tunnelId) { ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplate, Map.class); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); return ResponseEntity.ok(jsonResponse); } /** * Add an ingress mapping to a tunnel. * *

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.

* * @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> addTunnelconfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { ResponseEntity 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 response_ingress = config.getIngress(); response_ingress.add(response_ingress.size()-1, ingress); // Hitting put endpoint ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // Displaying response Map 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. * *

Removes an ingress rule by hostname from the tunnel configuration.

* * @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> deleteTunnelConfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); // Deleting the selected ingress value Config config = responseEntity.getBody().getResult().getConfig(); List response_ingress = config.getIngress(); Boolean result = Ingress.deleteByHostName(response_ingress, ingress.getHostname()); // Hitting put endpoint ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // Displaying response Map 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. * *

Creates a new request for changing tunnel ingress mappings. * The request starts in PENDING status and must be approved * before the changes are applied.

* * @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 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. * *

Approves a pending mapping request. If approved, the * mapping will be applied to the Cloudflare tunnel.

* * @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 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. * *

Rejects a pending mapping request. No changes * will be made to the tunnel.

* * @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 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. * *

Creates a local configuration entry for a tunnel, * associating it with the current environment (from spring.profiles.active).

* *

Response Codes:

*
    *
  • 200 - Created/updated with new tunnel
  • *
  • 204 - No changes needed
  • *
  • 404 - Tunnel not found in Cloudflare
  • *
* * @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 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(); } } }