diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08c0845 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +CLOUDFLARE_ACCOUNT_ID="" +CLOUDFLARE_API_KEY="" +CLOUDFLARE_EMAIL="" +ENV="" \ No newline at end of file diff --git a/.gitea/workflows/prod_image_tag_promote.yaml b/.gitea/workflows/prod_image_tag_promote.yaml new file mode 100644 index 0000000..a2a2a16 --- /dev/null +++ b/.gitea/workflows/prod_image_tag_promote.yaml @@ -0,0 +1,57 @@ +name: Promote image with tag test to prod +run-name: Build started by $ {{gitea.actor}} +on: + push: + branches: [main] +jobs: + tag: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.new_version.outputs.new_version }} + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Get new version + id: new_version + run: | + VERSION=$(git describe --tags --abbrev=0) + echo ${VERSION} + MAJOR=$(echo ${VERSION} | cut -d "." -f 1) + MINOR=$(echo ${VERSION} | cut -d "." -f 2) + PATCH=0 + NEW_MINOR=$(( ${MINOR} + 1)) + echo ${NEW_MINOR} + echo "new_version=$(echo "${MAJOR}.${NEW_MINOR}.${PATCH}")" >> $GITHUB_OUTPUT + build_tag_push: + runs-on: ubuntu-latest + needs: tag + container: + image: catthehacker/ubuntu:act-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create and push tag + run: | + echo "NEW_VERSION=${{ needs.tag.outputs.new_version }}" + git config --global user.name "${{gitea.actor}}" + git config --global user.email "${{ gitea.actor }}@users.noreply.github.com" + git tag -a ${{ needs.tag.outputs.new_version }} -m "Pushing new version ${{ needs.tag.outputs.new_version }}" + git push origin ${{ needs.tag.outputs.new_version }} + - name: Log in to Gitea Docker Registry + uses: docker/login-action@v3 + with: + registry: 'http://192.168.0.100:8928' + username: hitanshu + password: ${{ secrets.TOKEN }} + - name: Tag prod image + run: | + docker tag 192.168.0.100:8928/hithomelabs/cftunnels:test 192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} + docker tag 192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} 192.168.0.100:8928/hithomelabs/cftunnels:prod + - name: Push to Gitea Registry + run: | + docker push 192.168.0.100:8928/hithomelabs/cftunnels:prod + docker push 192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} diff --git a/.gitea/workflows/test_build.yml b/.gitea/workflows/test_build.yml new file mode 100644 index 0000000..ae070cd --- /dev/null +++ b/.gitea/workflows/test_build.yml @@ -0,0 +1,20 @@ +name: sample gradle build and test +run-name: Build started by $ {{gitea.actor}} +on: + pull_request: + branches: [test] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: JDK setup + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v3 + - name: Gradle build + run: ./gradlew build --info \ No newline at end of file diff --git a/.gitea/workflows/test_image_build_push.yml b/.gitea/workflows/test_image_build_push.yml index f5805cb..f53e140 100644 --- a/.gitea/workflows/test_image_build_push.yml +++ b/.gitea/workflows/test_image_build_push.yml @@ -3,16 +3,37 @@ run-name: Build started by $ {{gitea.actor}} on: push: branches: [test] - pull_request: - branches: [test] jobs: - build: + tag: runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.new_version.outputs.new_version }} + steps: + - name: Check out repository code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Get new version + id: new_version + run: | + VERSION=$(git describe --tags --abbrev=0) + echo ${VERSION} + MAJOR=$(echo ${VERSION} | cut -d "." -f 1) + MINOR=$(echo ${VERSION} | cut -d "." -f 2) + PATCH=$(echo ${VERSION} | cut -d "." -f 3) + NEW_PATCH=$(( ${PATCH} + 1)) + echo ${NEW_PATCH} + echo "new_version=$(echo "${MAJOR}.${MINOR}.${NEW_PATCH}")" >> $GITHUB_OUTPUT + build_tag_push: + runs-on: ubuntu-latest + needs: tag container: image: catthehacker/ubuntu:act-latest steps: - name: Check out repository code uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: JDK setup uses: actions/setup-java@v4 with: @@ -20,8 +41,13 @@ jobs: java-version: '17' - name: Validate Gradle Wrapper uses: gradle/actions/wrapper-validation@v3 - - name: Check secrets - run: echo "my secret is ${{ secrets.TOKEN }}" + - name: Create and push tag + run: | + echo "NEW_VERSION=${{ needs.tag.outputs.new_version }}" + git config --global user.name "${{gitea.actor}}" + git config --global user.email "${{ gitea.actor }}@users.noreply.github.com" + git tag -a ${{ needs.tag.outputs.new_version }} -m "Pushing new version ${{ needs.tag.outputs.new_version }}" + git push origin ${{ needs.tag.outputs.new_version }} - name: Log in to Gitea Docker Registry uses: docker/login-action@v3 with: @@ -29,6 +55,10 @@ jobs: username: hitanshu password: ${{ secrets.TOKEN }} - name: Gradle build - run: ./gradlew bootBuildImage --imageName=192.168.0.100:8928/hithomelabs/cftunnels:latest + run: ./gradlew bootBuildImage --imageName=192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} + - name: Tag image as test + run: docker tag 192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} 192.168.0.100:8928/hithomelabs/cftunnels:test - name: Push to Gitea Registry - run: docker push 192.168.0.100:8928/hithomelabs/cftunnels:latest + run: | + docker push 192.168.0.100:8928/hithomelabs/cftunnels:test + docker push 192.168.0.100:8928/hithomelabs/cftunnels:${{ needs.tag.outputs.new_version }} diff --git a/.gitignore b/.gitignore index c2065bc..c47508c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ HELP.md .gradle +.run build/ +.env* !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ diff --git a/CFTunnels/Get tunnels.bru b/CFTunnels/Get tunnels.bru index b3cb95a..231ee08 100644 --- a/CFTunnels/Get tunnels.bru +++ b/CFTunnels/Get tunnels.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{base_url}}cloudflare/tunnels + url: {{base_url}}/cloudflare/tunnels body: none auth: none } diff --git a/CFTunnels/Tunnel.bru b/CFTunnels/Tunnel.bru index d19374e..826b583 100644 --- a/CFTunnels/Tunnel.bru +++ b/CFTunnels/Tunnel.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{base_url}}cloudflare/tunnel/{{tunnel_id}} + url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}} body: none auth: none } diff --git a/CFTunnels/Write ingress.bru b/CFTunnels/Write ingress.bru index 61f4ebc..9930a0b 100644 --- a/CFTunnels/Write ingress.bru +++ b/CFTunnels/Write ingress.bru @@ -5,7 +5,7 @@ meta { } put { - url: {{base_url}}cloudflare/tunnel/{{tunnel_id}}/add + url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}}/add body: json auth: none } diff --git a/CFTunnels/delete mapping.bru b/CFTunnels/delete mapping.bru index 857a43e..c9e3dbb 100644 --- a/CFTunnels/delete mapping.bru +++ b/CFTunnels/delete mapping.bru @@ -5,7 +5,7 @@ meta { } put { - url: {{base_url}}cloudflare/tunnel/{{tunnel_id}}/delete + url: {{base_url}}/cloudflare/tunnel/{{tunnel_id}}/delete body: json auth: none } diff --git a/CFTunnels/environments/CFTunnels Local.bru b/CFTunnels/environments/CFTunnels Local.bru index 9e83a22..6d2b6b0 100644 --- a/CFTunnels/environments/CFTunnels Local.bru +++ b/CFTunnels/environments/CFTunnels Local.bru @@ -1,4 +1,4 @@ vars { tunnel_id: 50df9101-f625-4618-b7c5-100338a57124 - base_url: http://localhost:8080/ + base_url: http://localhost:8080 } diff --git a/CFTunnels/environments/CFTunnels.bru b/CFTunnels/environments/CFTunnels.bru index 1c61ef6..d8219ab 100644 --- a/CFTunnels/environments/CFTunnels.bru +++ b/CFTunnels/environments/CFTunnels.bru @@ -1,4 +1,4 @@ vars { tunnel_id: 50df9101-f625-4618-b7c5-100338a57124 - base_url: https://testcf.hithomelabs.com/ + base_url: https://testcf.hithomelabs.com } diff --git a/build.gradle b/build.gradle index 663b328..4328386 100644 --- a/build.gradle +++ b/build.gradle @@ -13,15 +13,26 @@ java { } } +test { + systemProperty 'spring.profiles.active', 'test' +} + repositories { mavenCentral() } dependencies { - implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.0.3' + implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.5' + implementation group: 'org.springframework.boot', name:'spring-boot-starter-oauth2-client', version: '3.5.5' + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + runtimeOnly 'org.postgresql:postgresql' + implementation 'org.hibernate.validator:hibernate-validator' } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..9eb1198 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,31 @@ +services: + app: + image: gitea.hithomelabs.com/hithomelabs/cftunnels:${ENV} + container_name: cftunnels_${ENV} + ports: + - ${HOST_PORT}:8080 + environment: + - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID} + - CLOUDFLARE_API_KEY=${CLOUDFLARE_API_KEY} + - CLOUDFLARE_EMAIL=${CLOUDFLARE_EMAIL} + - ENV=${ENV} + - OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID} + - OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET} + - HOST_PORT=${HOST_PORT} + - POSTGRES_USER=${POSTGRES_USERNAME} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + env_file: + - stack.env + restart: unless-stopped + postgres: + image: postgres:15-alpine + container_name: cftunnel-db-${ENV} + environment: + POSTGRES_DB: cftunnel + POSTGRES_USER: ${POSTGRES_USERNAME} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + restart: unless-stopped + ports: + - "${DB_PORT}:5432" + volumes: + - ${DB_PATH}:/var/lib/postgresql/data \ No newline at end of file diff --git a/src/main/java/com/hithomelabs/CFTunnels/Config/AuthoritiesToGroupMapping.java b/src/main/java/com/hithomelabs/CFTunnels/Config/AuthoritiesToGroupMapping.java new file mode 100644 index 0000000..b323700 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Config/AuthoritiesToGroupMapping.java @@ -0,0 +1,28 @@ +package com.hithomelabs.CFTunnels.Config; + +import com.hithomelabs.CFTunnels.Models.Authorities; +import com.hithomelabs.CFTunnels.Models.Groups; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Configuration +public class AuthoritiesToGroupMapping { + + public Map> getAuthorityForGroup(){ + HashMap> mappings = new HashMap<>(); + mappings.put(Groups.GITEA_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER)))); + mappings.put(Groups.POWER_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER)))); + mappings.put(Groups.HOMELAB_DEVELOPER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_DEVELOPER)))); + mappings.put(Groups.SYSTEM_ADMIN, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_APPROVER), new SimpleGrantedAuthority(Authorities.ROLE_ADMIN)))); + return mappings; + } + + + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Config/CustomOidcUserConfiguration.java b/src/main/java/com/hithomelabs/CFTunnels/Config/CustomOidcUserConfiguration.java new file mode 100644 index 0000000..ef077b9 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Config/CustomOidcUserConfiguration.java @@ -0,0 +1,47 @@ +package com.hithomelabs.CFTunnels.Config; + +import com.hithomelabs.CFTunnels.Entity.User; +import com.hithomelabs.CFTunnels.Repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Configuration +public class CustomOidcUserConfiguration extends OidcUserService { + + @Autowired + AuthoritiesToGroupMapping authoritiesToGroupMapping; + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) { + // * * Delegate to the default implementation for loading user and claims + OidcUser oidcUser = super.loadUser(userRequest); + + // * * Copy existing authorities (e.g. scopes → authorities) + Set mappedAuthorities = new HashSet<>(); + + // * * Extract your custom claim (change "roles" to your claim name) + List groups = oidcUser.getClaimAsStringList("groups"); + if (groups != null) { + groups.forEach(group -> + mappedAuthorities.addAll(authoritiesToGroupMapping.getAuthorityForGroup().get(group)) + ); + } + + // * * Return a new DefaultOidcUser with merged authorities + return new DefaultOidcUser( + mappedAuthorities, + oidcUser.getIdToken(), + oidcUser.getUserInfo() + ); + } + } + diff --git a/src/main/java/com/hithomelabs/CFTunnels/Config/OpenApiConfig.java b/src/main/java/com/hithomelabs/CFTunnels/Config/OpenApiConfig.java new file mode 100644 index 0000000..e24e6f5 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Config/OpenApiConfig.java @@ -0,0 +1,26 @@ +package com.hithomelabs.CFTunnels.Config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.ArrayList; + +@Configuration +public class OpenApiConfig { + + @Value("${api.baseUrl}") + private String baseUrl; + + @Bean + public OpenAPI openAPI(){ + Server httpsServer = new Server().url(baseUrl); + OpenAPI openApi = new OpenAPI(); + ArrayList servers = new ArrayList<>(); + servers.add(httpsServer); + openApi.setServers(servers); + return openApi; + } +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Config/Security/SecuirtyConfig.java b/src/main/java/com/hithomelabs/CFTunnels/Config/Security/SecuirtyConfig.java new file mode 100644 index 0000000..81def11 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Config/Security/SecuirtyConfig.java @@ -0,0 +1,40 @@ +package com.hithomelabs.CFTunnels.Config.Security; + +import com.hithomelabs.CFTunnels.Config.CustomOidcUserConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.web.SecurityFilterChain; + + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity( + prePostEnabled = true, + securedEnabled = true, + jsr250Enabled = true +) +public class SecuirtyConfig { + + @Autowired + private CustomOidcUserConfiguration customOidcUserConfiguration; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ).csrf(csrf -> csrf.disable()) + .with(new OAuth2LoginConfigurer<>(), + oauth2 -> oauth2.userInfoEndpoint(u -> u.oidcUserService(customOidcUserConfiguration))); + + + return http.build(); + } + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/HomeController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/HomeController.java new file mode 100644 index 0000000..43ea57a --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/HomeController.java @@ -0,0 +1,32 @@ +package com.hithomelabs.CFTunnels.Controllers; + + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +public class HomeController implements ErrorController { + + private static final String ERROR_PATH = "/error"; + + /** + * Redirects the root (including any query params like ?continue=…) + * straight into Swagger UI. + */ + @GetMapping("/") + public String rootRedirect() { + return "redirect:/swagger-ui/index.html"; + } + + /** + * Catches any errors (404s, unhandled paths) and punts them + * into the same Swagger UI page. + */ + @RequestMapping(ERROR_PATH) + public String onError() { + return "redirect:/swagger-ui/index.html"; + } + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java index 9e8ad9c..c36fd5b 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Controllers/TunnelController.java @@ -1,30 +1,40 @@ package com.hithomelabs.CFTunnels.Controllers; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; +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.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.Repositories.UserRepository; +import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; +import com.hithomelabs.CFTunnels.Services.MappingRequestService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.http.*; -import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +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.*; @RestController @RequestMapping("/cloudflare") -public class TunnelController { +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; @@ -34,15 +44,33 @@ public class TunnelController { @Autowired private RestTemplateConfig restTemplateConfig; + @Autowired + CloudflareAPIService cloudflareAPIService; + + @Autowired + UserRepository userRepository; + + @Autowired + MappingRequestService mappingRequestService; + + @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 + ); + } + + @PreAuthorize("hasAnyRole('USER')") @GetMapping("/tunnels") public ResponseEntity> getTunnels(){ - // * * Resource URL to hit get request at - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel"; - - HttpEntity httpEntity = new HttpEntity<>("",authKeyEmailHeader.getHttpHeaders()); - ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, Map.class); - + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnels(); Map jsonResponse = new HashMap<>(); jsonResponse.put("status", "success"); jsonResponse.put("data", responseEntity.getBody()); @@ -50,15 +78,11 @@ public class TunnelController { return ResponseEntity.ok(jsonResponse); } + @PreAuthorize("hasAnyRole('DEVELOPER')") @GetMapping("/tunnel/{tunnelId}") - public ResponseEntity> getTunnelConfigurations(@PathVariable String tunnelId) throws JsonProcessingException { - - // * * Resource URL to hit get request at - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - HttpEntity httpEntity = new HttpEntity<>("",authKeyEmailHeader.getHttpHeaders()); - ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, Map.class); + 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()); @@ -66,16 +90,12 @@ public class TunnelController { return ResponseEntity.ok(jsonResponse); } -// 50df9101-f625-4618-b7c5-100338a57124 + // 50df9101-f625-4618-b7c5-100338a57124 + @PreAuthorize("hasAnyRole('ADMIN')") @PutMapping("/tunnel/{tunnelId}/add") public ResponseEntity> addTunnelconfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - // * * Getting existing public hostname mappings - HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity<>("",httpHeaders); - ResponseEntity responseEntity = restTemplateConfig.restTemplate().exchange(url, HttpMethod.GET, httpEntity, TunnelResponse.class); + 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(); @@ -83,9 +103,7 @@ public class TunnelController { response_ingress.add(response_ingress.size()-1, ingress); // * * Hitting put endpoint - httpHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity entity = new HttpEntity<>(config, httpHeaders); - ResponseEntity response = restTemplateConfig.restTemplate().exchange(url, HttpMethod.PUT, entity, Map.class); + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // * * Displaying response Map jsonResponse = new HashMap<>(); @@ -95,15 +113,11 @@ public class TunnelController { return ResponseEntity.ok(jsonResponse); } + @PreAuthorize("hasAnyRole('DEVELOPER')") @PutMapping("/tunnel/{tunnelId}/delete") public ResponseEntity> deleteTunnelConfiguration(@PathVariable String tunnelId, @RequestBody Ingress ingress) throws JsonProcessingException { - String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; - - // * * Getting existing public hostname mappings - HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); - HttpEntity httpEntity = new HttpEntity<>("",httpHeaders); - ResponseEntity responseEntity = restTemplateConfig.restTemplate().exchange(url, HttpMethod.GET, httpEntity, TunnelResponse.class); + ResponseEntity responseEntity = cloudflareAPIService.getCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class); // * * Deleting the selected ingress value Config config = responseEntity.getBody().getResult().getConfig(); @@ -111,9 +125,7 @@ public class TunnelController { Boolean result = Ingress.deleteByHostName(response_ingress, ingress.getHostname()); // * * Hitting put endpoint - httpHeaders.setContentType(MediaType.APPLICATION_JSON); - HttpEntity entity = new HttpEntity<>(config, httpHeaders); - ResponseEntity response = restTemplateConfig.restTemplate().exchange(url, HttpMethod.PUT, entity, Map.class); + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations(tunnelId, restTemplateConfig.restTemplate(), TunnelResponse.class, config); // * * Displaying response Map jsonResponse = new HashMap<>(); @@ -129,4 +141,14 @@ public class TunnelController { return ResponseEntity.ok(jsonResponse); } + +// @PreAuthorize("hasAnyRole('DEVELOPER')") + @PutMapping("/tunnel/{tunnelId}/request") + 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(); + } + } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java new file mode 100644 index 0000000..0081205 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Mapping.java @@ -0,0 +1,34 @@ +package com.hithomelabs.CFTunnels.Entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +import java.util.UUID; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "mappings") +public class Mapping { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid", nullable = false, unique = true) + private UUID id; + + @Column(nullable = false) + private int port; + + @Column(length = 50, nullable = false) + private String subdomain; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tunnel_id", nullable = false) + private Tunnel tunnel; +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Request.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Request.java new file mode 100644 index 0000000..8d73de8 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Request.java @@ -0,0 +1,45 @@ +package com.hithomelabs.CFTunnels.Entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "requests") +public class Request { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid", unique = true, nullable = false) + private UUID id; + + @OneToOne + @JoinColumn(name = "mapping_id", unique = true, nullable = false) + private Mapping mapping; + + @ManyToOne + @JoinColumn(name = "created_by", nullable = false) + private User createdBy; + + @ManyToOne + @JoinColumn(name = "accepted_by") + private User acceptedBy; + + public enum RequestStatus { + PENDING, + APPROVED, + REJECTED + } + + @Enumerated(EnumType.STRING) + @Column(length = 10, nullable = false) + private RequestStatus status; +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java new file mode 100644 index 0000000..9c56663 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/Tunnel.java @@ -0,0 +1,29 @@ +package com.hithomelabs.CFTunnels.Entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name="tunnels") +public class Tunnel { + + @Id + @GeneratedValue + @Column(columnDefinition = "uuid", insertable = false, updatable = false, nullable = false) + private UUID id; + + @Column(length = 10, unique = true, nullable = false) + private String environment; + + @Column(name = "cf_tunnel_id", columnDefinition = "uuid", unique = true, nullable = false) + private UUID cfTunnelId; +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Entity/User.java b/src/main/java/com/hithomelabs/CFTunnels/Entity/User.java new file mode 100644 index 0000000..5783357 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Entity/User.java @@ -0,0 +1,30 @@ +package com.hithomelabs.CFTunnels.Entity; +import jakarta.persistence.*; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User { + @Id + @GeneratedValue + @Column(columnDefinition = "uuid", insertable = false, updatable = false, nullable = false) + private UUID id; + + @Column(length = 50, nullable = false) + @Size(max = 50) + private String name; + + @Column(length = 50, nullable = false) + @Size(max = 50) + private String email; +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/Authorities.java b/src/main/java/com/hithomelabs/CFTunnels/Models/Authorities.java new file mode 100644 index 0000000..f26a17b --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/Authorities.java @@ -0,0 +1,10 @@ +package com.hithomelabs.CFTunnels.Models; + +public class Authorities { + + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + public static final String ROLE_DEVELOPER = "ROLE_DEVELOPER"; + public static final String ROLE_USER = "ROLE_USER"; + public static final String ROLE_APPROVER = "ROLE_APPROVER"; + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/Groups.java b/src/main/java/com/hithomelabs/CFTunnels/Models/Groups.java new file mode 100644 index 0000000..f7c213d --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/Groups.java @@ -0,0 +1,10 @@ +package com.hithomelabs.CFTunnels.Models; + +public class Groups { + + public static final String HOMELAB_DEVELOPER = "homelab developer"; + public static final String SYSTEM_ADMIN = "authentik Admins"; + public static final String POWER_USER = "arr premium"; + public static final String GITEA_USER = "gitrestricted"; + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/Ingress.java b/src/main/java/com/hithomelabs/CFTunnels/Models/Ingress.java index 8adcc89..0cf84bb 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Models/Ingress.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/Ingress.java @@ -1,5 +1,6 @@ package com.hithomelabs.CFTunnels.Models; +import java.net.URI; import java.util.List; import java.util.Map; @@ -8,6 +9,16 @@ public class Ingress { private String service; private String hostname; private Map originRequest; + private String path; + + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } public static boolean deleteByHostName(List ingressList, String toBeDeleted){ return ingressList.removeIf(ingress -> ingress.getHostname() != null && ingress.getHostname().equals(toBeDeleted)); diff --git a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java index e51a3d5..f5a4fb4 100644 --- a/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java +++ b/src/main/java/com/hithomelabs/CFTunnels/Models/TunnelResponse.java @@ -1,8 +1,17 @@ package com.hithomelabs.CFTunnels.Models; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + import java.util.List; import java.util.Map; +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor public class TunnelResponse { private List> errors; @@ -12,45 +21,4 @@ public class TunnelResponse { private Boolean success; private Result result; - - public Result getResult() { - return result; - } - - public void setResult(Result result) { - this.result = result; - } - - public List> getErrors() { - return errors; - } - - public void setErrors(List> errors) { - this.errors = errors; - } - - public List> getMessages() { - return messages; - } - - public void setMessages(List> messages) { - this.messages = messages; - } - - public boolean isSuccess() { - return success; - } - - public void setSuccess(boolean success) { - this.success = success; - } - - public Boolean getSuccess() { - return success; - } - - public void setSuccess(Boolean success) { - this.success = success; - } - } diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/MappingRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/MappingRepository.java new file mode 100644 index 0000000..73b469c --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/MappingRepository.java @@ -0,0 +1,11 @@ +package com.hithomelabs.CFTunnels.Repositories; + +import com.hithomelabs.CFTunnels.Entity.Mapping; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface MappingRepository extends JpaRepository { +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java new file mode 100644 index 0000000..fe79625 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/RequestRepository.java @@ -0,0 +1,11 @@ +package com.hithomelabs.CFTunnels.Repositories; + +import com.hithomelabs.CFTunnels.Entity.Request; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface RequestRepository extends JpaRepository { +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java new file mode 100644 index 0000000..178f44e --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/TunnelRepository.java @@ -0,0 +1,13 @@ +package com.hithomelabs.CFTunnels.Repositories; + +import com.hithomelabs.CFTunnels.Entity.Tunnel; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TunnelRepository extends JpaRepository { + Optional findByCfTunnelId(UUID cfTunnelId); +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Repositories/UserRepository.java b/src/main/java/com/hithomelabs/CFTunnels/Repositories/UserRepository.java new file mode 100644 index 0000000..a1038a7 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Repositories/UserRepository.java @@ -0,0 +1,14 @@ +package com.hithomelabs.CFTunnels.Repositories; + +import com.hithomelabs.CFTunnels.Entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java new file mode 100644 index 0000000..2e0d71c --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIService.java @@ -0,0 +1,61 @@ +package com.hithomelabs.CFTunnels.Services; + +import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Models.Config; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class CloudflareAPIService { + + @Autowired + CloudflareConfig cloudflareConfig; + + @Autowired + AuthKeyEmailHeader authKeyEmailHeader; + + @Autowired + RestTemplate restTemplate; + + public ResponseEntity getCloudflareTunnels() { + + // * * Resource URL to hit get request at + String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel"; + + HttpEntity httpEntity = new HttpEntity<>("", authKeyEmailHeader.getHttpHeaders()); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, Map.class); + return responseEntity; + } + + public ResponseEntity getCloudflareTunnelConfigurations(String tunnelId, RestTemplate restTemplate, Class responseType) { + + // * * Resource URL to hit get request at + String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; + + HttpEntity httpEntity = new HttpEntity<>("",authKeyEmailHeader.getHttpHeaders()); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.GET, httpEntity, responseType); + return responseEntity; + } + + public ResponseEntity putCloudflareTunnelConfigurations(String tunnelId, RestTemplate restTemplate, Class responseType, Config config) { + + // * * Resource URL to hit get request at + String url = "https://api.cloudflare.com/client/v4/accounts/" + cloudflareConfig.getAccountId() + "/cfd_tunnel/" + tunnelId + "/configurations"; + + HttpHeaders httpHeaders = authKeyEmailHeader.getHttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(config, httpHeaders); + ResponseEntity responseEntity = restTemplate.exchange(url, HttpMethod.PUT, entity, responseType); + return responseEntity; + } + + + + + +} diff --git a/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java new file mode 100644 index 0000000..d026f65 --- /dev/null +++ b/src/main/java/com/hithomelabs/CFTunnels/Services/MappingRequestService.java @@ -0,0 +1,71 @@ +package com.hithomelabs.CFTunnels.Services; + +import com.hithomelabs.CFTunnels.Entity.Mapping; +import com.hithomelabs.CFTunnels.Entity.Request; +import com.hithomelabs.CFTunnels.Entity.Tunnel; +import com.hithomelabs.CFTunnels.Entity.User; +import com.hithomelabs.CFTunnels.Models.Ingress; +import com.hithomelabs.CFTunnels.Repositories.MappingRepository; +import com.hithomelabs.CFTunnels.Repositories.RequestRepository; +import com.hithomelabs.CFTunnels.Repositories.TunnelRepository; +import com.hithomelabs.CFTunnels.Repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.UUID; + +@Service +public class MappingRequestService { + + @Autowired + UserRepository userRepository; + + @Autowired + MappingRepository mappingRepository; + + @Autowired + RequestRepository requestRepository; + + @Autowired + TunnelRepository tunnelRepository; + + public Mapping createMapping(UUID tunnelId, Ingress ingress){ + Tunnel tunnel = tunnelRepository.findByCfTunnelId(tunnelId).orElseThrow(() -> new RuntimeException("Tunnel not found")); + Mapping mapping = createMappingFromTunnelIngress(tunnel, ingress); + return mappingRepository.save(mapping); + } + + public Request createRequest(Mapping mapping, User user){ + Request request = new Request(); + request.setMapping(mapping); + request.setCreatedBy(user); + request.setStatus(Request.RequestStatus.PENDING); + return requestRepository.save(request); + } + + public Request createMappingRequest(String tunnelId, Ingress ingress, OidcUser oidcUser){ + User user = userRepository.findByEmail(oidcUser.getEmail()).orElseGet(()-> mapUser(oidcUser)); + Mapping mapping = createMapping(UUID.fromString(tunnelId), ingress); + return createRequest(mapping, user); + } + + public User mapUser(OidcUser oidcUser){ + String email = oidcUser.getEmail(); + String name = oidcUser.getNickName(); + User user = new User(); + user.setEmail(email); + user.setName(name); + userRepository.save(user); + return user; + } + + public Mapping createMappingFromTunnelIngress(Tunnel tunnel, Ingress ingress){ + Mapping mapping = new Mapping(); + mapping.setTunnel(tunnel); + mapping.setPort(Integer.parseInt(ingress.getService().split(":")[2])); + mapping.setSubdomain(ingress.getHostname().split("\\.")[0]); + return mapping; + } +} diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 0000000..febde77 --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,10 @@ +api.baseUrl=http://localhost:8080 + +management.health.db.enabled=true +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=always + +logging.level.org.hibernate.SQL=DEBUG +debug=true + +spring.datasource.url=jdbc:postgresql://localhost:5432/cftunnel diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..dec0f4b --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1 @@ +api.baseUrl=https://cftunnels.hithomelabs.com \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 0000000..e5c014b --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1 @@ +api.baseUrl=https://testcf.hithomelabs.com \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2c265e1..89201b8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,3 +2,36 @@ spring.application.name=CFTunnels cloudflare.accountId=${CLOUDFLARE_ACCOUNT_ID} cloudflare.apiKey=${CLOUDFLARE_API_KEY} cloudflare.email=${CLOUDFLARE_EMAIL} +spring.profiles.active=${ENV} + +# set root level +logging.level.root=INFO +# package-specific +logging.level.org.springframework=TRACE +logging.level.com.myapp=INFO + +/ * * Masking sure app works behind a reverse proxy +server.forward-headers-strategy=framework + +spring.security.oauth2.client.registration.cftunnels.client-id=${OAUTH_CLIENT_ID} +spring.security.oauth2.client.registration.cftunnels.client-secret=${OAUTH_CLIENT_SECRET} +spring.security.oauth2.client.registration.cftunnels.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.cftunnels.redirect-uri={baseUrl}/login/oauth2/code/cftunnels +spring.security.oauth2.client.registration.cftunnels.scope=openid,profile,email,offline_access,cftunnels +spring.security.oauth2.client.provider.cftunnels.authorization-uri=https://auth.hithomelabs.com/application/o/authorize/ +spring.security.oauth2.client.provider.cftunnels.token-uri=https://auth.hithomelabs.com/application/o/token/ +spring.security.oauth2.client.provider.cftunnels.user-info-uri=https://auth.hithomelabs.com/application/o/userinfo/ +spring.security.oauth2.client.provider.cftunnels.jwk-set-uri=https://auth.hithomelabs.com/application/o/cftunnels/jwks/ +spring.security.oauth2.client.provider.cftunnels.issuer-uri=https://auth.hithomelabs.com/application/o/cftunnels/ + +spring.datasource.url=jdbc:postgresql://192.168.0.100:5432/cftunnel +spring.datasource.username=${POSTGRES_USERNAME} +spring.datasource.password=${POSTGRES_PASSWORD} +spring.datasource.driver-class-name=org.postgresql.Driver +spring.sql.init.mode=never + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +spring.jpa.open-in-view=false \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..4ff5844 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,29 @@ +--CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +-- +--CREATE TABLE IF NOT EXISTS tunnels ( +-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- environment VARCHAR(10) NOT NULL, +-- cf_tunnel_id UUID UNIQUE NOT NULL +--); +-- +--CREATE TABLE IF NOT EXISTS users ( +-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- name VARCHAR(50) NOT NULL +--); +-- +--CREATE TABLE IF NOT EXISTS mappings ( +-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- tunnel_id UUID NOT NULL REFERENCES tunnels(id) ON DELETE CASCADE, +-- port INT NOT NULL, +-- subdomain VARCHAR(50) NOT NULL +---- UNIQUE (tunnel_id, port), +---- UNIQUE (tunnel_id, subdomain) +--); +-- +--CREATE TABLE IF NOT EXISTS requests ( +-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +-- mapping_id UUID NOT NULL REFERENCES mappings(id) ON DELETE CASCADE, +-- created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, +-- accepted_by UUID REFERENCES users(id) ON DELETE SET NULL, +-- status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')) +--); \ No newline at end of file diff --git a/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java new file mode 100644 index 0000000..644288e --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/Controllers/TunnelControllerTest.java @@ -0,0 +1,219 @@ +package com.hithomelabs.CFTunnels.Controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hithomelabs.CFTunnels.Config.AuthoritiesToGroupMapping; +import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Config.RestTemplateConfig; +import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Models.Authorities; +import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.Groups; +import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import com.hithomelabs.CFTunnels.Services.CloudflareAPIService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.time.Instant; +import java.util.*; + +import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; +import static org.hamcrest.core.IsIterableContaining.hasItem; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.not; + + +@WebMvcTest(TunnelController.class) +class TunnelControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + AuthoritiesToGroupMapping authoritiesToGroupMapping; + + @MockitoBean + CloudflareConfig cloudflareConfig; + + @MockitoBean + AuthKeyEmailHeader authKeyEmailHeader; + + @MockitoBean + RestTemplate restTemplate; + + @MockitoBean + CloudflareAPIService cloudflareAPIService; + + @MockitoBean + RestTemplateConfig restTemplateConfig; + + private static final String tunnelResponseSmallIngressFile = "tunnelResponseSmallIngress.json"; + + private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; + + private static final String withAdditionalIngress; + + static { + try { + withAdditionalIngress = getClassPathDataResource(tunnelResponseLargeIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static final String withoutAdditionalIngress; + static { + try { + withoutAdditionalIngress = getClassPathDataResource(tunnelResponseSmallIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static final String ingressJson = """ + { + "service": "http://192.168.0.100:3457", + "hostname": "random.hithomelabs.com", + "originRequest": {} + } + """; + + private DefaultOidcUser buildOidcUser(String username, String role) { + + when(authoritiesToGroupMapping.getAuthorityForGroup()).thenReturn(Map.of(Groups.GITEA_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER))), + Groups.POWER_USER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_USER))), + Groups.HOMELAB_DEVELOPER, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_DEVELOPER))), + Groups.SYSTEM_ADMIN, new HashSet<>(Set.of(new SimpleGrantedAuthority(Authorities.ROLE_APPROVER), new SimpleGrantedAuthority(Authorities.ROLE_ADMIN))))); + + Map> roleAuthorityMapping = authoritiesToGroupMapping.getAuthorityForGroup(); + List authorities = roleAuthorityMapping.get(role).stream().toList(); + + OidcIdToken idToken = new OidcIdToken( + "mock-token", + Instant.now(), + Instant.now().plusSeconds(3600), + Map.of("preferred_username", username, "sub", username) + ); + + return new DefaultOidcUser(authorities, idToken, "preferred_username"); + } + + @Test + @DisplayName("should return appropriate user roles when use belongs to group GITEA_USER") + public void testWhoAmI_user() throws Exception { + mockMvc.perform(get("/cloudflare/whoami") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.username").value("username")) + .andExpect(jsonPath("$.roles", hasItem("ROLE_USER"))); + } + + @Test + @DisplayName("should hit tunnels endpoint successfully with ROLE_USER") + public void testGetTunnelsForRoleUser() throws Exception { + + when(cloudflareConfig.getAccountId()).thenReturn("abc123"); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Auth-Email", "me@example.com"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(headers); + + Map tunnelData = Map.of("tunnels", List.of(Map.of("id", "50df9101-f625-4618-b7c5-100338a57124"))); + ResponseEntity mockResponse = new ResponseEntity<>(tunnelData, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnels()).thenReturn(mockResponse); + + mockMvc.perform(get("/cloudflare/tunnels") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.GITEA_USER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.tunnels[0].id").value("50df9101-f625-4618-b7c5-100338a57124")); + + } + + @Test + void getTunnelConfigurations() throws Exception { + + Map tunnelData = Map.of("config", Map.of("result", "success", "ingress", "sample ingress object")); + ResponseEntity mockResponse = new ResponseEntity<>(tunnelData, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(Map.class))).thenReturn(mockResponse); + + mockMvc.perform(get("/cloudflare/tunnel/{tunnelId}", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("username", Groups.HOMELAB_DEVELOPER)))) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.config.ingress").value("sample ingress object")); + } + + @Test + void addTunnelconfiguration() throws Exception { + + when(restTemplateConfig.restTemplate()).thenReturn(new RestTemplate()); + + ObjectMapper mapper = new ObjectMapper(); + TunnelResponse tunnelStateBefore = mapper.readValue(withoutAdditionalIngress, TunnelResponse.class); + ResponseEntity tunnelResponseBefore = new ResponseEntity<>(tunnelStateBefore, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class))).thenReturn(tunnelResponseBefore); + + TunnelResponse expectedTunnelConfig = mapper.readValue(withAdditionalIngress, TunnelResponse.class); + ResponseEntity expectedHttpTunnelResponse = new ResponseEntity<>(expectedTunnelConfig, HttpStatus.OK); + when(cloudflareAPIService.putCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class), any(Config.class))).thenReturn(expectedHttpTunnelResponse); + + mockMvc.perform(put("/cloudflare/tunnel/{tunnelId}/add", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ingressJson)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", hasItem("random.hithomelabs.com"))); + } + + @Test + void deleteTunnelConfiguration() throws Exception { + + when(restTemplateConfig.restTemplate()).thenReturn(new RestTemplate()); + + ObjectMapper mapper = new ObjectMapper(); + TunnelResponse tunnelStateBefore = mapper.readValue(withAdditionalIngress, TunnelResponse.class); + ResponseEntity tunnelResponseBefore = new ResponseEntity<>(tunnelStateBefore, HttpStatus.OK); + + when(cloudflareAPIService.getCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class))).thenReturn(tunnelResponseBefore); + + TunnelResponse expectedTunnelConfig = mapper.readValue(withoutAdditionalIngress, TunnelResponse.class); + ResponseEntity expectedHttpTunnelResponse = new ResponseEntity<>(expectedTunnelConfig, HttpStatus.OK); + when(cloudflareAPIService.putCloudflareTunnelConfigurations(eq("sampleTunnelId"), any(RestTemplate.class), eq(TunnelResponse.class), any(Config.class))).thenReturn(expectedHttpTunnelResponse); + + mockMvc.perform(put("/cloudflare/tunnel/{tunnelId}/delete", "sampleTunnelId") + .with(oauth2Login().oauth2User(buildOidcUser("admin", Groups.SYSTEM_ADMIN))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(ingressJson)) + .andExpect(status().isOk()) + .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.data.result.config.ingress[*].hostname", not(hasItem("random.hithomelabs.com")))); + } + + +} diff --git a/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java new file mode 100644 index 0000000..03374e5 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/Services/CloudflareAPIServiceTest.java @@ -0,0 +1,117 @@ +package com.hithomelabs.CFTunnels.Services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hithomelabs.CFTunnels.Config.CloudflareConfig; +import com.hithomelabs.CFTunnels.Headers.AuthKeyEmailHeader; +import com.hithomelabs.CFTunnels.Models.Config; +import com.hithomelabs.CFTunnels.Models.TunnelResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static com.hithomelabs.CFTunnels.TestUtils.Util.getClassPathDataResource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CloudflareAPIServiceTest { + + @InjectMocks + private CloudflareAPIService cloudflareAPIService; + + @Mock + AuthKeyEmailHeader authKeyEmailHeader; + + @Mock + private RestTemplate restTemplate; + + @Mock + CloudflareConfig cloudflareConfig; + + private static final String tunnelResponseLargeIngressFile = "tunnelResponseLargeIngress.json"; + + private static final String bigTunnelResponse; + + static { + try { + bigTunnelResponse = getClassPathDataResource(tunnelResponseLargeIngressFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Test + void testGetCloudflareTunnels() { + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + Map mockBody = Map.of("tunnels", List.of(Map.of("id", "t1"))); + ResponseEntity mockResponse = new ResponseEntity<>(mockBody, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(Map.class) + )).thenReturn(mockResponse); + + ResponseEntity response = cloudflareAPIService.getCloudflareTunnels(); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + + @Test + void getCloudflareTunnelConfigurations() throws JsonProcessingException { + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + + TunnelResponse tunnelResponse = new ObjectMapper().readValue(bigTunnelResponse, TunnelResponse.class); + ResponseEntity tunnelResponseResponseEntity = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(tunnelResponseResponseEntity); + + ResponseEntity response = cloudflareAPIService.getCloudflareTunnelConfigurations("sampleTunnelID", restTemplate, TunnelResponse.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(response.getBody().getResult().getConfig().getIngress().get(0).getHostname(), "giteabkp.hithomelabs.com"); + } + + @Test + void putCloudflareTunnelConfigurations() throws JsonProcessingException { + + when(cloudflareConfig.getAccountId()).thenReturn("account-123"); + when(authKeyEmailHeader.getHttpHeaders()).thenReturn(new HttpHeaders()); + + TunnelResponse tunnelResponse = new ObjectMapper().readValue(bigTunnelResponse, TunnelResponse.class); + ResponseEntity tunnelResponseResponseEntity = new ResponseEntity<>(tunnelResponse, HttpStatus.OK); + + Config config = tunnelResponse.getResult().getConfig(); + + when(restTemplate.exchange( + any(String.class), + eq(HttpMethod.PUT), + any(HttpEntity.class), + eq(TunnelResponse.class) + )).thenReturn(tunnelResponseResponseEntity); + + ResponseEntity response = cloudflareAPIService.putCloudflareTunnelConfigurations("sampleTunnelID", restTemplate, TunnelResponse.class, config); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(response.getBody().getResult().getConfig().getIngress().get(2).getHostname(), "random.hithomelabs.com"); + } +} \ No newline at end of file diff --git a/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java b/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java new file mode 100644 index 0000000..a5f8c36 --- /dev/null +++ b/src/test/java/com/hithomelabs/CFTunnels/TestUtils/Util.java @@ -0,0 +1,17 @@ +package com.hithomelabs.CFTunnels.TestUtils; + +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +public class Util { + + public static String getClassPathDataResource(String filename) throws IOException { + return Files.readString( + new ClassPathResource(String.format("data/%s", filename)).getFile().toPath(), + StandardCharsets.UTF_8); + } + +} diff --git a/src/test/resources/data/tunnelResponseLargeIngress.json b/src/test/resources/data/tunnelResponseLargeIngress.json new file mode 100644 index 0000000..e24b930 --- /dev/null +++ b/src/test/resources/data/tunnelResponseLargeIngress.json @@ -0,0 +1,38 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http://192.168.0.100:3457", + "hostname": "random.hithomelabs.com", + "originRequest": {} + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } +} \ No newline at end of file diff --git a/src/test/resources/data/tunnelResponseSmallIngress.json b/src/test/resources/data/tunnelResponseSmallIngress.json new file mode 100644 index 0000000..9e840f0 --- /dev/null +++ b/src/test/resources/data/tunnelResponseSmallIngress.json @@ -0,0 +1,33 @@ +{ + "success": true, + "errors": [], + "messages": [], + "result": { + "tunnel_id": "50df9101-f625-4618-b7c5-100338a57124", + "version": 63, + "config": { + "ingress": [ + { + "service": "http://192.168.0.100:8928", + "hostname": "giteabkp.hithomelabs.com", + "originRequest": {} + }, + { + "service": "https://192.168.0.100:9442", + "hostname": "devdocker.hithomelabs.com", + "originRequest": { + "noTLSVerify": true + } + }, + { + "service": "http_status:404" + } + ], + "warp-routing": { + "enabled": false + } + }, + "source": "cloudflare", + "created_at": "2025-10-24T18:17:26.914217Z" + } +} \ No newline at end of file diff --git a/src/test/resources/docker-compose.yaml b/src/test/resources/docker-compose.yaml new file mode 100644 index 0000000..27068c8 --- /dev/null +++ b/src/test/resources/docker-compose.yaml @@ -0,0 +1,13 @@ +services: + postgres: + image: postgres:15-alpine + container_name: cftunnel-db-${ENV} + environment: + POSTGRES_DB: cftunnel + POSTGRES_USER: ${POSTGRES_USERNAME} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + restart: unless-stopped + ports: + - "${DB_PORT}:5432" + volumes: + - ${DB_PATH}:/var/lib/postgresql/data \ No newline at end of file