Repository: amak-tech/port-buddy Branch: master Commit: 01335f82f321 Files: 364 Total size: 1.3 MB Directory structure: gitextract_lb0oskn5/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .mvn/ │ └── wrapper/ │ └── maven-wrapper.properties ├── Dockerfile-cli ├── Dockerfile-eureka ├── Dockerfile-gateway ├── Dockerfile-net-proxy ├── Dockerfile-server ├── Dockerfile-ssl-service ├── Dockerfile-web ├── LICENSE ├── README.md ├── cli/ │ ├── pom.xml │ └── src/ │ └── main/ │ ├── java/ │ │ └── tech/ │ │ └── amak/ │ │ └── portbuddy/ │ │ └── cli/ │ │ ├── PortBuddy.java │ │ ├── config/ │ │ │ └── ConfigurationService.java │ │ ├── tunnel/ │ │ │ ├── HttpTunnelClient.java │ │ │ └── NetTunnelClient.java │ │ ├── ui/ │ │ │ ├── ConsoleUi.java │ │ │ ├── HttpLogSink.java │ │ │ └── NetTrafficSink.java │ │ └── utils/ │ │ ├── HttpUtils.java │ │ └── JsonUtils.java │ └── resources/ │ ├── META-INF/ │ │ └── native-image/ │ │ └── tech.amak/ │ │ └── port-buddy-cli/ │ │ ├── reflect-config.json │ │ └── resource-config.json │ ├── application-dev.yml │ ├── application.yml │ └── logback.xml ├── common/ │ ├── pom.xml │ └── src/ │ └── main/ │ └── java/ │ └── tech/ │ └── amak/ │ └── portbuddy/ │ └── common/ │ ├── ClientConfig.java │ ├── Plan.java │ ├── TunnelType.java │ ├── dto/ │ │ ├── DnsInstructionsEmailRequest.java │ │ ├── ExposeRequest.java │ │ ├── ExposeResponse.java │ │ ├── auth/ │ │ │ ├── RegisterRequest.java │ │ │ ├── RegisterResponse.java │ │ │ ├── TokenExchangeRequest.java │ │ │ └── TokenExchangeResponse.java │ │ └── jwks/ │ │ ├── JwkKey.java │ │ └── JwksResponse.java │ ├── tunnel/ │ │ ├── BinaryWsFrame.java │ │ ├── ControlMessage.java │ │ ├── HttpTunnelMessage.java │ │ ├── MessageEnvelope.java │ │ └── WsTunnelMessage.java │ └── utils/ │ └── IdUtils.java ├── docker-compose.yml ├── entrypoint-cli-native.sh ├── entrypoint-web.sh ├── entrypoint.sh ├── eureka/ │ ├── HELP.md │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── tech/ │ │ │ └── amak/ │ │ │ └── portbuddy/ │ │ │ └── eureka/ │ │ │ ├── EurekaApplication.java │ │ │ └── security/ │ │ │ └── SecurityConfig.java │ │ └── resources/ │ │ └── application.yml │ └── test/ │ └── java/ │ └── tech/ │ └── amak/ │ └── portbuddy/ │ └── eureka/ │ └── EurekaApplicationTests.java ├── gateway/ │ ├── HELP.md │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── tech/ │ │ │ └── amak/ │ │ │ └── portbuddy/ │ │ │ └── gateway/ │ │ │ ├── ApiGatewayApplication.java │ │ │ ├── client/ │ │ │ │ └── SslServiceClient.java │ │ │ ├── config/ │ │ │ │ ├── AppProperties.java │ │ │ │ ├── GlobalExceptionHandler.java │ │ │ │ ├── LoadBalancerClientsConfig.java │ │ │ │ ├── NetProxyLoadBalancerConfiguration.java │ │ │ │ ├── PortBuddyServerLoadBalancerConfiguration.java │ │ │ │ ├── SslServerConfig.java │ │ │ │ └── WebClientConfig.java │ │ │ ├── dto/ │ │ │ │ └── CertificateResponse.java │ │ │ ├── filter/ │ │ │ │ └── PortBuddyRewritePathGatewayFilterFactory.java │ │ │ ├── loadbalancer/ │ │ │ │ ├── NetProxyPublicHostLoadBalancer.java │ │ │ │ └── PortBuddySubdomainLoadBalancer.java │ │ │ ├── security/ │ │ │ │ ├── GatewayJwtConfig.java │ │ │ │ └── GatewaySecurityConfig.java │ │ │ └── ssl/ │ │ │ ├── DynamicSslProvider.java │ │ │ └── SniSslContextMapping.java │ │ └── resources/ │ │ └── application.yml │ └── test/ │ └── java/ │ └── tech/ │ └── amak/ │ ├── gateway/ │ │ └── ApiGatewayApplicationTests.java │ └── portbuddy/ │ └── gateway/ │ ├── config/ │ │ └── SslServerConfigTest.java │ └── ssl/ │ └── DynamicSslProviderTest.java ├── lombok.config ├── mvnw ├── mvnw.cmd ├── net-proxy/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── tech/ │ │ │ └── amak/ │ │ │ └── portbuddy/ │ │ │ └── netproxy/ │ │ │ ├── NetProxyApplication.java │ │ │ ├── config/ │ │ │ │ ├── AppProperties.java │ │ │ │ ├── JwtConfig.java │ │ │ │ └── WebSocketConfig.java │ │ │ ├── security/ │ │ │ │ └── SecurityConfig.java │ │ │ ├── tunnel/ │ │ │ │ ├── NetTunnelRegistry.java │ │ │ │ └── NetTunnelWebSocketHandler.java │ │ │ └── web/ │ │ │ └── NetProxyController.java │ │ └── resources/ │ │ └── application.yml │ └── test/ │ └── java/ │ └── tech/ │ └── amak/ │ └── portbuddy/ │ └── netproxy/ │ └── tunnel/ │ ├── NetTunnelLeakVerificationTest.java │ ├── NetTunnelOrphanCleanupTest.java │ ├── NetTunnelRegistryConcurrencyTest.java │ ├── NetTunnelRegistryTest.java │ └── NetTunnelUdpEvictionTest.java ├── pom.xml ├── server/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── tech/ │ │ │ └── amak/ │ │ │ └── portbuddy/ │ │ │ └── server/ │ │ │ ├── ServerApplication.java │ │ │ ├── client/ │ │ │ │ ├── NetProxyClient.java │ │ │ │ └── SslServiceClient.java │ │ │ ├── config/ │ │ │ │ ├── AppProperties.java │ │ │ │ ├── SchedulingConfig.java │ │ │ │ ├── ThreatFoxProperties.java │ │ │ │ └── TunnelsProperties.java │ │ │ ├── db/ │ │ │ │ ├── entity/ │ │ │ │ │ ├── AccountEntity.java │ │ │ │ │ ├── ApiKeyEntity.java │ │ │ │ │ ├── DomainEntity.java │ │ │ │ │ ├── InvitationEntity.java │ │ │ │ │ ├── PasswordResetTokenEntity.java │ │ │ │ │ ├── PortReservationEntity.java │ │ │ │ │ ├── Role.java │ │ │ │ │ ├── StripeEventEntity.java │ │ │ │ │ ├── TunnelEntity.java │ │ │ │ │ ├── TunnelStatus.java │ │ │ │ │ ├── UserAccountEntity.java │ │ │ │ │ └── UserEntity.java │ │ │ │ └── repo/ │ │ │ │ ├── AccountRepository.java │ │ │ │ ├── ApiKeyRepository.java │ │ │ │ ├── DomainRepository.java │ │ │ │ ├── InvitationRepository.java │ │ │ │ ├── PasswordResetTokenRepository.java │ │ │ │ ├── PortReservationRepository.java │ │ │ │ ├── StripeEventRepository.java │ │ │ │ ├── TunnelRepository.java │ │ │ │ ├── UserAccountRepository.java │ │ │ │ └── UserRepository.java │ │ │ ├── mail/ │ │ │ │ ├── EmailService.java │ │ │ │ ├── UserCreatedEvent.java │ │ │ │ └── WelcomeEmailService.java │ │ │ ├── security/ │ │ │ │ ├── ApiTokenAuthFilter.java │ │ │ │ ├── JwtConfig.java │ │ │ │ ├── JwtService.java │ │ │ │ ├── Oauth2SuccessHandler.java │ │ │ │ ├── RsaKeyProvider.java │ │ │ │ ├── SecurityConfig.java │ │ │ │ └── ThreatBlockedException.java │ │ │ ├── service/ │ │ │ │ ├── ApiTokenService.java │ │ │ │ ├── DomainService.java │ │ │ │ ├── PaymentCleanupService.java │ │ │ │ ├── PortReservationService.java │ │ │ │ ├── ProxyDiscoveryService.java │ │ │ │ ├── StaleTunnelsReaper.java │ │ │ │ ├── StripeService.java │ │ │ │ ├── StripeWebhookService.java │ │ │ │ ├── TeamService.java │ │ │ │ ├── TunnelService.java │ │ │ │ ├── threatfox/ │ │ │ │ │ ├── ThreatFoxClient.java │ │ │ │ │ ├── ThreatFoxIoc.java │ │ │ │ │ ├── ThreatFoxRequest.java │ │ │ │ │ ├── ThreatFoxResponse.java │ │ │ │ │ └── ThreatFoxService.java │ │ │ │ └── user/ │ │ │ │ ├── MissingEmailException.java │ │ │ │ ├── PasswordResetService.java │ │ │ │ └── UserProvisioningService.java │ │ │ ├── tunnel/ │ │ │ │ ├── PermissiveSubprotocolHandshakeHandler.java │ │ │ │ ├── PublicWebSocketProxyHandler.java │ │ │ │ ├── TunnelRegistry.java │ │ │ │ ├── TunnelWebSocketHandler.java │ │ │ │ └── WebSocketConfig.java │ │ │ └── web/ │ │ │ ├── AuthController.java │ │ │ ├── DomainsController.java │ │ │ ├── ExposeController.java │ │ │ ├── IngressController.java │ │ │ ├── IngressResolveController.java │ │ │ ├── InternalDomainController.java │ │ │ ├── InternalEmailController.java │ │ │ ├── JwksController.java │ │ │ ├── PaymentController.java │ │ │ ├── PortsController.java │ │ │ ├── StripeWebhookController.java │ │ │ ├── TeamController.java │ │ │ ├── TokensController.java │ │ │ ├── TunnelStatusController.java │ │ │ ├── TunnelsController.java │ │ │ ├── UsersController.java │ │ │ ├── admin/ │ │ │ │ ├── AdminAccountController.java │ │ │ │ ├── AdminSystemController.java │ │ │ │ ├── AdminTunnelController.java │ │ │ │ ├── AdminUserController.java │ │ │ │ └── dto/ │ │ │ │ ├── AdminAccountRow.java │ │ │ │ ├── AdminStatsRow.java │ │ │ │ ├── AdminTunnelRow.java │ │ │ │ ├── AdminUserRow.java │ │ │ │ └── SystemStatsResponse.java │ │ │ ├── advice/ │ │ │ │ └── GlobalExceptionHandler.java │ │ │ └── dto/ │ │ │ ├── DomainDto.java │ │ │ ├── LoginRequest.java │ │ │ ├── PasswordResetConfirm.java │ │ │ ├── PasswordResetRequest.java │ │ │ ├── PortRangeDto.java │ │ │ ├── PortReservationDto.java │ │ │ ├── PortReservationUpdateRequest.java │ │ │ ├── SetPasscodeRequest.java │ │ │ ├── UpdateCustomDomainRequest.java │ │ │ └── UpdateDomainRequest.java │ │ └── resources/ │ │ ├── application.yml │ │ ├── db/ │ │ │ └── migration/ │ │ │ ├── V10__link_http_tunnels_to_domain.sql │ │ │ ├── V11__add_deleted_column_to_domains.sql │ │ │ ├── V12__drop_tunnel_id_from_tunnels.sql │ │ │ ├── V13__password_reset_tokens.sql │ │ │ ├── V14__port_reservations.sql │ │ │ ├── V15__add_user_to_port_reservations.sql │ │ │ ├── V16__add_port_reservation_to_tunnels.sql │ │ │ ├── V17__soft_delete_port_reservations.sql │ │ │ ├── V18__add_passcode_to_domains.sql │ │ │ ├── V19__add_temp_passcode_to_tunnels.sql │ │ │ ├── V1__accounts_and_users.sql │ │ │ ├── V20__add_custom_domain_to_domains.sql │ │ │ ├── V21__add_roles_to_users.sql │ │ │ ├── V22__update_plans.sql │ │ │ ├── V23__add_stripe_fields.sql │ │ │ ├── V24__create_stripe_events_table.sql │ │ │ ├── V25__create_invitations_table.sql │ │ │ ├── V26__many_to_many_users_accounts.sql │ │ │ ├── V27__add_ssl_active_to_domains.sql │ │ │ ├── V28__add_blocked_to_accounts.sql │ │ │ ├── V29__add_name_to_port_reservations.sql │ │ │ ├── V2__api_keys.sql │ │ │ ├── V30__unique_port_reservation_name.sql │ │ │ ├── V31__lowercase_domains.sql │ │ │ ├── V3__tunnels.sql │ │ │ ├── V4__shedlock_and_heartbeat_indexes.sql │ │ │ ├── V5__add_password_and_admin.sql │ │ │ ├── V6__create_domains_table.sql │ │ │ ├── V7__link_tunnels_to_account.sql │ │ │ ├── V8__add_user_id_to_tunnels.sql │ │ │ └── V9__link_api_keys_to_account.sql │ │ ├── keys/ │ │ │ ├── dev_jwt.pem │ │ │ └── dev_jwt.pub │ │ └── templates/ │ │ └── email/ │ │ ├── base.html │ │ ├── dns-instructions.html │ │ ├── password-reset-success.html │ │ ├── password-reset.html │ │ ├── payment-failed.html │ │ ├── plan-changed.html │ │ ├── subscription-canceled.html │ │ ├── subscription-success.html │ │ ├── team-invite.html │ │ └── welcome.html │ └── test/ │ └── java/ │ └── tech/ │ └── amak/ │ └── portbuddy/ │ └── server/ │ ├── security/ │ │ └── Oauth2SuccessHandlerTest.java │ ├── service/ │ │ ├── DomainServiceTest.java │ │ ├── PaymentCleanupServiceTest.java │ │ ├── PortReservationServiceTest.java │ │ ├── StaleTunnelsReaperTest.java │ │ └── TunnelServiceTest.java │ ├── tunnel/ │ │ └── TunnelRegistryLeakTest.java │ ├── user/ │ │ └── PasswordResetServiceTest.java │ └── web/ │ ├── AuthControllerTest.java │ ├── IngressControllerTest.java │ ├── PaymentControllerTest.java │ ├── StripeWebhookControllerTest.java │ ├── TeamControllerTest.java │ ├── TokensControllerTest.java │ ├── UsersControllerTest.java │ └── admin/ │ └── AdminAccountControllerTest.java ├── ssl-service/ │ ├── pom.xml │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── tech/ │ │ │ └── amak/ │ │ │ └── portbuddy/ │ │ │ └── sslservice/ │ │ │ ├── SslServiceApplication.java │ │ │ ├── client/ │ │ │ │ └── ServerClient.java │ │ │ ├── config/ │ │ │ │ ├── AppProperties.java │ │ │ │ ├── AsyncConfig.java │ │ │ │ ├── JpaAuditingConfig.java │ │ │ │ ├── RestConfig.java │ │ │ │ └── SchedulingConfig.java │ │ │ ├── domain/ │ │ │ │ ├── CertificateEntity.java │ │ │ │ ├── CertificateJobEntity.java │ │ │ │ ├── CertificateJobStatus.java │ │ │ │ └── CertificateStatus.java │ │ │ ├── repo/ │ │ │ │ ├── CertificateJobRepository.java │ │ │ │ └── CertificateRepository.java │ │ │ ├── security/ │ │ │ │ └── SecurityConfig.java │ │ │ ├── service/ │ │ │ │ ├── AcmeAccountService.java │ │ │ │ ├── AcmeCertificateService.java │ │ │ │ ├── AcmeClientService.java │ │ │ │ ├── CertificateStorageService.java │ │ │ │ ├── DnsResolverService.java │ │ │ │ ├── EmailService.java │ │ │ │ ├── RenewalScheduler.java │ │ │ │ ├── RetryExecutor.java │ │ │ │ ├── TransientErrorClassifier.java │ │ │ │ └── impl/ │ │ │ │ ├── ServerEmailService.java │ │ │ │ └── SimpleDnsResolverService.java │ │ │ ├── web/ │ │ │ │ ├── CertificatesController.java │ │ │ │ ├── ChallengeController.java │ │ │ │ ├── InternalController.java │ │ │ │ ├── JobsController.java │ │ │ │ └── dto/ │ │ │ │ ├── CreateCertificateRequest.java │ │ │ │ ├── CreateManagedCertificateRequest.java │ │ │ │ └── CreateRootDomainRequest.java │ │ │ └── work/ │ │ │ └── ChallengeTokenStore.java │ │ └── resources/ │ │ ├── application.yml │ │ └── db/ │ │ └── migration/ │ │ ├── V1__init.sql │ │ ├── V2__shedlock_table.sql │ │ └── V3__add_full_chain_path.sql │ └── test/ │ ├── java/ │ │ └── tech/ │ │ └── amak/ │ │ └── portbuddy/ │ │ └── sslservice/ │ │ └── service/ │ │ └── CertificateRenewalServiceTest.java │ └── resources/ │ └── application-test.yml └── web/ ├── index.html ├── package.json ├── pom.xml ├── postcss.config.js ├── public/ │ ├── install.ps1 │ ├── install.sh │ ├── pages/ │ │ ├── contacts.html │ │ ├── docs/ │ │ │ └── guides/ │ │ │ ├── hytale-server.html │ │ │ └── minecraft-server.html │ │ ├── docs.html │ │ ├── index.html │ │ ├── install.html │ │ ├── privacy.html │ │ └── terms.html │ ├── robots.txt │ ├── setup-portbuddy-service.ps1 │ ├── setup-portbuddy-service.sh │ ├── site.webmanifest │ └── sitemap.xml ├── src/ │ ├── App.tsx │ ├── auth/ │ │ └── AuthContext.tsx │ ├── components/ │ │ ├── AppLayout.tsx │ │ ├── CodeBlock.tsx │ │ ├── LoadingContext.tsx │ │ ├── Modal.tsx │ │ ├── PageHeader.tsx │ │ ├── PlanComparison.tsx │ │ ├── ProgressBar.tsx │ │ └── ProtectedRoute.tsx │ ├── index.css │ ├── lib/ │ │ ├── api.ts │ │ └── utils.ts │ ├── main.tsx │ └── pages/ │ ├── AcceptInvite.tsx │ ├── Contacts.tsx │ ├── ForgotPassword.tsx │ ├── Installation.tsx │ ├── Landing.tsx │ ├── Login.tsx │ ├── NotFound.tsx │ ├── Passcode.tsx │ ├── Privacy.tsx │ ├── Register.tsx │ ├── ResetPassword.tsx │ ├── ServerError.tsx │ ├── Terms.tsx │ ├── app/ │ │ ├── AdminAccounts.tsx │ │ ├── AdminPanel.tsx │ │ ├── AdminTunnels.tsx │ │ ├── AdminUsers.tsx │ │ ├── Billing.tsx │ │ ├── BillingCancel.tsx │ │ ├── BillingSuccess.tsx │ │ ├── Domains.tsx │ │ ├── Ports.tsx │ │ ├── Profile.tsx │ │ ├── Settings.tsx │ │ ├── Team.tsx │ │ ├── Tokens.tsx │ │ └── Tunnels.tsx │ └── docs/ │ ├── DocsLayout.tsx │ ├── DocsOverview.tsx │ └── guides/ │ ├── HytaleGuide.tsx │ └── MinecraftGuide.tsx ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: Build and Deploy on: push: branches: [ "**" ] tags: - 'v*' - 'be*' - 'fe*' workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: Set environment variables based on branch run: | echo "ENV=dev" >> $GITHUB_ENV echo "VITE_API_BASE=http://localhost:8080" >> $GITHUB_ENV echo "VITE_CANONICAL=http://localhost:8080" >> $GITHUB_ENV echo "VITE_OG_URL=http://localhost:8080" >> $GITHUB_ENV - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 25 uses: actions/setup-java@v4 with: java-version: '25' distribution: 'temurin' cache: 'maven' - name: Build with Maven run: ./mvnw clean package build-backend: name: Build and Push Docker Images to YC CR needs: build if: startsWith(github.ref, 'refs/tags/be') || startsWith(github.ref, 'refs/tags/fe') runs-on: ubuntu-latest steps: - name: Set environment variables based on branch run: | echo "ENV=prod" >> $GITHUB_ENV echo "VITE_API_BASE=https://portbuddy.dev" >> $GITHUB_ENV echo "VITE_CANONICAL=https://portbuddy.dev" >> $GITHUB_ENV echo "VITE_OG_URL=https://portbuddy.dev" >> $GITHUB_ENV - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 25 uses: actions/setup-java@v4 with: java-version: '25' distribution: 'temurin' cache: 'maven' - name: Build with Maven run: ./mvnw clean package -pl web -pl eureka -pl gateway -pl net-proxy -pl server -pl ssl-service -am -DskipTests - name: Login to Yandex Cloud Container Registry id: login-cr uses: yc-actions/yc-cr-login@v3 with: yc-sa-json-credentials: ${{ secrets.YC_CR_JSON_CREDS }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Server uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/be') with: context: . file: Dockerfile-server platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-server:latest - name: Build and push Gateway uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/be') with: context: . file: Dockerfile-gateway platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-gateway:latest - name: Build and push Eureka uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/be') with: context: . file: Dockerfile-eureka platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-eureka:latest - name: Build and push Net Proxy uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/be') with: context: . file: Dockerfile-net-proxy platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-net-proxy:latest - name: Build and push SSL Service uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/be') with: context: . file: Dockerfile-ssl-service platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-ssl-service:latest - name: Build and push Web App uses: docker/build-push-action@v6 if: startsWith(github.ref, 'refs/tags/fe') with: context: . file: Dockerfile-web platforms: linux/amd64 push: true tags: cr.yandex/${{ vars.CR_REGISTRY }}/port-buddy-web-app:latest build-portbuddy-docker: name: Build and Push CLI Images needs: build if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Extract version id: extract_version run: | if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT else echo "VERSION=latest" >> $GITHUB_OUTPUT fi - name: Set up JDK 25 uses: actions/setup-java@v4 with: java-version: '25' distribution: 'temurin' cache: 'maven' - name: Build with Maven run: ./mvnw clean package -pl cli -am -DskipTests - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push CLI uses: docker/build-push-action@v6 with: context: . file: Dockerfile-cli platforms: linux/amd64,linux/arm64 push: true tags: | portbuddy/portbuddy:${{ steps.extract_version.outputs.VERSION }} portbuddy/portbuddy:latest build-portbuddy-native: name: Build Native CLI (${{ matrix.os }}) needs: build if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write strategy: fail-fast: false matrix: include: - os: ubuntu-latest asset_name: portbuddy-linux-x64 executable_path: cli/target/portbuddy - os: ubuntu-24.04-arm asset_name: portbuddy-linux-arm64 executable_path: cli/target/portbuddy - os: macos-15-intel asset_name: portbuddy-macos-x64 executable_path: cli/target/portbuddy - os: macos-latest asset_name: portbuddy-macos-arm64 executable_path: cli/target/portbuddy - os: windows-latest asset_name: portbuddy-windows-x64.exe executable_path: cli/target/portbuddy.exe runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Extract version id: extract_version shell: bash run: | if [[ $GITHUB_REF == refs/tags/v* ]]; then echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT else echo "VERSION=latest" >> $GITHUB_OUTPUT fi - name: Set up MSVC if: matrix.os == 'windows-latest' uses: ilammy/msvc-dev-cmd@v1 - name: Set up GraalVM uses: actions/setup-java@v4 with: java-version: '25' distribution: 'graalvm' cache: 'maven' - name: Build with Maven (Native Image) run: ./mvnw clean package -pl cli -am -DskipTests -Pnative - name: Prepare Artifact id: prepare_artifact shell: bash run: | cp ${{ matrix.executable_path }} ${{ matrix.asset_name }} if command -v shasum &> /dev/null; then SHA=$(shasum -a 256 ${{ matrix.asset_name }} | awk '{print $1}') elif command -v sha256sum &> /dev/null; then SHA=$(sha256sum ${{ matrix.asset_name }} | awk '{print $1}') else echo "No shasum or sha256sum found" exit 1 fi echo "sha256=$SHA" >> $GITHUB_OUTPUT - name: Release uses: softprops/action-gh-release@v2 with: files: ${{ matrix.asset_name }} tag_name: ${{ steps.extract_version.outputs.VERSION }} name: Release ${{ steps.extract_version.outputs.VERSION }} draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} update-homebrew: name: Update Homebrew Tap needs: build-portbuddy-native if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: - name: Checkout Homebrew Tap uses: actions/checkout@v4 with: repository: amak-tech/homebrew-tap # REPLACE with your actual tap repository token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # Needs a PAT with 'repo' scope - name: Extract version id: extract_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Update Formula run: | # Use values from build-native-cli outputs # Note: Accessing matrix outputs by job ID is complex. # An easier way is to download them from the release if they are already there. # Or since we are in the same workflow, we can use 'gh release download' gh release download ${{ steps.extract_version.outputs.VERSION }} -p "portbuddy-*" --repo ${{ github.repository }} if command -v shasum &> /dev/null; then MAC_ARM_SHA=$(shasum -a 256 portbuddy-macos-arm64 | awk '{print $1}') MAC_X64_SHA=$(shasum -a 256 portbuddy-macos-x64 | awk '{print $1}') LINUX_ARM_SHA=$(shasum -a 256 portbuddy-linux-arm64 | awk '{print $1}') LINUX_X64_SHA=$(shasum -a 256 portbuddy-linux-x64 | awk '{print $1}') else MAC_ARM_SHA=$(sha256sum portbuddy-macos-arm64 | awk '{print $1}') MAC_X64_SHA=$(sha256sum portbuddy-macos-x64 | awk '{print $1}') LINUX_ARM_SHA=$(sha256sum portbuddy-linux-arm64 | awk '{print $1}') LINUX_X64_SHA=$(sha256sum portbuddy-linux-x64 | awk '{print $1}') fi sed -i "s/version \".*\"/version \"${{ steps.extract_version.outputs.VERSION }}\"/" Formula/portbuddy.rb sed -i "/portbuddy-macos-arm64\"/{n;s/sha256 \".*\"/sha256 \"$MAC_ARM_SHA\"/;}" Formula/portbuddy.rb sed -i "/portbuddy-macos-x64\"/{n;s/sha256 \".*\"/sha256 \"$MAC_X64_SHA\"/;}" Formula/portbuddy.rb sed -i "/portbuddy-linux-arm64\"/{n;s/sha256 \".*\"/sha256 \"$LINUX_ARM_SHA\"/;}" Formula/portbuddy.rb sed -i "/portbuddy-linux-x64\"/{n;s/sha256 \".*\"/sha256 \"$LINUX_X64_SHA\"/;}" Formula/portbuddy.rb env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Commit and Push run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/portbuddy.rb git commit -m "Update portbuddy to v${{ steps.extract_version.outputs.VERSION }}" git push ================================================ FILE: .gitignore ================================================ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ .kotlin ### IntelliJ IDEA ### .idea/* .idea *.iws *.iml *.ipr ### Eclipse ### .apt_generated .classpath .factorypath .project .settings .springBeans .sts4-cache ### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ build/ !**/src/main/**/build/ !**/src/test/**/build/ ### VS Code ### .vscode/ ### Mac OS ### .DS_Store node_modules/ dist .env ref pg_data log /checkstyle_report.txt /test_output.txt /config/acme/account.key /ssl-service/config/acme/account.key .junie ================================================ FILE: .mvn/wrapper/maven-wrapper.properties ================================================ wrapperVersion=3.3.4 distributionType=only-script distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip ================================================ FILE: Dockerfile-cli ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY cli/target/cli-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENV JVM_OPTS="--enable-native-access=ALL-UNNAMED" ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-eureka ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY eureka/target/eureka-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-gateway ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY gateway/target/gateway-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-net-proxy ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY net-proxy/target/net-proxy-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-server ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY server/target/server-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-ssl-service ================================================ FROM eclipse-temurin:25-jre WORKDIR /app COPY ssl-service/target/ssl-service-*.jar /app/app.jar COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: Dockerfile-web ================================================ FROM alpine:latest WORKDIR /app COPY web/dist /app/dist COPY entrypoint-web.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # PortBuddy 🚀 PortBuddy is a powerful yet simple tool that allows you to expose a port opened on your local host or in a private network to the public internet. It works as a secure tunnel, similar to ngrok, providing a public URL for your local services. Whether you're developing a web app, testing webhooks, or sharing access to a local database, PortBuddy makes it easy and secure. ## ✨ Features - **Multi-protocol support**: Tunnel HTTP, TCP, and UDP traffic. - **SSL by default**: All HTTP tunnels are automatically secured with SSL. - **Customizable**: Support for static subdomains and custom domains. - **Websocket support**: Full support for real-time applications. - **Private tunnels**: Secure your tunnels with passcodes. - **Cross-platform CLI**: Lightweight CLI built with Java 25 and GraalVM (native executable). - **Web Dashboard**: Manage your tunnels, subscriptions, and team members easily. ## 🚀 Quick Start ### 1. Installation Download the latest version of the `portbuddy` CLI for your platform (Windows, Linux, or Mac). ### 2. Authentication Before exposing ports, you need to authenticate your CLI. 1. Log in to your account at [portbuddy.dev](https://portbuddy.dev). 2. Generate an API Token in your dashboard. 3. Run the following command: ```bash portbuddy init {YOUR_API_TOKEN} ``` ### 3. Expose a Port #### HTTP (Default) Expose a local web server running on port 3000: ```bash portbuddy 3000 ``` Output: `http://localhost:3000 exposed to: https://abc123.portbuddy.dev` #### TCP Expose a local PostgreSQL database: ```bash portbuddy tcp 5432 ``` Output: `tcp localhost:5432 exposed to: net-proxy-3.portbuddy.dev:43452` #### UDP Expose a local UDP service: ```bash portbuddy udp 9000 ``` ### 4. Run with Docker You can also run the PortBuddy CLI inside a Docker container. #### Pull the Image If you haven't built it locally, you can use the official image (replace with actual image name if applicable): ```bash docker pull amaktech/portbuddy:latest ``` *Note: If you are developing locally, you can build the image using `Dockerfile-cli`.* #### Authentication with Docker To use PortBuddy in Docker, you should mount your token file from your host machine to the container. The CLI expects the token at `/root/.port-buddy/token`. First, authenticate on your host machine: ```bash portbuddy init {YOUR_API_TOKEN} ``` Then, run the Docker container with the token mounted: ```bash docker run -it --rm \ -v ~/.port-buddy/token:/root/.port-buddy/token \ --network host \ amaktech/portbuddy 3000 ``` #### Mounting a Token File Directly If you have your API token in a file (e.g., `my_token.txt`), you can mount it directly: ```bash docker run -it --rm \ -v $(pwd)/my_token.txt:/root/.port-buddy/token \ --network host \ amaktech/portbuddy 3000 ``` *Note: `--network host` is used to allow the container to access services running on your host's localhost (e.g., port 3000).* ## 🛠️ CLI Usage ```text Usage: portbuddy [options] [mode] [host:][port] Modes: http (default), tcp, udp Options: -d, --domain= Requested static subdomain (e.g. my-app) -pr, --port-reservation= Use specific port reservation host:port for TCP/UDP -pc, --passcode= Protect tunnel with a passcode -v, --verbose Enable verbose logging -h, --help Show help message -V, --version Show version info ``` ## 💳 Subscription Plans | Feature | Pro ($0/mo) | Team ($10/mo) | | :--- | :--- | :--- | | Tunnels | HTTP, TCP, UDP | Everything in Pro | | SSL | Included | Included | | Subdomains | Static | Static | | Custom Domains | Supported | Supported | | Team Members | - | Included | | Free Tunnels | 1 at a time | 10 at a time | | Extra Tunnels | $1/mo each | $1/mo each | | Support | Standard | Priority | ## 🏗️ Architecture PortBuddy is built as a multi-modular system: - **`cli`**: GraalVM-native command-line application (Java 25). - **`server`**: Spring Boot 3.5.7 API & Tunnel Management. - **`net-proxy`**: High-performance TCP/UDP proxy. - **`gateway`**: Webflux-based API Gateway. - **`web`**: React-based dashboard (TypeScript + TailwindCSS). - **`eureka`**: Service discovery. - **`ssl-service`**: Automated SSL certificate management. - **`common`**: Shared DTOs and utilities. ## 🛠️ Development ### Prerequisites - Java 25 - Docker & Docker Compose - Spring Boot 3 - Maven 3.9+ - Node.js & npm (for web module) ### Build To build the entire project: ```bash ./mvnw clean install ``` ### Run with Docker Compose ```bash docker-compose up -d ``` ## 📄 License This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. ## 🤝 Community Projects Projects built by the PortBuddy community: - **[PortBuddy GUI](https://github.com/quack-stuff/portbuddy-gui)** by Quack - A graphical user interface for PortBuddy on Windows. ================================================ FILE: cli/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT cli port-buddy-cli 25 org.projectlombok lombok ${lombok.version} provided ch.qos.logback logback-classic 1.5.16 ch.qos.logback logback-core 1.5.16 com.fasterxml.jackson.core jackson-databind 2.18.1 com.fasterxml.jackson.dataformat jackson-dataformat-yaml 2.18.1 com.fasterxml.jackson.core jackson-annotations 2.18.1 com.fasterxml.jackson.core jackson-core 2.18.1 tech.amak common ${project.version} com.squareup.okhttp3 okhttp 4.12.0 org.jline jline 3.26.3 org.junit.jupiter junit-jupiter-api 5.11.4 test org.junit.jupiter junit-jupiter-engine 5.11.4 test org.apache.maven.plugins maven-compiler-plugin org.projectlombok lombok ${lombok.version} org.apache.maven.plugins maven-jar-plugin 3.4.2 tech.amak.portbuddy.cli.PortBuddy org.apache.maven.plugins maven-shade-plugin 3.6.0 package shade false tech.amak.portbuddy.cli.PortBuddy ${project.name} ${project.version} org.apache.maven.plugins maven-checkstyle-plugin native org.graalvm.buildtools native-maven-plugin 0.11.3 true build-native compile-no-fork package portbuddy tech.amak.portbuddy.cli.PortBuddy false false -Os --no-fallback --enable-http --enable-https --gc=serial -H:+UnlockExperimentalVMOptions -H:-StackTrace --initialize-at-build-time=org.slf4j,ch.qos.logback,tech.amak.portbuddy.common --enable-native-access=ALL-UNNAMED ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/PortBuddy.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli; import static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER; import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.Objects; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import tech.amak.portbuddy.cli.config.ConfigurationService; import tech.amak.portbuddy.cli.tunnel.HttpTunnelClient; import tech.amak.portbuddy.cli.tunnel.NetTunnelClient; import tech.amak.portbuddy.cli.ui.ConsoleUi; import tech.amak.portbuddy.cli.utils.HttpUtils; import tech.amak.portbuddy.common.ClientConfig; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.dto.ExposeRequest; import tech.amak.portbuddy.common.dto.ExposeResponse; import tech.amak.portbuddy.common.dto.auth.RegisterRequest; import tech.amak.portbuddy.common.dto.auth.RegisterResponse; import tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest; import tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse; /** * Main class for the PortBuddy CLI application. * Handles command-line argument parsing and coordinates tunnel exposure. */ @Slf4j public class PortBuddy { private static final String OUTDATED = "outdated"; private static final int EXIT_OK = 0; private static final int EXIT_ERROR = 1; private static final int EXIT_USAGE = 2; private final ConfigurationService configurationService = ConfigurationService.INSTANCE; private String domain; private String portReservation; private String passcode; private boolean verbose; private final List positionalArgs = new ArrayList<>(); private final OkHttpClient http = HttpUtils.createClient(); /** * Main entry point for the application. * * @param args command line arguments */ public static void main(final String[] args) { final var portBuddy = new PortBuddy(); final var exitCode = portBuddy.execute(args); System.exit(exitCode); } /** * Parses arguments and executes the requested command. * * @param args command line arguments * @return exit code */ public int execute(final String[] args) { if (args.length == 0) { printHelp(); return EXIT_USAGE; } var i = 0; while (i < args.length) { final var arg = args[i]; if ("-h".equals(arg) || "--help".equals(arg)) { printHelp(); return EXIT_OK; } else if ("-V".equals(arg) || "--version".equals(arg)) { printVersion(); return EXIT_OK; } else if ("-v".equals(arg) || "--verbose".equals(arg)) { this.verbose = true; } else if ("-d".equals(arg) || "--domain".equals(arg)) { if (++i < args.length) { this.domain = args[i]; } else { System.err.println("Error: Option '-d', '--domain' requires an argument."); return EXIT_USAGE; } } else if (arg.startsWith("--domain=")) { this.domain = arg.substring("--domain=".length()); } else if ("-pr".equals(arg) || "--port-reservation".equals(arg)) { if (++i < args.length) { this.portReservation = args[i]; } else { System.err.println("Error: Option '-pr', '--port-reservation' requires an argument."); return EXIT_USAGE; } } else if (arg.startsWith("--port-reservation=")) { this.portReservation = arg.substring("--port-reservation=".length()); } else if ("-pc".equals(arg) || "--passcode".equals(arg)) { if (++i < args.length) { this.passcode = args[i]; } else { System.err.println("Error: Option '-pc', '--passcode' requires an argument."); return EXIT_USAGE; } } else if (arg.startsWith("--passcode=")) { this.passcode = arg.substring("--passcode=".length()); } else if ("-n".equals(arg) || "--no-request-log".equals(arg)) { configurationService.getConfig().setLogEnabled(false); } else if ("init".equals(arg)) { if (++i < args.length) { return init(args[i]); } else { System.err.println("Error: Missing API token for 'init' command."); return EXIT_USAGE; } } else if (arg.startsWith("-")) { System.err.println("Unknown option: " + arg); printHelp(); return EXIT_USAGE; } else { positionalArgs.add(arg); } i++; } return expose(); } private void printHelp() { System.out.println("Usage: portbuddy [options] [mode] [host:][port]"); System.out.println("Expose local ports to public network (simple ngrok alternative)."); System.out.println(); System.out.println("Options:"); System.out.println(" -d, --domain= Requested domain (e.g. my-domain)"); System.out.println(" -pr, --port-reservation="); System.out.println(" Use specific port reservation host:port for TCP/UDP"); System.out.println(" -pc, --passcode= Passcode to secure HTTP tunnel (temporary for this tunnel)"); System.out.println(" -n, --no-request-log Disable request logging"); System.out.println(" -v, --verbose Verbose logging"); System.out.println(" -h, --help Show this help message and exit."); System.out.println(" -V, --version Print version information and exit."); System.out.println(); System.out.println("Commands:"); System.out.println(" init Initialize CLI with API token"); System.out.println(); System.out.println("Examples:"); System.out.println(" portbuddy 3000"); System.out.println(" portbuddy tcp 5432"); System.out.println(" portbuddy --domain=my-app 8080"); } private void printVersion() { System.out.println("portbuddy " + resolveCliVersion()); } private int init(final String apiToken) { try { configurationService.saveApiToken(apiToken); System.out.println("API token saved. You're now authenticated."); return EXIT_OK; } catch (final IOException e) { System.err.println("Failed to save API token: " + e.getMessage()); return EXIT_ERROR; } } private int expose() { final String modeStr; final String hostPortStr; if (positionalArgs.isEmpty()) { System.err.println("Usage: portbuddy [mode] [host:][port] or [schema://]host[:port]"); return EXIT_USAGE; } else if (positionalArgs.size() == 1) { modeStr = null; // default http hostPortStr = positionalArgs.get(0); } else { modeStr = positionalArgs.get(0); hostPortStr = positionalArgs.get(1); } final var mode = TunnelType.from(modeStr); final var hostPort = parseHostPort(hostPortStr); if (hostPort == null) { return EXIT_USAGE; } if (hostPort.port < 1 || hostPort.port > 65535) { System.err.println("Port must be in range [1, 65535]"); return EXIT_USAGE; } final var config = configurationService.getConfig(); // 1) Ensure API key is present and exchange it for a JWT at startup if (!ensureAuthenticated(config)) { return EXIT_ERROR; } final var apiKey = config.getApiToken(); final var jwt = exchangeApiTokenForJwt(config.getServerUrl(), apiKey); if (Objects.equals(jwt, OUTDATED)) { System.err.println(""" Your portbuddy CLI is outdated. Please upgrade to the latest version and try again."""); return EXIT_ERROR; } if (jwt == null || jwt.isBlank()) { System.err.println(""" Failed to authenticate with the provided API Key. CLI must be initialized with a valid API Key. Example: portbuddy init {API_TOKEN}"""); return EXIT_ERROR; } if (mode == TunnelType.HTTP) { final var expose = callExposeTunnel(config.getServerUrl(), jwt, new ExposeRequest(mode, hostPort.scheme, hostPort.host, hostPort.port, domain, null, passcode)); if (expose == null) { System.err.println("Failed to contact server to create tunnel"); return EXIT_ERROR; } final var localInfo = String.format("%s://%s:%d", hostPort.scheme, hostPort.host, hostPort.port); final var publicInfo = expose.publicUrl(); final var ui = new ConsoleUi(TunnelType.HTTP, localInfo, publicInfo); final var tunnelId = expose.tunnelId(); if (tunnelId == null) { System.err.println("Server did not return tunnelId"); return EXIT_ERROR; } final var client = new HttpTunnelClient( config.getServerUrl(), tunnelId, hostPort.host, hostPort.port, hostPort.scheme, jwt, publicInfo, ui, verbose ); final var thread = new Thread(client::runBlocking, "port-buddy-http-client"); ui.setOnExit(client::close); thread.start(); ui.start(); ui.waitForExit(); try { thread.join(2000); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } } else { final var scheme = mode == TunnelType.UDP ? "udp" : "tcp"; final var expose = callExposeTunnel(config.getServerUrl(), jwt, new ExposeRequest(mode, scheme, hostPort.host, hostPort.port, null, portReservation, null)); if (expose == null || expose.publicHost() == null || expose.publicPort() == null) { System.err.println("Failed to contact server to create " + mode + " tunnel"); return EXIT_ERROR; } final var localInfo = String.format("%s %s:%d", mode.name().toLowerCase(), hostPort.host, hostPort.port); final var publicInfo = String.format("%s:%d", expose.publicHost(), expose.publicPort()); final var ui = new ConsoleUi(mode, localInfo, publicInfo); final var tunnelId = expose.tunnelId(); if (tunnelId == null) { System.err.println("Server did not return tunnelId"); return EXIT_ERROR; } // Use configured API server URL for the WebSocket control channel, not the public TCP host final var serverUri = URI.create(config.getServerUrl()); final var wsHost = serverUri.getHost(); final var wsPort = serverUri.getPort() == -1 ? ("https".equalsIgnoreCase(serverUri.getScheme()) ? 443 : 80) : serverUri.getPort(); final var secure = "https".equalsIgnoreCase(serverUri.getScheme()); final var tcpClient = new NetTunnelClient( wsHost, wsPort, secure, tunnelId, hostPort.host, hostPort.port, mode, expose.publicHost(), expose.publicPort(), jwt, ui, verbose); final var thread = new Thread(tcpClient::runBlocking, "port-buddy-net-client-" + mode.name().toLowerCase()); ui.setOnExit(tcpClient::close); thread.start(); ui.start(); ui.waitForExit(); try { thread.join(2000); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } } System.out.println("\nThanks, bye!"); return EXIT_OK; } private ExposeResponse callExposeTunnel(final String baseUrl, final String jwt, final ExposeRequest requestBody) { final var tunnelType = requestBody.tunnelType(); try { final var url = baseUrl + "/api/expose/" + (tunnelType == TunnelType.HTTP ? "http" : "net"); final var json = MAPPER.writeValueAsString(requestBody); final var request = new Request.Builder() .url(url) .post(RequestBody.create(json, MediaType.parse("application/json"))) .header("Authorization", "Bearer " + jwt) .build(); try (final var response = http.newCall(request).execute()) { if (!response.isSuccessful()) { log.warn("Expose {} failed: {} {}", tunnelType, response.code(), response.message()); if (response.code() == 401) { System.err.println("Authentication failed. Please re-initialize CLI with a valid API Key.\n" + "Example: portbuddy init {API_TOKEN}"); } return null; } final var body = response.body(); if (body == null) { return null; } return MAPPER.readValue(body.string(), ExposeResponse.class); } } catch (final Exception e) { log.warn("Expose {} tunnel call error: {}", tunnelType, e.toString()); return null; } } /** * Exchanges the provided API token for a JWT using the server's auth endpoint. * Returns the JWT string if successful, otherwise null. */ private String exchangeApiTokenForJwt(final String baseUrl, final String apiToken) { try { final var url = baseUrl + "/api/auth/token-exchange"; final var cliVersion = resolveCliVersion(); final var payload = new TokenExchangeRequest(apiToken, cliVersion); final var json = MAPPER.writeValueAsString(payload); final var request = new Request.Builder() .url(url) .post(RequestBody.create(json, MediaType.parse("application/json"))) .build(); try (final var response = http.newCall(request).execute()) { if (!response.isSuccessful()) { log.warn("Token exchange failed: {} {}", response.code(), response.message()); if (response.code() == 426) { return OUTDATED; } return null; } final var body = response.body(); if (body == null) { return null; } final var resp = MAPPER.readValue(body.string(), TokenExchangeResponse.class); final var accessToken = resp.getAccessToken() == null ? "" : resp.getAccessToken(); final var tokenType = resp.getTokenType() == null ? "" : resp.getTokenType(); if (!accessToken.isBlank() && (tokenType.isBlank() || "Bearer".equalsIgnoreCase(tokenType))) { return accessToken; } return null; } } catch (final Exception e) { log.warn("Token exchange call error: {}", e.toString()); return null; } } private String resolveCliVersion() { final var pkg = PortBuddy.class.getPackage(); final var impl = pkg == null ? null : pkg.getImplementationVersion(); if (impl != null && !impl.isBlank()) { return impl.trim(); } return "1.0"; } private boolean ensureAuthenticated(final ClientConfig config) { final var apiKey = config.getApiToken(); if (apiKey != null && !apiKey.isBlank()) { return true; } System.out.println("No API key found. Please sign up."); try { final var request = ConsoleUi.promptForUserRegistration(); if (request == null) { System.err.println("Registration cancelled or failed to get details."); return false; } final var serverUrl = config.getServerUrl(); if (serverUrl == null || serverUrl.isBlank()) { System.err.println("Error: Server URL is not configured. Please check your application.yml."); return false; } final var newApiKey = registerUser(serverUrl, request); configurationService.saveApiToken(newApiKey); config.setApiToken(newApiKey); System.out.println("Registration successful! API key saved."); return true; } catch (final Exception e) { log.debug("Registration failed", e); System.err.println("Registration failed: " + e.getMessage()); if (e.getMessage() == null) { System.err.println("Error details: " + e.getClass().getName()); e.printStackTrace(System.err); // Print stack trace to see exactly where NPE happens } return false; } } private String registerUser(final String baseUrl, final RegisterRequest registerRequest) throws IOException { final var url = baseUrl + (baseUrl.endsWith("/") ? "" : "/") + "api/auth/register"; final var json = MAPPER.writeValueAsString(registerRequest); final var request = new Request.Builder() .url(url) .post(RequestBody.create(json, MediaType.parse("application/json"))) .build(); try (final var response = http.newCall(request).execute()) { final var respBody = response.body(); if (respBody == null) { if (!response.isSuccessful()) { throw new IOException("Server returned: " + response.message()); } throw new IOException("Empty response from server"); } if (response.code() == 503) { throw new IOException("Server is unavailable. Please try again later."); } final var bodyStr = respBody.string(); try { final var registerResponse = MAPPER.readValue(bodyStr, RegisterResponse.class); if (!registerResponse.isSuccess()) { throw new IOException(registerResponse.getMessage()); } return registerResponse.getApiKey(); } catch (final IOException e) { if (!response.isSuccessful()) { throw new IOException("Server returned: " + response.message()); } throw e; } } } private HostPort parseHostPort(final String arg) { var scheme = "http"; // default scheme var host = "localhost"; // default host Integer port = null; if (arg == null || arg.isBlank()) { System.err.println("Missing [host:][port] or [schema://]host[:port]. Example: 'portbuddy 3000' or 'portbuddy https://localhost'"); return null; } var url = arg.trim(); if (url.endsWith("/")) { url = url.substring(0, url.length() - 1); } // Case 1: pure port number, e.g. "3000" if (!url.contains("://") && !url.contains(":")) { // try parse as number; if fails, treat as host without port try { port = Integer.parseInt(url); return new HostPort(host, port, scheme); } catch (final NumberFormatException ignore) { // not a pure number -> host only, keep going host = url; port = null; } } var schemeExplicit = false; // Case 2: URL with scheme: http(s)://host[:port] if (url.contains("://")) { final var parts = url.split("://", 2); final var givenScheme = parts[0].toLowerCase(); if (!givenScheme.equals("http") && !givenScheme.equals("https")) { System.err.println("Unsupported schema: " + givenScheme + ". Only http or https are allowed."); return null; } scheme = givenScheme; schemeExplicit = true; final var rest = parts[1]; if (rest.contains(":")) { final var hostPortPart = rest.split(":", 2); host = hostPortPart[0].isBlank() ? host : hostPortPart[0]; try { port = Integer.parseInt(hostPortPart[1]); } catch (final NumberFormatException e) { System.err.println("Invalid port: " + hostPortPart[1]); return null; } } else if (!rest.isBlank()) { host = rest; } } else if (port == null) { // Case 3: host[:port] (no scheme) if (url.contains(":")) { final var hostPortPart = url.split(":", 2); host = hostPortPart[0].isBlank() ? host : hostPortPart[0]; try { port = Integer.parseInt(hostPortPart[1]); } catch (final NumberFormatException e) { System.err.println("Invalid port: " + hostPortPart[1]); return null; } } else { host = url; } } if (port == null) { // Default port by scheme port = scheme.equals("https") ? 443 : 80; } // If scheme is not explicit and port is 443/80, infer scheme from common defaults if (!schemeExplicit) { if (port == 443) { scheme = "https"; } else if (port == 80) { scheme = "http"; } } return new HostPort(host, port, scheme); } private static final class HostPort { private final String host; private final int port; private final String scheme; private HostPort(final String host, final int port, final String scheme) { this.host = host; this.port = port; this.scheme = scheme; } } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/config/ConfigurationService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.config; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; import java.util.HashSet; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.LoggerContext; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.ClientConfig; @Slf4j public class ConfigurationService { private static final String PORT_BUDDY_ENV = "PORT_BUDDY_ENV"; private static final String PORT_BUDDY_ENV_DEV = "dev"; private static final String APP_DIR = ".port-buddy"; private static final String TOKEN_FILE = "token"; private final String home = System.getProperty("user.home"); private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); private final AtomicReference config = new AtomicReference<>(); public static final ConfigurationService INSTANCE = new ConfigurationService(); private ConfigurationService() { try { configureLogging(); loadConfig(); loadToken(); } catch (final Exception e) { log.error("Failed to load config: {}", e.toString()); config.set(new ClientConfig()); } } private void configureLogging() { final var env = System.getenv(PORT_BUDDY_ENV); final var loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); final var rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME); rootLogger.setLevel(Level.ERROR); if (PORT_BUDDY_ENV_DEV.equalsIgnoreCase(env)) { final var appLogger = loggerContext.getLogger("tech.amak.portbuddy"); appLogger.setLevel(Level.DEBUG); } } private void loadConfig() throws IOException { final var envPart = Optional.ofNullable(System.getenv(PORT_BUDDY_ENV)) .map(String::toLowerCase) .map("-%s"::formatted) .orElse(""); final var resourceName = "/application%s.yml".formatted(envPart); log.debug("Loading config from resource: {}", resourceName); try (final var configStream = ConfigurationService.class.getResourceAsStream(resourceName)) { if (configStream != null) { final var clientConfig = yamlMapper.readValue(configStream, ClientConfig.class); log.debug("Loaded config: {}", clientConfig); config.set(clientConfig); } else { log.warn("Resource not found: {}", resourceName); if (config.get() == null) { config.set(new ClientConfig()); } } } } private void loadToken() throws IOException { final var tokenFile = Path.of(home, APP_DIR, TOKEN_FILE); if (Files.exists(tokenFile)) { final var token = Files.readString(tokenFile).trim(); if (!token.isBlank()) { config.get().setApiToken(token); } } } public ClientConfig getConfig() { return config.get(); } public boolean isDev() { final var env = System.getenv(PORT_BUDDY_ENV); return PORT_BUDDY_ENV_DEV.equalsIgnoreCase(env); } /** * Saves the provided API token to a file located in the user's home directory * under the ".port-buddy" directory. If the directory or file does not exist, * they are created. File permissions are restricted to the owner on POSIX systems. * * @param token the API token to be saved. It is stripped of leading and trailing whitespaces * before being written to the file. * @throws IOException if an I/O error occurs while creating the directory, file, or writing the token. */ public void saveApiToken(final String token) throws IOException { final var dir = Path.of(home, APP_DIR); if (!Files.exists(dir)) { Files.createDirectories(dir); } final var file = dir.resolve(TOKEN_FILE); Files.writeString(file, token.strip()); // Try to restrict permissions on POSIX systems try { final var perms = new HashSet(); perms.add(PosixFilePermission.OWNER_READ); perms.add(PosixFilePermission.OWNER_WRITE); Files.setPosixFilePermissions(file, perms); } catch (final UnsupportedOperationException ignore) { // Non-POSIX filesystem (e.g., Windows) - best effort only } } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/tunnel/HttpTunnelClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.tunnel; import static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okio.ByteString; import tech.amak.portbuddy.cli.config.ConfigurationService; import tech.amak.portbuddy.cli.ui.ConsoleUi; import tech.amak.portbuddy.cli.ui.HttpLogSink; import tech.amak.portbuddy.cli.utils.HttpUtils; import tech.amak.portbuddy.common.tunnel.ControlMessage; import tech.amak.portbuddy.common.tunnel.HttpTunnelMessage; import tech.amak.portbuddy.common.tunnel.MessageEnvelope; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; @Slf4j @RequiredArgsConstructor public class HttpTunnelClient { private final String serverUrl; // e.g. https://portbuddy.dev private final UUID tunnelId; private final String localHost; private final int localPort; private final String localScheme; // http or https private final String authToken; // Bearer token for API auth private final String publicBaseUrl; // e.g. https://abc123.portbuddy.dev private final HttpLogSink httpLogSink; private final boolean verbose; // OkHttp client used exclusively for the control WebSocket connection to the server private final OkHttpClient http = createHttpClient(); // Separate OkHttp client for calling the local target service (avoid any interference with WS client) private final OkHttpClient localHttp = createLocalHttpClient(); private static OkHttpClient createHttpClient() { final var builder = new OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive for WS .pingInterval(15, TimeUnit.SECONDS) // send pings to keep intermediaries/proxies from dropping idle WS .retryOnConnectionFailure(true); if (ConfigurationService.INSTANCE.isDev()) { HttpUtils.configureInsecureSsl(builder); } return builder.build(); } private static OkHttpClient createLocalHttpClient() { final var builder = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) // Do not follow redirects automatically; they must be proxied back to the client .followRedirects(false) .followSslRedirects(false) .retryOnConnectionFailure(true); if (ConfigurationService.INSTANCE.isDev()) { HttpUtils.configureInsecureSsl(builder); } return builder.build(); } private WebSocket webSocket; private CountDownLatch closed = new CountDownLatch(1); private final AtomicBoolean stop = new AtomicBoolean(false); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { final var thread = new Thread(runnable, "port-buddy-heartbeat"); thread.setDaemon(true); return thread; }); private volatile ScheduledFuture heartbeatTask; private final ExecutorService requestExecutor = Executors.newFixedThreadPool(4, runnable -> { final var thread = new Thread(runnable, "port-buddy-http-worker"); thread.setDaemon(true); return thread; }); private final Map localWebsocketMap = new ConcurrentHashMap<>(); /** * Establishes and maintains a blocking WebSocket connection to the server. * This method constructs a WebSocket connection to a server using a URL * derived from the server's URL combined with the tunnel identifier. The * method blocks until the WebSocket connection is closed or interrupted. * Behavior: * - Converts the server URL and tunnel identifier into a WebSocket URL. * - Opens a WebSocket connection to the calculated URL and uses a * {@code Listener} to handle WebSocket events, such as incoming messages, * connection closure, or failures. * - Waits on the {@code closeLatch} to ensure blocking behavior until the * connection is terminated. * Exceptions: * - Catches and handles {@link InterruptedException} if the wait operation * on the latch is interrupted. Restores the interrupted thread state. */ public void runBlocking() { var backoffMs = 1000L; final var maxBackoffMs = 30000L; while (!stop.get()) { try { closed = new CountDownLatch(1); final var wsUrl = toWebSocketUrl(serverUrl, "/api/http-tunnel/" + tunnelId); final var request = new Request.Builder().url(wsUrl); if (authToken != null && !authToken.isBlank()) { request.addHeader("Authorization", "Bearer " + authToken); } webSocket = http.newWebSocket(request.build(), new Listener()); // Block until this connection is closed closed.await(); if (stop.get()) { break; } // Reconnect with backoff log.info("Tunnel disconnected; reconnecting in {} ms...", backoffMs); Thread.sleep(backoffMs); backoffMs = Math.min(backoffMs * 2, maxBackoffMs); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (final Exception e) { log.warn("Tunnel loop error: {}", e.toString()); if (verbose) { e.printStackTrace(System.err); } try { Thread.sleep(backoffMs); } catch (final InterruptedException ie) { Thread.currentThread().interrupt(); break; } backoffMs = Math.min(backoffMs * 2, maxBackoffMs); } } } /** * Closes the WebSocket connection associated with this HTTP tunnel client. * This method attempts to gracefully close the WebSocket connection, if it exists, * using the standard WebSocket closure status code 1000 (indicating a normal closure) * and a reason message "Client exit". If an exception occurs during the closure process, * it is logged at the debug level and suppressed to ensure that the exception does not * disrupt the application's flow. * Behavior: * - If there is an active WebSocket (represented by the {@code webSocket} field), * it calls the {@code close} method on the WebSocket instance, passing the closure * status code and reason message. * - Logs any exception encountered during the close operation at the debug level * without re-throwing it. * Thread-safety: This method is thread-safe, as it uses a local reference to the * {@code webSocket} field to prevent potential null pointer exceptions caused by * concurrent modifications. */ public void close() { try { stop.set(true); final var task = heartbeatTask; if (task != null) { task.cancel(true); } scheduler.shutdownNow(); requestExecutor.shutdownNow(); if (webSocket != null) { webSocket.close(1000, "Client exit"); log.debug("Websocket closed: 1000 OK"); } localWebsocketMap.values().forEach(ws -> { try { ws.close(1000, "Tunnel closed"); } catch (final Exception ignore) { // ignore } }); localWebsocketMap.clear(); } catch (final Exception ignore) { log.debug("HTTP tunnel close error: {}", ignore.toString()); } } private String toWebSocketUrl(final String base, final String path) { final var uri = URI.create(base); var scheme = uri.getScheme(); if ("https".equalsIgnoreCase(scheme)) { scheme = "wss"; } else if ("http".equalsIgnoreCase(scheme)) { scheme = "ws"; } final var hostPort = (uri.getPort() == -1) ? uri.getHost() : (uri.getHost() + ":" + uri.getPort()); return scheme + "://" + hostPort + path; } private class Listener extends WebSocketListener { @Override public void onOpen(final WebSocket webSocket, final Response response) { log.debug("Tunnel connected to server"); // Start application-level heartbeat PINGs try { if (heartbeatTask != null && !heartbeatTask.isCancelled()) { heartbeatTask.cancel(true); } final var config = ConfigurationService.INSTANCE.getConfig(); final var intervalSec = config.getHealthcheckIntervalSec(); heartbeatTask = scheduler.scheduleAtFixedRate(() -> { try { final var ping = new ControlMessage(); ping.setType(ControlMessage.Type.PING); ping.setTs(System.currentTimeMillis()); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ping)); } catch (final Exception e) { log.debug("Heartbeat send failed: {}", e.toString()); } }, intervalSec, intervalSec, TimeUnit.SECONDS); } catch (final Exception e) { log.debug("Failed to start heartbeat: {}", e.toString()); } } @Override public void onMessage(final WebSocket webSocket, final String text) { try { log.debug("Received WS message: {}", text); final var env = MAPPER.readValue(text, MessageEnvelope.class); if (env.getKind() != null && env.getKind().equals("CTRL")) { final var ctrl = MAPPER.readValue(text, ControlMessage.class); if (ctrl.getType() == ControlMessage.Type.EXIT) { log.info("Received EXIT control message. Shutting down..."); try { HttpTunnelClient.this.webSocket.close(1000, "Server requested exit"); } catch (final Exception ignore) { // ignore } if (httpLogSink instanceof ConsoleUi ui) { ui.stop(); } } return; } if (env.getKind() != null && env.getKind().equals("WS")) { final var wsMsg = MAPPER.readValue(text, WsTunnelMessage.class); handleWsFromServer(wsMsg); return; } final var message = MAPPER.readValue(text, HttpTunnelMessage.class); if (message.getType() == HttpTunnelMessage.Type.REQUEST) { // Offload request processing to a worker thread to avoid blocking the WS listener requestExecutor.submit(() -> { try { final var resp = handleRequest(message); final var json = MAPPER.writeValueAsString(resp); HttpTunnelClient.this.webSocket.send(json); log.debug("Responded to WS request: {}", resp.getId()); } catch (final Exception ex) { log.warn("Failed to handle tunneled request {}: {}", message.getId(), ex.toString()); try { final var error = buildErrorMessage(message.getId(), 502, "Proxy error"); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(error)); } catch (final Exception e) { log.error("Failed to send error response: {}", e.getMessage(), e); } } }); } else { log.debug("Ignoring non-REQUEST msg"); } } catch (final Exception e) { log.warn("Failed to process WS message: {}", e.toString()); } } @Override public void onClosed(final WebSocket webSocket, final int code, final String reason) { log.info("Tunnel closed: {} {}", code, reason); final var task = heartbeatTask; if (task != null) { task.cancel(true); } localWebsocketMap.values().forEach(ws -> { try { ws.close(1000, "Tunnel closed"); } catch (final Exception ignore) { // ignore } }); localWebsocketMap.clear(); closed.countDown(); } @Override public void onFailure(final WebSocket webSocket, final Throwable error, final Response response) { log.warn("Tunnel failure: {}", error.toString()); final var task = heartbeatTask; if (task != null) { task.cancel(true); } localWebsocketMap.values().forEach(ws -> { try { ws.close(1000, "Tunnel failure"); } catch (final Exception ignore) { // ignore } }); localWebsocketMap.clear(); closed.countDown(); } } private void handleWsFromServer(final WsTunnelMessage message) { final var connId = message.getConnectionId(); switch (message.getWsType()) { case OPEN -> { // Connect to local target via WS final var localWsScheme = "https".equalsIgnoreCase(localScheme) ? "wss" : "ws"; var url = localWsScheme + "://" + localHost + ":" + localPort + (message.getPath() != null ? message.getPath() : "/"); if (message.getQuery() != null && !message.getQuery().isBlank()) { url += "?" + message.getQuery(); } final var builder = new Request.Builder().url(url); final var publicHost = URI.create(publicBaseUrl).getHost(); if (publicHost != null) { builder.header("Host", publicHost); } if (message.getHeaders() != null) { for (final var entry : message.getHeaders().entrySet()) { if (entry.getKey() != null && entry.getValue() != null) { if (entry.getKey().equalsIgnoreCase("Host")) { continue; } builder.addHeader(entry.getKey(), entry.getValue()); } } } final var local = http.newWebSocket(builder.build(), new LocalWsListener(connId)); localWebsocketMap.put(connId, local); } case TEXT -> { final var local = localWebsocketMap.get(connId); if (local != null) { local.send(message.getText() != null ? message.getText() : ""); } } case BINARY -> { final var local = localWebsocketMap.get(connId); if (local != null && message.getDataB64() != null) { local.send(ByteString.of(Base64.getDecoder().decode(message.getDataB64()))); } } case CLOSE -> { final var local = localWebsocketMap.remove(connId); if (local != null) { local.close(message.getCloseCode() != null ? message.getCloseCode() : 1000, message.getCloseReason()); } } default -> { } } } @RequiredArgsConstructor private class LocalWsListener extends WebSocketListener { private final String connectionId; @Override public void onOpen(final WebSocket webSocket, final Response response) { try { final var ack = new WsTunnelMessage(); ack.setWsType(WsTunnelMessage.Type.OPEN_OK); ack.setConnectionId(connectionId); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ack)); } catch (final Exception ignore) { log.error("Failed to send local WS open ack: {}", ignore.toString()); } } @Override public void onMessage(final WebSocket webSocket, final String text) { try { final var message = new WsTunnelMessage(); message.setWsType(WsTunnelMessage.Type.TEXT); message.setConnectionId(connectionId); message.setText(text); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message)); } catch (final Exception e) { log.debug("Failed to forward local text WS: {}", e.toString()); } } @Override public void onMessage(final WebSocket webSocket, final ByteString bytes) { try { final var message = new WsTunnelMessage(); message.setWsType(WsTunnelMessage.Type.BINARY); message.setConnectionId(connectionId); message.setDataB64(Base64.getEncoder().encodeToString(bytes.toByteArray())); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message)); } catch (final Exception e) { log.debug("Failed to forward local binary WS: {}", e.toString()); } } @Override public void onClosed(final WebSocket webSocket, final int code, final String reason) { try { localWebsocketMap.remove(connectionId); final var message = new WsTunnelMessage(); message.setWsType(WsTunnelMessage.Type.CLOSE); message.setConnectionId(connectionId); message.setCloseCode(code); message.setCloseReason(reason); HttpTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(message)); } catch (final Exception e) { log.debug("Failed to notify close: {}", e.toString()); } } @Override public void onFailure(final WebSocket webSocket, final Throwable error, final Response response) { onClosed(webSocket, 1011, error.toString()); } } private HttpTunnelMessage handleRequest(final HttpTunnelMessage requestMessage) { final var method = requestMessage.getMethod(); var url = localScheme + "://" + localHost + ":" + localPort + requestMessage.getPath(); if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) { url += "?" + requestMessage.getQuery(); } final var targetRequest = new Request.Builder() .url(url) .method(method, buildBody(method, requestMessage.getBodyB64(), requestMessage.getBodyContentType())); final var publicHost = URI.create(publicBaseUrl).getHost(); if (publicHost != null) { targetRequest.header("Host", publicHost); } if (requestMessage.getHeaders() != null) { for (final var header : requestMessage.getHeaders().entrySet()) { final var name = header.getKey(); final var values = header.getValue(); if (name == null || values == null) { continue; } if (name.equalsIgnoreCase("Host")) { continue; // Host will be set by client } if (name.equalsIgnoreCase("Content-Type")) { // Content-Type is derived from RequestBody media type continue; } for (final var value : values) { if (value != null) { targetRequest.addHeader(name, value); } } } } try (final var targetResponse = localHttp.newCall(targetRequest.build()).execute()) { final var successMessage = new HttpTunnelMessage(); successMessage.setId(requestMessage.getId()); successMessage.setType(HttpTunnelMessage.Type.RESPONSE); successMessage.setStatus(targetResponse.code()); successMessage.setRespHeaders(extractHeaders(targetResponse)); final var body = targetResponse.body(); if (body != null) { final var bytes = body.bytes(); if (bytes.length > 0) { successMessage.setRespBodyB64(Base64.getEncoder().encodeToString(bytes)); } } // Log to UI sink try { if (httpLogSink != null) { var displayUrl = publicBaseUrl; if (requestMessage.getPath() != null) { displayUrl += requestMessage.getPath(); } if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) { displayUrl += "?" + requestMessage.getQuery(); } httpLogSink.onHttpLog(method, displayUrl, targetResponse.code()); } } catch (final Exception ignore) { log.debug("HTTP log sink failed: {}", ignore.toString()); } return successMessage; } catch (final Exception e) { final var errorMessage = buildErrorMessage(requestMessage.getId(), 502, "Bad Gateway: " + e.getMessage()); try { if (httpLogSink != null) { var displayUrl = publicBaseUrl; if (requestMessage.getPath() != null) { displayUrl += requestMessage.getPath(); } if (requestMessage.getQuery() != null && !requestMessage.getQuery().isBlank()) { displayUrl += "?" + requestMessage.getQuery(); } httpLogSink.onHttpLog(method, displayUrl, 502); } } catch (final Exception ignore) { log.debug("HTTP log sink failed: {}", ignore.toString()); } return errorMessage; } } private static HttpTunnelMessage buildErrorMessage(final String id, final int status, final String message) { final var error = new HttpTunnelMessage(); error.setId(id); error.setType(HttpTunnelMessage.Type.RESPONSE); error.setStatus(status); final var headers = Map.>of("Content-Type", List.of("text/plain; charset=utf-8")); error.setRespHeaders(headers); error.setRespBodyB64(Base64.getEncoder().encodeToString((message).getBytes(StandardCharsets.UTF_8))); return error; } private RequestBody buildBody(final String method, final String bodyB64, final String contentType) { // Methods that usually don't have body if (bodyB64 == null) { return methodSupportsBody(method) ? RequestBody.create(new byte[0], contentType != null ? MediaType.parse(contentType) : null) : null; } final var bytes = Base64.getDecoder().decode(bodyB64); final var mediaType = contentType != null && !contentType.isBlank() ? MediaType.parse(contentType) : MediaType.parse("application/octet-stream"); return RequestBody.create(bytes, mediaType); } private boolean methodSupportsBody(final String method) { if (method == null) { return false; } return switch (method.toUpperCase()) { case "POST", "PUT", "PATCH" -> true; default -> false; }; } private Map> extractHeaders(final Response response) { final var map = new HashMap>(); for (final var name : response.headers().names()) { final var values = response.headers(name); if (values != null && !values.isEmpty()) { map.put(name, new ArrayList<>(values)); } } return map; } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/tunnel/NetTunnelClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.tunnel; import static tech.amak.portbuddy.cli.utils.JsonUtils.MAPPER; import java.io.InputStream; import java.io.OutputStream; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetSocketAddress; import java.net.Socket; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.WebSocket; import okhttp3.WebSocketListener; import okio.ByteString; import tech.amak.portbuddy.cli.config.ConfigurationService; import tech.amak.portbuddy.cli.ui.ConsoleUi; import tech.amak.portbuddy.cli.ui.NetTrafficSink; import tech.amak.portbuddy.cli.utils.HttpUtils; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.tunnel.BinaryWsFrame; import tech.amak.portbuddy.common.tunnel.ControlMessage; import tech.amak.portbuddy.common.tunnel.MessageEnvelope; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; @Slf4j @RequiredArgsConstructor public class NetTunnelClient { private final String proxyHost; private final int proxyHttpPort; /** * Whether the WebSocket should use TLS (wss). Must reflect the scheme of the configured server URL. */ private final boolean secure; private final UUID tunnelId; private final String localHost; private final int localPort; private final TunnelType tunnelType; // Expected public connection details returned by the server during expose REST call private final String expectedPublicHost; private final int expectedPublicPort; private final String authToken; // Bearer token if available private final NetTrafficSink trafficSink; private final boolean verbose; private final OkHttpClient http = HttpUtils.createClient(); private final OkHttpClient rest = HttpUtils.createClient(); private WebSocket webSocket; private final Map locals = new ConcurrentHashMap<>(); private final Map udpLocals = new ConcurrentHashMap<>(); private CountDownLatch closed = new CountDownLatch(1); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> { final var thread = new Thread(runnable, "pb-net-heartbeat"); thread.setDaemon(true); return thread; }); private volatile ScheduledFuture heartbeatTask; private volatile ScheduledFuture wsHeartbeatTask; private final AtomicBoolean closedReported = new AtomicBoolean(false); private final AtomicBoolean stop = new AtomicBoolean(false); private final AtomicBoolean warnedAboutReassignment = new AtomicBoolean(false); private final AtomicBoolean successfullyConnected = new AtomicBoolean(false); /** * Establishes and maintains a WebSocket connection for TCP/UDP tunneling. * This method constructs the WebSocket URL using the configured proxy host, port, and tunnel ID. * It sets up an authentication token in the request header, if provided, and initializes the WebSocket connection. * The method blocks the current thread until the connection is closed or an interruption occurs. * Behavior: * - Converts a base HTTP URL to a WebSocket URL using the {@code toWebSocketUrl} method. * - Adds an optional "Authorization" header to the WebSocket request for authentication. * - Creates a WebSocket connection using the provided URL and the {@code Listener} for handling events. * - Waits for the WebSocket connection to close by utilizing a {@code CountDownLatch}. * - Handles interruptions by setting the thread's interrupt status. */ public void runBlocking() { var backoffMs = 1000L; final var maxBackoffMs = 30000L; while (!stop.get()) { try { closed = new CountDownLatch(1); final var scheme = secure ? "https://" : "http://"; final var publicHostParam = (expectedPublicHost == null || expectedPublicHost.isBlank()) ? "" : "&public-host=" + URLEncoder.encode(expectedPublicHost, StandardCharsets.UTF_8); final var path = "/api/net-tunnel/" + tunnelId + "?type=" + tunnelType.name().toLowerCase() + "&port=" + expectedPublicPort + publicHostParam; final var url = toWebSocketUrl(scheme + proxyHost + ":" + proxyHttpPort, path); final var request = new Request.Builder().url(url); if (authToken != null && !authToken.isBlank()) { request.addHeader("Authorization", "Bearer " + authToken); } webSocket = http.newWebSocket(request.build(), new Listener()); successfullyConnected.set(false); // Block until this connection is closed closed.await(); if (stop.get()) { break; } if (successfullyConnected.get()) { backoffMs = 1000L; } // Reconnect with backoff log.info("Net tunnel disconnected; reconnecting in {} ms...", backoffMs); Thread.sleep(backoffMs); backoffMs = Math.min(backoffMs * 2, maxBackoffMs); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (final Exception e) { log.warn("Net tunnel loop error: {}", e.toString()); if (verbose) { e.printStackTrace(System.err); } try { Thread.sleep(backoffMs); } catch (final InterruptedException ie) { Thread.currentThread().interrupt(); break; } backoffMs = Math.min(backoffMs * 2, maxBackoffMs); } } } /** * Closes the WebSocket connection for the TCP tunnel client. * This method attempts to gracefully close the WebSocket connection, if it exists, * by sending a close frame with a status code of 1000 (normal closure) and a reason * message ("Client exit"). If an exception occurs during the close operation, the * error is logged for debugging purposes. * Behavior: * - Checks if the WebSocket instance (`ws`) is not null. * - If the WebSocket exists, sends a close frame with a normal status and reason. * - Catches and logs any exceptions encountered during the close operation, * ensuring the process does not disrupt the program flow. */ public void close() { try { stop.set(true); final var task = heartbeatTask; if (task != null) { task.cancel(true); } final var wsTask = wsHeartbeatTask; if (wsTask != null) { wsTask.cancel(true); } scheduler.shutdownNow(); if (webSocket != null) { webSocket.close(1000, "Client exit"); } locals.values().forEach(this::close); locals.clear(); udpLocals.values().forEach(this::close); udpLocals.clear(); reportClosedSafe(); } catch (final Exception e) { log.debug("TCP tunnel close error: {}", e.toString()); } } private void close(final LocalTcp localTcp) { if (localTcp != null) { try { localTcp.sock.close(); } catch (final Exception e) { log.debug("Failed to close TCP: {}", e.toString()); } } } private void close(final LocalUdp localUdp) { if (localUdp != null) { try { localUdp.sock.close(); } catch (final Exception e) { log.debug("Failed to close UDP local: {}", e.toString()); } } } private String toWebSocketUrl(final String httpUri, final String path) { var uri = httpUri; if (uri.startsWith("http://")) { uri = "ws://" + uri.substring(7); } else if (uri.startsWith("https://")) { uri = "wss://" + uri.substring(8); } if (uri.endsWith("/")) { uri = uri.substring(0, uri.length() - 1); } return uri + path; } private class Listener extends WebSocketListener { @Override public void onOpen(final WebSocket webSocket, final Response response) { successfullyConnected.set(true); // Report CONNECTED and start heartbeats try { postStatus("/api/tunnels/" + tunnelId + "/connected"); } catch (final Exception e) { log.debug("Failed to report NET connected: {}", e.toString()); } // allow reporting CLOSED again for future disconnects after a successful reconnect closedReported.set(false); final var config = ConfigurationService.INSTANCE.getConfig(); final var intervalSec = Math.max(1, config.getHealthcheckIntervalSec()); try { final var existing = heartbeatTask; if (existing != null && !existing.isCancelled()) { existing.cancel(true); } heartbeatTask = scheduler.scheduleAtFixedRate(() -> { try { postStatus("/api/tunnels/" + tunnelId + "/heartbeat"); } catch (final Exception e) { log.debug("NET heartbeat failed: {}", e.toString()); } }, 1, intervalSec, TimeUnit.SECONDS); } catch (final Exception e) { log.debug("Failed to start NET heartbeat: {}", e.toString()); } // Start WS application-level heartbeat (PING/PONG) try { final var existingWs = wsHeartbeatTask; if (existingWs != null && !existingWs.isCancelled()) { existingWs.cancel(true); } wsHeartbeatTask = scheduler.scheduleAtFixedRate(() -> { try { final var ping = new ControlMessage(); ping.setType(ControlMessage.Type.PING); ping.setTs(System.currentTimeMillis()); NetTunnelClient.this.webSocket.send(MAPPER.writeValueAsString(ping)); } catch (final Exception e) { log.debug("WS heartbeat send failed: {}", e.toString()); } }, intervalSec, intervalSec, TimeUnit.SECONDS); } catch (final Exception e) { log.debug("Failed to start WS heartbeat: {}", e.toString()); } } @Override public void onMessage(final WebSocket webSocket, final String text) { try { final var env = MAPPER.readValue(text, MessageEnvelope.class); if (env.getKind() != null && env.getKind().equals("CTRL")) { final var ctrl = MAPPER.readValue(text, ControlMessage.class); if (ctrl.getType() == ControlMessage.Type.EXIT) { log.info("Received EXIT control message. Shutting down..."); try { NetTunnelClient.this.webSocket.close(1000, "Server requested exit"); } catch (final Exception ignore) { // ignore } if (trafficSink instanceof ConsoleUi ui) { ui.stop(); } } return; } if (env.getKind() != null && env.getKind().equals("WS")) { final var msg = MAPPER.readValue(text, WsTunnelMessage.class); handleControl(msg); } // Unknown kinds are ignored for NET tunnels } catch (final Exception e) { log.warn("Failed to process WS text message: {}", e.toString()); } } @Override public void onMessage(final WebSocket webSocket, final ByteString bytes) { try { final var decoded = BinaryWsFrame.decode(bytes.toByteArray()); if (decoded == null) { return; } if (tunnelType == TunnelType.TCP) { final var local = locals.get(decoded.connectionId()); if (local != null) { try { local.out.write(decoded.data()); local.out.flush(); if (trafficSink != null) { trafficSink.onBytesIn(decoded.data().length); } } catch (final Exception e) { log.debug("Write to local TCP failed: {}", e.toString()); } } } else if (tunnelType == TunnelType.UDP) { // For UDP, forward the datagram to local UDP server using per-connection socket final var connId = decoded.connectionId(); var localUdp = udpLocals.get(connId); if (localUdp == null) { try { final var sock = new DatagramSocket(); localUdp = new LocalUdp(connId, sock); udpLocals.put(connId, localUdp); // start receive loop for this connection final var localUdpRef = localUdp; new Thread(() -> pumpUdpLocalToProxy(localUdpRef)).start(); } catch (final Exception e) { log.debug("Failed to create local UDP socket: {}", e.toString()); return; } } try { final var packet = new DatagramPacket(decoded.data(), decoded.data().length, new InetSocketAddress(localHost, localPort)); localUdp.sock.send(packet); if (trafficSink != null) { trafficSink.onBytesIn(decoded.data().length); } } catch (final Exception e) { log.debug("Write to local UDP failed: {}", e.toString()); } } } catch (final Exception e) { log.debug("Failed to handle binary WS frame: {}", e.toString()); } } @Override public void onClosed(final WebSocket webSocket, final int code, final String reason) { log.info("Tunnel closed: {} {}", code, reason); final var task = heartbeatTask; if (task != null) { task.cancel(true); } final var wsTask = wsHeartbeatTask; if (wsTask != null) { wsTask.cancel(true); } locals.values().forEach(NetTunnelClient.this::close); locals.clear(); udpLocals.values().forEach(NetTunnelClient.this::close); udpLocals.clear(); reportClosedSafe(); closed.countDown(); } @Override public void onFailure(final WebSocket webSocket, final Throwable throwable, final Response response) { log.warn("Tunnel failure: {}", throwable.toString()); final var task = heartbeatTask; if (task != null) { task.cancel(true); } final var wsTask = wsHeartbeatTask; if (wsTask != null) { wsTask.cancel(true); } locals.values().forEach(NetTunnelClient.this::close); locals.clear(); udpLocals.values().forEach(NetTunnelClient.this::close); udpLocals.clear(); reportClosedSafe(); closed.countDown(); } } private void reportClosedSafe() { if (closedReported.compareAndSet(false, true)) { try { postStatus("/api/tunnels/" + tunnelId + "/closed"); } catch (final Exception e) { log.debug("Failed to report NET closed: {}", e.toString()); } } } private void handleControl(final WsTunnelMessage message) throws Exception { final var connId = message.getConnectionId(); switch (message.getWsType()) { case EXPOSED -> { final var actualHost = message.getPublicHost(); final var actualPort = message.getPublicPort(); if (actualHost != null && actualPort != null) { final var hostDiffers = expectedPublicHost != null && !expectedPublicHost.equals(actualHost); final var portDiffers = expectedPublicPort != actualPort; if ((hostDiffers || portDiffers) && warnedAboutReassignment.compareAndSet(false, true)) { System.out.printf( "Warning: requested public %s:%d but exposed on %s:%d%n", expectedPublicHost, expectedPublicPort, actualHost, actualPort ); } } } case OPEN -> { if (tunnelType == TunnelType.TCP) { // Establish local TCP final var socket = new Socket(); socket.connect(new InetSocketAddress(localHost, localPort), 5000); final var local = new LocalTcp(connId, socket); locals.put(connId, local); // Ack final var ack = new WsTunnelMessage(); ack.setWsType(WsTunnelMessage.Type.OPEN_OK); ack.setConnectionId(connId); webSocket.send(MAPPER.writeValueAsString(ack)); // Start reader thread from local TCP to proxy WS new Thread(() -> pumpLocalToProxy(local)).start(); } else { // UDP does not use OPEN for per-flow; ignore or acknowledge for compatibility final var ack = new WsTunnelMessage(); ack.setWsType(WsTunnelMessage.Type.OPEN_OK); ack.setConnectionId(connId); webSocket.send(MAPPER.writeValueAsString(ack)); } } case BINARY -> { if (tunnelType == TunnelType.TCP) { // Base64 payload from proxy to local TCP (legacy) final var local = locals.get(connId); if (local != null && message.getDataB64() != null) { try { final var bytes = Base64.getDecoder().decode(message.getDataB64()); local.out.write(bytes); local.out.flush(); if (trafficSink != null) { trafficSink.onBytesIn(bytes.length); } } catch (final Exception e) { log.debug("Write to local TCP failed: {}", e.toString()); } } } else if (tunnelType == TunnelType.UDP) { // Legacy TEXT BINARY for UDP: forward to local as datagram if (message.getDataB64() != null) { var localUdp = udpLocals.get(connId); if (localUdp == null) { final var sock = new DatagramSocket(); localUdp = new LocalUdp(connId, sock); udpLocals.put(connId, localUdp); final var localUdpRef = localUdp; new Thread(() -> pumpUdpLocalToProxy(localUdpRef)).start(); } final var bytes = Base64.getDecoder().decode(message.getDataB64()); final var packet = new DatagramPacket(bytes, bytes.length, new InetSocketAddress(localHost, localPort)); localUdp.sock.send(packet); if (trafficSink != null) { trafficSink.onBytesIn(bytes.length); } } } } case CLOSE -> { if (tunnelType == TunnelType.TCP) { close(locals.remove(connId)); } else { close(udpLocals.remove(connId)); } } default -> { } } } private void pumpLocalToProxy(final LocalTcp local) { final var buffer = new byte[8192]; try { while (true) { final var byteCount = local.in.read(buffer); if (byteCount == -1) { break; } final var frame = BinaryWsFrame.encodeToArray(local.connectionId, buffer, 0, byteCount); final var byteString = ByteString.of(frame); webSocket.send(byteString); if (trafficSink != null) { trafficSink.onBytesOut(byteCount); } } } catch (final Exception e) { // ignore } finally { try { final var message = new WsTunnelMessage(); message.setWsType(WsTunnelMessage.Type.CLOSE); message.setConnectionId(local.connectionId); webSocket.send(MAPPER.writeValueAsString(message)); } catch (final Exception e) { log.error("Failed to send local WS close: {}", e.toString()); } close(local); locals.remove(local.connectionId); } } private static class LocalTcp { final String connectionId; final Socket sock; final InputStream in; final OutputStream out; LocalTcp(final String connectionId, final Socket sock) throws Exception { this.connectionId = connectionId; this.sock = sock; this.in = sock.getInputStream(); this.out = sock.getOutputStream(); } } private static class LocalUdp { final String connectionId; final DatagramSocket sock; LocalUdp(final String connectionId, final DatagramSocket sock) { this.connectionId = connectionId; this.sock = sock; } } private void pumpUdpLocalToProxy(final LocalUdp local) { final var buffer = new byte[65535]; try { while (!local.sock.isClosed()) { final var packet = new DatagramPacket(buffer, buffer.length); local.sock.receive(packet); final var frame = BinaryWsFrame .encodeToArray(local.connectionId, packet.getData(), packet.getOffset(), packet.getLength()); final var byteString = ByteString.of(frame); webSocket.send(byteString); if (trafficSink != null) { trafficSink.onBytesOut(packet.getLength()); } } } catch (final Exception e) { // ignore normal close } finally { close(local); udpLocals.remove(local.connectionId); } } private void postStatus(final String path) throws Exception { final var base = (secure ? "https://" : "http://") + proxyHost + ":" + proxyHttpPort; final var url = base + path; final var body = RequestBody.create("{}", MediaType.parse("application/json")); final var builder = new Request.Builder().url(url).post(body); if (authToken != null && !authToken.isBlank()) { builder.header("Authorization", "Bearer " + authToken); } try (final var response = rest.newCall(builder.build()).execute()) { if (!response.isSuccessful()) { log.debug("Status POST failed {} {} for {}", response.code(), response.message(), path); } } } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/ui/ConsoleUi.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.ui; import java.io.IOException; import java.io.PrintWriter; import java.time.Duration; import java.util.ArrayDeque; import java.util.Deque; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jline.reader.LineReaderBuilder; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; import org.jline.utils.InfoCmp; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.cli.config.ConfigurationService; import tech.amak.portbuddy.common.ClientConfig; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.dto.auth.RegisterRequest; @Slf4j @RequiredArgsConstructor public class ConsoleUi implements HttpLogSink, NetTrafficSink { public record HttpLog(String method, String url, int status) { } private final TunnelType tunnelType; private final String localDetails; private final String publicDetails; private Terminal terminal; private PrintWriter out; private final Deque httpLogs = new ArrayDeque<>(); private final AtomicLong inBytes = new AtomicLong(); private final AtomicLong outBytes = new AtomicLong(); private final AtomicBoolean running = new AtomicBoolean(false); private final CountDownLatch exit = new CountDownLatch(1); private final ClientConfig config = ConfigurationService.INSTANCE.getConfig(); private Thread renderThread; @Getter @Setter private Runnable onExit; /** * Prompts the user for registration details using the console. * When no API key is initialized, the user should only be asked for the email address. * * @return a RegisterRequest containing the input data (email only) * @throws IOException if an I/O error occurs or console is not available */ public static RegisterRequest promptForUserRegistration() throws IOException { try (final var terminal = buildTerminal()) { final var reader = LineReaderBuilder.builder().terminal(terminal).build(); String email; while (true) { email = reader.readLine("Email: "); if (isValidEmail(email)) { break; } terminal.writer().println("Invalid email address. Please try again."); terminal.flush(); } // Only email is required for registration. Name and password are set on the server side. return new RegisterRequest(email, null, null); } } private static boolean isValidEmail(final String email) { return email != null && email.matches("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"); } private static Terminal buildTerminal() throws IOException { final var terminal = TerminalBuilder.builder() .streams(System.in, System.out) .system(true) .jansi(true) .jna(true) .jni(true) .ffm(true) .exec(true) .dumb(true) .build(); return terminal; } /** * Starts the Console UI. This method initializes the terminal for interactive * output, sets up signal handling for interrupt signals, and spawns a new thread * to handle the rendering loop. * The method ensures that the UI is started only once by using an atomic flag. If * the UI is already running, the method exits early. * In case of initialization failure, such as terminal setup issues, an * IllegalStateException is thrown to indicate that the UI failed to start. * Once started, the rendering process runs on a daemon thread that periodically * updates the terminal output. The rendering loop works until instructed to stop * by external signals or method calls. */ public void start() { if (running.getAndSet(true)) { return; } try { terminal = buildTerminal(); out = terminal.writer(); terminal.handle(Terminal.Signal.INT, signal -> stop()); } catch (final Exception e) { throw new IllegalStateException("Failed to start console UI", e); } clear(); out.printf("Port Buddy - Mode: %s%n", tunnelType.name().toLowerCase()); out.println(); out.printf("Local: %s%n", localDetails); out.printf("Public: %s%n", publicDetails); out.println(); out.println("Press Ctrl+C to exit"); out.flush(); if (config.isLogEnabled()) { out.println("----------------------------------------------"); out.println(); if (tunnelType == TunnelType.HTTP) { out.println("HTTP requests log:"); } else { out.println(tunnelType + " traffic:"); } out.flush(); renderThread = new Thread(this::renderLoop, "port-buddy-ui"); renderThread.setDaemon(true); renderThread.start(); } } /** * Waits for the termination signal of the Console UI. This method blocks the * current thread until the internal exit condition is triggered, allowing * proper synchronization for dependent operations. * In case the thread is interrupted while waiting, the interrupt status is * restored to ensure the interruption can be detected by other parts of the * application. */ public void waitForExit() { try { exit.await(); } catch (final InterruptedException e) { Thread.currentThread().interrupt(); } } /** * Stops the Console UI. This method transitions the system out of a running state, * allowing for a clean shutdown. It ensures that the rendering loop halts, * decrements the exit latch, and executes the optional exit callback if provided. * The method is thread-safe and idempotent; it ensures that stopping the UI * multiple times has no adverse effects. If the UI is not currently running, * the method exits without performing any operations. * If an {@link Runnable} callback is set via {@link #setOnExit(Runnable)}, * the callback is executed upon stopping. Any exceptions thrown by the callback * are caught and logged to avoid disrupting the shutdown process. */ public void stop() { if (!running.getAndSet(false)) { return; } exit.countDown(); if (onExit != null) { try { onExit.run(); } catch (final Exception e) { log.warn("onExit handler failed: {}", e.toString()); } } } @Override public void onHttpLog(final String method, final String url, final int status) { synchronized (httpLogs) { if (httpLogs.size() == config.getLogLinesCount()) { httpLogs.removeFirst(); } httpLogs.addLast(new HttpLog(method, url, status)); } } @Override public void onBytesIn(final long bytes) { inBytes.addAndGet(Math.max(0, bytes)); } @Override public void onBytesOut(final long bytes) { outBytes.addAndGet(Math.max(0, bytes)); } private void renderLoop() { final var frameDelay = Duration.ofMillis(config.getConsoleFrameDelayMs()); while (running.get()) { try { terminal.puts(InfoCmp.Capability.cursor_address, 9, 0); terminal.flush(); render(); Thread.sleep(frameDelay.toMillis()); } catch (final Exception e) { log.debug("Render loop error: {}", e.toString()); } } clear(); } private void clear() { try { terminal.puts(InfoCmp.Capability.clear_screen); terminal.flush(); } catch (final Exception ignore) { // ignore } } private void render() { if (tunnelType == TunnelType.HTTP) { synchronized (httpLogs) { if (httpLogs.isEmpty()) { out.println("(no requests yet)"); } else { httpLogs.forEach(httpLog -> { terminal.puts(InfoCmp.Capability.clr_eol); terminal.flush(); out.printf("%-6s %-3d %s%n", safe(httpLog.method()), httpLog.status(), safe(httpLog.url())); }); } } } else { final var inKb = inBytes.get() / 1024.0; final var outKb = outBytes.get() / 1024.0; out.printf("IN %.2f KB | OUT %.2f KB%n", inKb, outKb); } out.flush(); } private String safe(final String value) { return value == null ? "" : value; } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/ui/HttpLogSink.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.ui; public interface HttpLogSink { void onHttpLog(final String method, final String url, final int status); } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/ui/NetTrafficSink.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.ui; public interface NetTrafficSink { void onBytesIn(final long bytes); void onBytesOut(final long bytes); } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/utils/HttpUtils.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.utils; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; import tech.amak.portbuddy.cli.config.ConfigurationService; @Slf4j @UtilityClass public class HttpUtils { /** * Creates a new OkHttpClient with default configuration. * If the application is running in dev mode, it disables SSL verification. * * @return a configured OkHttpClient instance */ public static OkHttpClient createClient() { final var builder = new OkHttpClient.Builder(); if (ConfigurationService.INSTANCE.isDev()) { configureInsecureSsl(builder); } return builder.build(); } /** * Configures the provided OkHttpClient.Builder to trust all SSL certificates. * Use with caution, primarily intended for development environments with self-signed certificates. * * @param builder the OkHttpClient.Builder to configure */ public static void configureInsecureSsl(final OkHttpClient.Builder builder) { try { final var trustAllCerts = new TrustManager[] { new X509TrustManager() { @Override public void checkClientTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { } @Override public void checkServerTrusted(final X509Certificate[] chain, final String authType) throws CertificateException { } @Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[] {}; } } }; final var sslContext = SSLContext.getInstance("SSL"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); final var sslSocketFactory = sslContext.getSocketFactory(); builder.sslSocketFactory(sslSocketFactory, (X509TrustManager) trustAllCerts[0]); builder.hostnameVerifier((hostname, session) -> true); } catch (final Exception e) { log.error("Failed to configure insecure SSL: {}", e.toString()); } } } ================================================ FILE: cli/src/main/java/tech/amak/portbuddy/cli/utils/JsonUtils.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.cli.utils; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class JsonUtils { public static final ObjectMapper MAPPER = new ObjectMapper(); static { MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } } ================================================ FILE: cli/src/main/resources/META-INF/native-image/tech.amak/port-buddy-cli/reflect-config.json ================================================ [ { "name": "tech.amak.portbuddy.common.dto.auth.RegisterRequest", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.dto.auth.RegisterResponse", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.dto.ExposeRequest", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true, "queryAllDeclaredMethods": true }, { "name": "tech.amak.portbuddy.common.dto.ExposeResponse", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true, "queryAllDeclaredMethods": true }, { "name": "tech.amak.portbuddy.common.ClientConfig", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.TunnelType", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "java.util.UUID", "allDeclaredConstructors": true, "allPublicMethods": true }, { "name": "tech.amak.portbuddy.cli.PortBuddy$HostPort", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.tunnel.ControlMessage", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.tunnel.ControlMessage$Type", "allPublicMethods": true }, { "name": "tech.amak.portbuddy.common.tunnel.HttpTunnelMessage", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.tunnel.HttpTunnelMessage$Type", "allPublicMethods": true }, { "name": "tech.amak.portbuddy.common.tunnel.MessageEnvelope", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.tunnel.WsTunnelMessage", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "tech.amak.portbuddy.common.tunnel.WsTunnelMessage$Type", "allPublicMethods": true }, { "name": "tech.amak.portbuddy.common.tunnel.BinaryWsFrame$Decoded", "allDeclaredConstructors": true, "allPublicMethods": true, "allDeclaredFields": true }, { "name": "com.fasterxml.jackson.dataformat.yaml.YAMLFactory", "allDeclaredConstructors": true }, { "name": "com.fasterxml.jackson.dataformat.yaml.YAMLGenerator$Feature", "allPublicMethods": true }, { "name": "com.fasterxml.jackson.dataformat.yaml.YAMLParser$Feature", "allPublicMethods": true } ] ================================================ FILE: cli/src/main/resources/META-INF/native-image/tech.amak/port-buddy-cli/resource-config.json ================================================ { "resources": [ { "pattern": "application\\.yml" }, { "pattern": "application-dev\\.yml" }, { "pattern": "META-INF/services/ch.qos.logback.classic.spi.Configurator" }, { "pattern": "logback\\.xml" }, { "pattern": "logback-test\\.xml" } ], "bundles": [] } ================================================ FILE: cli/src/main/resources/application-dev.yml ================================================ #serverUrl: "https://localhost:8443" serverUrl: https://localhost:8443 logEnabled: false ================================================ FILE: cli/src/main/resources/application.yml ================================================ serverUrl: "https://portbuddy.dev" logEnabled: true ================================================ FILE: cli/src/main/resources/logback.xml ================================================ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n ================================================ FILE: common/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT common port-buddy-common 25 org.projectlombok lombok ${lombok.version} provided com.fasterxml.jackson.core jackson-databind 2.18.1 ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/ClientConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ClientConfig { @JsonProperty("apiToken") private String apiToken; @JsonProperty("serverUrl") private String serverUrl; @JsonProperty("logLinesCount") private int logLinesCount = 20; @JsonProperty("logEnabled") private boolean logEnabled = false; @JsonProperty("consoleFrameDelay") private int consoleFrameDelayMs = 200; @JsonProperty("healthcheckIntervalSec") private int healthcheckIntervalSec = 5; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/Plan.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common; /** Subscription plans. */ public enum Plan { PRO, TEAM } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/TunnelType.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common; /** * Supported expose modes. */ public enum TunnelType { HTTP, TCP, UDP; /** * Converts a string representation of a mode to its corresponding {@code Mode} enum value. * If the provided string is {@code null}, defaults to {@code HTTP}. * * @param mode the string representation of the mode, such as "http" or "tcp". * Case-insensitive. If {@code null}, the method returns {@code HTTP}. * @return the corresponding {@code Mode} enum value. * @throws IllegalArgumentException if the string does not match any supported mode. */ public static TunnelType from(final String mode) { if (mode == null) { return HTTP; } return switch (mode.toLowerCase()) { case "http" -> HTTP; case "tcp" -> TCP; case "udp" -> UDP; default -> throw new IllegalArgumentException("Unknown mode: " + mode); }; } } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/DnsInstructionsEmailRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.UUID; import lombok.Builder; import lombok.Data; import lombok.extern.jackson.Jacksonized; @Data @Builder @Jacksonized public class DnsInstructionsEmailRequest { private final UUID jobId; private final String domain; private final String contactEmail; private final List> records; private final OffsetDateTime expiresAt; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/ExposeRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto; import tech.amak.portbuddy.common.TunnelType; /** Request to expose a local HTTP/TCP/UDP service. */ public record ExposeRequest( TunnelType tunnelType, String scheme, String host, int port, String domain, String portReservation /* optional, format: host:port for TCP/UDP */, String passcode /* optional, for HTTP tunnels only */ ) {} ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/ExposeResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto; import java.util.UUID; import com.fasterxml.jackson.annotation.JsonInclude; /** * Response with public exposure details. */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ExposeResponse( String source, String publicUrl, String publicHost, Integer publicPort, UUID tunnelId, String subdomain ) { } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/auth/RegisterRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.auth; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class RegisterRequest { @JsonProperty("email") private String email; @JsonProperty("name") private String name; @JsonProperty("password") private String password; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/auth/RegisterResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.auth; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class RegisterResponse { @JsonProperty("apiKey") private String apiKey; @JsonProperty("success") private boolean success; @JsonProperty("message") private String message; @JsonProperty("statusCode") private int statusCode; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/auth/TokenExchangeRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.auth; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class TokenExchangeRequest { @JsonProperty("apiToken") private String apiToken; @JsonProperty("cliClientVersion") private String cliClientVersion; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/auth/TokenExchangeResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.auth; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class TokenExchangeResponse { @JsonProperty("accessToken") private String accessToken; @JsonProperty("tokenType") private String tokenType; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/jwks/JwkKey.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.jwks; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class JwkKey { @JsonProperty("kty") private String kty; @JsonProperty("kid") private String kid; @JsonProperty("use") private String use; @JsonProperty("alg") private String alg; // RSA specific @JsonProperty("n") private String modulus; @JsonProperty("e") private String exponent; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/dto/jwks/JwksResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.dto.jwks; import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Data @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) public class JwksResponse { @JsonProperty("keys") private List keys; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/tunnel/BinaryWsFrame.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.tunnel; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; /** * Utility to encode/decode binary WebSocket frames for TCP tunneling. * Frame format (big-endian): * - 2 bytes: unsigned short representing the byte length of the UTF-8 encoded connectionId (N) * - N bytes: connectionId UTF-8 bytes * - R bytes: raw payload data */ public final class BinaryWsFrame { private BinaryWsFrame() { } /** * Encodes the given connection ID and data into a {@link ByteBuffer} following a specific binary * frame format. The encoded frame contains the connection ID length, the UTF-8 encoded connection ID, * and a portion of the data starting at the specified offset and up to the specified length. * * @param connectionId the connection identifier to be encoded (expected to be non-null) * @param data the raw payload data to be included in the frame (expected to be non-null) * @param offset the starting position of the data array to be included * @param length the number of bytes from the data array to be included * @return a {@link ByteBuffer} containing the encoded frame data */ public static ByteBuffer encodeToByteBuffer(final String connectionId, final byte[] data, final int offset, final int length) { final var idBytes = connectionId.getBytes(StandardCharsets.UTF_8); final var capacity = 2 + idBytes.length + length; final var buffer = ByteBuffer.allocate(capacity); buffer.putShort((short) (idBytes.length & 0xFFFF)); buffer.put(idBytes); buffer.put(data, offset, length); buffer.flip(); return buffer; } /** * Encodes the given connection ID and data into a byte array following a specific binary * frame format. The encoded frame includes the connection ID length, the UTF-8 encoded * connection ID, and a portion of the data starting at the specified offset and up to the * specified length. * * @param connectionId the connection identifier to be encoded (expected to be non-null) * @param data the raw payload data to be included in the frame (expected to be non-null) * @param offset the starting position of the data array to be included * @param length the number of bytes from the data array to be included * @return a byte array containing the encoded frame data */ public static byte[] encodeToArray(final String connectionId, final byte[] data, final int offset, final int length) { final var buffer = encodeToByteBuffer(connectionId, data, offset, length); final var out = new byte[buffer.remaining()]; buffer.get(out); buffer.clear(); return out; } /** * Decodes a binary frame from the provided {@link ByteBuffer} into a {@code Decoded} record object. * The frame is expected to have a specific format, starting with a 2-byte length * field indicating the UTF-8 encoded connection ID's length, followed by the connection ID bytes, * and ending with the remaining data bytes. * * @param buffer the {@link ByteBuffer} containing the binary frame data to decode; * must have sufficient remaining bytes to represent a valid frame. * @return a {@code Decoded} object containing the connection ID and data extracted from the frame, * or {@code null} if the buffer does not contain a valid or complete frame. */ public static Decoded decode(final ByteBuffer buffer) { if (buffer.remaining() < 2) { return null; } final var length = Short.toUnsignedInt(buffer.getShort()); if (buffer.remaining() < length) { return null; } final var idBytes = new byte[length]; buffer.get(idBytes); final var connectionId = new String(idBytes, StandardCharsets.UTF_8); final var data = new byte[buffer.remaining()]; buffer.get(data); return new Decoded(connectionId, data); } /** * Decodes a binary frame from the provided byte array into a {@code Decoded} record object. * The frame is expected to have a specific format, starting with a 2-byte length * field indicating the UTF-8 encoded connection ID's length, followed by the connection ID bytes, * and ending with the remaining data bytes. * * @param frameBytes the byte array containing the binary frame data to decode; * must have sufficient bytes to represent a valid frame. * @return a {@code Decoded} object containing the connection ID and data extracted from the frame, * or {@code null} if the array does not contain a valid or complete frame. */ public static Decoded decode(final byte[] frameBytes) { final var byteBuffer = ByteBuffer.wrap(frameBytes); final var decoded = decode(byteBuffer); byteBuffer.clear(); return decoded; } /** * A record that represents the result of decoding a binary WebSocket frame. * It contains a connection identifier and the corresponding payload data. * *
    *
  • The {@code connectionId} represents the unique identifier of the connection. *
  • The {@code data} represents the raw payload data associated with the frame. *
* Instances of this record are typically produced by decoding operations on binary * WebSocket frames, which follow a specific format. */ public record Decoded(String connectionId, byte[] data) { } } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/tunnel/ControlMessage.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.tunnel; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * Lightweight control envelope for tunnel health/keep-alive messages. */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ControlMessage { @JsonProperty("kind") private final String kind = "CTRL"; @JsonProperty("type") private Type type; /** * Optional unix timestamp millis to help with diagnostics. */ @JsonProperty("ts") private Long ts; public enum Type { PING, PONG, EXIT } } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/tunnel/HttpTunnelMessage.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.tunnel; import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * Envelope for HTTP tunnel messages exchanged over WebSocket between server and CLI. * To keep it simple, messages are whole-request/whole-response with base64 bodies. */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class HttpTunnelMessage { /** * Unique ID to correlate request and response. */ @JsonProperty("id") private String id; /** * Message type. */ @JsonProperty("type") private Type type; // Request fields @JsonProperty("method") private String method; @JsonProperty("path") private String path; @JsonProperty("query") private String query; @JsonProperty("headers") private Map> headers; /** * Request body encoded as Base64. */ @JsonProperty("bodyB64") private String bodyB64; /** * Original request body media type (e.g., "application/json; charset=utf-8"). * The server captures this from the ingress request and the CLI uses it to * reconstruct the upstream request body with the same Content-Type. */ @JsonProperty("bodyContentType") private String bodyContentType; // Response fields @JsonProperty("status") private Integer status; @JsonProperty("respHeaders") private Map> respHeaders; /** * Response body encoded as Base64. */ @JsonProperty("respBodyB64") private String respBodyB64; public enum Type { REQUEST, RESPONSE } } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/tunnel/MessageEnvelope.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.tunnel; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * Minimal envelope to route incoming WS messages without using JsonNode. * If {@code kind} is null, treat it as an HTTP tunnel message. */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class MessageEnvelope { @JsonProperty("kind") private String kind; // CTRL, WS or null (HTTP) } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/tunnel/WsTunnelMessage.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.tunnel; import java.util.Map; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; /** * Envelope for WebSocket tunneling over the existing control WebSocket between server and CLI. */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class WsTunnelMessage { /** * Constant marker to distinguish from HTTP messages. */ @JsonProperty("kind") private final String kind = "WS"; /** * Correlates messages of the same WS connection. */ @JsonProperty("connectionId") private String connectionId; /** * Optional request/response id alignment if needed. */ @JsonProperty("id") private String id; @JsonProperty("wsType") private Type wsType; // For OPEN from server to client @JsonProperty("path") private String path; @JsonProperty("query") private String query; @JsonProperty("headers") private Map headers; // Payload @JsonProperty("text") private String text; @JsonProperty("dataB64") private String dataB64; // Close details @JsonProperty("closeCode") private Integer closeCode; @JsonProperty("closeReason") private String closeReason; public enum Type { OPEN, OPEN_OK, TEXT, BINARY, CLOSE, ERROR, /** * Control message sent by Net Proxy after WebSocket is established to inform CLI * about the actual exposed public endpoint details (host/port). */ EXPOSED } // Public endpoint details for EXPOSED message @JsonProperty("publicHost") private String publicHost; @JsonProperty("publicPort") private Integer publicPort; } ================================================ FILE: common/src/main/java/tech/amak/portbuddy/common/utils/IdUtils.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.common.utils; import java.net.URI; import java.util.UUID; public class IdUtils { private IdUtils() { } /** * Extracts the tunnel ID from the given URI by taking the last part of the URI path * and parsing it as a UUID. If the URI is null, its path is null, or the last part * of the path is not a valid UUID, the method will return null. * * @param uri the URI from which the tunnel ID is to be extracted * @return the UUID extracted from the last part of the URI path, or null if the input is invalid */ public static UUID extractTunnelId(final URI uri) { if (uri == null) { return null; } final var path = uri.getPath(); if (path == null) { return null; } final var parts = path.split("/"); final var idPart = parts.length > 0 ? parts[parts.length - 1] : null; return idPart == null ? null : parseUuid(idPart); } /** * Parses a given string into a UUID. If the string cannot be parsed as a valid UUID, * the method returns null. * * @param id the string representation of the UUID to be parsed * @return the parsed UUID if the input is valid, or null if the input is invalid */ public static UUID parseUuid(final String id) { try { return UUID.fromString(id); } catch (final Exception e) { return null; } } } ================================================ FILE: docker-compose.yml ================================================ services: postgres: image: postgres:16-alpine container_name: pb-db restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} ports: - "5432:5432" volumes: - ${PWD}/pg_data:/var/lib/postgresql/data logging: options: max-size: 10M eureka: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-eureka:latest container_name: pb-eureka restart: unless-stopped network_mode: host environment: EUREKA_USERNAME: ${EUREKA_USERNAME} EUREKA_PASSWORD: ${EUREKA_PASSWORD} volumes: - ${PWD}/eureka/config:/app/config - ${PWD}/eureka/logs:/app/log logging: options: max-size: 10M server: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-server:latest container_name: pb-server restart: unless-stopped network_mode: host environment: DB_NAME: ${DB_NAME} DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} DB_HOST: ${DB_HOST} DB_PORT: ${DB_PORT} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USERNAME: ${SMTP_USERNAME} SMTP_PASSWORD: ${SMTP_PASSWORD} STRIPE_API_KEY: ${STRIPE_API_KEY} STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO} STRIPE_PRICE_TEAM: ${STRIPE_PRICE_TEAM} STRIPE_PRICE_EXTRA_TUNNEL: ${STRIPE_PRICE_EXTRA_TUNNEL} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID} OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET} OAUTH_GITHUB_CLIENT_ID: ${OAUTH_GITHUB_CLIENT_ID} OAUTH_GITHUB_CLIENT_SECRET: ${OAUTH_GITHUB_CLIENT_SECRET} APP_DOMAIN: ${APP_DOMAIN} JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} EUREKA_ZONE: ${EUREKA_ZONE} THREATFOX_ENABLED: ${THREATFOX_ENABLED} THREATFOX_AUTH_KEY: ${THREATFOX_AUTH_KEY} JVM_OPTS: -Dspring.profiles.active=prod,default volumes: - ${PWD}/server/config:/app/config - ${PWD}/server/logs:/app/log - ${PWD}/server/jwt_keys:/app/jwt_keys depends_on: - eureka - postgres logging: options: max-size: 100M web-app: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-web-app:latest container_name: pb-web-app restart: no volumes: - ${PWD}/web-app:/app/pb-web gateway: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-gateway:latest container_name: pb-gateway restart: unless-stopped network_mode: host environment: APP_DOMAIN: ${APP_DOMAIN} EUREKA_ZONE: ${EUREKA_ZONE} JVM_OPTS: -Dspring.profiles.active=prod,default volumes: - ${PWD}/gateway/config:/app/config - ${PWD}/gateway/logs:/app/log - ${PWD}/ssl-service/certs:/app/certs - ${PWD}/web-app:/app/web/dist depends_on: - web-app - eureka - ssl-service logging: options: max-size: 10M net-proxy: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-net-proxy:latest container_name: pb-net-proxy restart: unless-stopped network_mode: host environment: NET_PROXY_PUBLIC_HOST: ${NET_PROXY_PUBLIC_HOST} NET_PROXY_REGION: ${NET_PROXY_REGION} NET_PROXY_COORDINATES: ${NET_PROXY_COORDINATES} EUREKA_ZONE: ${EUREKA_ZONE} volumes: - ${PWD}/net-proxy/config:/app/config - ${PWD}/net-proxy/logs:/app/log depends_on: - eureka - ssl-service logging: options: max-size: 10M ssl-service: image: cr.yandex/crpop0r0f81mcsvg9kei/port-buddy-ssl-service:latest container_name: pb-ssl-service restart: unless-stopped network_mode: host environment: DB_NAME: ${DB_NAME} DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} EUREKA_ZONE: ${EUREKA_ZONE} volumes: - ${PWD}/ssl-service/config:/app/config - ${PWD}/ssl-service/logs:/app/log - ${PWD}/ssl-service/certs:/app/certs depends_on: - eureka - postgres logging: options: max-size: 10M ================================================ FILE: entrypoint-cli-native.sh ================================================ #!/bin/sh exec /app/portbuddy "$@" ================================================ FILE: entrypoint-web.sh ================================================ #!/bin/sh echo "Starting" && \ ls -al /app/dist/ && \ mkdir -p /app/pb-web && \ echo "Dir /app/pb-web/ (re)created" && \ rm -rf /app/pb-web/* && \ echo "Dir /app/pb-web/ cleaned" && \ cp -a /app/dist/. /app/pb-web/ && \ echo "Files copied to /app/pb-web/ folder" && \ ls -al /app/pb-web/ ================================================ FILE: entrypoint.sh ================================================ #!/bin/sh exec java $JVM_OPTS -jar /app/app.jar $@ ================================================ FILE: eureka/HELP.md ================================================ # Getting Started ### Reference Documentation For further reference, please consider the following sections: * [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) * [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.7/maven-plugin) * [Create an OCI image](https://docs.spring.io/spring-boot/3.5.7/maven-plugin/build-image.html) * [Eureka Server](https://docs.spring.io/spring-cloud-netflix/reference/spring-cloud-netflix.html#spring-cloud-eureka-server) ### Guides The following guides illustrate how to use some features concretely: * [Service Registration and Discovery with Eureka and Spring Cloud](https://spring.io/guides/gs/service-registration-and-discovery/) ### Maven Parent overrides Due to Maven's design, elements are inherited from the parent POM to the project POM. While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. To prevent this, the project POM contains empty overrides for these elements. If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. ================================================ FILE: eureka/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT eureka 0.0.1-SNAPSHOT eureka Discovery Server 25 org.springframework.cloud spring-cloud-starter-netflix-eureka-server org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin repackage ================================================ FILE: eureka/src/main/java/tech/amak/portbuddy/eureka/EurekaApplication.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.eureka; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; @SpringBootApplication @ConfigurationPropertiesScan @EnableEurekaServer public class EurekaApplication { public static void main(final String[] args) { SpringApplication.run(EurekaApplication.class, args); } } ================================================ FILE: eureka/src/main/java/tech/amak/portbuddy/eureka/security/SecurityConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.eureka.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.security.config.Customizer; 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.web.SecurityFilterChain; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean @Order(1) public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception { http.authorizeHttpRequests((authz) -> authz .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()); http.csrf(csrf -> csrf.ignoringRequestMatchers("/eureka/**")); return http.build(); } } ================================================ FILE: eureka/src/main/resources/application.yml ================================================ server: port: 8761 eureka: instance: hostname: localhost client: service-url: defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka register-with-eureka: false fetch-registry: false spring: application: name: eureka security: user: name: ${EUREKA_USERNAME:portbuddy} password: ${EUREKA_PASSWORD:portbuddy} logging: level: root: warn file: name: log/app.log logback: rollingpolicy: max-history: 14 total-size-cap: 100MB max-file-size: 10MB ================================================ FILE: eureka/src/test/java/tech/amak/portbuddy/eureka/EurekaApplicationTests.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.eureka; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class EurekaApplicationTests { @Test void contextLoads() { } } ================================================ FILE: gateway/HELP.md ================================================ # Getting Started ### Reference Documentation For further reference, please consider the following sections: * [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) * [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.7/maven-plugin) * [Create an OCI image](https://docs.spring.io/spring-boot/3.5.7/maven-plugin/build-image.html) * [GraalVM Native Image Support](https://docs.spring.io/spring-boot/3.5.7/reference/packaging/native-image/introducing-graalvm-native-images.html) * [Spring Configuration Processor](https://docs.spring.io/spring-boot/3.5.7/specification/configuration-metadata/annotation-processor.html) * [Reactive Gateway](https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway.html) ### Guides The following guides illustrate how to use some features concretely: * [Using Spring Cloud Gateway](https://github.com/spring-cloud-samples/spring-cloud-gateway-sample) ### Additional Links These additional references should also help you: * [Configure AOT settings in Build Plugin](https://docs.spring.io/spring-boot/3.5.7/how-to/aot.html) ## GraalVM Native Support This project has been configured to let you generate either a lightweight container or a native executable. It is also possible to run your tests in a native image. ### Lightweight Container with Cloud Native Buildpacks If you're already familiar with Spring Boot container images support, this is the easiest way to get started. Docker should be installed and configured on your machine prior to creating the image. To create the image, run the following goal: ``` $ ./mvnw spring-boot:build-image -Pnative ``` Then, you can run the app like any other container: ``` $ docker run --rm gateway:0.0.1-SNAPSHOT ``` ### Executable with Native Build Tools Use this option if you want to explore more options such as running your tests in a native image. The GraalVM `native-image` compiler should be installed and configured on your machine. NOTE: GraalVM 22.3+ is required. To create the executable, run the following goal: ``` $ ./mvnw native:compile -Pnative ``` Then, you can run the app as follows: ``` $ target/gateway ``` You can also run your existing tests suite in a native image. This is an efficient way to validate the compatibility of your application. To run your existing tests in a native image, run the following goal: ``` $ ./mvnw test -PnativeTest ``` ### Maven Parent overrides Due to Maven's design, elements are inherited from the parent POM to the project POM. While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. To prevent this, the project POM contains empty overrides for these elements. If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. ================================================ FILE: gateway/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT gateway 0.0.1-SNAPSHOT api-gateway API Gateway to proxy incoming requests to downstream 25 org.springframework.cloud spring-cloud-starter-gateway-server-webflux org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.cloud spring-cloud-starter-netflix-eureka-client org.springframework.boot spring-boot-configuration-processor true org.projectlombok lombok true com.github.ben-manes.caffeine caffeine org.bouncycastle bcpkix-jdk18on 1.80 org.springframework.boot spring-boot-starter-test test io.projectreactor reactor-test test org.springframework.boot spring-boot-maven-plugin org.projectlombok lombok repackage ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/ApiGatewayApplication.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication @ConfigurationPropertiesScan public class ApiGatewayApplication { public static void main(final String[] args) { SpringApplication.run(ApiGatewayApplication.class, args); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/client/SslServiceClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.client; import java.time.Duration; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; import tech.amak.portbuddy.gateway.dto.CertificateResponse; @Service @Slf4j public class SslServiceClient { private final WebClient webClient; /** * Constructs an instance of SslServiceClient with a load-balanced WebClient configured * to interact with the ssl-service. * * @param loadBalancedWebClientBuilder the WebClient.Builder instance used to configure * the load-balanced WebClient for communication with * the ssl-service */ public SslServiceClient(final WebClient.Builder loadBalancedWebClientBuilder) { this.webClient = loadBalancedWebClientBuilder .baseUrl("lb://ssl-service") .build(); } /** * Retrieves certificate metadata for a given domain from the ssl-service. * * @param domain domain name * @return certificate response mono */ public Mono getCertificate(final String domain) { return webClient.get() .uri("/internal/api/certificates/{domain}", domain) .retrieve() .bodyToMono(CertificateResponse.class) .timeout(Duration.ofSeconds(5)) .onErrorResume(e -> { log.warn("Failed to retrieve certificate for domain [{}]: {}", domain, e.getMessage()); return Mono.empty(); }); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/AppProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.core.io.Resource; @ConfigurationProperties(prefix = "app") public record AppProperties( int httpPort, String domain, String url, String serverErrorPage, Jwt jwt, Ssl ssl ) { public record Ssl( boolean enabled, Certificate fallback ) { } public record Certificate( boolean enabled, Resource keyCertChainFile, Resource keyFile ) { } public record Jwt( String issuer, String jwkSetUri ) { } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/GlobalExceptionHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import static java.nio.charset.StandardCharsets.UTF_8; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.reactive.result.view.RedirectView; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.UriUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @ControllerAdvice @Slf4j @RequiredArgsConstructor public class GlobalExceptionHandler { private final AppProperties properties; @ExceptionHandler(ResponseStatusException.class) public ProblemDetail handleIllegalArgumentException(final ResponseStatusException ex) { log.error("Status exception: Status: {} - {}", ex.getStatusCode(), ex.getMessage()); return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getMessage()); } /** * Handles all uncaught exceptions by logging the error and redirecting to a server error page * with the original request URI encoded as a retry parameter. * * @param ex the exception that was thrown * @param exchange the current server web exchange containing the request details * @return a {@link RedirectView} pointing to the configured server error page with a retry parameter */ @ExceptionHandler(Exception.class) public RedirectView handleGenericException(final Exception ex, final ServerWebExchange exchange) { log.error("Global exception: {}", ex.getMessage(), ex); final var string = exchange.getRequest().getURI().toString(); final var redirectUri = properties.serverErrorPage() + "?retry=" + UriUtils.encode(string, UTF_8); return new RedirectView(redirectUri, HttpStatus.TEMPORARY_REDIRECT); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/LoadBalancerClientsConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient; import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients; import org.springframework.context.annotation.Configuration; /** * Registers custom load balancer configuration for specific downstream services. */ @Configuration @LoadBalancerClients({ @LoadBalancerClient(name = "port-buddy-server", configuration = PortBuddyServerLoadBalancerConfiguration.class), @LoadBalancerClient(name = "net-proxy", configuration = NetProxyLoadBalancerConfiguration.class) }) public class LoadBalancerClientsConfig { } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/NetProxyLoadBalancerConfiguration.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.gateway.loadbalancer.NetProxyPublicHostLoadBalancer; @Slf4j public class NetProxyLoadBalancerConfiguration { @Bean public ReactorServiceInstanceLoadBalancer reactorServiceInstanceLoadBalancer( final Environment environment, final LoadBalancerClientFactory loadBalancerClientFactory ) { final var serviceId = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); final ObjectProvider provider = loadBalancerClientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class); log.info("Created NetProxyPublicHostLoadBalancer for service {}", serviceId); return new NetProxyPublicHostLoadBalancer(provider, serviceId); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/PortBuddyServerLoadBalancerConfiguration.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.gateway.loadbalancer.PortBuddySubdomainLoadBalancer; @Slf4j public class PortBuddyServerLoadBalancerConfiguration { @Bean public ReactorServiceInstanceLoadBalancer reactorServiceInstanceLoadBalancer( final Environment environment, final LoadBalancerClientFactory loadBalancerClientFactory ) { final var serviceId = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME); final ObjectProvider provider = loadBalancerClientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class); final var loadBalancer = new PortBuddySubdomainLoadBalancer(provider, serviceId); log.info("Created PortBuddySubdomainLoadBalancer for service {}", serviceId); return loadBalancer; } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/SslServerConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.ssl.SniHandler; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.netty.DisposableServer; import reactor.netty.http.server.HttpServer; import tech.amak.portbuddy.gateway.ssl.SniSslContextMapping; @Configuration @Slf4j @RequiredArgsConstructor public class SslServerConfig { private final AppProperties properties; private final SniSslContextMapping sniSslContextMapping; private final HttpHandler httpHandler; private DisposableServer httpServer; /** * Customizes Netty server to support dynamic SSL termination via SNI. * The main server will be SSL-enabled. * * @return NettyServerCustomizer */ @Bean @Order(Ordered.HIGHEST_PRECEDENCE) public WebServerFactoryCustomizer sslCustomizer() { return factory -> factory.addServerCustomizers(server -> { if (properties.ssl().enabled()) { // We use doOnChannelInit to configure the pipeline at the transport level. // This ensures SniHandler is added before any data is read and enables dynamic SSL via SNI. server = server.doOnChannelInit((observer, channel, remoteAddress) -> { channel.pipeline().addFirst("sni-handler", new SniHandler(sniSslContextMapping)); }); } return server.httpRequestDecoder(spec -> spec.allowDuplicateContentLengths(true) .maxInitialLineLength(65536) .maxHeaderSize(65536) .maxChunkSize(65536) .validateHeaders(false)); }); } /** * Starts an HTTP server to handle incoming requests. If SSL is enabled, the server redirects * non-secure requests to the HTTPS endpoint. The server also handles requests to the ACME * challenge endpoint for SSL certificate validation. * Behavior details: * - If SSL is enabled: * - Requests to paths starting with "/.well-known/acme-challenge/" are processed directly by * the {@code httpHandler} via the {@code ReactorHttpHandlerAdapter}. * - Requests to other paths: * - If the request does not include a "Host" header, a {@code 400 Bad Request} response * is returned. * - If the "Host" header is present, the server constructs a redirect URL based on the * secure port provided in the SSL properties. Non-secure requests are redirected to * their HTTPS equivalents with an appropriate {@code 301 Moved Permanently} response. * Prerequisites: * - The server requires SSL properties configuration to determine if SSL is enabled and to * identify the secure port for redirections. * - The {@code httpHandler} must be set up to process ACME challenge requests or other * required application logic. * This method is annotated with {@code @PostConstruct}, ensuring it is executed automatically * during the initialization phase of the containing class. */ @PostConstruct public void startHttpServer() { if (properties.ssl().enabled()) { final var adapter = new ReactorHttpHandlerAdapter(httpHandler); this.httpServer = HttpServer.create() .port(properties.httpPort()) .handle((request, response) -> { final var path = request.uri(); if (path.startsWith("/.well-known/acme-challenge/")) { return adapter.apply(request, response); } else { final var host = request.requestHeaders().get(HttpHeaderNames.HOST); if (host == null) { response.status(HttpStatus.BAD_REQUEST.value()); return response.send(); } final var redirectUrl = properties.url() + path; response.status(HttpStatus.MOVED_PERMANENTLY.value()); response.header(HttpHeaderNames.LOCATION, redirectUrl); return response.send(); } }) .bindNow(); } } /** * Stops the currently running HTTP server, if it is initialized. * This method is invoked automatically when the containing class is being destroyed, * as indicated by the {@code @PreDestroy} annotation. It ensures proper release of resources * by shutting down the HTTP server. If no server instance is present, the method exits quietly. * Behavior: * - If the {@code httpServer} is non-null, it invokes {@code disposeNow()} to stop the server immediately. * Prerequisites: * - A valid {@code httpServer} instance must exist for this method to perform the shutdown process. * If the instance is null, the method performs no action. * Usage context: * - Typically used in applications with lifecycle management to ensure clean shutdown * and resource deallocation. */ @PreDestroy public void stopHttpServer() { if (this.httpServer != null) { this.httpServer.disposeNow(); } } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/config/WebClientConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; @Configuration public class WebClientConfig { @Bean @LoadBalanced public WebClient.Builder loadBalancedWebClientBuilder() { return WebClient.builder(); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/dto/CertificateResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.dto; public record CertificateResponse( String domain, String certificatePath, String privateKeyPath, String chainPath, String fullChainPath ) { } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/filter/PortBuddyRewritePathGatewayFilterFactory.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.filter; import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.addOriginalRequestUrl; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.GatewayFilterChain; import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory; import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; @Component public class PortBuddyRewritePathGatewayFilterFactory extends RewritePathGatewayFilterFactory { @Override public GatewayFilter apply(final Config config) { final var replacement = config.getReplacement().replace("$\\", "$"); final var pattern = Pattern.compile(config.getRegexp()); return new GatewayFilter() { @Override public Mono filter(final ServerWebExchange exchange, final GatewayFilterChain chain) { final var request = exchange.getRequest(); addOriginalRequestUrl(exchange, request.getURI()); final var path = request.getURI().getRawPath(); final var replacementReference = new AtomicReference<>(replacement); final var uriTemplateVariables = ServerWebExchangeUtils.getUriTemplateVariables(exchange); uriTemplateVariables.forEach((key, value) -> { final var placeholder = "${%s}".formatted(key); var cleanValue = value; if ("customDomain".equals(key)) { final var colonIdx = cleanValue.indexOf(':'); if (colonIdx > 0) { cleanValue = cleanValue.substring(0, colonIdx); } } replacementReference.set(replacementReference.get().replace(placeholder, cleanValue)); }); // Fallback for customDomain and support for host if they are not in uriTemplateVariables if (replacementReference.get().contains("${customDomain}") || replacementReference.get().contains("${host}")) { var host = request.getHeaders().getFirst("Host"); if (host != null) { final var colonIdx = host.indexOf(':'); if (colonIdx > 0) { host = host.substring(0, colonIdx); } replacementReference.set(replacementReference.get().replace("${customDomain}", host)); replacementReference.set(replacementReference.get().replace("${host}", host)); } } final var newPath = pattern.matcher(path).replaceAll(replacementReference.get()); final var mutatedRequest = request.mutate().path(newPath).build(); exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, mutatedRequest.getURI()); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } @Override public String toString() { return filterToStringCreator(PortBuddyRewritePathGatewayFilterFactory.this) .append(config.getRegexp(), replacement) .toString(); } }; } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/loadbalancer/NetProxyPublicHostLoadBalancer.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.loadbalancer; import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.DefaultResponse; import org.springframework.cloud.client.loadbalancer.EmptyResponse; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.RequestData; import org.springframework.cloud.client.loadbalancer.RequestDataContext; import org.springframework.cloud.client.loadbalancer.Response; import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.util.StringUtils; import reactor.core.publisher.Mono; /** * A custom load balancer for the {@code net-proxy} service that selects an instance * by matching a request query parameter {@code public-host} against the instance * metadata {@code public-host} published to Eureka. If the parameter is missing or * no instance matches, the first available instance is selected. */ public class NetProxyPublicHostLoadBalancer implements ReactorServiceInstanceLoadBalancer { private static final String METADATA_PUBLIC_HOST = "public-host"; private static final String QUERY_PARAM_PUBLIC_HOST = "public-host"; private final ObjectProvider supplierProvider; private final String serviceId; /** * Constructs load balancer. * * @param supplierProvider supplier provider * @param serviceId target service id */ public NetProxyPublicHostLoadBalancer(final ObjectProvider supplierProvider, final String serviceId) { this.supplierProvider = supplierProvider; this.serviceId = serviceId; } @Override public Mono> choose(final Request request) { final var supplier = supplierProvider.getIfAvailable(); if (supplier == null) { return Mono.just(new EmptyResponse()); } final var requestedPublicHost = extractRequestedPublicHost(request); return supplier.get().next().map(instances -> { if (instances == null || instances.isEmpty()) { return new EmptyResponse(); } if (!StringUtils.hasText(requestedPublicHost)) { return new DefaultResponse(instances.getFirst()); } final var matched = findByPublicHost(instances, requestedPublicHost); if (matched != null) { return new DefaultResponse(matched); } return new DefaultResponse(instances.getFirst()); }); } private ServiceInstance findByPublicHost(final List instances, final String publicHost) { for (final var instance : instances) { final Map metadata = instance.getMetadata(); if (metadata == null) { continue; } final var instanceHost = metadata.get(METADATA_PUBLIC_HOST); if (instanceHost != null && instanceHost.equalsIgnoreCase(publicHost)) { return instance; } } return null; } private String extractRequestedPublicHost(final Request request) { if (!(request.getContext() instanceof RequestDataContext context)) { return null; } final RequestData data = context.getClientRequest(); if (data == null) { return null; } final URI url = data.getUrl(); if (url == null) { return null; } final var query = url.getQuery(); if (!StringUtils.hasText(query)) { return null; } final var pairs = query.split("&"); for (final var pair : pairs) { final var idx = pair.indexOf('='); if (idx <= 0) { continue; } final var name = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8); if (!Objects.equals(name, QUERY_PARAM_PUBLIC_HOST)) { continue; } return URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8); } return null; } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/loadbalancer/PortBuddySubdomainLoadBalancer.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.loadbalancer; import java.net.URI; import java.time.Duration; import java.util.List; import org.springframework.beans.factory.ObjectProvider; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.loadbalancer.DefaultResponse; import org.springframework.cloud.client.loadbalancer.EmptyResponse; import org.springframework.cloud.client.loadbalancer.Request; import org.springframework.cloud.client.loadbalancer.RequestDataContext; import org.springframework.cloud.client.loadbalancer.Response; import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; import org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.http.HttpHeaders; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** * Custom load balancer that, for subdomain ingress requests, chooses the server instance * that currently holds an active tunnel for the requested subdomain. If no instance confirms * ownership, it falls back to the first instance from the list. For all other requests, * it delegates to round-robin. */ public class PortBuddySubdomainLoadBalancer implements ReactorServiceInstanceLoadBalancer { private final ObjectProvider supplierProvider; private final String serviceId; private final RoundRobinLoadBalancer roundRobin; private final WebClient webClient; /** * Constructor. * * @param supplierProvider the service instance supplier provider * @param serviceId service ID */ public PortBuddySubdomainLoadBalancer(final ObjectProvider supplierProvider, final String serviceId) { this.supplierProvider = supplierProvider; this.serviceId = serviceId; this.roundRobin = new RoundRobinLoadBalancer(supplierProvider, serviceId); this.webClient = WebClient.builder().build(); } @Override public Mono> choose(final Request request) { // Extract subdomain or custom domain from host if present; otherwise delegate. final var host = extractHost(request); if (!StringUtils.hasText(host)) { return roundRobin.choose(request); } final var supplier = supplierProvider.getIfAvailable(); if (supplier == null) { return Mono.just(new EmptyResponse()); } final var isCustomDomain = isCustomDomain(host); final var target = isCustomDomain ? host : extractSubdomain(host); if (!StringUtils.hasText(target)) { return roundRobin.choose(request); } return supplier.get().next().flatMap(instances -> { if (instances == null || instances.isEmpty()) { return Mono.just(new EmptyResponse()); } // Probe all instances concurrently; pick the first that returns 200 OK. final var probeTimeout = Duration.ofMillis(500); return findOwningInstance(instances, target, isCustomDomain, probeTimeout) .map(DefaultResponse::new) .switchIfEmpty(Mono.just(new DefaultResponse(instances.getFirst()))); }); } private Mono findOwningInstance(final List instances, final String target, final boolean isCustomDomain, final Duration timeout) { return Flux.fromIterable(instances) .flatMap(instance -> checkInstance(instance, target, isCustomDomain, timeout) .onErrorResume(ex -> Mono.empty()), instances.size()) .next(); } private Mono checkInstance(final ServiceInstance instance, final String target, final boolean isCustomDomain, final Duration timeout) { final var scheme = instance.isSecure() ? "https" : "http"; final var path = isCustomDomain ? "resolve-custom" : "resolve"; final var uri = URI.create("%s://%s:%d/ingress/%s/%s".formatted( scheme, instance.getHost(), instance.getPort(), path, target)); return webClient.get() .uri(uri) .exchangeToMono(resp -> resp.statusCode().is2xxSuccessful() ? Mono.just(instance) : Mono.empty()) .timeout(timeout) .onErrorResume(ex -> Mono.empty()); } private String extractHost(final Request request) { if (!(request.getContext() instanceof RequestDataContext context)) { return null; } final var data = context.getClientRequest(); if (data == null) { return null; } final HttpHeaders headers = data.getHeaders(); final var hostHeader = headers == null ? null : headers.getFirst(HttpHeaders.HOST); if (!StringUtils.hasText(hostHeader)) { return null; } // Strip port if present return hostHeader.contains(":") ? hostHeader.substring(0, hostHeader.indexOf(':')) : hostHeader; } private boolean isCustomDomain(final String host) { // Simple logic: if it doesn't end with .portbuddy.dev (or whatever is configured), it's custom. // In a real app, this should probably be more robust or use a property. return !host.endsWith(".portbuddy.dev") && host.contains("."); } private String extractSubdomain(final String host) { final var dotIdx = host.indexOf('.'); if (dotIdx <= 0) { return null; } return host.substring(0, dotIdx); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/security/GatewayJwtConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.web.reactive.function.client.WebClient; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.gateway.config.AppProperties; @Configuration @RequiredArgsConstructor public class GatewayJwtConfig { private final WebClient.Builder loadBalancedWebClientBuilder; private final AppProperties properties; @Bean public ReactiveJwtDecoder reactiveJwtDecoder() { final var decoder = NimbusReactiveJwtDecoder.withJwkSetUri(properties.jwt().jwkSetUri()) .webClient(loadBalancedWebClientBuilder.build()) .build(); final var withIssuer = new JwtIssuerValidator(properties.jwt().issuer()); final var validator = new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), withIssuer); decoder.setJwtValidator(validator); return decoder; } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/security/GatewaySecurityConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.security; import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; @Configuration @EnableWebFluxSecurity public class GatewaySecurityConfig { @Bean public SecurityWebFilterChain springSecurityFilterChain(final ServerHttpSecurity http) { http .csrf(ServerHttpSecurity.CsrfSpec::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeExchange(exchange -> exchange // Public endpoints (static, SPA, OAuth callbacks, JWKS, etc.) .pathMatchers( "/", "/index.html", "/assets/**", "/favicon.*", "/app/**", "/login/**", "/auth/callback", "/forgot-password**", "/reset-password**", "/oauth2/**", "/login/oauth2/**", "/.well-known/jwks.json", // Token exchange must be public to let CLI obtain a JWT "/api/auth/token-exchange", "/api/auth/login", "/api/auth/register", "/api/auth/password-reset/**", "/api/webhooks/stripe" ).permitAll() // Secure API endpoints .pathMatchers("/api/**").authenticated() // Everything else is allowed (e.g., subdomain ingress and public tunnels) .anyExchange().permitAll() ) // Validate bearer tokens for secured endpoints .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } /** * Configures CORS to allow all origins. * * @return the CORS configuration source */ @Bean public CorsConfigurationSource corsConfigurationSource() { final var configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(List.of("*")); configuration.setAllowedMethods(List.of("*")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); final var source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/ssl/DynamicSslProvider.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.ssl; import java.io.File; import java.io.FileInputStream; import java.io.SequenceInputStream; import java.time.Duration; import java.util.concurrent.CompletableFuture; import org.springframework.stereotype.Service; import com.github.benmanes.caffeine.cache.AsyncCache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.RemovalCause; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.util.SelfSignedCertificate; import io.netty.util.ReferenceCountUtil; import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; import tech.amak.portbuddy.gateway.client.SslServiceClient; import tech.amak.portbuddy.gateway.config.AppProperties; @Service @Slf4j public class DynamicSslProvider { private final AppProperties properties; private final SslServiceClient sslServiceClient; private final AsyncCache sslContextCache; private final String baseDomain; @Getter private final SslContext fallbackSslContext; /** * Constructs a new instance of the DynamicSslProvider. * * @param sslServiceClient an instance of SslServiceClient used to communicate with the SSL service * @param properties an instance of AppProperties containing configuration values */ public DynamicSslProvider(final SslServiceClient sslServiceClient, final AppProperties properties) { this.sslServiceClient = sslServiceClient; this.properties = properties; this.baseDomain = properties.domain(); this.fallbackSslContext = createFallbackSslContext(); this.sslContextCache = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(Duration.ofMinutes(30)) .removalListener((String key, Object value, RemovalCause cause) -> { if (value instanceof CompletableFuture future) { future.whenComplete((context, ex) -> { releaseSslContext(context, key); }); } else { releaseSslContext(value, key); } }) .buildAsync(); } private void releaseSslContext(final Object context, final String key) { if (context instanceof SslContext sslContext && sslContext != fallbackSslContext) { log.debug("Evicted SSL context for {}. Releasing resources.", key); ReferenceCountUtil.release(sslContext); } } /** * Releases fallback SSL context and clears the cache on shutdown to prevent resource leaks. */ @PreDestroy public void shutdown() { log.info("Shutting down DynamicSslProvider. Releasing resources."); sslContextCache.synchronous().invalidateAll(); sslContextCache.synchronous().cleanUp(); if (fallbackSslContext != null) { ReferenceCountUtil.release(fallbackSslContext); } } private SslContext createFallbackSslContext() { final var fallback = properties.ssl().fallback(); try { if (fallback == null || !fallback.enabled()) { log.info("Fallback certificate is disabled. Generating a temporary self-signed certificate."); final var ssc = new SelfSignedCertificate(); return SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build(); } log.info("Loading fallback certificate from: {} and {}", fallback.keyCertChainFile(), fallback.keyFile()); try (var certStream = fallback.keyCertChainFile().getInputStream(); var keyStream = fallback.keyFile().getInputStream()) { return SslContextBuilder.forServer(certStream, keyStream).build(); } } catch (final Exception e) { log.error("Failed to create fallback SSL context", e); try { final var ssc = new SelfSignedCertificate(); return SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build(); } catch (final Exception ex) { log.error("Failed to create even a temporary self-signed certificate", ex); return null; } } } /** * Retrieves SslContext for a given hostname, utilizing Caffeine cache. * * @param hostname requested hostname * @return Mono of SslContext or fallback if not found */ public Mono getSslContext(final String hostname) { if (hostname == null) { return Mono.just(fallbackSslContext); } final var normalizedHostname = hostname.toLowerCase(); return Mono.fromFuture(sslContextCache.get(normalizedHostname, (h, executor) -> loadSslContext(h).toFuture())); } private Mono loadSslContext(final String hostname) { var lookupDomain = hostname; if (hostname.equals(baseDomain) || hostname.endsWith("." + baseDomain)) { lookupDomain = "*." + baseDomain; } log.debug("Loading SSL context for hostname: {}, lookup domain: {}", hostname, lookupDomain); final String finalLookupDomain = lookupDomain; return sslServiceClient.getCertificate(lookupDomain) .flatMap(cert -> { if (cert == null || cert.certificatePath() == null || cert.privateKeyPath() == null) { log.warn("No certificate found for {}. Using fallback.", finalLookupDomain); return Mono.just(fallbackSslContext); } try { final SslContext context; if (cert.fullChainPath() != null) { context = SslContextBuilder.forServer( new File(cert.fullChainPath()), new File(cert.privateKeyPath()) ).build(); } else if (cert.chainPath() != null && !cert.chainPath().isBlank()) { log.debug("Full chain path missing, but chain path present. Concatenating for {}.", finalLookupDomain); try (final var certIs = new FileInputStream(cert.certificatePath()); final var chainIs = new FileInputStream(cert.chainPath()); final var fullChainIs = new SequenceInputStream(certIs, chainIs); final var keyIs = new FileInputStream(cert.privateKeyPath())) { context = SslContextBuilder.forServer(fullChainIs, keyIs).build(); } } else { context = SslContextBuilder.forServer( new File(cert.certificatePath()), new File(cert.privateKeyPath()) ).build(); } ReferenceCountUtil.retain(context); return Mono.just(context); } catch (final Exception e) { log.error("Failed to create SslContext for {}. Using fallback.", finalLookupDomain, e); return Mono.just(fallbackSslContext); } }) .doOnDiscard(SslContext.class, ctx -> { if (ctx != fallbackSslContext) { ReferenceCountUtil.release(ctx); } }) .defaultIfEmpty(fallbackSslContext) .onErrorResume(e -> { log.error("Error retrieving certificate for {}. Using fallback.", finalLookupDomain, e); return Mono.just(fallbackSslContext); }); } } ================================================ FILE: gateway/src/main/java/tech/amak/portbuddy/gateway/ssl/SniSslContextMapping.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.ssl; import org.springframework.stereotype.Component; import io.netty.handler.ssl.SslContext; import io.netty.util.AsyncMapping; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.Promise; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Component @RequiredArgsConstructor @Slf4j public class SniSslContextMapping implements AsyncMapping { private final DynamicSslProvider sslProvider; @Override public Future map(final String hostname, final Promise promise) { log.debug("SNI lookup for hostname: {}", hostname); final var normalizedHostname = hostname != null ? hostname.toLowerCase() : null; sslProvider.getSslContext(normalizedHostname) .subscribe( promise::setSuccess, promise::setFailure ); return promise; } } ================================================ FILE: gateway/src/main/resources/application.yml ================================================ server: port: 8443 app: http-port: 8080 domain: localhost:${server.port} url: https://${app.domain} server-error-page: ${app.url}/500 spa-backend-enabled: true spa-fallback-enabled: false ssl: enabled: true # brew install mkcert # mkcert -install # mkcert -key-file key.pem -cert-file cert.pem localhost 127.0.0.1 ::1 fallback: enabled: true key-cert-chain-file: ${SELF_SERT_CHAIN_FILE:} key-file: ${SELF_SERT_KEY_FILE:} jwt: jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json # Must match the issuer used by the Server when minting JWTs issuer: port-buddy eureka: client: # Fetch registry updates from Eureka more frequently so the gateway sees # service instance changes with lower latency. registryFetchIntervalSeconds: 2 # Poll interval for refreshing Eureka server URLs (keep modest, default is 5 minutes) eurekaServiceUrlPollIntervalSeconds: 60 service-url: defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka} spring: application: name: api-gateway cloud: loadbalancer: # Reduce cache TTL so instance list updates propagate quickly after # Eureka registry refresh. This helps newly registered/restarted # services become routable within a few seconds. cache: enabled: true ttl: 2s # Proactively check instance health and refresh the list to avoid # routing to stale instances and to speed up availability. health-check: enabled: true interval: 2s gateway: server: webflux: httpserver: wiretap: off httpclient: websocket: # Allow large WS frames (100 MiB) max-frame-payload-length: 104857600 wiretap: off x-forwarded: enabled: true discovery: locator: lower-case-service-id: true # filter: # preserve-host-header: # enabled: true routes: - id: acme_http01_route order: -1 uri: lb://ssl-service predicates: - Path=/.well-known/acme-challenge/** filters: - PreserveHostHeader - id: api_ws_route order: -2 uri: lb:ws://port-buddy-server predicates: - Path=/api/http-tunnel/** - Header=Upgrade, (?i)websocket - id: net_proxy_ws_route order: -2 uri: lb:ws://net-proxy predicates: - Path=/api/net-tunnel/** - Header=Upgrade, (?i)websocket - id: api_route order: 2 uri: lb://port-buddy-server predicates: - Path=/api/**, /oauth2/**, /login/oauth2/**, /.well-known/jwks.json # Exclude tunnel paths explicitly to ensure no overlap even with Order. # Since Path predicates are ANDed, we can't easily exclude here without custom predicates. # However, placing it AFTER WebSocket routes with higher Order (larger value) is the standard. # I will ensure Order is correctly spaced. - id: net_proxy_route order: 1 uri: lb://net-proxy predicates: - Path=/api/net-tunnel/** # WebSocket route for subdomain ingress. Must be evaluated before HTTP route. - id: subdomain_ingress_ws_route order: 0 uri: lb:ws://port-buddy-server predicates: - Host={subdomain}.${app.domain} # Route ONLY real websocket upgrade requests to the WS upstream. # Use case-insensitive match to be robust across clients/proxies. - Header=Upgrade, (?i)websocket # WebSocket handshakes are GET by spec; narrow to GET as an extra guard. - Method=GET filters: - PreserveHostHeader - name: PortBuddyRewritePath args: regexp: /(?.*) replacement: /_ws/$\{subdomain}/$\{remaining} # WebSocket route for custom domains. - id: custom_domain_ingress_ws_route order: 0 uri: lb:ws://port-buddy-server predicates: # Route ONLY real websocket upgrade requests to the WS upstream. - Header=Upgrade, (?i)websocket - Method=GET # Exclude main domain and subdomains to avoid overlap - Header=Host, ^(?!(.*\.${app.domain}|${app.domain})$).* filters: - PreserveHostHeader - name: PortBuddyRewritePath args: regexp: /(?.*) replacement: /_ws/$\{customDomain}/$\{remaining} # HTTP route for subdomain ingress (non-WS traffic) - id: subdomain_ingress_route order: 1 uri: lb://port-buddy-server predicates: - Host={subdomain}.${app.domain} filters: - PreserveHostHeader - name: PortBuddyRewritePath args: regexp: /(?.*) replacement: /_/$\{subdomain}/$\{remaining} # Route for custom domains - id: custom_domain_ingress_route order: 2 uri: lb://port-buddy-server predicates: # Exclude main domain and subdomains to avoid overlap - Header=Host, ^(?!(.*\.${app.domain}|${app.domain})$).* filters: - PreserveHostHeader - name: PortBuddyRewritePath args: regexp: /(?.*) replacement: /_custom/$\{customDomain}/$\{remaining} - id: static_guides_route enabled: ${app.spa-fallback-enabled} uri: forward:/ order: 2 predicates: - Path=/docs/guides/{guide} filters: - SetPath=/docs/guides/{guide}/index.html - id: static_route enabled: ${app.spa-fallback-enabled} uri: forward:/ order: 2 predicates: - Path=/{page:docs|install|terms|privacy|contacts} filters: - SetPath=/{page}/index.html - id: spa_fallback_route enabled: ${app.spa-fallback-enabled} uri: forward:/index.html order: 3 predicates: - Path=/, /register, /passcode, /app/**, /login/**, /auth/callback, /forgot-password**, /reset-password**, /404, /500 # Dev: Proxy SPA paths to the Vite dev server - id: spa_frontend_route enabled: ${app.spa-backend-enabled} order: 100 uri: http://localhost:5173 predicates: - Path=/** filters: - PreserveHostHeader web: resources: static-locations: file:./web/dist/ cache: use-last-modified: true period: 3600 logging: level: root: info reactor.netty.http.client: warn io.netty.handler.codec.http.websocketx: warn org.springframework.cloud.gateway: warn org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: warn com.netflix.discovery: warn file: name: log/app.log logback: rollingpolicy: max-history: 14 total-size-cap: 1000MB max-file-size: 100MB --- spring: config: activate: on-profile: prod server: port: 443 app: http-port: 80 domain: ${APP_DOMAIN:portbuddy.dev} spa-backend-enabled: false spa-fallback-enabled: true ssl: enabled: true fallback: enabled: false ================================================ FILE: gateway/src/test/java/tech/amak/gateway/ApiGatewayApplicationTests.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.gateway; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySources; import tech.amak.portbuddy.gateway.ApiGatewayApplication; @SpringBootTest( classes = ApiGatewayApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT ) @TestPropertySources( {@TestPropertySource(properties = {"app.ssl.enabled=false"})} ) class ApiGatewayApplicationTests { @Test void contextLoads() { } } ================================================ FILE: gateway/src/test/java/tech/amak/portbuddy/gateway/config/SslServerConfigTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.config; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import javax.net.ssl.SNIHostName; import javax.net.ssl.SSLException; import org.junit.jupiter.api.Test; import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; import org.springframework.http.server.reactive.HttpHandler; import io.netty.handler.ssl.SslContextBuilder; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; import io.netty.handler.ssl.util.SelfSignedCertificate; import reactor.core.publisher.Mono; import reactor.netty.http.client.HttpClient; import tech.amak.portbuddy.gateway.ssl.DynamicSslProvider; import tech.amak.portbuddy.gateway.ssl.SniSslContextMapping; class SslServerConfigTest { @Test void shouldInvokeDynamicSslProviderOnSniHandshake() throws Exception { // Given final var sslProvider = mock(DynamicSslProvider.class); final var sniMapping = new SniSslContextMapping(sslProvider); final var properties = mock(AppProperties.class); final var sslProperties = mock(AppProperties.Ssl.class); final var httpHandler = mock(HttpHandler.class); final var ssc = new SelfSignedCertificate(); final var fallbackContext = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build(); when(properties.ssl()).thenReturn(sslProperties); when(sslProperties.enabled()).thenReturn(true); when(sslProvider.getFallbackSslContext()).thenReturn(fallbackContext); // Return fallback even for dynamic to avoid complex setup, we just want to see if it's called when(sslProvider.getSslContext(anyString())).thenReturn(Mono.just(fallbackContext)); when(httpHandler.handle(any(), any())).thenReturn(Mono.empty()); final var sslServerConfig = new SslServerConfig(properties, sniMapping, httpHandler); final var customizer = sslServerConfig.sslCustomizer(); final var factory = new NettyReactiveWebServerFactory(0); customizer.customize(factory); final var webServer = factory.getWebServer(httpHandler); webServer.start(); try { final int port = webServer.getPort(); // When - attempt a connection with SNI HttpClient.create() .port(port) .remoteAddress(() -> new java.net.InetSocketAddress("127.0.0.1", port)) .secure(spec -> { try { spec.sslContext(SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .build()) .serverNames(new SNIHostName("test.portbuddy.dev")); } catch (final SSLException e) { throw new RuntimeException(e); } }) .get() .uri("/") .response() .block(Duration.ofSeconds(5)); } catch (final Exception e) { // It might fail for various reasons (no actual handler for the request), but we care about SNI lookup } finally { webServer.stop(); } // Then verify(sslProvider, atLeastOnce()).getSslContext("test.portbuddy.dev"); } } ================================================ FILE: gateway/src/test/java/tech/amak/portbuddy/gateway/ssl/DynamicSslProviderTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.gateway.ssl; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import java.io.File; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.util.SelfSignedCertificate; import reactor.core.publisher.Mono; import tech.amak.portbuddy.gateway.client.SslServiceClient; import tech.amak.portbuddy.gateway.config.AppProperties; import tech.amak.portbuddy.gateway.dto.CertificateResponse; @ExtendWith(MockitoExtension.class) class DynamicSslProviderTest { @Mock private SslServiceClient sslServiceClient; @Mock private AppProperties properties; @Mock private AppProperties.Ssl sslProperties; @TempDir File tempDir; private DynamicSslProvider sslProvider; @BeforeEach void setUp() { when(properties.domain()).thenReturn("portbuddy.dev"); when(properties.ssl()).thenReturn(sslProperties); when(sslProperties.fallback()).thenReturn(null); sslProvider = new DynamicSslProvider(sslServiceClient, properties); } @Test void shouldReturnFallbackWhenCertificateNotFound() { // Given when(sslServiceClient.getCertificate(anyString())).thenReturn(Mono.empty()); // When final SslContext context = sslProvider.getSslContext("UNKNOWN.COM").block(); // Then assertNotNull(context); } @Test void shouldReturnFallbackWhenHostnameIsNull() { // When final SslContext context = sslProvider.getSslContext(null).block(); // Then assertNotNull(context); } @Test void shouldLoadSslContextFromFile() throws Exception { // Given final SelfSignedCertificate ssc = new SelfSignedCertificate(); final String hostname = "test.portbuddy.dev"; final CertificateResponse response = new CertificateResponse( hostname, ssc.certificate().getAbsolutePath(), ssc.privateKey().getAbsolutePath(), null, null ); when(sslServiceClient.getCertificate("*.portbuddy.dev")).thenReturn(Mono.just(response)); // When final SslContext context = sslProvider.getSslContext(hostname).block(); // Then assertNotNull(context); assertTrue(context.isServer()); } } ================================================ FILE: lombok.config ================================================ lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value lombok.copyableAnnotations += org.springframework.context.annotation.Lazy ================================================ FILE: mvnw ================================================ #!/bin/sh # ---------------------------------------------------------------------------- # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- # Apache Maven Wrapper startup batch script, version 3.3.4 # # Optional ENV vars # ----------------- # JAVA_HOME - location of a JDK home dir, required when download maven via java source # MVNW_REPOURL - repo url base for downloading maven distribution # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output # ---------------------------------------------------------------------------- set -euf [ "${MVNW_VERBOSE-}" != debug ] || set -x # OS specific support. native_path() { printf %s\\n "$1"; } case "$(uname)" in CYGWIN* | MINGW*) [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" native_path() { cygpath --path --windows "$1"; } ;; esac # set JAVACMD and JAVACCMD set_java_home() { # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched if [ -n "${JAVA_HOME-}" ]; then if [ -x "$JAVA_HOME/jre/sh/java" ]; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD="$JAVA_HOME/jre/sh/java" JAVACCMD="$JAVA_HOME/jre/sh/javac" else JAVACMD="$JAVA_HOME/bin/java" JAVACCMD="$JAVA_HOME/bin/javac" if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 return 1 fi fi else JAVACMD="$( 'set' +e 'unset' -f command 2>/dev/null 'command' -v java )" || : JAVACCMD="$( 'set' +e 'unset' -f command 2>/dev/null 'command' -v javac )" || : if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 return 1 fi fi } # hash string like Java String::hashCode hash_string() { str="${1:-}" h=0 while [ -n "$str" ]; do char="${str%"${str#?}"}" h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) str="${str#?}" done printf %x\\n $h } verbose() { :; } [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } die() { printf %s\\n "$1" >&2 exit 1 } trim() { # MWRAPPER-139: # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. # Needed for removing poorly interpreted newline sequences when running in more # exotic environments such as mingw bash on Windows. printf "%s" "${1}" | tr -d '[:space:]' } scriptDir="$(dirname "$0")" scriptName="$(basename "$0")" # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties while IFS="=" read -r key value; do case "${key-}" in distributionUrl) distributionUrl=$(trim "${value-}") ;; distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; esac done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" case "${distributionUrl##*/}" in maven-mvnd-*bin.*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; :Linux*x86_64*) distributionPlatform=linux-amd64 ;; *) echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 distributionPlatform=linux-amd64 ;; esac distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" ;; maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; *) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; esac # apply MVNW_REPOURL and calculate MAVEN_HOME # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" distributionUrlName="${distributionUrl##*/}" distributionUrlNameMain="${distributionUrlName%.*}" distributionUrlNameMain="${distributionUrlNameMain%-bin}" MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" exec_maven() { unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" } if [ -d "$MAVEN_HOME" ]; then verbose "found existing MAVEN_HOME at $MAVEN_HOME" exec_maven "$@" fi case "${distributionUrl-}" in *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; esac # prepare tmp dir if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } trap clean HUP INT TERM EXIT else die "cannot create temp dir" fi mkdir -p -- "${MAVEN_HOME%/*}" # Download and Install Apache Maven verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." verbose "Downloading from: $distributionUrl" verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" # select .zip or .tar.gz if ! command -v unzip >/dev/null; then distributionUrl="${distributionUrl%.zip}.tar.gz" distributionUrlName="${distributionUrl##*/}" fi # verbose opt __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v # normalize http auth case "${MVNW_PASSWORD:+has-password}" in '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; esac if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then verbose "Found wget ... using wget" wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then verbose "Found curl ... using curl" curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" elif set_java_home; then verbose "Falling back to use Java to download" javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" cat >"$javaSource" <<-END public class Downloader extends java.net.Authenticator { protected java.net.PasswordAuthentication getPasswordAuthentication() { return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); } public static void main( String[] args ) throws Exception { setDefault( new Downloader() ); java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); } } END # For Cygwin/MinGW, switch paths to Windows format before running javac and java verbose " - Compiling Downloader.java ..." "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" verbose " - Running Downloader.java ..." "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" fi # If specified, validate the SHA-256 sum of the Maven distribution zip file if [ -n "${distributionSha256Sum-}" ]; then distributionSha256Result=false if [ "$MVN_CMD" = mvnd.sh ]; then echo "Checksum validation is not supported for maven-mvnd." >&2 echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 exit 1 elif command -v sha256sum >/dev/null; then if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then distributionSha256Result=true fi elif command -v shasum >/dev/null; then if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then distributionSha256Result=true fi else echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 exit 1 fi if [ $distributionSha256Result = false ]; then echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 exit 1 fi fi # unzip and move if command -v unzip >/dev/null; then unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" else tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi # Find the actual extracted directory name (handles snapshots where filename != directory name) actualDistributionDir="" # First try the expected directory name (for regular distributions) if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then actualDistributionDir="$distributionUrlNameMain" fi fi # If not found, search for any directory with the Maven executable (for snapshots) if [ -z "$actualDistributionDir" ]; then # enable globbing to iterate over items set +f for dir in "$TMP_DOWNLOAD_DIR"/*; do if [ -d "$dir" ]; then if [ -f "$dir/bin/$MVN_CMD" ]; then actualDistributionDir="$(basename "$dir")" break fi fi done set -f fi if [ -z "$actualDistributionDir" ]; then verbose "Contents of $TMP_DOWNLOAD_DIR:" verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" die "Could not find Maven distribution directory in extracted archive" fi verbose "Found extracted Maven distribution directory: $actualDistributionDir" printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" clean || : exec_maven "$@" ================================================ FILE: mvnw.cmd ================================================ <# : batch portion @REM ---------------------------------------------------------------------------- @REM Licensed to the Apache Software Foundation (ASF) under one @REM or more contributor license agreements. See the NOTICE file @REM distributed with this work for additional information @REM regarding copyright ownership. The ASF licenses this file @REM to you under the Apache License, Version 2.0 (the @REM "License"); you may not use this file except in compliance @REM with the License. You may obtain a copy of the License at @REM @REM http://www.apache.org/licenses/LICENSE-2.0 @REM @REM Unless required by applicable law or agreed to in writing, @REM software distributed under the License is distributed on an @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @REM KIND, either express or implied. See the License for the @REM specific language governing permissions and limitations @REM under the License. @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- @REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Optional ENV vars @REM MVNW_REPOURL - repo url base for downloading maven distribution @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output @REM ---------------------------------------------------------------------------- @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) @SET __MVNW_CMD__= @SET __MVNW_ERROR__= @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% @SET PSModulePath= @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) ) @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% @SET __MVNW_PSMODULEP_SAVE= @SET __MVNW_ARG0_NAME__= @SET MVNW_USERNAME= @SET MVNW_PASSWORD= @IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @echo Cannot start maven from wrapper >&2 && exit /b 1 @GOTO :EOF : end batch / begin powershell #> $ErrorActionPreference = "Stop" if ($env:MVNW_VERBOSE -eq "true") { $VerbosePreference = "Continue" } # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl if (!$distributionUrl) { Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" } switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { "maven-mvnd-*" { $USE_MVND = $true $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" $MVN_CMD = "mvnd.cmd" break } default { $USE_MVND = $false $MVN_CMD = $script -replace '^mvnw','mvn' break } } # apply MVNW_REPOURL and calculate MAVEN_HOME # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ if ($env:MVNW_REPOURL) { $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" } $distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' $MAVEN_M2_PATH = "$HOME/.m2" if ($env:MAVEN_USER_HOME) { $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" } if (-not (Test-Path -Path $MAVEN_M2_PATH)) { New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null } $MAVEN_WRAPPER_DISTS = $null if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" } else { $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" } $MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" $MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" exit $? } if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" } # prepare tmp dir $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null trap { if ($TMP_DOWNLOAD_DIR.Exists) { try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } } } New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null # Download and Install Apache Maven Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." Write-Verbose "Downloading from: $distributionUrl" Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" $webclient = New-Object System.Net.WebClient if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null # If specified, validate the SHA-256 sum of the Maven distribution zip file $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum if ($distributionSha256Sum) { if ($USE_MVND) { Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." } Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." } } # unzip and move Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null # Find the actual extracted directory name (handles snapshots where filename != directory name) $actualDistributionDir = "" # First try the expected directory name (for regular distributions) $expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" $expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { $actualDistributionDir = $distributionUrlNameMain } # If not found, search for any directory with the Maven executable (for snapshots) if (!$actualDistributionDir) { Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { $testPath = Join-Path $_.FullName "bin/$MVN_CMD" if (Test-Path -Path $testPath -PathType Leaf) { $actualDistributionDir = $_.Name } } } if (!$actualDistributionDir) { Write-Error "Could not find Maven distribution directory in extracted archive" } Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null try { Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null } catch { if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { Write-Error "fail to move MAVEN_HOME" } } finally { try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } } Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" ================================================ FILE: net-proxy/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT net-proxy port-buddy-net-proxy org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-websocket org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.cloud spring-cloud-starter-netflix-eureka-client org.projectlombok lombok ${lombok.version} provided tech.amak common ${project.version} org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin repackage org.apache.maven.plugins maven-checkstyle-plugin ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/NetProxyApplication.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; import lombok.extern.slf4j.Slf4j; @Slf4j @SpringBootApplication @ConfigurationPropertiesScan public class NetProxyApplication { static void main(final String[] args) { SpringApplication.run(NetProxyApplication.class, args); } @Bean CommandLineRunner onStart() { return args -> { log.info("Net Proxy service started"); }; } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/AppProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.config; import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.util.unit.DataSize; @ConfigurationProperties(prefix = "app") public record AppProperties( String publicHost, WebSocket webSocket, Jwt jwt ) { public record WebSocket( DataSize maxTextMessageSize, DataSize maxBinaryMessageSize, Duration sessionIdleTimeout, Duration sendTimeLimit, DataSize sendBufferSizeLimit ) { } public record Jwt( String issuer, String jwkSetUri ) { } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/JwtConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.config; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.web.client.RestTemplate; import lombok.RequiredArgsConstructor; /** * Configuration for JWT decoding using a load-balanced client. */ @Configuration @RequiredArgsConstructor public class JwtConfig { private final AppProperties appProperties; @Bean @LoadBalanced public RestTemplate loadBalancedRestTemplate() { return new RestTemplate(); } /** * Creates a JwtDecoder that uses a load-balanced RestTemplate to fetch the JWK set. * * @param restTemplate the load-balanced RestTemplate * @return the JwtDecoder */ @Bean public JwtDecoder jwtDecoder(final RestTemplate restTemplate) { final var decoder = NimbusJwtDecoder .withJwkSetUri(appProperties.jwt().jwkSetUri()) .restOperations(restTemplate) .build(); final var withIssuer = new JwtIssuerValidator(appProperties.jwt().issuer()); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( new JwtTimestampValidator(), withIssuer )); return decoder; } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/config/WebSocketConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.netproxy.tunnel.NetTunnelWebSocketHandler; @Configuration @EnableWebSocket @RequiredArgsConstructor public class WebSocketConfig implements WebSocketConfigurer { private final NetTunnelWebSocketHandler tcpHandler; private final AppProperties properties; @Override public void registerWebSocketHandlers(final WebSocketHandlerRegistry registry) { registry.addHandler(tcpHandler, "/api/net-tunnel/{tunnelId}").setAllowedOrigins("*"); } /** * Configure the underlying servlet WebSocket container to allow larger text and * binary messages. We increase limits to 2 MiB to support larger tunneled * payloads between the CLI and the server. */ @Bean public ServletServerContainerFactoryBean websocketContainer() { final var container = new ServletServerContainerFactoryBean(); final var webSocket = properties.webSocket(); container.setMaxTextMessageBufferSize((int) webSocket.maxTextMessageSize().toBytes()); container.setMaxBinaryMessageBufferSize((int) webSocket.maxBinaryMessageSize().toBytes()); // Prevent premature session termination by increasing idle timeout if (webSocket.sessionIdleTimeout() != null) { container.setMaxSessionIdleTimeout(webSocket.sessionIdleTimeout().toMillis()); } return container; } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/security/SecurityConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; 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.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtDecoder jwtDecoder; @Bean @Order(1) public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.GET, "/actuator/health", "/actuator/health/**").permitAll() .anyRequest().authenticated() ) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .securityContext(sc -> sc.securityContextRepository(new RequestAttributeSecurityContextRepository())) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .decoder(jwtDecoder) .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ); return http.build(); } @Bean @Order(2) public SecurityFilterChain otherSecurityFilterChain(final HttpSecurity http) throws Exception { http .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.GET, "/actuator/health", "/actuator/health/**").permitAll() .anyRequest().permitAll() ); return http.build(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter(); } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistry.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PushbackInputStream; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; import java.util.Base64; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.tunnel.BinaryWsFrame; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; import tech.amak.portbuddy.netproxy.config.AppProperties; @Slf4j @Component public class NetTunnelRegistry { private static final int MIN_PORT = 10000; private static final int MAX_PORT = 65535; private static final byte[][] HTTP_METHODS_BYTES = { "GET ".getBytes(), "POST ".getBytes(), "PUT ".getBytes(), "DELETE ".getBytes(), "HEAD ".getBytes(), "OPTIONS ".getBytes(), "PATCH ".getBytes(), "TRACE ".getBytes(), "CONNECT ".getBytes() }; /** * Map of tunnels by their ID. */ final Map byTunnelId = new ConcurrentHashMap<>(); /** * Map of session ID to tunnel ID for fast lookup during detachment. */ private final Map sessionToTunnelId = new ConcurrentHashMap<>(); /** * Pool for IO operations using Virtual Threads. */ private final ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor(); /** * Scheduler for cleanup tasks. */ private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); /** * Timeout for orphaned tunnels (no session attached). */ private static final long ORPHAN_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(5); /** * Jackson object mapper. */ private final ObjectMapper mapper; /** * Application properties. */ private final AppProperties properties; /** * Constructor for NetTunnelRegistry. * * @param mapper Jackson object mapper * @param properties application properties */ public NetTunnelRegistry(final ObjectMapper mapper, final AppProperties properties) { this.mapper = mapper; this.properties = properties; this.scheduler.scheduleAtFixedRate(this::cleanupOrphanedTunnels, 1, 1, TimeUnit.MINUTES); } /** * Shuts down the executor services when the component is destroyed. */ @PreDestroy public void shutdown() { log.info("Shutting down NetTunnelRegistry..."); scheduler.shutdown(); ioPool.shutdown(); try { if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { scheduler.shutdownNow(); } if (!ioPool.awaitTermination(5, TimeUnit.SECONDS)) { ioPool.shutdownNow(); } } catch (final InterruptedException e) { scheduler.shutdownNow(); ioPool.shutdownNow(); Thread.currentThread().interrupt(); } for (final var tunnelId : byTunnelId.keySet()) { closeTunnel(tunnelId); } } /** * Periodically cleans up "orphaned" tunnels that have no active session and * have been inactive for more than {@link #ORPHAN_TIMEOUT_MS}. */ private void cleanupOrphanedTunnels() { final var now = System.currentTimeMillis(); for (final var tunnel : byTunnelId.values()) { final var session = tunnel.session; final var isOrphaned = session == null || !session.isOpen(); if (!isOrphaned) { continue; } final var age = now - tunnel.createdAt; final var udpInactiveTime = now - tunnel.lastUdpActivity; final var isUdpTunnel = tunnel.udpSocket != null; if (age > ORPHAN_TIMEOUT_MS) { log.warn("Cleaning up orphaned tunnel {} (no session or closed for {}ms)", tunnel.tunnelId, age); closeTunnel(tunnel.tunnelId); } else if (isUdpTunnel && udpInactiveTime > ORPHAN_TIMEOUT_MS) { log.warn("Cleaning up inactive UDP tunnel {} (no activity for {}ms)", tunnel.tunnelId, udpInactiveTime); closeTunnel(tunnel.tunnelId); } } } /** * Finds and closes a tunnel that is using the specified port for TCP or UDP. * * @param port the port to check */ private void closeTunnelUsingPort(final int port) { for (final var tunnel : byTunnelId.values()) { final var tcpPort = tunnel.serverSocket != null ? tunnel.serverSocket.getLocalPort() : -1; final var udpPort = tunnel.udpSocket != null ? tunnel.udpSocket.getLocalPort() : -1; if (tcpPort == port || udpPort == port) { log.info("Closing existing tunnel {} using port {}", tunnel.tunnelId, port); closeTunnel(tunnel.tunnelId); break; } } } /** * Exposes a network tunnel for either TCP or UDP based on tunnelType parameter. * * @param tunnelId tunnel identifier * @param tunnelType tunnelType string ("tcp" or "udp") * @return exposed public port info * @throws IOException on IO errors */ public ExposedPort expose(final UUID tunnelId, final TunnelType tunnelType, final int desiredPort) throws IOException { if (desiredPort <= MIN_PORT) { throw new IllegalArgumentException("desiredPort must be greater than " + MIN_PORT); } if (desiredPort > MAX_PORT) { throw new IllegalArgumentException("desiredPort must be less than " + MAX_PORT); } return switch (tunnelType) { case UDP -> exposeUdp(tunnelId, desiredPort); case TCP -> exposeTcp(tunnelId, desiredPort); default -> throw new IllegalArgumentException("Unsupported tunnel type: " + tunnelType); }; } /** * Expose TCP. */ private ExposedPort exposeTcp(final UUID tunnelId, final Integer desiredPort) throws IOException { final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new); synchronized (tunnel) { if (byTunnelId.get(tunnelId) != tunnel) { // The tunnel was replaced or removed; retry. return exposeTcp(tunnelId, desiredPort); } if (tunnel.serverSocket != null && !tunnel.serverSocket.isClosed()) { return new ExposedPort(tunnel.serverSocket.getLocalPort()); } try { tunnel.serverSocket = new ServerSocket(desiredPort); log.info("New Tunnel {} using port {}", tunnel.tunnelId, desiredPort); } catch (final IOException bindEx) { log.info("TCP port {} is busy. Trying to close existing tunnel and retry.", desiredPort); closeTunnelUsingPort(desiredPort); try { tunnel.serverSocket = new ServerSocket(desiredPort); } catch (final IOException secondBindEx) { log.error("TCP port {} is still busy.", desiredPort); throw secondBindEx; } } tunnel.acceptLoopFuture = ioPool.submit(() -> acceptLoop(tunnel)); return new ExposedPort(tunnel.serverSocket.getLocalPort()); } } /** * Expose UDP by binding a datagram socket and starting a receive loop that forwards * datagrams over the control WebSocket using binary frames. */ private ExposedPort exposeUdp(final UUID tunnelId, final int desiredPort) throws IOException { final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new); synchronized (tunnel) { if (byTunnelId.get(tunnelId) != tunnel) { // The tunnel was replaced or removed; retry. return exposeUdp(tunnelId, desiredPort); } if (tunnel.udpSocket != null && !tunnel.udpSocket.isClosed()) { return new ExposedPort(tunnel.udpSocket.getLocalPort()); } final DatagramSocket socket; DatagramSocket sock; try { sock = new DatagramSocket(desiredPort); } catch (final IOException bindEx) { log.info("UDP port {} is busy. Trying to close existing tunnel and retry.", desiredPort); closeTunnelUsingPort(desiredPort); try { sock = new DatagramSocket(desiredPort); } catch (final IOException secondBindEx) { log.error("UDP port {} is still busy.", desiredPort); throw secondBindEx; } } socket = sock; tunnel.udpSocket = socket; tunnel.udpReceiveLoopFuture = ioPool.submit(() -> udpReceiveLoop(tunnel)); return new ExposedPort(socket.getLocalPort()); } } /** * Attach session to tunnel. * * @param tunnelId tunnel id. * @param session websocket session. */ public void attachSession(final UUID tunnelId, final WebSocketSession session) { final var tunnel = byTunnelId.computeIfAbsent(tunnelId, Tunnel::new); synchronized (tunnel) { if (byTunnelId.get(tunnelId) != tunnel) { // The tunnel was replaced or removed; retry. attachSession(tunnelId, session); return; } final var ws = properties.webSocket(); tunnel.session = new ConcurrentWebSocketSessionDecorator( session, (int) ws.sendTimeLimit().toMillis(), (int) ws.sendBufferSizeLimit().toBytes() ); sessionToTunnelId.put(session.getId(), tunnelId); } } /** * Returns the WebSocket session associated with the given tunnel ID. * * @param tunnelId the tunnel identifier * @return the WebSocket session, or null if not found */ public WebSocketSession getSession(final UUID tunnelId) { final var tunnel = byTunnelId.get(tunnelId); return tunnel != null ? tunnel.session : null; } /** * Detaches a given WebSocket session from any associated tunnel. * If the specified session is currently linked to a tunnel, the link is severed. * * @param session the WebSocket session to detach */ public void detachSession(final WebSocketSession session) { final var tunnelId = sessionToTunnelId.remove(session.getId()); if (tunnelId != null) { log.info("Session detached for tunnel {}. Closing tunnel.", tunnelId); closeTunnel(tunnelId); } } /** * Closes and removes the entire tunnel identified by the given tunnelId. * This will immediately close the TCP ServerSocket (if any), all accepted TCP * connections, and the UDP DatagramSocket (if any). Any associated WebSocket * session reference is cleared. The tunnel entry is removed from the registry. * * @param tunnelId identifier of the tunnel to close */ public void closeTunnel(final UUID tunnelId) { final var tunnel = byTunnelId.remove(tunnelId); if (tunnel == null) { return; } synchronized (tunnel) { // Interrupt loops if (tunnel.acceptLoopFuture != null) { tunnel.acceptLoopFuture.cancel(true); tunnel.acceptLoopFuture = null; } if (tunnel.udpReceiveLoopFuture != null) { tunnel.udpReceiveLoopFuture.cancel(true); tunnel.udpReceiveLoopFuture = null; } // Close TCP acceptor first so accept loops break final var server = tunnel.serverSocket; if (server != null) { try { server.close(); } catch (final Exception e) { log.debug("Failed to close ServerSocket: {}", e.toString()); } tunnel.serverSocket = null; } // Close all live TCP connections for (final var connection : tunnel.connections.values()) { connection.close(); } tunnel.connections.clear(); // Close UDP socket final var udp = tunnel.udpSocket; if (udp != null) { try { udp.close(); } catch (final Exception e) { log.debug("Failed to close DatagramSocket: {}", e.toString()); } tunnel.udpSocket = null; } tunnel.udpRemotes.clear(); final var session = tunnel.session; if (session != null) { sessionToTunnelId.remove(session.getId()); if (session.isOpen()) { try { session.close(); } catch (final IOException e) { log.debug("Failed to close WebSocket session for tunnel {}: {}", tunnelId, e.getMessage()); } } tunnel.session = null; } } } private void acceptLoop(final Tunnel tunnel) { try { while (!tunnel.serverSocket.isClosed() && !Thread.currentThread().isInterrupted()) { final var socket = tunnel.serverSocket.accept(); ioPool.submit(() -> handleNewConnection(tunnel, socket)); } } catch (final Exception e) { log.info("Accept loop ended for tunnel {}: {}", tunnel.tunnelId, e.getMessage()); } } /** * Handles a new TCP connection by peeking at the initial bytes to detect HTTP requests. * If an HTTP request is detected, the connection is closed. * * @param tunnel the tunnel associated with the connection * @param socket the newly accepted socket */ private void handleNewConnection(final Tunnel tunnel, final Socket socket) { if (tunnel.session == null || !tunnel.session.isOpen()) { try { socket.close(); } catch (final IOException ignore) { // ignore } return; } String connId = null; try { socket.setSoTimeout(30000); // 30s timeout for initial handshake connId = UUID.randomUUID().toString(); final var pushbackIn = new PushbackInputStream(socket.getInputStream(), 16); final var connection = new Connection(connId, socket, pushbackIn); tunnel.connections.put(connId, connection); if (!sendOpen(tunnel, connId)) { throw new IOException("Failed to send OPEN message to client"); } // Start a task to cleanup this connection if it's not opened within a reasonable time final var finalConnId = connId; final var cleanupTask = scheduler.schedule(() -> { final var conn = tunnel.connections.get(finalConnId); if (conn != null && !conn.pumpStarted) { log.warn("Connection {} for tunnel {} was not opened by client within 60s. Closing.", finalConnId, tunnel.tunnelId); onClientClose(tunnel.tunnelId, finalConnId); } }, 60, TimeUnit.SECONDS); connection.setCleanupTask(cleanupTask); } catch (final Exception e) { log.error("Failed to handle new connection for tunnel {}: {}", tunnel.tunnelId, e.toString()); final var conn = connId != null ? tunnel.connections.remove(connId) : null; if (conn != null) { conn.close(); } else { try { socket.close(); } catch (final IOException ignore) { log.warn("Failed to close socket for tunnel {}: {}", tunnel.tunnelId, e.getMessage()); } } } } private void pumpFromPublic(final Tunnel tunnel, final Connection connection) { final var buffer = new byte[8192]; try { connection.socket.setSoTimeout((int) properties.webSocket().sessionIdleTimeout().toMillis()); // Peek at initial bytes to detect HTTP requests final var peekBuffer = new byte[16]; final var bytesRead = connection.in.read(peekBuffer); if (bytesRead != -1) { for (final var methodBytes : HTTP_METHODS_BYTES) { if (startsWith(peekBuffer, bytesRead, methodBytes)) { log.warn("Blocking HTTP request on TCP tunnel {}: {}", tunnel.tunnelId, new String(peekBuffer, 0, bytesRead).trim()); final var closeMsg = new WsTunnelMessage(); closeMsg.setWsType(WsTunnelMessage.Type.CLOSE); closeMsg.setConnectionId(connection.connectionId); sendToClient(tunnel, closeMsg); connection.close(); tunnel.connections.remove(connection.connectionId); return; } } // If not HTTP, send the peeked bytes and continue if (!sendBinaryToClient(tunnel, connection.connectionId, peekBuffer, 0, bytesRead)) { return; } } while (!Thread.currentThread().isInterrupted()) { final var next = connection.in.read(buffer); if (next == -1) { break; } if (!sendBinaryToClient(tunnel, connection.connectionId, buffer, 0, next)) { break; } } } catch (final Exception e) { log.error("Failed to read from public socket for tunnel {}: {}", tunnel.tunnelId, e.getMessage(), e); } finally { log.info("Public socket closed for tunnel {}: {}", tunnel.tunnelId, connection.connectionId); connection.close(); tunnel.connections.remove(connection.connectionId); } } private boolean startsWith(final byte[] buffer, final int bytesRead, final byte[] prefix) { if (prefix.length > bytesRead) { return false; } for (int i = 0; i < prefix.length; i++) { if (buffer[i] != prefix[i]) { return false; } } return true; } private void udpReceiveLoop(final Tunnel tunnel) { final var buffer = new byte[8192]; try { while (tunnel.udpSocket != null && !tunnel.udpSocket.isClosed() && !Thread.currentThread().isInterrupted()) { final var packet = new DatagramPacket(buffer, buffer.length); tunnel.udpSocket.receive(packet); tunnel.lastUdpActivity = System.currentTimeMillis(); final var remote = new InetSocketAddress(packet.getAddress(), packet.getPort()); final var connectionId = remote.getHostString() + ":" + remote.getPort(); tunnel.udpRemotes.put(connectionId, remote); sendBinaryToClient(tunnel, connectionId, packet.getData(), packet.getOffset(), packet.getLength()); } } catch (final Exception e) { log.info("UDP receive loop ended for tunnel {}: {}", tunnel.tunnelId, e.toString()); } } /** * Called when client acknowledges an OPEN with OPEN_OK. Starts pumping data * from the public socket to the client over WebSocket for the given connection. */ public void onClientOpenOk(final UUID tunnelId, final String connectionId) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } final var connection = tunnel.connections.get(connectionId); if (connection == null) { return; } if (connection.cleanupTask != null) { connection.cleanupTask.cancel(false); connection.cleanupTask = null; } if (connection.pumpStarted) { return; } synchronized (connection) { if (connection.pumpStarted) { return; } connection.pumpStarted = true; connection.pumpFuture = ioPool.submit(() -> pumpFromPublic(tunnel, connection)); } } /** * Backward compatibility handler for older clients that still send TEXT frames * with base64-encoded payload inside {@link WsTunnelMessage} of type BINARY. */ public void onClientBinary(final UUID tunnelId, final String connectionId, final String dataB64) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } final var connection = tunnel.connections.get(connectionId); if (connection == null) { return; } final var out = connection.out; if (out == null) { return; } try { out.write(Base64.getDecoder().decode(dataB64)); out.flush(); } catch (final IOException e) { log.debug("Failed to write to public socket: {}", e.toString()); onClientClose(tunnelId, connectionId); } } /** * Handles incoming binary WebSocket frames from the client. Data is routed directly * to the corresponding public TCP socket without base64 encoding. */ public void onClientBinaryBytes(final UUID tunnelId, final String connectionId, final byte[] data) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } // If UDP is active on this tunnel, route as a datagram if (tunnel.udpSocket != null) { tunnel.lastUdpActivity = System.currentTimeMillis(); final var remote = tunnel.udpRemotes.get(connectionId); if (remote == null) { return; } try { final var packet = new DatagramPacket(data, 0, data.length, remote); tunnel.udpSocket.send(packet); } catch (final IOException e) { log.debug("Failed to send UDP packet: {}", e.toString()); } return; } // Else assume TCP final var connection = tunnel.connections.get(connectionId); if (connection == null) { return; } final var out = connection.out; if (out == null) { return; } try { out.write(data); out.flush(); } catch (final IOException e) { log.debug("Failed to write to public socket: {}. Closing connection.", e.toString()); onClientClose(tunnel.tunnelId, connectionId); } } /** * Handles the closure of a client connection associated with a specific tunnel. * If the tunnel and connection exist, the connection is removed and its socket is closed. * If either the tunnel or connection does not exist, no operation is performed. * * @param tunnelId the identifier of the */ public void onClientClose(final UUID tunnelId, final String connectionId) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } if (tunnel.udpSocket != null) { // Just remove mapping; no need to close the UDP socket itself tunnel.udpRemotes.remove(connectionId); } else { final var connection = tunnel.connections.remove(connectionId); if (connection != null) { connection.close(); } } } private boolean sendOpen(final Tunnel tunnel, final String connId) { final var message = new WsTunnelMessage(); message.setWsType(WsTunnelMessage.Type.OPEN); message.setConnectionId(connId); return sendToClient(tunnel, message); } private boolean sendToClient(final Tunnel tunnel, final WsTunnelMessage message) { try { if (tunnel.session != null && tunnel.session.isOpen()) { tunnel.session.sendMessage(new TextMessage(mapper.writeValueAsString(message))); return true; } } catch (final Exception e) { log.debug("Failed to send to client: {}", e.toString()); } return false; } private boolean sendBinaryToClient(final Tunnel tunnel, final String connectionId, final byte[] bytes, final int offset, final int length) { try { if (tunnel.session != null && tunnel.session.isOpen()) { final var payload = BinaryWsFrame.encodeToByteBuffer(connectionId, bytes, offset, length); final var binaryMessage = new BinaryMessage(payload); tunnel.session.sendMessage(binaryMessage); return true; } } catch (final Exception e) { log.debug("Failed to send binary to client: {}", e.getMessage()); } return false; } @Data public static class ExposedPort { private final int port; } @Data public static class Tunnel { private final UUID tunnelId; private long createdAt = System.currentTimeMillis(); private volatile WebSocketSession session; private volatile ServerSocket serverSocket; private volatile Future acceptLoopFuture; private final Map connections = new ConcurrentHashMap<>(); private volatile DatagramSocket udpSocket; private volatile Future udpReceiveLoopFuture; private final Map udpRemotes = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(final Map.Entry eldest) { return size() > 100; } }); private long lastUdpActivity = System.currentTimeMillis(); Tunnel(final UUID tunnelId) { this.tunnelId = tunnelId; } } @Data public static class Connection { final String connectionId; Socket socket; InputStream in; OutputStream out; volatile boolean pumpStarted = false; volatile Future pumpFuture; volatile ScheduledFuture cleanupTask; Connection(final String connectionId, final Socket socket, final InputStream in) throws IOException { this.connectionId = connectionId; this.socket = socket; this.in = in; this.out = socket.getOutputStream(); } void setCleanupTask(final ScheduledFuture cleanupTask) { this.cleanupTask = cleanupTask; } /** * Closes the socket and nullifies all resource references. */ void close() { try { if (cleanupTask != null) { cleanupTask.cancel(false); cleanupTask = null; } } catch (final Exception e) { log.error("Failed to cancel cleanup task: {}", e.getMessage()); } try { if (pumpFuture != null) { pumpFuture.cancel(true); pumpFuture = null; } } catch (final Exception e) { log.error("Failed to cancel pump future: {}", e.getMessage()); } try { if (socket != null && !socket.isClosed()) { socket.close(); } } catch (final Exception e) { log.error("Failed to close socket: {}", e.toString()); } socket = null; if (in != null) { try { in.close(); } catch (final Exception e) { log.error("Failed to close input stream: {}", e.getMessage()); } in = null; } if (out != null) { try { out.close(); } catch (final Exception e) { log.error("Failed to close output stream: {}", e.getMessage()); } out = null; } } } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelWebSocketHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import java.net.URI; import java.util.HashMap; import java.util.Map; import java.util.UUID; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.AbstractWebSocketHandler; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.tunnel.BinaryWsFrame; import tech.amak.portbuddy.common.tunnel.ControlMessage; import tech.amak.portbuddy.common.tunnel.MessageEnvelope; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; import tech.amak.portbuddy.common.utils.IdUtils; import tech.amak.portbuddy.netproxy.config.AppProperties; @Slf4j @Component @RequiredArgsConstructor public class NetTunnelWebSocketHandler extends AbstractWebSocketHandler { private final NetTunnelRegistry registry; private final ObjectMapper mapper; private final AppProperties properties; @Override public void afterConnectionEstablished(final WebSocketSession session) throws Exception { final var tunnelId = extractTunnelId(session); if (tunnelId == null) { session.close(CloseStatus.BAD_DATA); return; } // Parse query params: type and port final var params = parseQueryParams(session.getUri()); final var typeStr = params.get("type"); final var portStr = params.get("port"); if (typeStr == null || portStr == null) { session.close(CloseStatus.BAD_DATA); return; } final TunnelType tunnelType; try { tunnelType = "udp".equalsIgnoreCase(typeStr) ? TunnelType.UDP : TunnelType.TCP; } catch (final Exception ignore) { session.close(CloseStatus.BAD_DATA); return; } final Integer desiredPort; try { desiredPort = Integer.parseInt(portStr); } catch (final Exception ignore) { session.close(CloseStatus.BAD_DATA); return; } // Prepare exposure and then attach the session final NetTunnelRegistry.ExposedPort exposedPort; try { exposedPort = registry.expose(tunnelId, tunnelType, desiredPort); } catch (final Exception e) { log.warn("Failed to expose {} on {}: {}", tunnelType, desiredPort, e.toString()); session.close(CloseStatus.SERVER_ERROR); return; } // TODO: validate Authorization header/JWT registry.attachSession(tunnelId, session); final var decoratedSession = registry.getSession(tunnelId); log.info("Net tunnel WS established: {} type={} port={}", tunnelId, tunnelType, desiredPort); // Inform client about actual public details in case port was re-assigned try { final var info = new WsTunnelMessage(); info.setWsType(WsTunnelMessage.Type.EXPOSED); info.setPublicHost(properties.publicHost()); info.setPublicPort(exposedPort.getPort()); if (decoratedSession != null) { decoratedSession.sendMessage(new TextMessage(mapper.writeValueAsString(info))); } } catch (final Exception e) { log.debug("Failed to send EXPOSED info: {}", e.toString()); } } @Override protected void handleTextMessage(final WebSocketSession session, final TextMessage textMessage) throws Exception { final var tunnelId = extractTunnelId(session); final var payload = textMessage.getPayload(); // Route by envelope kind: CTRL (heartbeat), WS (control/data) final var env = mapper.readValue(payload, MessageEnvelope.class); if (env.getKind() != null && env.getKind().equals("CTRL")) { final var ctrl = mapper.readValue(payload, ControlMessage.class); if (ctrl.getType() == ControlMessage.Type.PING) { final var pong = new ControlMessage(); pong.setType(ControlMessage.Type.PONG); pong.setTs(System.currentTimeMillis()); final var decoratedSession = registry.getSession(tunnelId); if (decoratedSession != null) { decoratedSession.sendMessage(new TextMessage(mapper.writeValueAsString(pong))); } } return; } if (env.getKind() != null && env.getKind().equals("WS")) { final var message = mapper.readValue(payload, WsTunnelMessage.class); switch (message.getWsType()) { case OPEN_OK -> registry.onClientOpenOk(tunnelId, message.getConnectionId()); case BINARY -> { // Backward compatibility: accept base64 text payloads registry.onClientBinary(tunnelId, message.getConnectionId(), message.getDataB64()); } case CLOSE -> registry.onClientClose(tunnelId, message.getConnectionId()); default -> log.debug("Ignoring WS control type: {}", message.getWsType()); } return; } // Unknown kinds are ignored } @Override protected void handleBinaryMessage(final WebSocketSession session, final BinaryMessage message) { final var tunnelId = extractTunnelId(session); final var payload = message.getPayload(); final var decoded = BinaryWsFrame.decode(payload); if (decoded == null) { return; } registry.onClientBinaryBytes(tunnelId, decoded.connectionId(), decoded.data()); payload.clear(); } @Override public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { // Detach session and close exposed sockets for this tunnel immediately try { final var tunnelId = extractTunnelId(session); if (tunnelId != null) { registry.closeTunnel(tunnelId); } } catch (final Exception e) { log.debug("Failed to close tunnel on WS close: {}", e.toString()); } finally { registry.detachSession(session); } } private UUID extractTunnelId(final WebSocketSession session) { return IdUtils.extractTunnelId(session.getUri()); } private Map parseQueryParams(final URI uri) { final var map = new HashMap(); if (uri == null) { return map; } final var query = uri.getQuery(); if (query == null || query.isBlank()) { return map; } final var parts = query.split("&"); for (final var part : parts) { final var idx = part.indexOf('='); if (idx <= 0) { continue; } final var key = part.substring(0, idx); final var value = part.substring(idx + 1); map.put(key, value); } return map; } } ================================================ FILE: net-proxy/src/main/java/tech/amak/portbuddy/netproxy/web/NetProxyController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.web; import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.dto.ExposeResponse; import tech.amak.portbuddy.netproxy.config.AppProperties; import tech.amak.portbuddy.netproxy.tunnel.NetTunnelRegistry; @RestController @RequestMapping(path = "/api/net-proxy", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class NetProxyController { private final NetTunnelRegistry registry; private final AppProperties properties; @PostMapping("/expose") public ExposeResponse expose(final @RequestParam("tunnelId") UUID tunnelId, final @RequestParam("type") TunnelType type, final @RequestParam(value = "desiredPort") int desiredPort) throws Exception { final var exposedPort = registry.expose(tunnelId, type, desiredPort); return new ExposeResponse(null, null, properties.publicHost(), exposedPort.getPort(), tunnelId, null); } /** * Closes a TCP/UDP tunnel by its ID. Invoked by the server when an account is blocked * or when an admin explicitly closes a tunnel. * * @param tunnelId unique identifier of the tunnel to close */ @PostMapping("/close") public void close(final @RequestParam("tunnelId") UUID tunnelId) { registry.closeTunnel(tunnelId); } } ================================================ FILE: net-proxy/src/main/resources/application.yml ================================================ server: port: ${NET_PROXY_PORT:8070} eureka: client: registryFetchIntervalSeconds: 2 eurekaServiceUrlPollIntervalSeconds: 60 service-url: defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka} instance: metadata-map: public-host: ${app.public-host} region: ${NET_PROXY_REGION:Netherlands/Amsterdam} coordinates: ${NET_PROXY_COORDINATES:52.378,4.9} spring: application: name: net-proxy security: oauth2: resourceserver: jwt: # Validate incoming JWTs using the Server's public JWKS jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json app: public-host: ${NET_PROXY_PUBLIC_HOST:localhost} web-socket: max-text-message-size: 1MB max-binary-message-size: 1MB session-idle-timeout: 10m send-time-limit: 10s send-buffer-size-limit: 1MB jwt: jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json issuer: port-buddy logging: level: root: info com.netflix.discovery: warn file: name: log/app.log logback: rollingpolicy: max-history: 14 total-size-cap: 1000MB max-file-size: 100MB ================================================ FILE: net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelLeakVerificationTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.net.Socket; import java.time.Duration; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.unit.DataSize; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.netproxy.config.AppProperties; class NetTunnelLeakVerificationTest { private final ObjectMapper mapper = new ObjectMapper(); private final AppProperties properties = new AppProperties( "localhost", new AppProperties.WebSocket( DataSize.ofMegabytes(10), DataSize.ofMegabytes(10), Duration.ofMinutes(10), Duration.ofSeconds(10), DataSize.ofMegabytes(1) ), new AppProperties.Jwt("port-buddy", "http://localhost:8080") ); @Test void testConnectionCleanupOnSendFailure() throws IOException, InterruptedException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); // Simulate failure on sendMessage doThrow(new IOException("Simulated failure")).when(session).sendMessage(any(BinaryMessage.class)); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10020); try (final var clientSocket = new Socket("localhost", exposedPort.getPort())) { clientSocket.setSoTimeout(5000); // Wait for registry to send OPEN verify(session, timeout(2000)).sendMessage(any(TextMessage.class)); final var tunnel = registry.byTunnelId.get(tunnelId); final var connectionId = tunnel.getConnections().keySet().iterator().next(); // Signal OPEN_OK registry.onClientOpenOk(tunnelId, connectionId); // Send some data from client clientSocket.getOutputStream().write("some data".getBytes()); clientSocket.getOutputStream().flush(); // The sendBinaryToClient should fail, and pumpFromPublic should exit and cleanup // We wait a bit for the async processing final var start = System.currentTimeMillis(); while (tunnel.getConnections().containsKey(connectionId) && (System.currentTimeMillis() - start) < 5000) { Thread.sleep(100); } assertNull(tunnel.getConnections().get(connectionId), "Connection should have been cleaned up after send failure"); } finally { registry.closeTunnel(tunnelId); registry.shutdown(); } } @Test void testExecutorShutdown() { final var registry = new NetTunnelRegistry(mapper, properties); final var ioPool = (ExecutorService) ReflectionTestUtils.getField(registry, "ioPool"); final var scheduler = (ScheduledExecutorService) ReflectionTestUtils.getField(registry, "scheduler"); assertFalse(ioPool.isShutdown()); assertFalse(scheduler.isShutdown()); registry.shutdown(); assertTrue(ioPool.isShutdown()); assertTrue(scheduler.isShutdown()); } } ================================================ FILE: net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelOrphanCleanupTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; import java.time.Duration; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.util.unit.DataSize; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.netproxy.config.AppProperties; class NetTunnelOrphanCleanupTest { private final ObjectMapper mapper = new ObjectMapper(); private final AppProperties properties = new AppProperties( "localhost", new AppProperties.WebSocket( DataSize.ofMegabytes(10), DataSize.ofMegabytes(10), Duration.ofMinutes(10), Duration.ofSeconds(10), DataSize.ofMegabytes(1) ), new AppProperties.Jwt("port-buddy", "http://localhost:8080") ); @Test void testOrphanedTunnelCleanup() throws IOException, InterruptedException { try { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); // Expose a tunnel - this creates it in byTunnelId registry.expose(tunnelId, TunnelType.TCP, 10010); assertNotNull(registry.byTunnelId.get(tunnelId), "Tunnel should be present after expose"); try { final var cleanupMethod = NetTunnelRegistry.class.getDeclaredMethod("cleanupOrphanedTunnels"); cleanupMethod.setAccessible(true); // First call immediately - should NOT remove because createdAt is too recent (timeout is 5 mins) cleanupMethod.invoke(registry); assertNotNull(registry.byTunnelId.get(tunnelId), "Tunnel should still be present immediately"); // Instead of waiting 5 minutes, we'll set the createdAt timestamp back final var tunnel = registry.byTunnelId.get(tunnelId); tunnel.setCreatedAt(System.currentTimeMillis() - (6 * 60 * 1000)); // 6 mins ago // Second call - should remove cleanupMethod.invoke(registry); assertNull(registry.byTunnelId.get(tunnelId), "Tunnel should be removed after timeout"); } catch (final Exception e) { throw new RuntimeException(e); } } finally { // No cleanup needed for static fields } } } ================================================ FILE: net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistryConcurrencyTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.io.IOException; import java.net.Socket; import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.util.unit.DataSize; import org.springframework.web.socket.WebSocketSession; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.netproxy.config.AppProperties; @Slf4j class NetTunnelRegistryConcurrencyTest { private final ObjectMapper mapper = new ObjectMapper(); private final AppProperties properties = new AppProperties( "localhost", new AppProperties.WebSocket( DataSize.ofMegabytes(10), DataSize.ofMegabytes(10), Duration.ofMinutes(10), Duration.ofSeconds(10), DataSize.ofMegabytes(1) ), new AppProperties.Jwt("port-buddy", "http://localhost:8080") ); @Test void testManyConcurrentConnections() throws IOException, InterruptedException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10005); final int connectionCount = 300; // More than the previous 200 limit final List sockets = new ArrayList<>(); try { for (int i = 0; i < connectionCount; i++) { sockets.add(new Socket("localhost", exposedPort.getPort())); } // Give it a moment to process all connections Thread.sleep(2000); // Check that all connections are registered final var tunnel = registry.byTunnelId.get(tunnelId); assertEquals(connectionCount, tunnel.getConnections().size(), "Should have " + connectionCount + " active connections"); } finally { for (final var socket : sockets) { try { socket.close(); } catch (final IOException ignore) { log.debug("Failed to close socket during test cleanup", ignore); } } registry.closeTunnel(tunnelId); } } } ================================================ FILE: net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelRegistryTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.netproxy.tunnel; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.util.unit.DataSize; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.netproxy.config.AppProperties; class NetTunnelRegistryTest { private final ObjectMapper mapper = new ObjectMapper(); private final AppProperties properties = new AppProperties( "localhost", new AppProperties.WebSocket( DataSize.ofMegabytes(10), DataSize.ofMegabytes(10), Duration.ofMinutes(10), Duration.ofSeconds(10), DataSize.ofMegabytes(1) ), new AppProperties.Jwt("port-buddy", "http://localhost:8080") ); @Test void testBlockHttpOnTcpTunnel() throws IOException, InterruptedException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10001); try (final var clientSocket = new Socket("localhost", exposedPort.getPort())) { clientSocket.setSoTimeout(5000); // Wait for registry to send OPEN verify(session, timeout(2000)).sendMessage(any(TextMessage.class)); // Signal that client is ready (OPEN_OK) to trigger pumpFromPublic registry.onClientOpenOk(tunnelId, registry.byTunnelId.get(tunnelId).getConnections().keySet().iterator().next()); final var out = clientSocket.getOutputStream(); out.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8)); out.flush(); // Wait a bit to see if anything happens Thread.sleep(1000); // If it blocked, the socket should be closed on the server side try { final int read = clientSocket.getInputStream().read(); assertTrue(read == -1, "Socket should be closed by server, but read: " + read); } catch (final IOException e) { // Connection reset is also a valid indication that the server closed the socket assertTrue(e.getMessage().contains("reset") || e.getMessage().contains("closed"), "Expected connection reset or closed, but got: " + e.getMessage()); } } finally { registry.closeTunnel(tunnelId); } } @Test void testAllowNonHttpOnTcpTunnel() throws IOException, InterruptedException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10002); try (final var clientSocket = new Socket("localhost", exposedPort.getPort())) { final var out = clientSocket.getOutputStream(); out.write(new byte[] {0, 1, 2, 3}); out.flush(); // Registry should send OPEN message verify(session, timeout(1000)).sendMessage(any(TextMessage.class)); } finally { registry.closeTunnel(tunnelId); } } @Test void testAllowPostgresSslRequest() throws IOException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10003); try (final var clientSocket = new Socket("localhost", exposedPort.getPort())) { final var out = clientSocket.getOutputStream(); // PostgreSQL SSLRequest: Int32(8), Int32(80877103) final byte[] sslRequest = {0, 0, 0, 8, 0x04, (byte) 0xd2, 0x16, 0x2f}; out.write(sslRequest); out.flush(); // Registry should send OPEN message verify(session, timeout(1000)).sendMessage(any(TextMessage.class)); } finally { registry.closeTunnel(tunnelId); } } @Test void testConnectionWithNoDataSentInitially() throws IOException, InterruptedException { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); final var session = mock(WebSocketSession.class); when(session.getId()).thenReturn(UUID.randomUUID().toString()); when(session.isOpen()).thenReturn(true); registry.attachSession(tunnelId, session); final var exposedPort = registry.expose(tunnelId, TunnelType.TCP, 10004); try (final var clientSocket = new Socket("localhost", exposedPort.getPort())) { // Wait for registry to send OPEN without sending anything from client verify(session, timeout(2000)).sendMessage(any(TextMessage.class)); } finally { registry.closeTunnel(tunnelId); } } } ================================================ FILE: net-proxy/src/test/java/tech/amak/portbuddy/netproxy/tunnel/NetTunnelUdpEvictionTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package tech.amak.portbuddy.netproxy.tunnel; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import java.net.InetSocketAddress; import java.time.Duration; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.util.unit.DataSize; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.netproxy.config.AppProperties; class NetTunnelUdpEvictionTest { private final ObjectMapper mapper = new ObjectMapper(); private final AppProperties properties = new AppProperties( "localhost", new AppProperties.WebSocket( DataSize.ofMegabytes(10), DataSize.ofMegabytes(10), Duration.ofMinutes(10), Duration.ofSeconds(10), DataSize.ofMegabytes(1) ), new AppProperties.Jwt("port-buddy", "http://localhost:8080") ); @Test void testUdpRemoteLruEviction() { final var registry = new NetTunnelRegistry(mapper, properties); final var tunnelId = UUID.randomUUID(); registry.byTunnelId.put(tunnelId, new NetTunnelRegistry.Tunnel(tunnelId)); final var tunnel = registry.byTunnelId.get(tunnelId); assertNotNull(tunnel); // Fill the map to its capacity (1000) for (int i = 0; i < 100; i++) { final var id = "remote-" + i; tunnel.getUdpRemotes().put(id, new InetSocketAddress("127.0.0.1", 10000 + i)); } assertEquals(100, tunnel.getUdpRemotes().size()); assertNotNull(tunnel.getUdpRemotes().get("remote-0")); // Add one more entry to trigger eviction tunnel.getUdpRemotes().put("remote-100", new InetSocketAddress("127.0.0.1", 11000)); assertEquals(100, tunnel.getUdpRemotes().size()); assertNotNull(tunnel.getUdpRemotes().get("remote-0"), "remote-0 should still be present because it was recently accessed"); assertNull(tunnel.getUdpRemotes().get("remote-1"), "remote-1 should have been evicted as the eldest entry"); assertNotNull(tunnel.getUdpRemotes().get("remote-100")); } } ================================================ FILE: pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT pom Port Buddy A tool to share a port opened on the local host to the public network. https://portbuddy.dev Apache License, Version 2.0 https://www.apache.org/licenses/LICENSE-2.0.txt repo common cli server net-proxy web gateway eureka ssl-service 25 25 25 UTF-8 3.5.7 2025.0.0 4.7.6 1.18.42 10.17.0 v24.11.1 11.6.2 org.springframework.boot spring-boot-dependencies ${spring-boot.version} pom import org.springframework.cloud spring-cloud-dependencies ${spring-cloud.version} pom import org.apache.maven.plugins maven-compiler-plugin 3.13.0 ${java.version} org.projectlombok lombok ${lombok.version} org.apache.maven.plugins maven-surefire-plugin 3.5.0 org.apache.maven.plugins maven-failsafe-plugin 3.5.0 org.apache.maven.plugins maven-checkstyle-plugin 3.4.0 https://raw.githubusercontent.com/amak-tech/checkstyle-java/refs/heads/main/checkstyle.xml true true warning true validate validate check org.springframework.boot spring-boot-maven-plugin ${spring-boot.version} ================================================ FILE: server/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT server port-buddy-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-mail org.springframework.boot spring-boot-configuration-processor org.springframework.boot spring-boot-starter-websocket org.springframework.boot spring-boot-starter-thymeleaf org.springframework.boot spring-boot-starter-actuator org.springframework.boot spring-boot-starter-data-jpa org.springframework.cloud spring-cloud-starter-netflix-eureka-client org.springframework.cloud spring-cloud-starter-openfeign org.postgresql postgresql 42.7.4 org.flywaydb flyway-core org.flywaydb flyway-database-postgresql org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-client org.springframework.boot spring-boot-starter-oauth2-resource-server org.projectlombok lombok ${lombok.version} provided tech.amak common ${project.version} com.stripe stripe-java 31.1.0 net.javacrumbs.shedlock shedlock-spring 7.2.0 net.javacrumbs.shedlock shedlock-provider-jdbc-template 7.2.0 org.springframework.boot spring-boot-starter-test test org.springframework.security spring-security-test test org.springframework.boot spring-boot-maven-plugin repackage org.apache.maven.plugins maven-checkstyle-plugin ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/ServerApplication.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @ConfigurationPropertiesScan @EnableFeignClients @EnableAsync public class ServerApplication { public static void main(final String[] args) { SpringApplication.run(ServerApplication.class, args); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/client/NetProxyClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.client; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import java.util.UUID; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.context.annotation.Bean; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; import tech.amak.portbuddy.common.dto.ExposeResponse; @FeignClient( name = "net-proxy", configuration = NetProxyClient.Configuration.class ) public interface NetProxyClient { @PostMapping("/api/net-proxy/expose") ExposeResponse exposePort(@RequestParam("tunnelId") UUID tunnelId, @RequestParam("type") String protocol, @RequestParam(value = "desiredPort", required = false) Integer desiredPort); @PostMapping("/api/net-proxy/close") void closeTunnel(@RequestParam("tunnelId") UUID tunnelId); class Configuration { @Bean public RequestInterceptor authorizationHeaderForwarder() { return template -> { final var attributes = RequestContextHolder.getRequestAttributes(); if (attributes instanceof ServletRequestAttributes requestAttributes) { final var auth = requestAttributes.getRequest().getHeader(AUTHORIZATION); if (auth != null && !auth.isBlank()) { template.header(AUTHORIZATION, auth); } } }; } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/client/SslServiceClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.client; import static org.springframework.http.HttpHeaders.AUTHORIZATION; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.context.annotation.Bean; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import feign.RequestInterceptor; @FeignClient( name = "ssl-service", configuration = SslServiceClient.Configuration.class ) public interface SslServiceClient { @PostMapping("/api/certificates/jobs") void submitJob(@RequestParam("domain") String domain, @RequestParam("requestedBy") String requestedBy, @RequestParam(value = "managed", defaultValue = "false") boolean managed); class Configuration { @Bean public RequestInterceptor authorizationHeaderForwarder() { return template -> { final var attributes = RequestContextHolder.getRequestAttributes(); if (attributes instanceof ServletRequestAttributes requestAttributes) { final var auth = requestAttributes.getRequest().getHeader(AUTHORIZATION); if (auth != null && !auth.isBlank()) { template.header(AUTHORIZATION, auth); } } }; } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/config/AppProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.config; import java.time.Duration; import java.util.List; import java.util.Map; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.core.io.Resource; import org.springframework.util.unit.DataSize; import tech.amak.portbuddy.common.Plan; @ConfigurationProperties(prefix = "app") public record AppProperties( Gateway gateway, WebSocket webSocket, Jwt jwt, Mail mail, Cli cli, PortReservations portReservations, Subscriptions subscriptions, Stripe stripe ) { public record Subscriptions( Duration gracePeriod, Duration checkInterval, Tunnels tunnels ) { public record Tunnels(Map base, Map increment) { } } public record Gateway( String url, String domain, String subdomainUrlTemplate, String notFoundPage, String passcodePage, DataSize maxRequestBodySize ) { public String subdomainHost() { return "." + domain; } } public record WebSocket( DataSize maxTextMessageSize, DataSize maxBinaryMessageSize, Duration sessionIdleTimeout ) { } public record Jwt( String issuer, Duration ttl, Rsa rsa ) { public record Rsa( String currentKeyId, List keys ) { } public record RsaKey( String id, Resource publicKeyPem, Resource privateKeyPem ) { } } public record Mail( String fromAddress, String fromName ) { } public record PortReservations( Range range ) { public record Range( int min, int max ) { } } public record Cli( String minVersion ) { } public record Stripe( String webhookSecret, String apiKey, PriceIds priceIds ) { public record PriceIds( String pro, String team, String extraTunnel ) { } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/config/SchedulingConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.config; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; @Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = "PT5M", defaultLockAtLeastFor = "PT5S") public class SchedulingConfig { @Bean public LockProvider lockProvider(final DataSource dataSource) { return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new org.springframework.jdbc.core.JdbcTemplate(dataSource)) .usingDbTime() .withTableName("shedlock") .build()); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/config/ThreatFoxProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.config; import java.time.Duration; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.ConfigurationProperties; @ConditionalOnProperty(name = "threatfox.enabled", havingValue = "true") @ConfigurationProperties(prefix = "threatfox") public record ThreatFoxProperties( boolean enabled, String authKey, Duration fetchInterval ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/config/TunnelsProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.config; import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import lombok.Getter; import lombok.Setter; /** Configuration for tunnels housekeeping. */ @Getter @Setter @Component("tunnelsProperties") @ConfigurationProperties(prefix = "app.tunnels") public class TunnelsProperties { /** * How long a tunnel may stay without heartbeats before it is considered stale and closed. * Defaults to 2 minutes. */ private Duration heartbeatTimeout = Duration.ofMinutes(2); /** * How often the server checks for stale tunnels. * Defaults to 30 seconds. */ private Duration checkInterval = Duration.ofSeconds(30); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/AccountEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import tech.amak.portbuddy.common.Plan; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "accounts") public class AccountEntity { @Id @Column(name = "id", nullable = false) private UUID id; @Column(name = "name", nullable = false) private String name; @Enumerated(EnumType.STRING) @Column(name = "plan", nullable = false) private Plan plan; @Column(name = "extra_tunnels", nullable = false) private int extraTunnels = 0; @Column(name = "stripe_customer_id") private String stripeCustomerId; @Column(name = "stripe_subscription_id") private String stripeSubscriptionId; @Column(name = "subscription_status") private String subscriptionStatus; @Column(name = "blocked", nullable = false) private boolean blocked = false; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; @OneToMany(mappedBy = "account", fetch = FetchType.LAZY) private List users = new ArrayList<>(); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/ApiKeyEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "api_keys") public class ApiKeyEntity { @Id @Column(name = "id", nullable = false) private UUID id; @Column(name = "user_id", nullable = false) private UUID userId; @Column(name = "account_id", nullable = false) private UUID accountId; @Column(name = "label", nullable = false) private String label; @Column(name = "token_hash", nullable = false, unique = true) private String tokenHash; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @Column(name = "last_used_at") private OffsetDateTime lastUsedAt; @Column(name = "revoked", nullable = false) private boolean revoked; @Column(name = "revoked_at") private OffsetDateTime revokedAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/DomainEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "domains") @SQLDelete(sql = "UPDATE domains SET deleted = true WHERE id = ?") @SQLRestriction("deleted = false") public class DomainEntity { @Id @Column(name = "id", nullable = false) private UUID id; @Column(name = "subdomain", nullable = false, unique = true) private String subdomain; @Column(name = "domain", nullable = false) private String domain; @Column(name = "custom_domain") private String customDomain; @Column(name = "cname_verified", nullable = false) private boolean cnameVerified = false; @Column(name = "ssl_active", nullable = false) private boolean sslActive = false; @Column(name = "passcode_hash") private String passcodeHash; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "account_id", nullable = false) private AccountEntity account; @Column(name = "deleted", nullable = false) private boolean deleted = false; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; @PrePersist @PreUpdate private void toLowerCase() { if (subdomain != null) { subdomain = subdomain.toLowerCase(); } if (domain != null) { domain = domain.toLowerCase(); } if (customDomain != null) { customDomain = customDomain.toLowerCase(); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/InvitationEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "invitations") public class InvitationEntity { @Id @Column(name = "id", nullable = false) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "account_id", nullable = false) private AccountEntity account; @Column(name = "email", nullable = false) private String email; @Column(name = "token", nullable = false, unique = true) private String token; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "invited_by_id", nullable = false) private UserEntity invitedBy; @Column(name = "accepted_at") private OffsetDateTime acceptedAt; @Column(name = "expires_at", nullable = false) private OffsetDateTime expiresAt; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/PasswordResetTokenEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "password_reset_tokens") public class PasswordResetTokenEntity { @Id @Column(name = "id", nullable = false) private UUID id; @Column(name = "token", nullable = false, unique = true) private String token; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private UserEntity user; @Column(name = "expiry_date", nullable = false) private OffsetDateTime expiryDate; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/PortReservationEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.annotations.Where; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "port_reservations") @SQLDelete(sql = "UPDATE port_reservations SET deleted = true, updated_at = NOW() WHERE id = ?") @Where(clause = "deleted = false") public class PortReservationEntity { @Id @Column(name = "id", nullable = false) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "account_id", nullable = false) private AccountEntity account; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private UserEntity user; @Column(name = "public_host", nullable = false) private String publicHost; @Column(name = "public_port", nullable = false) private Integer publicPort; @Column(name = "name") private String name; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; @Column(name = "deleted", nullable = false) private boolean deleted = false; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/Role.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; /** * Represents the roles that a user can have in the system. */ public enum Role { ADMIN, ACCOUNT_ADMIN, USER } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/StripeEventEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "stripe_events") public class StripeEventEntity { @Id @Column(name = "id", nullable = false) private String id; @Column(name = "type", nullable = false) private String type; @Column(name = "payload", nullable = false, columnDefinition = "TEXT") private String payload; @Column(name = "status", nullable = false) private String status; @Column(name = "error_message", columnDefinition = "TEXT") private String errorMessage; @Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt; @Column(name = "processed_at") private OffsetDateTime processedAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/TunnelEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import tech.amak.portbuddy.common.TunnelType; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "tunnels") public class TunnelEntity { @Id @Column(name = "id", nullable = false) private UUID id; @Enumerated(EnumType.STRING) @Column(name = "type", nullable = false) private TunnelType type; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private TunnelStatus status; // Ownership @Column(name = "account_id", nullable = false) private UUID accountId; @Column(name = "user_id") private UUID userId; @Column(name = "api_key_id") private UUID apiKeyId; // Local resource details @Column(name = "local_scheme") private String localScheme; @Column(name = "local_host") private String localHost; @Column(name = "local_port") private Integer localPort; // Public exposure details @Column(name = "public_url") private String publicUrl; @Column(name = "public_host") private String publicHost; @Column(name = "public_port") private Integer publicPort; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "domain_id") private DomainEntity domain; // Optional temporary passcode hash set for this tunnel via CLI @Column(name = "temp_passcode_hash") private String tempPasscodeHash; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "port_reservation_id") private PortReservationEntity portReservation; @Column(name = "last_heartbeat_at") private OffsetDateTime lastHeartbeatAt; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/TunnelStatus.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; /** Tunnel lifecycle status. */ public enum TunnelStatus { PENDING, CONNECTED, CLOSED } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/UserAccountEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.io.Serializable; import java.time.OffsetDateTime; import java.util.Set; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.UpdateTimestamp; import org.hibernate.type.SqlTypes; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Entity @Table(name = "user_accounts") public class UserAccountEntity { @EmbeddedId private UserAccountId id; @ManyToOne(fetch = FetchType.LAZY) @MapsId("userId") @JoinColumn(name = "user_id") private UserEntity user; @ManyToOne(fetch = FetchType.LAZY) @MapsId("accountId") @JoinColumn(name = "account_id") private AccountEntity account; @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.ARRAY) @Column(name = "roles", nullable = false) private Set roles; @Column(name = "last_used_at", nullable = false) private OffsetDateTime lastUsedAt; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; /** * Constructs a new UserAccountEntity linking a user to an account with specific roles. * * @param user the user. * @param account the account. * @param roles the roles of the user within the account. */ public UserAccountEntity(final UserEntity user, final AccountEntity account, final Set roles) { this.user = user; this.account = account; this.roles = roles; this.id = new UserAccountId(user.getId(), account.getId()); this.lastUsedAt = OffsetDateTime.now(); } /** * Gets the user's email by delegating to the associated UserEntity. * * @return the user's email. */ public String getEmail() { return user.getEmail(); } /** * Gets the user's first name by delegating to the associated UserEntity. * * @return the user's first name. */ public String getFirstName() { return user.getFirstName(); } /** * Gets the user's last name by delegating to the associated UserEntity. * * @return the user's last name. */ public String getLastName() { return user.getLastName(); } @Getter @Setter @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode @Embeddable public static class UserAccountId implements Serializable { private UUID userId; private UUID accountId; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/entity/UserEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.entity; import java.time.OffsetDateTime; import java.util.Set; import java.util.UUID; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @NoArgsConstructor @Entity @Table(name = "users") public class UserEntity { @Id @Column(name = "id", nullable = false) private UUID id; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private Set accounts; @Column(name = "email", nullable = false, length = 320) private String email; @Column(name = "first_name") private String firstName; @Column(name = "last_name") private String lastName; @Column(name = "auth_provider", nullable = false) private String authProvider; @Column(name = "external_id", nullable = false) private String externalId; @Column(name = "avatar_url") private String avatarUrl; @Column(name = "password") private String password; @CreationTimestamp @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/AccountRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.web.admin.dto.AdminAccountRow; import tech.amak.portbuddy.server.web.admin.dto.AdminStatsRow; public interface AccountRepository extends JpaRepository { Optional findByStripeCustomerId(String stripeCustomerId); @Query("SELECT a FROM AccountEntity a WHERE a.subscriptionStatus <> 'active' AND a.updatedAt < :cutoff") List findBySubscriptionStatusNotActiveAndUpdatedAtBefore(@Param("cutoff") OffsetDateTime cutoff); @Query(value = """ SELECT a.id AS account_id, a.name AS name, a.plan AS plan, a.extra_tunnels AS extra_tunnels, COALESCE(SUM(CASE WHEN t.status = 'CONNECTED' THEN 1 ELSE 0 END), 0) AS active_tunnels, a.blocked AS blocked, a.created_at AS created_at FROM accounts a LEFT JOIN tunnels t ON t.account_id = a.id WHERE (:search IS NULL OR a.name ILIKE CONCAT('%', :search, '%') OR CAST(a.id AS TEXT) ILIKE CONCAT('%', :search, '%')) GROUP BY a.id, a.name, a.plan, a.extra_tunnels, a.blocked, a.created_at ORDER BY active_tunnels DESC, a.created_at DESC """, nativeQuery = true) List findAdminAccounts(@Param("search") String search); @Query(value = """ WITH RECURSIVE days AS ( SELECT CURRENT_DATE - INTERVAL '29 days' as day UNION ALL SELECT day + INTERVAL '1 day' FROM days WHERE day < CURRENT_DATE ) SELECT d.day::date as "date", (SELECT count(*) FROM users u WHERE u.created_at::date = d.day) as new_users_count, (SELECT count(*) FROM tunnels t WHERE t.created_at::date = d.day) as tunnels_count, (SELECT count(*) FROM stripe_events s WHERE s.created_at::date = d.day) as payment_events FROM days d ORDER BY d.day DESC """, nativeQuery = true) List findDailyStats(); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/ApiKeyRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.server.db.entity.ApiKeyEntity; public interface ApiKeyRepository extends JpaRepository { List findAllByUserId(UUID userId); List findAllByAccountId(UUID accountId); Optional findByIdAndUserId(UUID id, UUID userId); Optional findByIdAndAccountId(UUID id, UUID accountId); Optional findByTokenHashAndRevokedFalse(String tokenHash); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/DomainRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; @Repository public interface DomainRepository extends JpaRepository { boolean existsBySubdomain(String subdomain); @Query(value = "SELECT count(*) > 0 FROM domains WHERE subdomain = LOWER(:subdomain)", nativeQuery = true) boolean existsBySubdomainGlobal(@Param("subdomain") String subdomain); List findAllByAccount(AccountEntity account); Optional findByAccountAndSubdomain(AccountEntity account, String subdomain); Optional findByIdAndAccount(UUID id, AccountEntity account); Optional findBySubdomain(String subdomain); Optional findByCustomDomain(String customDomain); long countByAccount(AccountEntity account); long countByAccountAndCustomDomainIsNotNull(AccountEntity account); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/InvitationRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.InvitationEntity; public interface InvitationRepository extends JpaRepository { Optional findByToken(String token); List findAllByAccountAndAcceptedAtIsNull(AccountEntity account); Optional findByAccountAndEmailAndAcceptedAtIsNull(AccountEntity account, String email); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/PasswordResetTokenRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; public interface PasswordResetTokenRepository extends JpaRepository { Optional findByToken(String token); void deleteByUser(UserEntity user); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/PortReservationRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; public interface PortReservationRepository extends JpaRepository { List findAllByAccount(AccountEntity account); Optional findByIdAndAccount(UUID id, AccountEntity account); boolean existsByPublicHostAndPublicPort(String publicHost, Integer publicPort); long countByPublicHost(String publicHost); @Query("select max(pr.publicPort) from PortReservationEntity pr where pr.publicHost = :host") Optional findMaxPortByHost(@Param("host") String publicHost); Optional findByAccountAndPublicHostAndPublicPort(AccountEntity account, String host, Integer port); List findAllByAccountAndPublicPort(AccountEntity account, Integer port); boolean existsByAccountAndName(AccountEntity account, String name); Optional findByAccountAndNameIgnoreCase(AccountEntity account, String name); /** * Finds the minimal free public port for the specified host within the provided inclusive range * using a single SQL query. Only non-deleted reservations are considered busy. */ @Query(value = """ select gs.port from generate_series(:min, :max) as gs(port) left join port_reservations pr on pr.public_host = :host and pr.public_port = gs.port and pr.deleted = false where pr.public_port is null order by gs.port limit 1 """, nativeQuery = true) Optional findMinimalFreePort(@Param("host") String host, @Param("min") int min, @Param("max") int max); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/StripeEventRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.server.db.entity.StripeEventEntity; public interface StripeEventRepository extends JpaRepository { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/TunnelRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.entity.TunnelEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.web.admin.dto.AdminTunnelRow; @Repository public interface TunnelRepository extends JpaRepository { boolean existsByDomainAndStatus(DomainEntity domain, TunnelStatus status); boolean existsByDomainAndStatusNot(DomainEntity domain, TunnelStatus status); Optional findFirstByAccountIdAndLocalHostAndLocalPortAndDomainIsNotNullOrderByCreatedAtDesc( UUID accountId, String localHost, Integer localPort); default Optional findUsedTunnel(final UUID accountId, final String localHost, final Integer localPort) { return findFirstByAccountIdAndLocalHostAndLocalPortAndDomainIsNotNullOrderByCreatedAtDesc( accountId, localHost, localPort); } Page findAllByAccountId(UUID accountId, Pageable pageable); boolean existsByPortReservationAndStatusNot(PortReservationEntity portReservation, TunnelStatus status); Optional findFirstByAccountIdAndLocalHostAndLocalPortAndPortReservationIsNotNullOrderByCreatedAtDesc( UUID accountId, String localHost, Integer localPort); @Query(value = """ SELECT t FROM TunnelEntity t LEFT JOIN FETCH t.portReservation LEFT JOIN FETCH t.domain WHERE t.accountId = :accountId ORDER BY t.lastHeartbeatAt DESC NULLS LAST, t.createdAt DESC""") Page pageByAccountOrderByLastHeartbeatDescNullsLast( @Param("accountId") UUID accountId, Pageable pageable); long countByAccountIdAndStatusIn(UUID accountId, List statuses); List findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc( UUID accountId, List statuses); long countByStatusIn(List statuses); /** * Closes tunnels that are in CONNECTED status but have stale or missing heartbeat. * Uses native SQL to also update the updated_at timestamp. * * @param cutoff heartbeats older than this timestamp are considered stale * @return number of rows updated */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = """ UPDATE tunnels SET status = 'CLOSED', updated_at = NOW() WHERE created_at < :cutoff AND status <> 'CLOSED' AND (last_heartbeat_at IS NULL OR last_heartbeat_at < :cutoff) RETURNING id""", nativeQuery = true) List closeStaleConnected(@Param("cutoff") final OffsetDateTime cutoff); @Query(value = """ SELECT t.id AS id, t.type AS type, CONCAT(t.local_host, ':', t.local_port) AS local_address, COALESCE(t.public_url, CONCAT(t.public_host, ':', t.public_port)) AS public_address, t.last_heartbeat_at AS last_activity, CONCAT(u.first_name, ' ', u.last_name) AS user_name, t.user_id AS user_id, t.account_id AS account_id FROM tunnels t LEFT JOIN users u ON u.id = t.user_id WHERE t.status = 'CONNECTED' AND (:search IS NULL OR t.public_url ILIKE CONCAT('%', :search, '%') OR t.public_host ILIKE CONCAT('%', :search, '%') OR u.email ILIKE CONCAT('%', :search, '%') OR u.first_name ILIKE CONCAT('%', :search, '%') OR u.last_name ILIKE CONCAT('%', :search, '%')) ORDER BY t.last_heartbeat_at DESC NULLS LAST, t.created_at DESC """, nativeQuery = true) List findAdminActiveTunnels(@Param("search") String search); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/UserAccountRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; public interface UserAccountRepository extends JpaRepository { List findAllByUserId(UUID userId); @Query(""" SELECT ua FROM UserAccountEntity ua JOIN FETCH ua.account WHERE ua.user.id = :userId ORDER BY ua.lastUsedAt DESC LIMIT 1""") Optional findLatestUsedByUserId(@Param("userId") UUID userId); @Query(""" SELECT ua FROM UserAccountEntity ua JOIN FETCH ua.account WHERE ua.user.id = :userId AND ua.account.id = :accountId""") Optional findByUserIdAndAccountId(@Param("userId") UUID userId, @Param("accountId") UUID accountId); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/db/repo/UserRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.db.repo; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.web.admin.dto.AdminUserRow; public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "accounts") @Override Optional findById(UUID id); Optional findByAuthProviderAndExternalId(String authProvider, String externalId); Optional findByEmailIgnoreCase(String email); @Query("SELECT ua.user FROM UserAccountEntity ua WHERE ua.account = :account") List findAllByAccount(@Param("account") AccountEntity account); @Query(value = """ SELECT u.id AS id, a.id AS account_id, CONCAT(u.first_name, ' ', u.last_name) AS name, u.email AS email, COALESCE(SUM(CASE WHEN t.status = 'CONNECTED' THEN 1 ELSE 0 END), 0) AS active_tunnels, a.blocked AS blocked, u.created_at AS created_at FROM users u INNER JOIN user_accounts ua ON ua.user_id = u.id INNER JOIN accounts a ON a.id = ua.account_id LEFT JOIN tunnels t ON t.account_id = a.id WHERE (:search IS NULL OR u.email ILIKE CONCAT('%', :search, '%') OR u.first_name ILIKE CONCAT('%', :search, '%') OR u.last_name ILIKE CONCAT('%', :search, '%') OR CAST(u.id AS TEXT) ILIKE CONCAT('%', :search, '%')) GROUP BY u.id, a.id, u.first_name, u.last_name, u.email, a.blocked, u.created_at ORDER BY active_tunnels DESC, u.created_at DESC """, nativeQuery = true) List findAdminUsers(@Param("search") String search); } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/mail/EmailService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.mail; import java.nio.charset.StandardCharsets; import java.util.Map; import org.springframework.lang.Nullable; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import jakarta.mail.MessagingException; import jakarta.mail.internet.InternetAddress; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.config.AppProperties; /** * Service for sending HTML email based on Thymeleaf templates. */ @Service @RequiredArgsConstructor @Slf4j public class EmailService { private final JavaMailSender mailSender; private final SpringTemplateEngine templateEngine; private final AppProperties properties; /** * Sends an HTML email rendered from a Thymeleaf template. * * @param to recipient email * @param subject email subject * @param template template name under templates directory (e.g. "email/welcome") * @param model model variables for the template */ @Async public void sendTemplate(final String to, final String subject, final String template, final @Nullable Map model) { try { final var message = mailSender.createMimeMessage(); final var helper = new MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, StandardCharsets.UTF_8.name()); final var fromName = properties.mail().fromName(); final var fromAddress = properties.mail().fromAddress(); helper.setFrom(new InternetAddress(fromAddress, fromName)); helper.setTo(to); helper.setSubject(subject); final var context = new Context(); if (model != null) { model.forEach(context::setVariable); } final var html = templateEngine.process(template, context); helper.setText(html, true); mailSender.send(message); } catch (final MessagingException e) { log.warn("Email send failed (MessagingException): {}", e.getMessage()); } catch (final Exception e) { log.warn("Email send failed: {}", e.getMessage()); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/mail/UserCreatedEvent.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.mail; import java.util.UUID; /** * Application domain event published when a new user is created in the system. */ public record UserCreatedEvent(UUID userId, UUID accountId, String email, String firstName, String lastName, String passwordResetLink) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/mail/WelcomeEmailService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.mail; import java.util.HashMap; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.config.AppProperties; /** * Sends a welcome email to newly registered users. */ @Service @RequiredArgsConstructor @Slf4j public class WelcomeEmailService { private final EmailService emailService; private final AppProperties properties; /** * Handles user created events and sends a welcome email after the transaction is committed. * * @param event domain event carrying user details */ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onUserCreated(final UserCreatedEvent event) { try { final var webAppUrl = properties.gateway().url(); final var fullName = buildFullName(event.firstName(), event.lastName()); final var model = new HashMap(); model.put("subject", "Welcome to Port Buddy"); model.put("greeting", fullName == null ? "Welcome to Port Buddy!" : "Welcome, " + fullName + "!"); model.put("intro", "Let’s get your local and private services online in seconds."); if (event.passwordResetLink() != null) { model.put("ctaText", "Setup My Account"); model.put("ctaUrl", event.passwordResetLink()); } else { model.put("ctaText", "Open My Account"); model.put("ctaUrl", webAppUrl + "/app"); } model.put("webAppUrl", webAppUrl); model.put("featuresTitle", "What you can do with Port Buddy"); model.put("feature1Title", "Expose HTTP apps"); model.put("feature1Desc", "Share your local web app with a secure public URL (HTTP & WebSocket)."); model.put("feature2Title", "Share TCP services"); model.put("feature2Desc", "Give teammates access to databases or any TCP service with a temporary public port."); model.put("feature3Title", "Simple CLI"); model.put("feature3Desc", "One command to expose a port. Auth with API token. Pro plan is free."); log.info("sending welcome emails to user {}", event.userId()); emailService.sendTemplate(event.email(), "Welcome to Port Buddy", "email/welcome", model); log.info("welcome emails to user {} is sent", event.userId()); } catch (final Exception e) { log.warn("Failed to send welcome email: {}", e.getMessage()); } } private static String buildFullName(final String firstName, final String lastName) { String result = firstName; if (result == null && lastName == null) { return null; } if (result == null) { result = ""; } if (lastName != null && !lastName.isBlank()) { result = (result + " " + lastName).trim(); } return result.isBlank() ? null : result; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/ApiTokenAuthFilter.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import static java.util.List.of; import java.io.IOException; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.service.ApiTokenService; /** * ApiTokenAuthFilter is a Spring Security filter that processes each incoming HTTP request * to authenticate users based on an API token provided in the request headers. * This filter looks for the API token in the "Authorization" header (prefixed with "Bearer ") * or the "X-API-Token" header. The token is then validated using the ApiTokenService, and if * valid, the corresponding user ID is extracted and set in the Security Context. * This filter ensures that any valid request carrying an API token will have the appropriate * authentication details set for use during subsequent processing. * The filter is executed once per request and extends the OncePerRequestFilter class provided * by the Spring Framework. */ @Component @RequiredArgsConstructor @Slf4j public class ApiTokenAuthFilter extends OncePerRequestFilter { private final ApiTokenService apiTokenService; @Override protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { try { final var header = request.getHeader(HttpHeaders.AUTHORIZATION); String token = null; if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { token = header.substring(7); } if (!StringUtils.hasText(token)) { token = request.getHeader("X-API-Token"); } if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) { final var userIdOpt = apiTokenService.validateAndGetUserId(token); if (userIdOpt.isPresent()) { final var userId = userIdOpt.get(); final var auth = new UsernamePasswordAuthenticationToken( userId, null, of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth); } } } catch (final Exception e) { log.debug("API token auth error: {}", e.toString()); } filterChain.doFilter(request, response); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/JwtConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.DefaultJWTProcessor; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.config.AppProperties; @Configuration @RequiredArgsConstructor public class JwtConfig { private final RsaKeyProvider rsaKeyProvider; private final AppProperties properties; @Bean public JWKSource jwkSource() { return rsaKeyProvider.jwkSource(); } @Bean public JwtEncoder jwtEncoder(final JWKSource jwkSource) { return new NimbusJwtEncoder(jwkSource); } @Bean public JwtDecoder jwtDecoder(final JWKSource jwkSource) { final var jwtProcessor = new DefaultJWTProcessor<>(); final var keySelector = new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); jwtProcessor.setJWSKeySelector(keySelector); final var decoder = new NimbusJwtDecoder(jwtProcessor); final var withIssuer = new JwtIssuerValidator(properties.jwt().issuer()); final var validator = new DelegatingOAuth2TokenValidator<>(new JwtTimestampValidator(), withIssuer); decoder.setJwtValidator(validator); return decoder; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/JwtService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import java.time.Instant; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.Role; @Service @RequiredArgsConstructor public class JwtService { public static final String TOKEN_TYPE = "JWT"; private final JwtEncoder jwtEncoder; private final AppProperties properties; private final RsaKeyProvider rsaKeyProvider; /** * Creates a JWT token using the specified claims and subject. The generated token includes * details such as issuer, issued time, expiration time, subject, and any custom claims provided. * * @param claims a map containing custom claims to include in the token. Can be null if no custom claims are * required. * @param subject the subject of the token, typically representing the identity of the authenticated user. * @return a string representation of the generated JWT token. */ public String createToken(final Map claims, final String subject) { return createToken(claims, subject, null); } /** * Creates a JWT token using the specified claims and subject. The generated token includes * details such as issuer, issued time, expiration time, subject, and any custom claims provided. * * @param claims a map containing custom claims to include in the token. Can be null if no custom claims are * required. * @param subject the subject of the token, typically representing the identity of the authenticated user. * @param roles the roles assigned to the user. * @return a string representation of the generated JWT token. */ public String createToken(final Map claims, final String subject, final Set roles) { final var now = Instant.now(); final var expiresAt = now.plus(properties.jwt().ttl()); final var builder = JwtClaimsSet.builder() .issuer(properties.jwt().issuer()) .issuedAt(now) .expiresAt(expiresAt) .subject(subject); if (claims != null) { claims.forEach(builder::claim); } if (roles != null && !roles.isEmpty()) { builder.claim(Oauth2SuccessHandler.ROLES_CLAIM, roles.stream().map(Enum::name).collect(Collectors.toSet())); } final var header = JwsHeader.with(SignatureAlgorithm.RS256) .type(TOKEN_TYPE) .keyId(rsaKeyProvider.getCurrentKid()) .build(); final var jwt = jwtEncoder.encode(JwtEncoderParameters.from(header, builder.build())); return jwt.getTokenValue(); } public static UUID resolveUserId(final Jwt jwt) { return UUID.fromString(jwt.getSubject()); } /** * Resolves the account id from the JWT token. * * @param jwt the JWT token. * @return the account id. */ public static UUID resolveAccountId(final Jwt jwt) { final var claim = jwt.getClaimAsString(Oauth2SuccessHandler.ACCOUNT_ID_CLAIM); if (claim == null) { throw new IllegalArgumentException("Account ID claim is missing."); } return UUID.fromString(claim); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/Oauth2SuccessHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import static java.nio.charset.StandardCharsets.UTF_8; import java.io.IOException; import java.net.URLEncoder; import java.util.HashMap; import java.util.List; import java.util.Map; import org.springframework.core.ParameterizedTypeReference; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.service.user.MissingEmailException; import tech.amak.portbuddy.server.service.user.UserProvisioningService; @Component public class Oauth2SuccessHandler implements AuthenticationSuccessHandler { public static final String EMAIL_CLAIM = "email"; public static final String NAME_CLAIM = "name"; public static final String PICTURE_CLAIM = "picture"; public static final String AVATAR_URL_CLAIM = "avatar_url"; public static final String FIRST_NAME_CLAIM = "given_name"; public static final String LAST_NAME_CLAIM = "family_name"; public static final String ACCOUNT_ID_CLAIM = "aid"; public static final String ACCOUNT_NAME_CLAIM = "aname"; public static final String USER_ID_CLAIM = "uid"; public static final String SUBJECT_CLAIM = "sub"; public static final String ID_CLAIM = "id"; public static final String ROLES_CLAIM = "roles"; private static final String UNKNOWN = "unknown"; private final JwtService jwtService; private final AppProperties properties; private final UserProvisioningService userProvisioningService; private final OAuth2AuthorizedClientService authorizedClientService; private final RestClient restClient; /** * Constructs an instance of {@code Oauth2SuccessHandler} with the required dependencies. * Handles OAuth2 authentication success events and manages token creation, user provisioning, * and authorized client services. * * @param jwtService the service responsible for creating and handling JWT tokens. * @param properties the application properties configuration. * @param userProvisioningService the service responsible for provisioning users based on OAuth2 authentication. * @param authorizedClientService the service for managing OAuth2 authorized clients. * @param restClientBuilder the builder for constructing REST clients. */ public Oauth2SuccessHandler(final JwtService jwtService, final AppProperties properties, final UserProvisioningService userProvisioningService, final OAuth2AuthorizedClientService authorizedClientService, final RestClient.Builder restClientBuilder) { this.jwtService = jwtService; this.properties = properties; this.userProvisioningService = userProvisioningService; this.authorizedClientService = authorizedClientService; this.restClient = restClientBuilder.build(); } @Override public void onAuthenticationSuccess(final HttpServletRequest request, final HttpServletResponse response, final Authentication authentication) throws IOException { final var provider = resolveProvider(authentication); var externalId = UNKNOWN; var email = (String) null; var name = (String) null; var picture = (String) null; var firstName = (String) null; var lastName = (String) null; final var principal = authentication.getPrincipal(); if (principal instanceof DefaultOidcUser oidc) { externalId = oidc.getSubject(); email = oidc.getEmail(); name = oidc.getFullName(); picture = (String) oidc.getClaims().getOrDefault(PICTURE_CLAIM, null); firstName = (String) oidc.getClaims().getOrDefault(FIRST_NAME_CLAIM, null); lastName = (String) oidc.getClaims().getOrDefault(LAST_NAME_CLAIM, null); } else if (principal instanceof OAuth2User oauth2) { final var attrs = oauth2.getAttributes(); externalId = String.valueOf(attrs.getOrDefault(SUBJECT_CLAIM, attrs.getOrDefault(ID_CLAIM, UNKNOWN))); email = asNullableString(attrs, EMAIL_CLAIM); name = asNullableString(attrs, NAME_CLAIM); picture = asNullableString(attrs, PICTURE_CLAIM); if (picture == null) { picture = asNullableString(attrs, AVATAR_URL_CLAIM); } if (attrs.containsKey(FIRST_NAME_CLAIM) || attrs.containsKey(LAST_NAME_CLAIM)) { firstName = asNullableString(attrs, FIRST_NAME_CLAIM); lastName = asNullableString(attrs, LAST_NAME_CLAIM); } if (email == null && "github".equals(provider)) { email = fetchGithubEmail(authentication); } } if ((firstName == null || lastName == null) && name != null && !name.isBlank()) { final var parts = name.trim().split(" ", 2); firstName = firstName == null ? parts[0] : firstName; if (parts.length > 1) { lastName = lastName == null ? parts[1] : lastName; } } // Ensure user + account exist or updated final var provisioned = provisionOrRedirectOnMissingEmail( response, provider, externalId, email, firstName, lastName, picture); if (provisioned == null) { // Redirect already sent (e.g., missing email). Stop further processing. return; } final var claims = new HashMap(); if (email != null) { claims.put(EMAIL_CLAIM, email); } if (name != null) { claims.put(NAME_CLAIM, name); } if (picture != null) { claims.put(PICTURE_CLAIM, picture); } claims.put(ACCOUNT_ID_CLAIM, provisioned.accountId().toString()); claims.put(ACCOUNT_NAME_CLAIM, provisioned.accountName()); claims.put(USER_ID_CLAIM, provisioned.userId().toString()); final var token = jwtService.createToken(claims, provisioned.userId().toString(), provisioned.roles()); final var redirectUrl = properties.gateway().url() + "/auth/callback?token=" + URLEncoder.encode(token, UTF_8); response.sendRedirect(redirectUrl); } private static String resolveProvider(final Authentication authentication) { if (authentication instanceof OAuth2AuthenticationToken oauthToken) { return oauthToken.getAuthorizedClientRegistrationId(); } return "unknown"; } private String fetchGithubEmail(final Authentication authentication) { if (!(authentication instanceof OAuth2AuthenticationToken oauthToken)) { return null; } final var client = authorizedClientService.loadAuthorizedClient( oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); if (client == null || client.getAccessToken() == null) { return null; } try { final var emails = restClient.get() .uri("https://api.github.com/user/emails") .headers(headers -> headers.setBearerAuth(client.getAccessToken().getTokenValue())) .retrieve() .body(new ParameterizedTypeReference>>() { }); if (emails == null || emails.isEmpty()) { return null; } // Prefer primary email return emails.stream() .filter(map -> Boolean.TRUE.equals(map.get("primary")) && Boolean.TRUE.equals(map.get("verified"))) .map(map -> (String) map.get("email")) .findFirst() .orElseGet(() -> emails.stream() .filter(map -> Boolean.TRUE.equals(map.get("verified"))) .map(map -> (String) map.get("email")) .findFirst() .orElse(null)); } catch (final Exception e) { return null; } } private static String asNullableString(final Map attrs, final String key) { final var value = attrs.get(key); return value == null ? null : String.valueOf(value); } private UserProvisioningService.ProvisionedUser provisionOrRedirectOnMissingEmail( final HttpServletResponse response, final String provider, final String externalId, final String email, final String firstName, final String lastName, final String picture) throws IOException { try { return userProvisioningService.provision(provider, externalId, email, firstName, lastName, picture); } catch (final MissingEmailException ex) { final var url = properties.gateway().url() + "/auth/callback?error=" + URLEncoder.encode("missing_email", UTF_8) + "&message=" + URLEncoder.encode("Email is required to sign in", UTF_8); response.sendRedirect(url); return null; } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/RsaKeyProvider.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.Objects; import org.springframework.stereotype.Component; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.config.AppProperties; /** * Loads RSA keys from configuration and exposes a JWKSource for signing and a public JWKSet for discovery. */ @Component @Slf4j public class RsaKeyProvider { private final ImmutableJWKSet jwkSource; @Getter private final JWKSet publicJwkSet; @Getter private final String currentKid; /** * Constructs an instance of {@code RsaKeyProvider} by loading RSA keys from the provided application properties. * This constructor validates the configuration of RSA keys and ensures the presence of at least one valid key. * * @param properties the application properties containing the JWT and RSA key configuration; must not be null * and must properly configure the `app.jwt.rsa` and its required fields: *
    *
  • `app.jwt.rsa.currentKeyId`: the ID of the current RSA key, used for signing JWTs
  • *
  • `app.jwt.rsa.keys`: a list of RSA key configurations, * each including public and optionally private keys
  • *
* An exception is thrown if any fields are improperly configured. */ public RsaKeyProvider(final AppProperties properties) { final var jwt = Objects.requireNonNull(properties.jwt(), "app.jwt must be configured"); final var rsa = Objects.requireNonNull(jwt.rsa(), "app.jwt.rsa must be configured"); this.currentKid = Objects.requireNonNull(rsa.currentKeyId(), "app.jwt.rsa.currentKeyId must be set"); final var keys = Objects.requireNonNull(rsa.keys(), "app.jwt.rsa.keys must be set"); if (keys.isEmpty()) { throw new IllegalStateException("app.jwt.rsa.keys must contain at least one key"); } final List all = new ArrayList<>(); final List publicOnly = new ArrayList<>(); for (final var key : keys) { final var id = Objects.requireNonNull(key.id(), "RSA key id must be set"); try { final var pub = parsePublicKey(key.publicKeyPem().getContentAsString(StandardCharsets.UTF_8)); final var builder = new RSAKey.Builder(pub).keyID(id); publicOnly.add(builder.build()); final var privatePem = key.privateKeyPem().getContentAsString(StandardCharsets.UTF_8); if (!privatePem.isBlank()) { final var rsaPrivateKey = parsePrivateKey(privatePem); all.add(new RSAKey.Builder(pub).privateKey(rsaPrivateKey).keyID(id).build()); } else { // Public-only key; not usable for signing all.add(builder.build()); } } catch (final Exception e) { throw new IllegalStateException("Failed to parse RSA key id=" + id + ": " + e.getMessage(), e); } } final var allJwks = new ArrayList(all); final var publicJwks = new ArrayList(publicOnly); this.jwkSource = new ImmutableJWKSet<>(new JWKSet(allJwks)); this.publicJwkSet = new JWKSet(publicJwks); log.info("Loaded {} RSA keys; current kid={}", all.size(), this.currentKid); } public JWKSource jwkSource() { return jwkSource; } private static RSAPublicKey parsePublicKey(final String pem) throws Exception { final var content = stripPemHeaders(pem, "PUBLIC KEY"); final var decoded = Base64.getMimeDecoder().decode(content); final var kf = KeyFactory.getInstance("RSA"); return (RSAPublicKey) kf.generatePublic(new X509EncodedKeySpec(decoded)); } private static RSAPrivateKey parsePrivateKey(final String pem) throws Exception { final var content = stripPemHeaders(pem, "PRIVATE KEY"); final var decoded = Base64.getMimeDecoder().decode(content); final var kf = KeyFactory.getInstance("RSA"); return (RSAPrivateKey) kf.generatePrivate(new PKCS8EncodedKeySpec(decoded)); } private static String stripPemHeaders(final String pem, final String type) { if (pem == null) { throw new IllegalArgumentException("PEM is null"); } return pem .replace("-----BEGIN " + type + "-----", "") .replace("-----END " + type + "-----", "") .replaceAll("\\s", ""); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/SecurityConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; 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.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final ApiTokenAuthFilter apiTokenAuthFilter; private final @Lazy Oauth2SuccessHandler oauth2SuccessHandler; @Bean @Order(1) public SecurityFilterChain apiSecurityFilterChain(final HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST, "/api/auth/token-exchange", "/api/auth/login", "/api/auth/register", "/api/webhooks/stripe").permitAll() .requestMatchers("/api/auth/password-reset/**").permitAll() .requestMatchers("/api/internal/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(apiTokenAuthFilter, BearerTokenAuthenticationFilter.class) // Enforce stateless API: no HTTP session will be created or used for authentication .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Store SecurityContext only in the request, never in an HTTP session or cookie .securityContext(sc -> sc.securityContextRepository(new RequestAttributeSecurityContextRepository())) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) ) .logout(logout -> logout // Logout for stateless API: handle POST /api/auth/logout on this server (relative URL) .logoutUrl("/api/auth/logout") .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT)) .permitAll() ); return http.build(); } @Bean @Order(2) public SecurityFilterChain webSecurityFilterChain(final HttpSecurity http) throws Exception { http .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers( "/", "/index.html", "/assets/**", "/favicon.*", "/actuator/health**", "/ingress/**", "/ws/**", "/_ws/**", "/oauth2/**", "/login**", "/.well-known/jwks.json" ).permitAll() .anyRequest().permitAll() ) .oauth2Login(oauth -> oauth // OAuth2 is used only to mint a JWT and redirect back. API calls must use Authorization: Bearer .successHandler(oauth2SuccessHandler) ); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { final var converter = new JwtGrantedAuthoritiesConverter(); converter.setAuthoritiesClaimName("roles"); converter.setAuthorityPrefix("ROLE_"); final var authConverter = new JwtAuthenticationConverter(); authConverter.setJwtGrantedAuthoritiesConverter(converter); return authConverter; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/security/ThreatBlockedException.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; public class ThreatBlockedException extends SecurityException { public ThreatBlockedException(final String message) { super(message); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/ApiTokenService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Instant; import java.time.OffsetDateTime; import java.util.Base64; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.db.entity.ApiKeyEntity; import tech.amak.portbuddy.server.db.repo.ApiKeyRepository; /** * API token service backed by the database. Stores only token hashes. */ @Service @Slf4j public class ApiTokenService { private final SecureRandom secureRandom = new SecureRandom(); private final ApiKeyRepository apiKeyRepository; public ApiTokenService(final ApiKeyRepository apiKeyRepository) { this.apiKeyRepository = apiKeyRepository; } /** * Creates a new API token for the specified user and stores its hash in the DB. * * @param accountId the account ID (UUID) * @param userId the user ID (UUID) * @param label optional label for the token * @return token id and raw token (displayed once) */ @Transactional public CreatedToken createToken(final UUID accountId, final UUID userId, final String label) { final var rawToken = generateRawToken(); final var tokenHash = sha256(rawToken); final var apiKey = new ApiKeyEntity(); apiKey.setId(UUID.randomUUID()); apiKey.setAccountId(accountId); apiKey.setUserId(userId); apiKey.setLabel(label == null || label.isBlank() ? "cli" : label); apiKey.setTokenHash(tokenHash); apiKey.setRevoked(false); final var saved = apiKeyRepository.save(apiKey); log.info("Created API token id={} for userId={} accountId={}", saved.getId(), userId, accountId); return new CreatedToken(saved.getId().toString(), rawToken); } /** * Lists tokens for the account without exposing secrets. */ @Transactional(readOnly = true) public List listTokens(final UUID accountId) { return apiKeyRepository.findAllByAccountId(accountId).stream() .map(apiKey -> new TokenView( apiKey.getId().toString(), apiKey.getLabel(), toInstant(apiKey.getCreatedAt()), apiKey.isRevoked(), toInstant(apiKey.getLastUsedAt()))) .toList(); } /** * Marks a token as revoked. */ @Transactional public boolean revoke(final UUID accountId, final String tokenId) { final var tid = UUID.fromString(tokenId); return apiKeyRepository.findByIdAndAccountId(tid, accountId) .map(apiKey -> { apiKey.setRevoked(true); apiKey.setRevokedAt(OffsetDateTime.now()); apiKeyRepository.save(apiKey); log.info("Revoked API token id={} for accountId={}", tid, accountId); return true; }) .orElseGet(() -> { log.warn("Token {} not found for account {}", tokenId, accountId); return false; }); } /** * Validates raw token and returns user id if valid. */ @Transactional public Optional validateAndGetUserId(final String rawToken) { if (rawToken == null || rawToken.isBlank()) { return Optional.empty(); } final var hash = sha256(rawToken); final var entityOpt = apiKeyRepository.findByTokenHashAndRevokedFalse(hash); if (entityOpt.isEmpty()) { return Optional.empty(); } final var entity = entityOpt.get(); entity.setLastUsedAt(OffsetDateTime.now()); apiKeyRepository.save(entity); return Optional.of(entity.getUserId().toString()); } /** * Validates raw token and returns user id, account id and api key id if valid. */ @Transactional public Optional validateAndGetApiKey(final String rawToken) { if (rawToken == null || rawToken.isBlank()) { return Optional.empty(); } final var hash = sha256(rawToken); final var entityOpt = apiKeyRepository.findByTokenHashAndRevokedFalse(hash); if (entityOpt.isEmpty()) { return Optional.empty(); } final var entity = entityOpt.get(); entity.setLastUsedAt(OffsetDateTime.now()); apiKeyRepository.save(entity); return Optional.of(new ValidatedApiKey( entity.getUserId(), entity.getAccountId(), entity.getId())); } private String generateRawToken() { final var bytes = new byte[32]; secureRandom.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private String sha256(final String val) { try { final var md = MessageDigest.getInstance("SHA-256"); final var bytes = md.digest(val.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(bytes); } catch (final Exception e) { throw new IllegalStateException("SHA-256 not available", e); } } public record CreatedToken(String id, String token) { } public record TokenView(String id, String label, Instant createdAt, boolean revoked, Instant lastUsedAt) { } public record ValidatedApiKey(UUID userId, UUID accountId, UUID apiKeyId) { } private static Instant toInstant(final OffsetDateTime odt) { return odt == null ? null : odt.toInstant(); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/DomainService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.security.SecureRandom; import java.util.Hashtable; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.UUID; import javax.naming.Context; import javax.naming.NamingException; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.client.SslServiceClient; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.DomainRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; @Service @RequiredArgsConstructor @Slf4j public class DomainService { private static final int MAX_RETRIES = 30; private final DomainRepository domainRepository; private final TunnelRepository tunnelRepository; private final AppProperties properties; private final PasswordEncoder passwordEncoder; private final SslServiceClient sslServiceClient; private final UserRepository userRepository; private final SecureRandom random = new SecureRandom(); @Transactional(readOnly = true) public List getDomains(final AccountEntity account) { return domainRepository.findAllByAccount(account); } /** * Creates a new domain for the given account. A unique subdomain is * generated and assigned to the domain entity associated with the account. * If a unique subdomain cannot be generated after the maximum number of retries, * an exception is thrown. * * @param account The account entity to which the new domain will be associated. * @return The newly created domain entity with its associated subdomain and account. * @throws RuntimeException if a unique subdomain cannot be generated within the maximum attempts. */ @Transactional public Optional createDomain(final AccountEntity account) { String subdomain; int retries = 0; do { subdomain = generateRandomSubdomain(); retries++; } while (domainRepository.existsBySubdomainGlobal(subdomain) && retries < MAX_RETRIES); if (domainRepository.existsBySubdomainGlobal(subdomain)) { log.warn("Failed to generate unique subdomain after all attempts"); return Optional.empty(); } final var domain = new DomainEntity(); domain.setId(UUID.randomUUID()); domain.setSubdomain(subdomain); domain.setDomain(properties.gateway().domain()); domain.setAccount(account); log.info("Assigned subdomain {} to account {}", subdomain, account.getId()); return Optional.of(domainRepository.save(domain)); } @Transactional public void assignRandomDomain(final AccountEntity account) { createDomain(account); } /** * Updates the subdomain of a specified domain entity associated with a given account. * If the domain is currently being used by an active tunnel, the update will not be allowed. * Additionally, it ensures that the new subdomain is unique globally before updating. * * @param id the unique identifier of the domain to update * @param account the account associated with the domain * @param newSubdomain the new subdomain to assign to the domain * @return the updated DomainEntity if the operation is successful * @throws RuntimeException if the domain is not found for the given id and account * @throws RuntimeException if the domain is currently used by an active tunnel * @throws RuntimeException if the new subdomain is already taken globally */ @Transactional public DomainEntity updateDomain(final UUID id, final AccountEntity account, final String newSubdomain) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); if (isTunnelActive(domain)) { throw new RuntimeException("Cannot update domain used by active tunnel"); } final var normalizedSubdomain = newSubdomain != null ? newSubdomain.toLowerCase() : null; if (!domain.getSubdomain().equals(normalizedSubdomain)) { if (domainRepository.existsBySubdomainGlobal(normalizedSubdomain)) { throw new RuntimeException("Subdomain " + normalizedSubdomain + " is already taken"); } domain.setSubdomain(normalizedSubdomain); domain.setCnameVerified(false); domain.setSslActive(false); return domainRepository.save(domain); } return domain; } /** * Deletes a domain associated with the specified account. * Ensures the domain exists and is not being used by an active tunnel before deletion. * * @param id The unique identifier of the domain to be deleted. * @param account The account entity associated with the domain to validate ownership. * @throws RuntimeException if the domain is not found or is being used by an active tunnel. */ @Transactional public void deleteDomain(final UUID id, final AccountEntity account) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); if (isTunnelActive(domain)) { throw new RuntimeException("Cannot delete domain used by active tunnel"); } domainRepository.delete(domain); log.info("Deleted domain {} for account {}", domain.getSubdomain(), account.getId()); } /** * Sets or updates a passcode for the specified domain belonging to the provided account. * The passcode will be stored as a hash using the configured {@link PasswordEncoder}. * * @param id the unique identifier of the domain * @param account the owner account of the domain * @param passcode the new raw passcode value to set * @return the updated domain entity */ @Transactional public DomainEntity setPasscode(final UUID id, final AccountEntity account, final String passcode) { if (passcode == null || passcode.isBlank()) { throw new RuntimeException("Passcode must not be blank"); } if (passcode.length() < 4 || passcode.length() > 128) { throw new RuntimeException("Passcode length must be between 4 and 128 characters"); } final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); final var hash = passwordEncoder.encode(passcode); domain.setPasscodeHash(hash); return domainRepository.save(domain); } /** * Clears passcode protection for the specified domain belonging to the provided account. * * @param id the unique identifier of the domain * @param account the owner account of the domain */ @Transactional public void clearPasscode(final UUID id, final AccountEntity account) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); domain.setPasscodeHash(null); domainRepository.save(domain); } /** * Updates the custom domain for the given domain entity. * * @param id domain id * @param account account entity * @param customDomain custom domain name * @return updated domain entity */ @Transactional public DomainEntity updateCustomDomain(final UUID id, final AccountEntity account, final String customDomain) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); final var normalizedCustomDomain = customDomain != null ? customDomain.toLowerCase() : null; if (!Objects.equals(domain.getCustomDomain(), normalizedCustomDomain)) { domain.setCustomDomain(normalizedCustomDomain); domain.setCnameVerified(false); domain.setSslActive(false); return domainRepository.save(domain); } return domain; } /** * Deletes the custom domain from the given domain entity. * * @param id domain id * @param account account entity */ @Transactional public void deleteCustomDomain(final UUID id, final AccountEntity account) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); domain.setCustomDomain(null); domain.setCnameVerified(false); domain.setSslActive(false); domainRepository.save(domain); } /** * Verifies that the custom domain has a CNAME record pointing to the Port Buddy subdomain. * Once verified, it triggers SSL certificate issuance. * * @param id domain id * @param account account entity * @param userId user id * @return updated domain entity */ @Transactional public DomainEntity verifyCname(final UUID id, final AccountEntity account, final UUID userId) { final var domain = domainRepository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Domain not found")); final var user = userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found")); final var customDomain = domain.getCustomDomain(); if (customDomain == null || customDomain.isBlank()) { throw new RuntimeException("Custom domain is not set"); } final var expectedCname = domain.getSubdomain() + "." + domain.getDomain(); final var isVerified = checkCname(customDomain, expectedCname); if (isVerified) { domain.setCnameVerified(true); domainRepository.save(domain); // Trigger SSL issuance try { sslServiceClient.submitJob(customDomain, user.getEmail(), true); } catch (final Exception e) { log.error("Failed to trigger SSL issuance for {}", customDomain, e); } } else { throw new RuntimeException("CNAME verification failed. Please ensure " + customDomain + " points to " + expectedCname); } return domain; } private boolean checkCname(final String domain, final String expectedTarget) { final var env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); DirContext ictx = null; try { ictx = new InitialDirContext(env); final Attributes attrs = ictx.getAttributes(domain, new String[] {"CNAME"}); final Attribute cnameAttr = attrs.get("CNAME"); if (cnameAttr != null) { final String cname = (String) cnameAttr.get(); // Remove trailing dot if present final String normalizedCname = cname.endsWith(".") ? cname.substring(0, cname.length() - 1) : cname; return normalizedCname.equalsIgnoreCase(expectedTarget); } } catch (final NamingException e) { log.debug("Failed to lookup CNAME for {}", domain, e); } finally { if (ictx != null) { try { ictx.close(); } catch (final NamingException e) { log.debug("Failed to close DNS context", e); } } } return false; } /** * Marks the domain with the specified custom domain as SSL active. * * @param customDomain the custom domain name */ @Transactional public void markSslActive(final String customDomain) { if (customDomain == null) { return; } domainRepository.findByCustomDomain(customDomain.toLowerCase()).ifPresent(domain -> { domain.setSslActive(true); domainRepository.save(domain); log.info("Marked domain {} as SSL active", customDomain); }); } /** * Resolves a domain for the specified account and user, based on the requested domain * or available domains associated with the account. If a specific domain is requested, * it attempts to return the domain after verifying its availability. Otherwise, it picks * an available domain, taking into account resource affinity. * * @param account the account entity for which the domain resolution is performed * @param requestedDomain the fully-qualified domain name requested by the user, or null if no specific domain is * requested * @param localHost the local hostname of the user's resource requesting the domain * @param localPort the local port of the user's resource requesting the domain * @return the resolved domain entity satisfying the resolution criteria * @throws RuntimeException if the requested domain is unavailable or no domains are available for the account */ @Transactional(readOnly = true) public DomainEntity resolveDomain(final AccountEntity account, final String requestedDomain, final String localHost, final Integer localPort) { if (requestedDomain != null && !requestedDomain.isBlank()) { // User requested specific domain final var normalizedDomain = requestedDomain.toLowerCase(); String targetSubdomain = normalizedDomain; final var baseDomain = properties.gateway().domain(); if (normalizedDomain.endsWith("." + baseDomain)) { targetSubdomain = normalizedDomain.substring(0, normalizedDomain.length() - baseDomain.length() - 1); } final var finalSubdomain = targetSubdomain; return domainRepository.findByAccountAndSubdomain(account, finalSubdomain) .filter(domain -> !isTunnelConnected(domain)) .orElseThrow(() -> new RuntimeException("Domain not found or unavailable: " + requestedDomain)); } // No specific domain requested // Filter out CONNECTED domains final var availableDomains = domainRepository.findAllByAccount(account).stream() .filter(domain -> !isTunnelConnected(domain)) .toList(); if (availableDomains.isEmpty()) { throw new RuntimeException("No available domains found. Please add a new domain at https://portbuddy.dev/app/domains"); } // Affinity check: Find the last used subdomain for this resource final var lastTunnel = tunnelRepository.findUsedTunnel(account.getId(), localHost, localPort); if (lastTunnel.isPresent() && lastTunnel.get().getDomain() != null) { final var lastDomain = lastTunnel.get().getDomain(); final var matched = availableDomains.stream() .filter(domain -> Objects.equals(domain.getId(), lastDomain.getId())) .findFirst(); if (matched.isPresent()) { return matched.get(); } } // Pick any return availableDomains.getFirst(); } private boolean isTunnelConnected(final DomainEntity domain) { return tunnelRepository.existsByDomainAndStatus(domain, TunnelStatus.CONNECTED); } private boolean isTunnelActive(final DomainEntity domain) { return tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED); } private String generateRandomSubdomain() { final var animals = new String[] {"falcon", "lynx", "orca", "otter", "swift", "sparrow", "tiger", "puma"}; final var name = animals[random.nextInt(animals.length)]; final var num = 1000 + random.nextInt(9000); return name + "-" + num; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/PaymentCleanupService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.time.OffsetDateTime; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.repo.AccountRepository; /** * Periodically checks for accounts with failed payments or non-active subscriptions * and freezes their tunnels after a grace period. */ @Service @RequiredArgsConstructor @Slf4j public class PaymentCleanupService { private final AccountRepository accountRepository; private final TunnelService tunnelService; private final AppProperties appProperties; /** * Checks for accounts that need tunnel freezing due to non-active subscription status. */ @Scheduled( fixedDelayString = "#{@'app-tech.amak.portbuddy.server.config.AppProperties'.subscriptions().checkInterval().toMillis()}", initialDelayString = "#{@'app-tech.amak.portbuddy.server.config.AppProperties'.subscriptions().checkInterval().toMillis()}" ) @SchedulerLock(name = "paymentCleanupTask", lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M") @Transactional public void cleanupFailedPayments() { final var gracePeriod = appProperties.subscriptions().gracePeriod(); final var cutoff = OffsetDateTime.now().minus(gracePeriod); log.debug("Checking for non-active accounts older than {}", cutoff); final var accountsToFreeze = accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(cutoff); if (!accountsToFreeze.isEmpty()) { log.info("Found {} accounts to freeze due to non-active subscription status", accountsToFreeze.size()); for (final var account : accountsToFreeze) { log.info("Freezing tunnels for account {} (status={}, updatedAt={})", account.getId(), account.getSubscriptionStatus(), account.getUpdatedAt()); tunnelService.closeAllTunnels(account); } } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/PortReservationService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.PortReservationRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; @Service @RequiredArgsConstructor @Slf4j public class PortReservationService { private static final int MAX_RETRIES = 10; private final PortReservationRepository repository; private final ProxyDiscoveryService proxyDiscoveryService; private final TunnelRepository tunnelRepository; private final AppProperties properties; @Transactional(readOnly = true) public List getReservations(final AccountEntity account) { return repository.findAllByAccount(account); } /** * Attempts to reserve a unique (publicHost, publicPort) pair for the given account following rules: * - Discover available tcp-proxy public hosts and select the host with the least number of reservations. * - Port assignments are incremental per host within configurable range [min,max]. * - If next port for the selected host is out of range, try the next host. * - If no combination can be generated, throw an exception. * Uniqueness is enforced by a DB unique constraint; in case of race conflicts, the operation retries. */ @Transactional public Optional createReservation(final AccountEntity account, final UserEntity user) { final var hosts = proxyDiscoveryService.listPublicHosts(); if (hosts.isEmpty()) { log.warn("No available tcp-proxy hosts found"); return Optional.empty(); } final var range = properties.portReservations().range(); final int min = range.min(); final int max = range.max(); if (min <= 0 || max <= 0 || min > max) { throw new IllegalStateException("Invalid port range configuration: [" + min + ", " + max + "]"); } int attempts = 0; while (attempts++ < MAX_RETRIES) { // Order hosts by least reservations final var orderedHosts = hosts.stream() .sorted(Comparator.comparingLong(repository::countByPublicHost)) .toList(); for (final String host : orderedHosts) { final var nextPort = computeNextPort(host, min, max); if (nextPort == null) { // This host is exhausted, try next continue; } try { final var reservation = new PortReservationEntity(); reservation.setId(UUID.randomUUID()); reservation.setAccount(account); reservation.setUser(user); reservation.setPublicHost(host); reservation.setPublicPort(nextPort); final var saved = repository.save(reservation); log.info("Reserved port {}:{} for account {}", host, nextPort, account.getId()); return Optional.of(saved); } catch (final DataIntegrityViolationException e) { // Unique constraint violation possible due to race; retry log.warn("Port reservation conflict for {}:{}, will retry (attempt {}/{})", host, nextPort, attempts, MAX_RETRIES); } } // If we got here, we either had conflicts on all hosts or all were exhausted; retry loop continues } log.warn("Failed to reserve a unique port after " + MAX_RETRIES + " attempts"); return Optional.empty(); } private Integer computeNextPort(final String host, final int min, final int max) { // Efficiently find the minimal available port via a single DB query. return repository.findMinimalFreePort(host, min, max).orElse(null); } /** * Deletes a reservation associated with the specified account. * * @param id the unique identifier of the reservation to delete * @param account the account entity associated with the reservation */ @Transactional public void deleteReservation(final UUID id, final AccountEntity account) { final var entity = repository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Reservation not found")); if (isReservationInUse(entity)) { throw new IllegalStateException("Reservation is in use by active tunnels"); } repository.delete(entity); } /** * Resolve a port reservation for a NET (TCP/UDP) expose request according to rules: * - If explicit reservation (host:port or port) provided, ensure it belongs to the account and is not used by * any active tunnel. If multiple reservations found for the same port, take the first one. * - Otherwise, if there was a previous tunnel for the same local resource that used a reservation * and it's free, reuse it. * - Otherwise, pick the first existing reservation of the account that is not in use by any active tunnel. * - If none exist, create a new reservation and return it. */ @Transactional public PortReservationEntity resolveForNetExpose(final AccountEntity account, final UserEntity user, final String localHost, final int localPort, final String explicitHostPort) { // 1) Explicit reservation if (explicitHostPort != null && !explicitHostPort.isBlank()) { final var hostPort = explicitHostPort.trim(); final int colon = hostPort.lastIndexOf(':'); final PortReservationEntity reservation; if (colon > 0 && colon < hostPort.length() - 1) { // public_host:port format final var host = hostPort.substring(0, colon); final var port = Integer.parseInt(hostPort.substring(colon + 1)); reservation = repository.findByAccountAndPublicHostAndPublicPort(account, host, port) .orElseThrow(() -> new IllegalArgumentException("Port reservation not found for this account: " + hostPort)); } else if (colon == -1) { // Try as name first (lookup by account and port reservation name case insensitive) reservation = repository.findByAccountAndNameIgnoreCase(account, hostPort) .or(() -> { try { final var port = Integer.parseInt(hostPort); final var reservations = repository.findAllByAccountAndPublicPort(account, port); if (reservations.isEmpty()) { throw new IllegalArgumentException( "Port reservation not found for this account and port: " + port); } return Optional.of(reservations.getFirst()); } catch (final NumberFormatException e) { throw new IllegalArgumentException( "Port reservation name or port not found for this account: " + hostPort); } }) .orElseThrow(() -> new IllegalArgumentException("Port reservation not found for this account: " + hostPort)); } else { throw new IllegalArgumentException("Invalid --port-reservation value, expected host:port or port"); } if (isReservationInUse(reservation)) { throw new IllegalStateException("Port reservation is currently in use: " + hostPort); } return reservation; } // 2) Reuse by same local resource if possible final var prev = tunnelRepository .findFirstByAccountIdAndLocalHostAndLocalPortAndPortReservationIsNotNullOrderByCreatedAtDesc( account.getId(), localHost, localPort); if (prev.isPresent()) { final var res = prev.get().getPortReservation(); if (res != null && !isReservationInUse(res)) { return res; } } // 3) First available among existing reservations final var existing = repository.findAllByAccount(account).stream() .sorted(Comparator.comparing(PortReservationEntity::getCreatedAt)) .filter(res -> !isReservationInUse(res)) .findFirst(); // 4) Create new reservation return existing.orElseGet(() -> createReservation(account, user) .orElseThrow(() -> new IllegalStateException("Failed to create a new port reservation"))); } private boolean isReservationInUse(final PortReservationEntity reservation) { return tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED); } /** * Updates an existing reservation host/port ensuring constraints. */ @Transactional public PortReservationEntity updateReservation(final AccountEntity account, final UUID id, final String host, final Integer port, final String name) { final var entity = repository.findByIdAndAccount(id, account) .orElseThrow(() -> new RuntimeException("Reservation not found")); if (isReservationInUse(entity)) { throw new IllegalStateException("Reservation is in use by active tunnels"); } if (host != null) { final var hosts = proxyDiscoveryService.listPublicHosts(); if (hosts.isEmpty() || !hosts.contains(host)) { throw new IllegalArgumentException("Unknown public host: " + host); } entity.setPublicHost(host); } if (port != null) { final var range = properties.portReservations().range(); if (port < range.min() || port > range.max()) { throw new IllegalArgumentException("Port is out of allowed range"); } entity.setPublicPort(port); } if (name != null && !name.equals(entity.getName())) { if (repository.existsByAccountAndName(account, name)) { throw new IllegalArgumentException("Reservation with name '" + name + "' already exists"); } entity.setName(name); } // Trigger unique check on save return repository.saveAndFlush(entity); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/ProxyDiscoveryService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class ProxyDiscoveryService { public static final String SERVICE_ID = "net-proxy"; private final DiscoveryClient discoveryClient; /** * Returns a list of public hosts of all available tcp-proxy instances registered in Eureka. * The public host is resolved from instance metadata keys in the following order: * - "public-host" * - "publicHost" * - "app.public-host" * - "app.publicHost" * Falls back to {@link ServiceInstance#getHost()} if none present. */ public List listPublicHosts() { final var instances = discoveryClient.getInstances(SERVICE_ID); final Set hosts = new LinkedHashSet<>(); for (final ServiceInstance instance : instances) { final var md = instance.getMetadata(); String host = null; if (md != null) { host = firstNonBlank( md.get("public-host"), md.get("publicHost"), md.get("app.public-host"), md.get("app.publicHost") ); } if (host == null || host.isBlank()) { host = instance.getHost(); } if (host != null && !host.isBlank()) { hosts.add(host); } } return hosts.stream().collect(Collectors.toList()); } private String firstNonBlank(final String... values) { for (final String v : values) { if (v != null && !v.isBlank()) { return v; } } return null; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/StaleTunnelsReaper.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.time.OffsetDateTime; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import tech.amak.portbuddy.server.config.TunnelsProperties; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; /** * Periodically closes tunnels that stopped sending heartbeats. */ @Service @RequiredArgsConstructor @Slf4j public class StaleTunnelsReaper { private final TunnelRepository tunnelRepository; private final TunnelsProperties tunnelsProperties; private final TunnelRegistry tunnelRegistry; /** * Monitors stale tunnels and closes them. */ @Scheduled( fixedDelayString = "#{@tunnelsProperties.checkInterval.toMillis()}", initialDelayString = "#{@tunnelsProperties.checkInterval.toMillis()}" ) @SchedulerLock(name = "staleTunnelsReaper", lockAtMostFor = "PT4M", lockAtLeastFor = "PT3S") @Transactional public void closeStaleTunnels() { final var timeout = tunnelsProperties.getHeartbeatTimeout(); final var cutoff = OffsetDateTime.now().minus(timeout); final var closedIds = tunnelRepository.closeStaleConnected(cutoff); if (!closedIds.isEmpty()) { log.info("Closed {} stale tunnels (cutoff={})", closedIds.size(), cutoff); for (final var tunnelId : closedIds) { tunnelRegistry.closeTunnel(tunnelId); } } else { log.debug("No stale tunnels found (cutoff={})", cutoff); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/StripeService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.util.Map; import org.springframework.stereotype.Service; import com.stripe.Stripe; import com.stripe.exception.StripeException; import com.stripe.model.Customer; import com.stripe.model.Subscription; import com.stripe.model.checkout.Session; import com.stripe.param.CustomerCreateParams; import com.stripe.param.SubscriptionUpdateParams; import com.stripe.param.billingportal.SessionCreateParams; import com.stripe.param.checkout.SessionCreateParams.LineItem; import com.stripe.param.checkout.SessionCreateParams.Mode; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; @Slf4j @Service public class StripeService { private final AppProperties properties; public StripeService(final AppProperties properties) { this.properties = properties; Stripe.apiKey = properties.stripe().apiKey(); } /** * Creates a checkout session for the given account and plan. * * @param account the account * @param plan the plan * @param extraTunnels the number of extra tunnels * @return the checkout session URL * @throws StripeException if Stripe API call fails */ public String createCheckoutSession(final AccountEntity account, final Plan plan, final int extraTunnels) throws StripeException { log.info("Creating checkout session for account: {}, plan: {}, extraTunnels: {}", account.getId(), plan, extraTunnels); if (account.getStripeSubscriptionId() != null) { log.info("Account {} already has an active subscription: {}. It will be replaced.", account.getId(), account.getStripeSubscriptionId()); } final var customerId = getOrCreateCustomer(account); final var stripeProperties = properties.stripe(); final var priceId = switch (plan) { case PRO -> stripeProperties.priceIds().pro(); case TEAM -> stripeProperties.priceIds().team(); }; final var paramsBuilder = com.stripe.param.checkout.SessionCreateParams.builder() .setCustomer(customerId) .setMode(Mode.SUBSCRIPTION) .setSuccessUrl(properties.gateway().url() + "/app/billing?success=true") .setCancelUrl(properties.gateway().url() + "/app/billing?canceled=true") .addLineItem(LineItem.builder() .setPrice(priceId) .setQuantity(1L) .build()) .putMetadata("accountId", account.getId().toString()) .putMetadata("plan", plan.name()) .putMetadata("extraTunnels", String.valueOf(extraTunnels)); if (account.getStripeSubscriptionId() != null) { paramsBuilder.putMetadata("oldSubscriptionId", account.getStripeSubscriptionId()); } // Include extra tunnels in the checkout session if requested if (extraTunnels > 0) { paramsBuilder.addLineItem(LineItem.builder() .setPrice(stripeProperties.priceIds().extraTunnel()) .setQuantity((long) extraTunnels) .build()); } final var session = Session.create(paramsBuilder.build()); log.info("Created checkout session: id={}, url={}", session.getId(), session.getUrl()); return session.getUrl(); } /** * Creates a checkout session for the given account and plan using current account's extra tunnels. * * @param account the account * @param plan the plan * @return the checkout session URL * @throws StripeException if Stripe API call fails */ public String createCheckoutSession(final AccountEntity account, final Plan plan) throws StripeException { return createCheckoutSession(account, plan, account.getExtraTunnels()); } /** * Cancels the given subscription in Stripe. * * @param subscriptionId the subscription ID * @throws StripeException if Stripe API call fails */ public void cancelSubscription(final String subscriptionId) throws StripeException { if (subscriptionId == null) { return; } log.info("Cancelling Stripe subscription: {}", subscriptionId); final var subscription = Subscription.retrieve(subscriptionId); subscription.cancel(); } /** * Cancels the given subscription in Stripe and resets extra tunnels. * * @param account the account * @throws StripeException if Stripe API call fails */ public void cancelSubscription(final AccountEntity account) throws StripeException { cancelSubscription(account.getStripeSubscriptionId()); } /** * Creates a billing portal session for the given account. * * @param account the account * @return the billing portal session URL * @throws StripeException if Stripe API call fails */ public String createPortalSession(final AccountEntity account) throws StripeException { log.info("Creating billing portal session for account: {}, customer: {}", account.getId(), account.getStripeCustomerId()); final var params = SessionCreateParams.builder() .setCustomer(account.getStripeCustomerId()) .setReturnUrl(properties.gateway().url() + "/app/billing") .build(); final var session = com.stripe.model.billingportal.Session.create(params); log.info("Created billing portal session: id={}, url={}", session.getId(), session.getUrl()); return session.getUrl(); } /** * Updates the number of extra tunnels for the given account. * * @param account the account * @param newCount the new count of extra tunnels * @throws StripeException if Stripe API call fails */ public void updateExtraTunnels(final AccountEntity account, final int newCount) throws StripeException { log.info("Updating extra tunnels for account: {}, newCount: {}", account.getId(), newCount); if (account.getStripeSubscriptionId() == null) { log.warn("Account {} has no Stripe subscription, cannot update tunnels in Stripe", account.getId()); return; } final var subscription = Subscription.retrieve(account.getStripeSubscriptionId()); final var extraTunnelPriceId = properties.stripe().priceIds().extraTunnel(); final var subscriptionItemId = subscription.getItems().getData().stream() .filter(item -> item.getPrice().getId().equals(extraTunnelPriceId)) .map(com.stripe.model.SubscriptionItem::getId) .findFirst() .orElse(null); final var paramsBuilder = SubscriptionUpdateParams.builder() .setProrationBehavior(SubscriptionUpdateParams.ProrationBehavior.ALWAYS_INVOICE); if (subscriptionItemId != null) { if (newCount == 0) { log.info("Removing extra tunnels item from subscription: {}", account.getStripeSubscriptionId()); // Remove the extra tunnels item paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder() .setId(subscriptionItemId) .setDeleted(true) .build()); } else { log.info("Updating extra tunnels quantity to {} for subscription: {}", newCount, account.getStripeSubscriptionId()); // Update quantity paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder() .setId(subscriptionItemId) .setQuantity((long) newCount) .build()); } } else if (newCount > 0) { log.info("Adding extra tunnels item (quantity: {}) to subscription: {}", newCount, account.getStripeSubscriptionId()); // Add extra tunnels item paramsBuilder.addItem(SubscriptionUpdateParams.Item.builder() .setPrice(extraTunnelPriceId) .setQuantity((long) newCount) .build()); } else { log.debug("No changes needed for extra tunnels on subscription: {}", account.getStripeSubscriptionId()); return; } subscription.update(paramsBuilder.build()); log.info("Successfully updated Stripe subscription for account: {}", account.getId()); } private String getOrCreateCustomer(final AccountEntity account) throws StripeException { if (account.getStripeCustomerId() != null) { log.debug("Using existing Stripe customer {} for account {}", account.getStripeCustomerId(), account.getId()); return account.getStripeCustomerId(); } log.info("Creating new Stripe customer for account: {}", account.getId()); final var params = CustomerCreateParams.builder() .setName(account.getName()) .setMetadata(Map.of("accountId", account.getId().toString())) .build(); final var customer = Customer.create(params); log.info("Created Stripe customer: id={}", customer.getId()); return customer.getId(); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/StripeWebhookService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import org.springframework.stereotype.Service; import com.stripe.exception.SignatureVerificationException; import com.stripe.model.Event; import com.stripe.net.Webhook; /** * Service for Stripe webhooks. */ @Service public class StripeWebhookService { /** * Constructs a Stripe event from the payload and signature. * * @param payload the payload * @param sigHeader the signature header * @param secret the webhook secret * @return the event * @throws SignatureVerificationException if signature is invalid */ public Event constructEvent(final String payload, final String sigHeader, final String secret) throws SignatureVerificationException { return Webhook.constructEvent(payload, sigHeader, secret); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/TeamService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.time.OffsetDateTime; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.InvitationEntity; import tech.amak.portbuddy.server.db.entity.Role; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.InvitationRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.mail.EmailService; import tech.amak.portbuddy.server.service.user.UserProvisioningService.ProvisionedUser; /** * Service for managing team members and invitations. */ @Service @RequiredArgsConstructor @Slf4j public class TeamService { private final InvitationRepository invitationRepository; private final UserRepository userRepository; private final UserAccountRepository userAccountRepository; private final EmailService emailService; private final AppProperties properties; /** * Returns all members of the team. * * @param account the account to get members for. * @return list of team members. */ public List getMembers(final AccountEntity account) { return userRepository.findAllByAccount(account); } /** * Returns all pending invitations for the team. * * @param account the account to get invitations for. * @return list of pending invitations. */ public List getPendingInvitations(final AccountEntity account) { return invitationRepository.findAllByAccountAndAcceptedAtIsNull(account); } /** * Invites a new member to the team. * * @param account the account to invite to. * @param invitedBy the user who is sending the invitation. * @param email the email of the invited person. * @return the created invitation. */ @Transactional public InvitationEntity inviteMember(final AccountEntity account, final UserEntity invitedBy, final String email) { if (account.getPlan() != Plan.TEAM) { throw new IllegalStateException("Invitations are only available for Team plan."); } userRepository.findByEmailIgnoreCase(email).ifPresent(user -> { if (userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId()).isPresent()) { throw new IllegalStateException("User is already a member of this team."); } }); invitationRepository.findByAccountAndEmailAndAcceptedAtIsNull(account, email).ifPresent(inv -> { throw new IllegalStateException("An invitation has already been sent to this email."); }); final var invitation = new InvitationEntity(); invitation.setId(UUID.randomUUID()); invitation.setAccount(account); invitation.setEmail(email.toLowerCase()); invitation.setInvitedBy(invitedBy); invitation.setToken(UUID.randomUUID().toString()); invitation.setExpiresAt(OffsetDateTime.now().plusDays(7)); final var saved = invitationRepository.save(invitation); sendInvitationEmail(saved); return saved; } /** * Removes a member from the team. * * @param account the account to remove from. * @param userId the user to remove. * @param currentUser the user who is performing the removal. */ @Transactional public void removeMember(final AccountEntity account, final UUID userId, final UserEntity currentUser) { if (currentUser.getId().equals(userId)) { throw new IllegalArgumentException("You cannot remove yourself from the account."); } final var userAccount = userAccountRepository.findByUserIdAndAccountId(userId, account.getId()) .orElseThrow(() -> new IllegalArgumentException("User does not belong to this account.")); userAccountRepository.delete(userAccount); } /** * Cancels a pending invitation. * * @param account the account. * @param invitationId the invitation id. */ @Transactional public void cancelInvitation(final AccountEntity account, final UUID invitationId) { final var invitation = invitationRepository.findById(invitationId) .orElseThrow(() -> new IllegalArgumentException("Invitation not found.")); if (!invitation.getAccount().getId().equals(account.getId())) { throw new IllegalArgumentException("Invitation does not belong to this account."); } invitationRepository.delete(invitation); } /** * Accepts an invitation. * * @param token the invitation token. * @param user the user who is accepting the invitation. */ @Transactional public void acceptInvitation(final String token, final UserEntity user) { final var invitation = invitationRepository.findByToken(token) .orElseThrow(() -> new IllegalArgumentException("Invalid or expired invitation token.")); if (invitation.getAcceptedAt() != null) { throw new IllegalStateException("Invitation has already been accepted."); } if (invitation.getExpiresAt().isBefore(OffsetDateTime.now())) { throw new IllegalStateException("Invitation has expired."); } // Add user to the account final var userAccountId = invitation.getAccount().getId(); final var userAccount = userAccountRepository.findByUserIdAndAccountId(user.getId(), userAccountId) .orElseGet(() -> new UserAccountEntity(user, invitation.getAccount(), new HashSet<>(List.of(Role.USER)))); userAccount.setLastUsedAt(OffsetDateTime.now()); userAccountRepository.save(userAccount); invitation.setAcceptedAt(OffsetDateTime.now()); invitationRepository.save(invitation); log.info("User {} accepted invitation to account {}", user.getId(), invitation.getAccount().getId()); } /** * Switches the current account for the user. * * @param userId the user id. * @param accountId the account id to switch to. * @return provisioned user information with the new account. */ @Transactional public ProvisionedUser switchAccount(final UUID userId, final UUID accountId) { final var userAccount = userAccountRepository.findByUserIdAndAccountId(userId, accountId) .orElseThrow(() -> new IllegalArgumentException("User does not belong to this account.")); userAccount.setLastUsedAt(OffsetDateTime.now()); userAccountRepository.save(userAccount); return new ProvisionedUser(userId, accountId, userAccount.getAccount().getName(), userAccount.getRoles()); } /** * Resends a pending invitation. * * @param account the account. * @param invitationId the invitation id. */ @Transactional public void resendInvitation(final AccountEntity account, final UUID invitationId) { final var invitation = invitationRepository.findById(invitationId) .orElseThrow(() -> new IllegalArgumentException("Invitation not found.")); if (!invitation.getAccount().getId().equals(account.getId())) { throw new IllegalArgumentException("Invitation does not belong to this account."); } if (invitation.getAcceptedAt() != null) { throw new IllegalStateException("Invitation has already been accepted."); } invitation.setToken(UUID.randomUUID().toString()); invitation.setExpiresAt(OffsetDateTime.now().plusDays(7)); final var saved = invitationRepository.save(invitation); sendInvitationEmail(saved); } private void sendInvitationEmail(final InvitationEntity invitation) { final var model = Map.of( "inviterName", invitation.getInvitedBy().getEmail(), "accountName", invitation.getAccount().getName(), "inviteUrl", properties.gateway().url() + "/accept-invite?token=" + invitation.getToken() ); emailService.sendTemplate(invitation.getEmail(), "You've been invited to join " + invitation.getAccount().getName() + " on Port Buddy", "email/team-invite", model); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/TunnelService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.dto.ExposeRequest; import tech.amak.portbuddy.server.client.NetProxyClient; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.entity.TunnelEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.service.threatfox.ThreatFoxService; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; @Service @RequiredArgsConstructor @Slf4j public class TunnelService { public static final List ACTIVE_STATUSES = List.of(TunnelStatus.CONNECTED, TunnelStatus.PENDING); private final TunnelRepository tunnelRepository; private final AccountRepository accountRepository; private final AppProperties properties; private final Optional threatfoxService; private final TunnelRegistry tunnelRegistry; private final NetProxyClient netProxyClient; /** * Creates a new HTTP tunnel using the database entity id as the tunnel id. * The record is saved with a 'PENDING' status and returned id is used as the tunnel identifier. * * @param account The account creating the tunnel. * @param userId The unique identifier of the user creating the tunnel. * @param apiKeyId The optional API key identifier associated with the tunnel. * @param request The HTTP expose request containing details of the local HTTP service (scheme, host, port). * @param publicUrl The public URL where the service will be accessible. * @param domain The domain entity used for the public HTTP endpoint. * @return the created tunnel id (same as entity id string) */ @Transactional public TunnelEntity createHttpTunnel(final AccountEntity account, final UUID userId, final String apiKeyId, final ExposeRequest request, final String publicUrl, final DomainEntity domain) { checkTunnelLimit(account); return createTunnel(account.getId(), userId, apiKeyId, request, publicUrl, domain); } /** * Creates a pending TCP tunnel and returns its tunnel id (entity id). * Public host/port can be set later via {@link #updateTunnelPublicConnection(UUID, String, Integer)}. * * @param account The account creating the tunnel. * @param userId The unique identifier of the user creating the tunnel. * @param apiKeyId The optional API key identifier associated with the tunnel. * @param request The expose request containing details of the local service. * @return the created tunnel id (same as entity id string) */ @Transactional public TunnelEntity createNetTunnel(final AccountEntity account, final UUID userId, final String apiKeyId, final ExposeRequest request) { checkTunnelLimit(account); return createTunnel(account.getId(), userId, apiKeyId, request, null, null); } private void checkSubscriptionStatus(final AccountEntity account) { if (account.isBlocked()) { throw new IllegalStateException("Account is blocked. Please contact support."); } final var status = account.getSubscriptionStatus(); if (status == null) { // Allow Pro plan with 0 extra tunnels without an active subscription record if (account.getPlan() == Plan.PRO && account.getExtraTunnels() == 0) { return; } throw new IllegalStateException("No active subscription found. Please check your billing information."); } if (!"active".equals(status)) { throw new IllegalStateException( "Subscription is not active (current status: %s). Please check your billing information." .formatted(status)); } } private void checkTunnelLimit(final AccountEntity account) { checkSubscriptionStatus(account); final var currentTunnels = tunnelRepository.countByAccountIdAndStatusIn(account.getId(), ACTIVE_STATUSES); final int totalLimit = calculateTunnelLimit(account); if (currentTunnels >= totalLimit) { throw new IllegalStateException( "Tunnel limit reached for your plan (%d). Please upgrade or add more tunnels.".formatted(totalLimit)); } } /** * Calculates the total tunnel limit for an account (base plan limit + extra tunnels). * * @param account the account entity * @return the total number of allowed tunnels */ public int calculateTunnelLimit(final AccountEntity account) { final var plan = account.getPlan(); final var baseLimit = properties.subscriptions().tunnels().base().get(plan); return baseLimit + account.getExtraTunnels(); } /** * Checks if the account exceeds its tunnel limit and closes excess tunnels if necessary. * Tunnels are closed starting from the ones with no heartbeat or the oldest heartbeat. * * @param account the account entity to check */ @Transactional public void enforceTunnelLimit(final AccountEntity account) { final int limit = calculateTunnelLimit(account); closeExcessTunnels(account, limit); } /** * Closes all active tunnels for the given account. * * @param account the account entity */ @Transactional public void closeAllTunnels(final AccountEntity account) { closeExcessTunnels(account, 0); } private void closeExcessTunnels(final AccountEntity account, final int limit) { final List activeTunnels = tunnelRepository .findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc(account.getId(), ACTIVE_STATUSES); if (activeTunnels.size() > limit) { final int toClose = activeTunnels.size() - limit; log.info("Account {} has {} active tunnels (limit={}). Closing {} tunnels.", account.getId(), activeTunnels.size(), limit, toClose); for (int i = 0; i < toClose; i++) { final var tunnel = activeTunnels.get(i); final var tunnelId = tunnel.getId(); log.info("Closing tunnel: tunnelId={} accountId={} type={}", tunnelId, account.getId(), tunnel.getType()); try { if (tunnel.getType() == TunnelType.HTTP) { tunnelRegistry.closeTunnel(tunnelId); } else { netProxyClient.closeTunnel(tunnelId); } } catch (final Exception e) { log.warn("Failed to close active tunnel {}: {}", tunnelId, e.toString()); } tunnel.setStatus(TunnelStatus.CLOSED); tunnelRepository.save(tunnel); } } } private TunnelEntity createTunnel(final UUID accountId, final UUID userId, final String apiKeyId, final ExposeRequest request, final String publicUrl, final DomainEntity domain) { threatfoxService.ifPresent(threatfox -> threatfox.checkThreat(request.host(), request.port())); final var tunnel = new TunnelEntity(); tunnel.setId(UUID.randomUUID()); tunnel.setType(request.tunnelType()); tunnel.setStatus(TunnelStatus.PENDING); tunnel.setAccountId(accountId); tunnel.setUserId(userId); tunnel.setLocalScheme(request.scheme()); tunnel.setLocalHost(request.host()); tunnel.setLocalPort(request.port()); tunnel.setPublicUrl(publicUrl); tunnel.setDomain(domain); if (apiKeyId != null && !apiKeyId.isBlank()) { tunnel.setApiKeyId(UUID.fromString(apiKeyId)); } // Ensure non-null timestamps for created_at/updated_at to satisfy DB NOT NULL constraints // in case the persistence provider performs an update instead of insert for a new entity. tunnel.setCreatedAt(OffsetDateTime.now()); tunnel.setUpdatedAt(OffsetDateTime.now()); tunnelRepository.save(tunnel); log.info("Created pending {} tunnel record tunnelId={} accountId={} userId={}", tunnel.getType(), tunnel.getId(), accountId, userId); return tunnel; } /** * Updates public host and port for a TCP tunnel identified by tunnelId. * * @param tunnelId The tunnel id (entity id string) * @param publicHost Public host allocated by TCP proxy * @param publicPort Public port allocated by TCP proxy */ @Transactional public void updateTunnelPublicConnection(final UUID tunnelId, final String publicHost, final Integer publicPort) { findByTunnelId(tunnelId).ifPresent(entity -> { entity.setPublicHost(publicHost); entity.setPublicPort(publicPort); tunnelRepository.save(entity); }); } /** * Updates the tunnel with selected port reservation and sets public host/port from reservation. */ @Transactional public void assignReservation(final UUID tunnelId, final PortReservationEntity reservation) { findByTunnelId(tunnelId).ifPresent(entity -> { entity.setPortReservation(reservation); entity.setPublicHost(reservation.getPublicHost()); entity.setPublicPort(reservation.getPublicPort()); tunnelRepository.save(entity); }); } /** * Retrieves a tunnel entity based on the provided tunnel ID. * * @param tunnelId The unique identifier of the tunnel to retrieve. * @return An {@code Optional} containing the {@code TunnelEntity} if found, * or an empty {@code Optional} if not found. */ public Optional findByTunnelId(final UUID tunnelId) { return Optional.ofNullable(tunnelId) .flatMap(tunnelRepository::findById); } /** * Sets a temporary passcode hash on the tunnel entity. */ @Transactional public void setTempPasscodeHash(final UUID tunnelId, final String hash) { findByTunnelId(tunnelId).ifPresent(entity -> { entity.setTempPasscodeHash(hash); tunnelRepository.save(entity); }); } /** * Returns the temporary passcode hash for a tunnel, if present. */ public Optional getTempPasscodeHash(final UUID tunnelId) { return findByTunnelId(tunnelId).map(TunnelEntity::getTempPasscodeHash); } /** * Updates the status of a tunnel to 'CONNECTED' and sets its last heartbeat * timestamp to the current time. This method retrieves the tunnel from the * repository using the provided tunnel ID and applies the updates if the * tunnel is found. * * @param tunnelId The unique identifier of the tunnel to update. If null or * the tunnel is not found, no action is taken. */ @Transactional public void markConnected(final UUID tunnelId) { findByTunnelId(tunnelId).ifPresent(entity -> { accountRepository.findById(entity.getAccountId()) .ifPresent(this::checkSubscriptionStatus); entity.setStatus(TunnelStatus.CONNECTED); entity.setLastHeartbeatAt(OffsetDateTime.now()); tunnelRepository.save(entity); }); } /** * Updates the last heartbeat timestamp of a tunnel to the current time. This method * retrieves the tunnel from the repository using the provided tunnel ID and updates * the last heartbeat timestamp if the tunnel is found. * * @param tunnelId The unique identifier of the tunnel whose heartbeat should be updated. * If null or the tunnel is not found, no action is taken. */ @Transactional public void heartbeat(final UUID tunnelId) { findByTunnelId(tunnelId).ifPresent(entity -> { accountRepository.findById(entity.getAccountId()) .ifPresent(this::checkSubscriptionStatus); entity.setLastHeartbeatAt(OffsetDateTime.now()); tunnelRepository.save(entity); }); } /** * Updates the status of a tunnel to 'CLOSED'. This method retrieves the tunnel * from the repository using the provided tunnel ID and updates the status if * the tunnel is found. * * @param tunnelId The unique identifier of the tunnel to be marked as closed. * If null or the tunnel is not found, no action is taken. */ @Transactional public void markClosed(final UUID tunnelId) { findByTunnelId(tunnelId).ifPresent(entity -> { entity.setStatus(TunnelStatus.CLOSED); tunnelRepository.save(entity); }); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.threatfox; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.context.annotation.Bean; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import feign.RequestInterceptor; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.config.ThreatFoxProperties; @FeignClient( name = "threatfox-client", url = "${threatfox.url}", configuration = ThreatFoxClient.Configuration.class ) @ConditionalOnProperty(value = "threatfox.enabled", havingValue = "true") public interface ThreatFoxClient { @PostMapping("/api/v1/") ThreatFoxResponse fetchIoc(@RequestBody final ThreatFoxRequest request); @RequiredArgsConstructor class Configuration { private static final String AUTH_KEY = "Auth-Key"; private final ThreatFoxProperties properties; @Bean public RequestInterceptor authorizationHeaderForwarder() { return template -> template.header(AUTH_KEY, properties.authKey()); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxIoc.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.threatfox; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record ThreatFoxIoc( @JsonProperty("id") String id, @JsonProperty("ioc") String ioc, @JsonProperty("threat_type") String threatType, @JsonProperty("ioc_type") String iocType, @JsonProperty("malware") String malware, @JsonProperty("malware_printable") String malwarePrintable, @JsonProperty("confidence_level") Integer confidenceLevel, @JsonProperty("first_seen") String firstSeen, @JsonProperty("reporter") String reporter ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.threatfox; public record ThreatFoxRequest( String query, Integer days ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.threatfox; import java.util.List; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @JsonIgnoreProperties(ignoreUnknown = true) public record ThreatFoxResponse( @JsonProperty("query_status") String queryStatus, @JsonProperty("data") List data ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/threatfox/ThreatFoxService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.threatfox; import java.util.HashSet; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.security.ThreatBlockedException; @Slf4j @Service @RequiredArgsConstructor @ConditionalOnProperty(name = "threatfox.enabled", havingValue = "true") public class ThreatFoxService { private static final String IOC_TYPE_URL = "url"; private static final Set IOC_TYPES = Set.of("domain", "ip:port", IOC_TYPE_URL); private static final ThreatFoxRequest FETCH_IOC_REQUEST = new ThreatFoxRequest("get_iocs", 7); private final ThreatFoxClient client; private volatile Set cache = new HashSet<>(); /** * Fetches threat intelligence data and processes it to update the internal cache. * This method is scheduled to run periodically, determined by the configuration value * specified in the {@code threatfox.fetch-interval} property. It makes a request to * retrieve Indicators of Compromise (IOCs) from the ThreatFox API and processes the * response to extract and normalize relevant data. The normalized data is stored in a * cache, which can later be used to identify and block potential threats. * If the fetch operation fails for any reason, an error message is logged to assist * in diagnosing the issue. * The scheduling configuration specifies the following: * - {@code fixedDelayString}: Configured interval between method executions in milliseconds. * - {@code initialDelay}: Delay before the first invocation, set to 0. * Logs are generated for both successful and failed fetch operations. * * @throws RuntimeException If an unhandled exception occurs during the fetch or processing. */ @Scheduled( fixedDelayString = "${threatfox.fetch-interval}", initialDelay = 0 ) public void fetchData() { try { final var threatFoxResponse = client.fetchIoc(FETCH_IOC_REQUEST); process(threatFoxResponse); } catch (final Exception e) { log.error("[Threatfox] Fetch failed: {}", e.getMessage()); } } private void process(final ThreatFoxResponse response) { if (response == null || response.data() == null) { log.warn("[Threatfox] returned empty response"); return; } cache = response.data().stream().parallel() .filter(ioc -> isRelevantType(ioc.iocType())) .map(this::normalize) .filter(Objects::nonNull) .collect(Collectors.toSet()); log.info("[Threatfox] cache updated: {} iocs loaded", cache.size()); } private boolean isRelevantType(final String type) { return IOC_TYPES.contains(type); } private String normalize(final ThreatFoxIoc ioc) { final var iocValue = Objects.equals(ioc.iocType(), IOC_TYPE_URL) ? extractDomain(ioc.ioc()) : ioc.ioc(); return normalize(iocValue); } private String normalize(final String ioc) { if (ioc == null || ioc.isBlank()) { return null; } return ioc.toLowerCase().trim(); } private String extractDomain(final String url) { final var doubleSlashIndex = url.indexOf("//"); final var start = doubleSlashIndex == -1 ? 0 : doubleSlashIndex + 2; final var lastSlashIndex = url.indexOf("/", start); return url.substring(start, lastSlashIndex == -1 ? url.length() : lastSlashIndex); } private boolean isBlacklisted(final String target) { return cache.contains(target); } /** * Checks if the provided host and port combination is blacklisted as a potential threat. *
* The method first normalizes the host by converting it to lowercase and trimming any * leading or trailing whitespace. It then verifies if the host alone or the combination * of host and port is present in the blacklist cache. If a match is found, a * {@code ThreatBlockedException} is thrown to indicate the presence of a threat. * * @param host The domain or IP address to be checked. Must not be null or empty. * @param port The port number associated with the host to be checked. * Should be a valid integer port number. * @throws ThreatBlockedException If the host or host-port combination matches an entry in the blacklist. */ public void checkThreat(final String host, final int port) { final var normalizeHost = normalize(host); if (normalizeHost == null) { return; } if (isBlacklisted(normalizeHost)) { log.warn("[Threatfox] Domain {} matches ioc", host); throw new ThreatBlockedException("Target domain is blacklisted: " + host); } final var hostPort = normalizeHost + ":" + port; if (isBlacklisted(hostPort)) { log.warn("[Threatfox] {} matches ioc", hostPort); throw new ThreatBlockedException("Target is blacklisted: " + hostPort); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/user/MissingEmailException.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.user; /** * Thrown when a user provisioning attempt is made without an email address. */ public class MissingEmailException extends RuntimeException { public MissingEmailException(final String message) { super(message); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/user/PasswordResetService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.user; import java.time.Duration; import java.time.OffsetDateTime; import java.util.HashMap; import java.util.UUID; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.PasswordResetTokenRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.mail.EmailService; @Service @RequiredArgsConstructor @Slf4j public class PasswordResetService { private final UserRepository userRepository; private final PasswordResetTokenRepository tokenRepository; private final EmailService emailService; private final PasswordEncoder passwordEncoder; private final AppProperties properties; /** * Initiates the password reset process for the given email. * * @param email the user's email address */ @Transactional public void requestReset(final String email) { final var userOpt = userRepository.findByEmailIgnoreCase(email); if (userOpt.isEmpty()) { log.info("Password reset requested for non-existent email: {}", email); // Do not reveal that user does not exist return; } final var user = userOpt.get(); final var resetLink = generateResetPasswordLink(user, Duration.ofHours(1)); final var model = new HashMap(); model.put("subject", "Reset Your Password"); model.put("resetLink", resetLink); model.put("webAppUrl", properties.gateway().url()); final var firstName = user.getFirstName(); model.put("greeting", firstName == null ? "Hello!" : "Hello " + firstName + ","); emailService.sendTemplate(user.getEmail(), "Reset Your Password", "email/password-reset", model); log.info("Password reset email sent to user: {}", user.getId()); } /** * Generates a reset password link for the given user and token time-to-live (TTL). * This method invalidates any existing reset tokens for the user, creates a new * token, saves it in the database, and returns the corresponding reset link. * * @param user the user entity for whom the reset password link is being generated * @param ttl the duration for which the reset token is valid * @return the complete reset password link containing the generated token */ @Transactional public String generateResetPasswordLink(final UserEntity user, final Duration ttl) { // Invalidate existing tokens tokenRepository.deleteByUser(user); final var token = UUID.randomUUID().toString(); final var entity = new PasswordResetTokenEntity(); entity.setId(UUID.randomUUID()); entity.setToken(token); entity.setUser(user); // Token TTL entity.setExpiryDate(OffsetDateTime.now().plus(ttl)); tokenRepository.save(entity); return properties.gateway().url() + "/reset-password?token=" + token; } /** * Validates if the reset token exists and is not expired. * * @param token the reset token * @return true if valid, false otherwise */ @Transactional(readOnly = true) public boolean validateToken(final String token) { final var tokenEntityOpt = tokenRepository.findByToken(token); if (tokenEntityOpt.isEmpty()) { return false; } final var tokenEntity = tokenEntityOpt.get(); return tokenEntity.getExpiryDate().isAfter(OffsetDateTime.now()); } /** * Resets the user's password using the valid token. * * @param token the reset token * @param newPassword the new password */ @Transactional public void resetPassword(final String token, final String newPassword) { final var tokenEntity = tokenRepository.findByToken(token) .orElseThrow(() -> new IllegalArgumentException("Invalid token")); if (tokenEntity.getExpiryDate().isBefore(OffsetDateTime.now())) { throw new IllegalArgumentException("Token expired"); } final var user = tokenEntity.getUser(); user.setPassword(passwordEncoder.encode(newPassword)); userRepository.save(user); tokenRepository.delete(tokenEntity); // Send confirmation email final var model = new HashMap(); model.put("subject", "Password Changed Successfully"); model.put("webAppUrl", properties.gateway().url() + "/app"); final var firstName = user.getFirstName(); model.put("greeting", firstName == null ? "Hello!" : "Hello " + firstName + ","); emailService.sendTemplate(user.getEmail(), "Password Changed Successfully", "email/password-reset-success", model); log.info("Password reset successfully for user: {}", user.getId()); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/service/user/UserProvisioningService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service.user; import java.time.Duration; import java.util.HashSet; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import org.apache.commons.lang3.StringUtils; import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.Role; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.mail.UserCreatedEvent; import tech.amak.portbuddy.server.service.DomainService; import tech.amak.portbuddy.server.service.PortReservationService; @Service @RequiredArgsConstructor @Slf4j public class UserProvisioningService { private final UserRepository userRepository; private final AccountRepository accountRepository; private final UserAccountRepository userAccountRepository; private final PasswordEncoder passwordEncoder; private final DomainService domainService; private final PortReservationService portReservationService; private final ApplicationEventPublisher eventPublisher; private final PasswordResetService passwordResetService; public record ProvisionedUser(UUID userId, UUID accountId, String accountName, Set roles) { } /** * Creates a new local user with email and password. */ @Transactional public ProvisionedUser createLocalUser(final String email, final String name, final String password) { final var normalizedEmail = normalizeEmail(email); if (normalizedEmail == null) { throw new IllegalArgumentException("Email is required"); } if (userRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { throw new IllegalArgumentException("User already exists"); } final var userName = Optional.ofNullable(name) .filter(StringUtils::isNotBlank) .orElse("Unknown Buddy"); // Split name String lastName = null; final var parts = userName.trim().split("\\s+", 2); final var firstName = parts[0]; if (parts.length > 1) { lastName = parts[1]; } // Create new account and user final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setName(defaultAccountName(firstName, lastName, normalizedEmail)); account.setPlan(Plan.PRO); accountRepository.save(account); final var user = new UserEntity(); user.setId(UUID.randomUUID()); user.setEmail(normalizedEmail); user.setFirstName(firstName); user.setLastName(lastName); user.setAuthProvider("local"); user.setExternalId(normalizedEmail); Optional.ofNullable(password) .filter(StringUtils::isNotBlank) .map(passwordEncoder::encode) .ifPresent(user::setPassword); userRepository.save(user); final var userAccount = new UserAccountEntity(user, account, determineRoles(true)); userAccountRepository.save(userAccount); // Try to create an initial port reservation and domain for the account by this user try { domainService.assignRandomDomain(account); portReservationService.createReservation(account, user); } catch (final Exception ex) { log.error("Failed to create initial reservations for account {}: {}", account.getId(), ex.getMessage(), ex); } String resetPasswordLink = null; if (StringUtils.isBlank(password)) { resetPasswordLink = passwordResetService.generateResetPasswordLink(user, Duration.ofDays(30)); } // Publish event after user persisted; listener will send email AFTER_COMMIT eventPublisher.publishEvent(new UserCreatedEvent( user.getId(), account.getId(), user.getEmail(), user.getFirstName(), user.getLastName(), resetPasswordLink )); return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles()); } /** * Ensures a user and an owning account exist for the given identity. If the user does not exist, * a new account (default plan PRO) and user are created. If the user exists, profile fields are updated. * * @param provider the OAuth2 provider registration id (e.g. google, github) * @param externalId the unique external identifier from the provider (subject/id) * @param email the email address, can be null * @param firstName optional first name * @param lastName optional last name * @param avatarUrl optional avatar URL * @return identifiers of provisioned user and owning account */ @Transactional public ProvisionedUser provision(final String provider, final String externalId, final String email, final String firstName, final String lastName, final String avatarUrl) { final var normalizedEmail = normalizeEmail(email); // If user already exists by provider/externalId, allow sign-in even when current OAuth response // does not include email (keep stored email). Update profile fields and return. final var existing = userRepository.findByAuthProviderAndExternalId(provider, externalId); if (existing.isPresent()) { final var user = existing.get(); boolean changed = false; if (normalizedEmail != null && !Objects.equals(user.getEmail(), normalizedEmail)) { user.setEmail(normalizedEmail); changed = true; } if (!Objects.equals(user.getFirstName(), firstName)) { user.setFirstName(firstName); changed = true; } if (!Objects.equals(user.getLastName(), lastName)) { user.setLastName(lastName); changed = true; } if (!Objects.equals(user.getAvatarUrl(), avatarUrl)) { user.setAvatarUrl(avatarUrl); changed = true; } if (changed) { userRepository.save(user); } final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId()) .orElseThrow(() -> new IllegalStateException("User has no accounts")); final var account = userAccount.getAccount(); return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles()); } // For new identities we must have a non-null email to create/merge a user if (normalizedEmail == null) { throw new MissingEmailException("Email is required to provision a user"); } // No user for this provider/external identity. Try to locate an existing user by email if (normalizedEmail != null) { final var userByEmail = userRepository.findByEmailIgnoreCase(normalizedEmail); if (userByEmail.isPresent()) { final var user = userByEmail.get(); boolean changed = false; // Ensure email normalized if (!Objects.equals(user.getEmail(), normalizedEmail)) { user.setEmail(normalizedEmail); changed = true; } if (!Objects.equals(user.getFirstName(), firstName)) { user.setFirstName(firstName); changed = true; } if (!Objects.equals(user.getLastName(), lastName)) { user.setLastName(lastName); changed = true; } if (!Objects.equals(user.getAvatarUrl(), avatarUrl)) { user.setAvatarUrl(avatarUrl); changed = true; } // Re-link identity to this provider/external to allow future lookups by provider if (!Objects.equals(user.getAuthProvider(), provider)) { user.setAuthProvider(provider); changed = true; } if (!Objects.equals(user.getExternalId(), externalId)) { user.setExternalId(externalId); changed = true; } if (changed) { userRepository.save(user); } final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId()) .orElseThrow(() -> new IllegalStateException("User has no accounts")); final var account = userAccount.getAccount(); return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles()); } } // Create new account and user final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setName(defaultAccountName(firstName, lastName, email)); account.setPlan(Plan.PRO); accountRepository.save(account); final var user = new UserEntity(); user.setId(UUID.randomUUID()); user.setEmail(normalizedEmail); user.setFirstName(firstName); user.setLastName(lastName); user.setAuthProvider(provider); user.setExternalId(externalId); user.setAvatarUrl(avatarUrl); userRepository.save(user); final var userAccount = new UserAccountEntity(user, account, determineRoles(true)); userAccountRepository.save(userAccount); // Try to create an initial port reservation and domain for the account by this user try { domainService.assignRandomDomain(account); portReservationService.createReservation(account, user); } catch (final Exception ignored) { // per spec: ignore if no host/port available during account creation } // Publish event for brand new user eventPublisher.publishEvent(new UserCreatedEvent( user.getId(), account.getId(), user.getEmail(), user.getFirstName(), user.getLastName(), null )); return new ProvisionedUser(user.getId(), account.getId(), account.getName(), userAccount.getRoles()); } private static String defaultAccountName(final String firstName, final String lastName, final String email) { final var fullName = "%s %s".formatted(firstName, lastName).trim(); if (!fullName.isEmpty()) { return fullName + "’s account"; } if (email != null && !email.isBlank()) { final var at = email.indexOf('@'); final var local = at > 0 ? email.substring(0, at) : email; return local + "’s account"; } return "New account"; } private static String normalizeEmail(final String email) { if (email == null) { return null; } final var trimmed = email.trim(); return trimmed.isEmpty() ? null : trimmed.toLowerCase(); } private Set determineRoles(final boolean isAccountOwner) { final var roles = new HashSet(); if (userRepository.count() == 0) { roles.add(Role.ADMIN); } if (isAccountOwner) { roles.add(Role.ACCOUNT_ADMIN); } else { roles.add(Role.USER); } return roles; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/tunnel/PermissiveSubprotocolHandshakeHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import java.util.List; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.support.DefaultHandshakeHandler; /** * A permissive WebSocket HandshakeHandler that negotiates the subprotocol by simply * echoing back the first protocol requested by the client, if any. This is helpful * for clients (e.g., Vaadin) that expect the selected subprotocol to match their * request without the server advertising a fixed list. */ public class PermissiveSubprotocolHandshakeHandler extends DefaultHandshakeHandler { @Override protected String selectProtocol(final List requestedProtocols, final WebSocketHandler webSocketHandler) { return requestedProtocols.stream().findFirst().orElse(null); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/tunnel/PublicWebSocketProxyHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.AbstractWebSocketHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.DomainRepository; /** * Accepts public WebSocket connections from browsers for tunneled subdomains and bridges them * over the control WebSocket to the CLI client. */ @Slf4j @Component @RequiredArgsConstructor public class PublicWebSocketProxyHandler extends AbstractWebSocketHandler { private final TunnelRegistry registry; private final AppProperties properties; private final DomainRepository domainRepository; private final AccountRepository accountRepository; @Override public void afterConnectionEstablished(final WebSocketSession browserSession) throws Exception { final var subdomain = extractSubdomain(browserSession); if (subdomain == null) { log.debug("WS: missing/invalid host header"); browserSession.close(CloseStatus.POLICY_VIOLATION); return; } var tunnel = registry.getBySubdomain(subdomain); if (tunnel == null) { // It might be a custom domain, try to resolve it to a subdomain final var domainOpt = domainRepository.findByCustomDomain(subdomain); if (domainOpt.isPresent()) { final var resolvedSubdomain = domainOpt.get().getSubdomain(); tunnel = registry.getBySubdomain(resolvedSubdomain); } } if (tunnel == null || !tunnel.isOpen()) { browserSession.close(CloseStatus.SERVICE_RESTARTED); return; } // Check subscription status final var accountOpt = accountRepository.findById(tunnel.accountId()); if (accountOpt.isPresent()) { final var status = accountOpt.get().getSubscriptionStatus(); if (status != null && !"active".equals(status)) { log.warn("Blocked WS request to subdomain {} because subscription is not active (status: {})", subdomain, status); browserSession.close(CloseStatus.POLICY_VIOLATION.withReason("Subscription inactive")); return; } } final var connectionId = UUID.randomUUID().toString(); registry.registerBrowserWs(tunnel.tunnelId(), connectionId, browserSession); final var uri = browserSession.getUri(); final var message = new WsTunnelMessage(); message.setConnectionId(connectionId); message.setWsType(WsTunnelMessage.Type.OPEN); if (uri != null) { final var normalized = normalizePublicPath(subdomain, uri.getPath()); message.setPath(normalized); message.setQuery(uri.getQuery()); } // Forward essential handshake headers (cookies, origin, vaadin-specific, etc.) final var forwarded = collectForwardedHandshakeHeaders(browserSession); if (!forwarded.isEmpty()) { message.setHeaders(forwarded); } registry.sendWsToClient(tunnel.tunnelId(), message); } @Override protected void handleTextMessage(final WebSocketSession session, final TextMessage message) { final var ids = registry.findIdsByBrowserSession(session); if (ids == null) { return; } final var websocketMessage = new WsTunnelMessage(); websocketMessage.setConnectionId(ids.getConnectionId()); websocketMessage.setWsType(WsTunnelMessage.Type.TEXT); websocketMessage.setText(message.getPayload()); registry.sendWsToClient(ids.getTunnelId(), websocketMessage); } @Override protected void handleBinaryMessage(final WebSocketSession session, final BinaryMessage message) { final var ids = registry.findIdsByBrowserSession(session); if (ids == null) { return; } final var websocketMessage = new WsTunnelMessage(); websocketMessage.setConnectionId(ids.getConnectionId()); websocketMessage.setWsType(WsTunnelMessage.Type.BINARY); websocketMessage.setDataB64(Base64.getEncoder().encodeToString(message.getPayload().array())); registry.sendWsToClient(ids.getTunnelId(), websocketMessage); } @Override public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { final var ids = registry.unregisterBrowserWs(session); if (ids == null) { return; } final var websocketMessage = new WsTunnelMessage(); websocketMessage.setConnectionId(ids.getConnectionId()); websocketMessage.setWsType(WsTunnelMessage.Type.CLOSE); websocketMessage.setCloseCode(status.getCode()); websocketMessage.setCloseReason(status.getReason()); registry.sendWsToClient(ids.getTunnelId(), websocketMessage); } private String extractSubdomain(final WebSocketSession session) { // Prefer X-Forwarded-Host because requests are routed via Spring Cloud Gateway, // which by default does not preserve the original Host header to the upstream. var host = firstNonBlank( session.getHandshakeHeaders().getFirst("X-Forwarded-Host"), session.getHandshakeHeaders().getFirst(HttpHeaders.HOST) ); if (host != null) { // X-Forwarded-Host may contain a comma-separated list — take the first final var commaIdx = host.indexOf(','); if (commaIdx > 0) { host = host.substring(0, commaIdx).trim(); } // Strip port if present final var colonIdx = host.indexOf(':'); if (colonIdx > 0) { host = host.substring(0, colonIdx); } if (host.endsWith(properties.gateway().subdomainHost())) { final var idx = host.indexOf('.'); if (idx > 0) { return host.substring(0, idx); } } else { // Check if it's a custom domain final var domainOpt = domainRepository.findByCustomDomain(host.toLowerCase()); if (domainOpt.isPresent()) { return domainOpt.get().getSubdomain(); } } } // Fallback: the gateway rewrites path to /_/{subdomain}/... for HTTP // and to /_ws/{subdomain}/... for WebSocket handshakes. // Try to extract subdomain from the request path if headers are unavailable/unexpected. final var uri = session.getUri(); if (uri != null) { final var path = uri.getPath(); if (path != null) { String prefix = null; if (path.startsWith("/_ws/")) { prefix = "/_ws/"; } else if (path.startsWith("/_/")) { prefix = "/_/"; } if (prefix != null) { final var rest = path.substring(prefix.length()); final var slash = rest.indexOf('/'); if (slash > 0) { return rest.substring(0, slash); } if (!rest.isBlank()) { return rest; // path was exactly /_ws/{subdomain} or /_/{subdomain} } } } } return null; } private static String firstNonBlank(final String a, final String b) { if (a != null && !a.isBlank()) { return a; } return (b != null && !b.isBlank()) ? b : null; } /** * Normalize the path coming from the gateway so the client connects to the local * application using its original path. The gateway rewrites incoming public requests * to "/_ws/{subdomain}/..." for WebSocket (and "/_/{subdomain}/..." for HTTP). * We must strip that internal prefix before forwarding the OPEN to the CLI, * otherwise the CLI will try to open a WS to a non-existent path like * "/_ws/{subdomain}/..." on the user's local app causing HTTP 200 instead of 101. */ private static String normalizePublicPath(final String subdomain, final String rawPath) { if (rawPath == null || rawPath.isBlank()) { return "/"; } var path = rawPath; final var wsPrefix = "/_ws/" + subdomain; final var httpPrefix = "/_/" + subdomain; if (path.startsWith(wsPrefix)) { path = path.substring(wsPrefix.length()); } else if (path.startsWith(httpPrefix)) { // fallback safety path = path.substring(httpPrefix.length()); } if (path.isBlank()) { return "/"; } if (!path.startsWith("/")) { path = "/" + path; } return path; } /** * Build a safe subset of headers from the browser's WS handshake that should be forwarded * to the local application when establishing the tunneled WS connection. This helps * frameworks like Vaadin associate the WS with the existing HTTP session (via cookies) * and preserve security context. */ private Map collectForwardedHandshakeHeaders(final WebSocketSession browserSession) { final var result = new HashMap(); final var headers = browserSession.getHandshakeHeaders(); // Explicit allow-list (case-insensitive) final var allowedExact = Set.of( "cookie", "origin", "authorization", "referer", "x-requested-with", "x-csrf-token", // Keep subprotocol if requested by the browser (e.g., Vaadin) "sec-websocket-protocol" ); // Explicit deny-list of hop-by-hop and WS negotiation headers we must not forward final var forbidden = Set.of( HttpHeaders.HOST.toLowerCase(), HttpHeaders.CONNECTION.toLowerCase(), HttpHeaders.UPGRADE.toLowerCase(), "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-accept" ); for (final var name : headers.keySet()) { if (name == null) { continue; } final var nlc = name.toLowerCase(); if (forbidden.contains(nlc)) { continue; } final var isAllowedExact = allowedExact.contains(nlc); final var isVaadinSpecific = nlc.startsWith("x-vaadin-") || nlc.startsWith("vaadin-"); if (isAllowedExact || isVaadinSpecific) { final var value = headers.getFirst(name); if (value != null && !value.isBlank()) { result.put(name, value); } } } // Add/normalize forwarding context headers // X-Forwarded-Host: take it from handshake if present, otherwise use Host var xfHost = firstNonBlank(headers.getFirst("X-Forwarded-Host"), headers.getFirst(HttpHeaders.HOST)); if (xfHost != null && !xfHost.isBlank()) { final var commaIdx = xfHost.indexOf(','); if (commaIdx > 0) { xfHost = xfHost.substring(0, commaIdx).trim(); } result.put("X-Forwarded-Host", xfHost); } // X-Forwarded-Proto: trust existing if present, otherwise derive from ws/wss scheme var xfProto = headers.getFirst("X-Forwarded-Proto"); if (xfProto == null || xfProto.isBlank()) { final var uri = browserSession.getUri(); if (uri != null && uri.getScheme() != null) { final var sch = uri.getScheme(); if ("wss".equalsIgnoreCase(sch)) { xfProto = "https"; } else if ("ws".equalsIgnoreCase(sch)) { xfProto = "http"; } } } if (xfProto != null && !xfProto.isBlank()) { result.put("X-Forwarded-Proto", xfProto); } if (!result.isEmpty()) { try { log.debug("WS: forwarding handshake headers: {}", List.copyOf(result.keySet())); } catch (final Exception ignored) { // ignore logging issues } } return result; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/tunnel/TunnelRegistry.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import java.io.IOException; import java.time.Duration; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import org.springframework.stereotype.Component; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.tunnel.ControlMessage; import tech.amak.portbuddy.common.tunnel.HttpTunnelMessage; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; import tech.amak.portbuddy.server.db.entity.TunnelEntity; /** * Registry of active HTTP tunnels and WS connections. */ @Slf4j @Component @RequiredArgsConstructor public class TunnelRegistry { private final Map bySubdomain = new ConcurrentHashMap<>(); private final Map byTunnelId = new ConcurrentHashMap<>(); public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); private final ObjectMapper mapper; /** * Registers a WebSocket session for a given tunnel entity by associating it with a newly created * tunnel instance based on the subdomain and tunnel ID. * * @param tunnelEntity the {@code TunnelEntity} containing information about the domain and tunnel identifiers * @param session the {@code WebSocketSession} to be associated with the created tunnel instance * @return {@code true} to indicate successful registration */ public boolean register(final TunnelEntity tunnelEntity, final WebSocketSession session) { final var tunnel = register(tunnelEntity.getDomain().getSubdomain(), tunnelEntity.getId(), tunnelEntity.getAccountId()); tunnel.setSession(session); log.info("Registered tunnel {} with session {}", tunnel.tunnelId(), session.getId()); return true; } /** * Creates a new pending Tunnel instance with the specified subdomain and tunnel ID * and registers it in the internal mappings. * * @param subdomain the subdomain associated with the tunnel * @param tunnelId the unique identifier for the tunnel * @param accountId the account identifier for the tunnel * @return the created Tunnel instance */ private Tunnel register(final String subdomain, final UUID tunnelId, final UUID accountId) { final var tunnel = new Tunnel(subdomain, tunnelId, accountId); bySubdomain.put(subdomain, tunnel); byTunnelId.put(tunnelId, tunnel); return tunnel; } public Tunnel getBySubdomain(final String subdomain) { return bySubdomain.get(subdomain); } public Tunnel getByTunnelId(final UUID tunnelId) { return byTunnelId.get(tunnelId); } /** * Forwards an HTTP tunnel request through a WebSocket session associated with a specified subdomain. * If the tunnel is not connected or not open, the request will fail with an exception. * A timeout can be specified to limit the operation’s duration. * * @param subdomain the subdomain associated with the destination tunnel * @param request the HTTP tunnel message to be forwarded * @param timeout the maximum duration to wait for a response; null indicates default timeout * @return a CompletableFuture that will complete with the response message or fail with an exception */ public CompletableFuture forwardRequest(final String subdomain, final HttpTunnelMessage request, final Duration timeout) { final var tunnel = bySubdomain.get(subdomain); if (tunnel == null || !tunnel.isOpen()) { final var future = new CompletableFuture(); future.completeExceptionally(new IllegalStateException("Tunnel not connected")); return future; } // Assign id if missing if (request.getId() == null) { request.setId(UUID.randomUUID().toString()); } request.setType(HttpTunnelMessage.Type.REQUEST); final var future = new CompletableFuture(); tunnel.pending().put(request.getId(), future); try { final var json = mapper.writeValueAsString(request); tunnel.session().sendMessage(new TextMessage(json)); log.trace("Forwarded request {} to tunnel {}", json, tunnel.tunnelId()); } catch (final IOException e) { tunnel.pending().remove(request.getId()); future.completeExceptionally(e); return future; } final var futureTimeout = timeout == null ? DEFAULT_TIMEOUT : timeout; // Apply timeout final var timedFuture = future.orTimeout(futureTimeout.toMillis(), TimeUnit.MILLISECONDS); timedFuture.whenComplete((res, err) -> tunnel.pending().remove(request.getId())); return timedFuture; } /** * Processes an HTTP tunnel response message associated with the specified tunnel ID. * If the tunnel with the given ID exists and the response matches an existing pending * request in the tunnel, the request's future is completed with the response. * * @param tunnelId the unique identifier of the tunnel associated with the response * @param response the HTTP tunnel message representing the response to be processed */ public void onResponse(final UUID tunnelId, final HttpTunnelMessage response) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } final var future = tunnel.pending() .get(response.getId()); if (future != null) { future.complete(response); } } /** * Sends a WebSocket message to the client associated with the specified tunnel. * If the specified tunnel is not open or does not exist, the operation is aborted. * * @param tunnelId the unique identifier of the tunnel to send the message to * @param message the WebSocket message to be sent to the client */ // ============ WebSocket tunneling support ============ public void sendWsToClient(final UUID tunnelId, final WsTunnelMessage message) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null || !tunnel.isOpen()) { return; } try { final var json = mapper.writeValueAsString(message); tunnel.session().sendMessage(new TextMessage(json)); } catch (final IOException e) { log.warn("Failed to send WS message to client: {}", e.toString()); } } /** * Registers a browser WebSocket session associated with the specified tunnel ID and connection ID. * If no tunnel with the provided tunnel ID exists, the operation is aborted. * The session is mapped in both the forward and reverse lookup structures for later reference. * * @param tunnelId the unique identifier of the tunnel to associate with the browser session * @param connectionId the unique identifier of the connection within the tunnel * @param browserSession the WebSocket session representing the browser connection */ public void registerBrowserWs(final UUID tunnelId, final String connectionId, final WebSocketSession browserSession) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return; } tunnel.browserByConnection().put(connectionId, browserSession); tunnel.browserReverse().put(browserSession, new Ids(tunnelId, connectionId)); } /** * Unregisters a browser WebSocket session from the tunnel registry. This method * removes the browser session from the reverse mapping and connection ID mapping * of the associated tunnel. If the session is successfully unregistered, the related * IDs (tunnel ID and connection ID) are returned; otherwise, null is returned. * * @param browserSession the WebSocketSession representing the browser connection to be unregistered * @return an {@code Ids} object containing the tunnel ID and connection ID associated with the * unregistered browser session, or {@code null} if the session was not found */ public Ids unregisterBrowserWs(final WebSocketSession browserSession) { for (final var tunnel : byTunnelId.values()) { final var ids = tunnel.browserReverse().remove(browserSession); if (ids != null) { tunnel.browserByConnection().remove(ids.connectionId); return ids; } } return null; } /** * Retrieves the tunnel and connection IDs associated with a given browser WebSocket session. * Iterates through the registered tunnels to find a reverse mapping for the specified session. * * @param browserSession the WebSocketSession representing the browser connection to look up * @return an {@code Ids} object containing the tunnel ID and connection ID associated with * the specified session, or {@code null} if no match is found */ public Ids findIdsByBrowserSession(final WebSocketSession browserSession) { for (final var tunnel : byTunnelId.values()) { final var ids = tunnel.browserReverse().get(browserSession); if (ids != null) { return ids; } } return null; } /** * Retrieves a WebSocket session associated with the specified tunnel ID and connection ID. * If no tunnel exists for the given tunnel ID or no session is associated with the * provided connection ID, this method returns {@code null}. * * @param tunnelId the unique identifier of the tunnel from which to retrieve the session * @param connectionId the unique identifier of the connection within the tunnel * @return the WebSocketSession associated with the specified tunnel ID and connection ID, * or {@code null} if no matching session is found */ public WebSocketSession getBrowserSession(final UUID tunnelId, final String connectionId) { final var tunnel = byTunnelId.get(tunnelId); if (tunnel == null) { return null; } return tunnel.browserByConnection().get(connectionId); } /** * Closes the WebSocket session associated with the specified tunnel ID after sending an EXIT control message. * * @param tunnelId the unique identifier of the tunnel to close */ public void closeTunnel(final UUID tunnelId) { final var tunnel = byTunnelId.remove(tunnelId); if (tunnel == null) { return; } // Remove from bySubdomain if exists if (tunnel.subdomain() != null) { bySubdomain.remove(tunnel.subdomain()); } // Close all browser sessions associated with this tunnel tunnel.browserByConnection().values().forEach(session -> { if (session.isOpen()) { try { session.close(); } catch (final IOException e) { log.warn("Failed to close browser WS session: {}", e.toString()); } } }); tunnel.browserByConnection().clear(); tunnel.browserReverse().clear(); // Fail all pending HTTP requests and clear tunnel.pending().forEach((id, future) -> future.completeExceptionally(new IllegalStateException("Tunnel closed"))); tunnel.pending().clear(); if (tunnel.isOpen()) { try { final var exitMsg = new ControlMessage(); exitMsg.setType(ControlMessage.Type.EXIT); exitMsg.setTs(System.currentTimeMillis()); tunnel.session().sendMessage(new TextMessage(mapper.writeValueAsString(exitMsg))); tunnel.session().close(); } catch (final IOException e) { log.warn("Failed to close tunnel WS session: {}", e.toString()); } } } @Data @AllArgsConstructor public static final class Ids { private final UUID tunnelId; private final String connectionId; } @RequiredArgsConstructor public static class Tunnel { private final String subdomain; private final UUID tunnelId; private final UUID accountId; @Setter private volatile WebSocketSession session; private final Map> pending = new ConcurrentHashMap<>(); // Browser WS peers for this tunnel private final Map browserByConnection = new ConcurrentHashMap<>(); private final Map browserReverse = new ConcurrentHashMap<>(); public String subdomain() { return subdomain; } public UUID tunnelId() { return tunnelId; } public UUID accountId() { return accountId; } public WebSocketSession session() { return session; } public Map> pending() { return pending; } public boolean isOpen() { return session != null && session.isOpen(); } public Map browserByConnection() { return browserByConnection; } public Map browserReverse() { return browserReverse; } // No passcode kept in-memory; use DB via TunnelService when needed } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/tunnel/TunnelWebSocketHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import java.util.Base64; import java.util.UUID; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.tunnel.ControlMessage; import tech.amak.portbuddy.common.tunnel.HttpTunnelMessage; import tech.amak.portbuddy.common.tunnel.MessageEnvelope; import tech.amak.portbuddy.common.tunnel.WsTunnelMessage; import tech.amak.portbuddy.common.utils.IdUtils; import tech.amak.portbuddy.server.service.TunnelService; @Slf4j @Component @RequiredArgsConstructor public class TunnelWebSocketHandler extends TextWebSocketHandler { private final TunnelRegistry registry; private final ObjectMapper mapper; private final TunnelService tunnelService; @Override @Transactional public void afterConnectionEstablished(final WebSocketSession session) { final var tunnelId = extractTunnelId(session); tunnelService.findByTunnelId(tunnelId).ifPresentOrElse( tunnel -> { registry.register(tunnel, session); tunnelService.markConnected(tunnelId); log.info("Tunnel session established: {}", tunnelId); }, () -> { log.warn("Tunnel not found for id={}", tunnelId); closeWebsocket(session, CloseStatus.NORMAL); }); } private void closeWebsocket(final WebSocketSession session, final CloseStatus status) { try { session.close(status); log.debug("Closed websocket. Status: {}, URI: {}", status, session.getUri()); } catch (final Exception e) { log.warn("Failed to close websocket: {}", e.toString()); } } @Override protected void handleTextMessage(final WebSocketSession session, final TextMessage message) { try { log.trace("Received message from client: {}", message.getPayload()); final var tunnelId = extractTunnelId(session); tunnelService.heartbeat(tunnelId); final String payload = message.getPayload(); final var env = mapper.readValue(payload, MessageEnvelope.class); // Control health checks if (env.getKind() != null && env.getKind().equals("CTRL")) { final var ctrl = mapper.readValue(payload, ControlMessage.class); if (ctrl.getType() == ControlMessage.Type.PING) { final var pong = new ControlMessage(); pong.setType(ControlMessage.Type.PONG); pong.setTs(System.currentTimeMillis()); session.sendMessage(new TextMessage(mapper.writeValueAsString(pong))); } return; } if (env.getKind() != null && env.getKind().equals("WS")) { final var wsMsg = mapper.readValue(payload, WsTunnelMessage.class); handleWsFromClient(tunnelId, wsMsg); return; } final var httpMsg = mapper.readValue(payload, HttpTunnelMessage.class); if (httpMsg.getType() == HttpTunnelMessage.Type.RESPONSE) { registry.onResponse(tunnelId, httpMsg); } else { log.debug("Ignoring unexpected message type from client: {}", httpMsg.getType()); } } catch (final Exception e) { log.warn("Tunnel message handling error: {}", e.toString()); } } private void handleWsFromClient(final UUID tunnelId, final WsTunnelMessage message) throws Exception { final var browser = registry.getBrowserSession(tunnelId, message.getConnectionId()); if (browser == null) { log.debug("No browser WS for connectionId={} tunnelId={}", message.getConnectionId(), tunnelId); return; } switch (message.getWsType()) { case OPEN_OK -> { /* nothing extra for now */ } case TEXT -> browser.sendMessage(new TextMessage(message.getText() != null ? message.getText() : "")); case BINARY -> { if (message.getDataB64() != null) { final var bytes = Base64.getDecoder().decode(message.getDataB64()); browser.sendMessage(new org.springframework.web.socket.BinaryMessage(bytes)); } } case CLOSE -> { final var code = message.getCloseCode() != null ? message.getCloseCode() : CloseStatus.NORMAL.getCode(); final var reason = message.getCloseReason(); browser.close(new CloseStatus(code, reason)); } default -> { } } } @Override public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { final var tunnelId = extractTunnelId(session); log.info("Tunnel session closed: {} code={} reason={}", tunnelId, status != null ? status.getCode() : null, status != null ? status.getReason() : null); registry.closeTunnel(tunnelId); tunnelService.markClosed(tunnelId); } private UUID extractTunnelId(final WebSocketSession session) { return IdUtils.extractTunnelId(session.getUri()); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/tunnel/WebSocketConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.config.AppProperties; @Configuration @EnableWebSocket @RequiredArgsConstructor public class WebSocketConfig implements WebSocketConfigurer { private final TunnelWebSocketHandler tunnelWebSocketHandler; private final PublicWebSocketProxyHandler publicWebSocketProxyHandler; private final AppProperties properties; @Override public void registerWebSocketHandlers(final WebSocketHandlerRegistry registry) { registry.addHandler(tunnelWebSocketHandler, "/api/http-tunnel/{tunnelId}") .setAllowedOrigins("*") // Echo back any requested subprotocol (some clients require it, e.g., Vaadin) .setHandshakeHandler(new PermissiveSubprotocolHandshakeHandler()); // Public WS endpoint for tunneled hosts (dedicated base path to avoid MVC collisions) registry.addHandler(publicWebSocketProxyHandler, "/_ws/**") .setAllowedOrigins("*") // Echo back any requested subprotocol .setHandshakeHandler(new PermissiveSubprotocolHandshakeHandler()); } /** * Configure the underlying servlet WebSocket container to allow larger text and * binary messages. We increase limits to 2 MiB to support larger tunneled * payloads between the CLI and the server. */ @Bean public ServletServerContainerFactoryBean websocketContainer() { final var container = new ServletServerContainerFactoryBean(); final var webSocket = properties.webSocket(); container.setMaxTextMessageBufferSize((int) webSocket.maxTextMessageSize().toBytes()); container.setMaxBinaryMessageBufferSize((int) webSocket.maxBinaryMessageSize().toBytes()); // Prevent premature session termination by increasing idle timeout if (webSocket.sessionIdleTimeout() != null) { container.setMaxSessionIdleTimeout(webSocket.sessionIdleTimeout().toMillis()); } return container; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/AuthController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.util.HashMap; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.dto.auth.RegisterRequest; import tech.amak.portbuddy.common.dto.auth.RegisterResponse; import tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest; import tech.amak.portbuddy.common.dto.auth.TokenExchangeResponse; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.service.ApiTokenService; import tech.amak.portbuddy.server.service.user.PasswordResetService; import tech.amak.portbuddy.server.service.user.UserProvisioningService; import tech.amak.portbuddy.server.web.dto.LoginRequest; import tech.amak.portbuddy.server.web.dto.PasswordResetConfirm; import tech.amak.portbuddy.server.web.dto.PasswordResetRequest; @RestController @RequestMapping(path = "/api/auth", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor @Slf4j public class AuthController { private final ApiTokenService apiTokenService; private final JwtService jwtService; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final UserProvisioningService userProvisioningService; private final PasswordResetService passwordResetService; private final AppProperties properties; private final UserAccountRepository userAccountRepository; private final AccountRepository accountRepository; /** * Exchanges a valid API token for a short-lived JWT suitable for authenticating API and WebSocket calls. */ @PostMapping("/token-exchange") public TokenExchangeResponse tokenExchange(final @RequestBody TokenExchangeRequest payload) { final var apiToken = payload == null ? "" : String.valueOf(payload.getApiToken()).trim(); final var cliClientVersion = payload == null ? null : payload.getCliClientVersion(); if (cliClientVersion == null || cliClientVersion.isBlank()) { throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED, "CLI client version is missing or not supported. Please upgrade your port-buddy CLI."); } if (!isCliVersionSupported(cliClientVersion.trim(), properties.cli().minVersion())) { throw new ResponseStatusException(HttpStatus.UPGRADE_REQUIRED, "Your port-buddy CLI is outdated. Please upgrade to the latest version."); } final var validatedOpt = apiTokenService.validateAndGetApiKey(apiToken); if (validatedOpt.isEmpty()) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid API token"); } final var validated = validatedOpt.get(); final var accountId = validated.accountId(); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")); if (account.isBlocked()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Account is blocked"); } final var userId = validated.userId(); final var claims = new HashMap(); claims.put("typ", "cli"); claims.put("akid", validated.apiKeyId().toString()); claims.put("aid", validated.accountId().toString()); final var jwt = jwtService.createToken(claims, userId.toString()); return new TokenExchangeResponse(jwt, "Bearer"); } private boolean isCliVersionSupported(final String clientVersion, final String minimalVersion) { // allow dev builds final var cv = clientVersion.toLowerCase(); if (cv.contains("dev")) { return true; } return compareVersions(clientVersion, minimalVersion) >= 0; } private int compareVersions(final String v1, final String v2) { final var a = v1.split("[.\\-]"); final var b = v2.split("[.\\-]"); final var len = Math.max(a.length, b.length); for (var i = 0; i < len; i++) { final var ai = i < a.length ? parseIntSafe(a[i]) : 0; final var bi = i < b.length ? parseIntSafe(b[i]) : 0; if (ai != bi) { return Integer.compare(ai, bi); } } return 0; } private int parseIntSafe(final String part) { try { return Integer.parseInt(part.replaceAll("[^0-9]", "")); } catch (final Exception ignored) { return 0; } } /** * Registers a new local user and returns an API key. */ @PostMapping("/register") public RegisterResponse register(final @RequestBody RegisterRequest payload) { if (payload == null || payload.getEmail() == null) { return new RegisterResponse(null, false, "Email is required", 400); } try { final var provisioned = userProvisioningService.createLocalUser( payload.getEmail(), payload.getName(), payload.getPassword() ); final var createdToken = apiTokenService.createToken( provisioned.accountId(), provisioned.userId(), "prtb-client"); return new RegisterResponse(createdToken.token(), true, "User registered successfully", 200); } catch (final IllegalArgumentException e) { log.error(e.getMessage(), e); return new RegisterResponse(null, false, e.getMessage(), 400); } catch (final Exception e) { log.error(e.getMessage(), e); return new RegisterResponse(null, false, "Internal Server Error: " + e.getMessage(), 500); } } /** * Authenticates a user with email and password. */ @PostMapping("/login") public TokenExchangeResponse login(final @RequestBody LoginRequest payload) { if (payload == null || payload.email() == null || payload.password() == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email and password are required"); } final var user = userRepository.findByEmailIgnoreCase(payload.email()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials")); if (user.getPassword() == null || !passwordEncoder.matches(payload.password(), user.getPassword())) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); } final var claims = new HashMap(); claims.put("email", user.getEmail()); String name = user.getFirstName(); if (user.getLastName() != null) { name = name + " " + user.getLastName(); } claims.put("name", name.trim()); if (user.getAvatarUrl() != null) { claims.put("picture", user.getAvatarUrl()); } final var userAccount = userAccountRepository.findLatestUsedByUserId(user.getId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "User has no accounts")); if (userAccount.getAccount().isBlocked()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Account is blocked"); } claims.put("aid", userAccount.getAccount().getId().toString()); claims.put("uid", user.getId().toString()); final var jwt = jwtService.createToken(claims, user.getId().toString(), userAccount.getRoles()); return new TokenExchangeResponse(jwt, "Bearer"); } /** * Requests a password reset email. */ @PostMapping("/password-reset/request") @ResponseStatus(HttpStatus.NO_CONTENT) public void requestPasswordReset(final @RequestBody PasswordResetRequest payload) { if (payload == null || payload.email() == null || payload.email().isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is required"); } passwordResetService.requestReset(payload.email()); } /** * Validates a password reset token. */ @GetMapping("/password-reset/validate") @ResponseStatus(HttpStatus.NO_CONTENT) public void validateResetToken(final @RequestParam("token") String token) { if (token == null || token.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token is required"); } if (!passwordResetService.validateToken(token)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid or expired token"); } } /** * Confirms password reset and sets a new password. */ @PostMapping("/password-reset/confirm") @ResponseStatus(HttpStatus.NO_CONTENT) public void confirmPasswordReset(final @RequestBody PasswordResetConfirm payload) { if (payload == null || payload.token() == null || payload.newPassword() == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Token and new password are required"); } try { passwordResetService.resetPassword(payload.token(), payload.newPassword()); } catch (final IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/DomainsController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static tech.amak.portbuddy.server.security.JwtService.resolveAccountId; import static tech.amak.portbuddy.server.security.JwtService.resolveUserId; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.DomainService; import tech.amak.portbuddy.server.web.dto.DomainDto; import tech.amak.portbuddy.server.web.dto.SetPasscodeRequest; import tech.amak.portbuddy.server.web.dto.UpdateCustomDomainRequest; import tech.amak.portbuddy.server.web.dto.UpdateDomainRequest; @RestController @RequestMapping(path = "/api/domains", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class DomainsController { private final DomainService domainService; private final UserRepository userRepository; /** * Retrieves a list of domains associated with the account of the authenticated user. * * @param principal the JWT representing the authenticated user * @return a list of domain DTOs representing the user's associated domains */ @GetMapping public List list(final @AuthenticationPrincipal Jwt principal) { final var account = getAccount(principal); return domainService.getDomains(account).stream() .map(DomainsController::toDto) .toList(); } /** * Creates a new domain associated with the authenticated user's account. * * @param principal the JWT token representing the authenticated user * @return the created domain as a DomainDto object * @throws RuntimeException if no available domains can be created */ @PostMapping public DomainDto create(final @AuthenticationPrincipal Jwt principal) { final var account = getAccount(principal); return domainService.createDomain(account) .map(DomainsController::toDto) .orElseThrow(() -> new RuntimeException("No available domains")); } @PutMapping("/{id}") public DomainDto update(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id, @RequestBody final UpdateDomainRequest request) { final var account = getAccount(principal); return toDto(domainService.updateDomain(id, account, request.subdomain())); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id) { final var account = getAccount(principal); domainService.deleteDomain(id, account); } /** * Sets or updates a passcode for the given domain. The provided passcode will be hashed * and stored server-side. Subsequent requests to the public domain will require the passcode. * * @param principal authenticated user token * @param id domain id * @param request passcode payload * @return updated domain dto */ @PutMapping("/{id}/passcode") public DomainDto setPasscode(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id, @RequestBody final SetPasscodeRequest request) { final var account = getAccount(principal); return toDto(domainService.setPasscode(id, account, request.passcode())); } /** * Deletes passcode protection for the given domain. * * @param principal authenticated user token * @param id domain id */ @DeleteMapping("/{id}/passcode") @ResponseStatus(HttpStatus.NO_CONTENT) public void deletePasscode(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id) { final var account = getAccount(principal); domainService.clearPasscode(id, account); } /** * Updates the custom domain for the given domain. * * @param principal authenticated user token * @param id domain id * @param request update payload * @return updated domain dto */ @PutMapping("/{id}/custom-domain") public DomainDto updateCustomDomain(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id, @RequestBody final UpdateCustomDomainRequest request) { final var account = getAccount(principal); return toDto(domainService.updateCustomDomain(id, account, request.customDomain())); } /** * Deletes the custom domain from the given domain. * * @param principal authenticated user token * @param id domain id */ @DeleteMapping("/{id}/custom-domain") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteCustomDomain(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id) { final var account = getAccount(principal); domainService.deleteCustomDomain(id, account); } /** * Triggers CNAME verification and SSL issuance for the custom domain. * * @param principal authenticated user token * @param id domain id * @return updated domain dto */ @PostMapping("/{id}/verify-cname") public DomainDto verifyCname(final @AuthenticationPrincipal Jwt principal, @PathVariable("id") final UUID id) { final var account = getAccount(principal); final var userId = resolveUserId(principal); return toDto(domainService.verifyCname(id, account, userId)); } private AccountEntity getAccount(final Jwt jwt) { final var accountId = resolveAccountId(jwt); final var userId = UUID.fromString(jwt.getSubject()); return userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")) .getAccounts().stream() .filter(ua -> ua.getAccount().getId().equals(accountId)) .findFirst() .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")) .getAccount(); } private static DomainDto toDto(final DomainEntity domain) { return new DomainDto( domain.getId(), domain.getSubdomain(), domain.getDomain(), domain.getCustomDomain(), domain.isCnameVerified(), domain.isSslActive(), domain.getPasscodeHash() != null && !domain.getPasscodeHash().isBlank(), domain.getCreatedAt(), domain.getUpdatedAt() ); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/ExposeController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static tech.amak.portbuddy.server.security.JwtService.resolveAccountId; import static tech.amak.portbuddy.server.security.JwtService.resolveUserId; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.dto.ExposeRequest; import tech.amak.portbuddy.common.dto.ExposeResponse; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.DomainService; import tech.amak.portbuddy.server.service.PortReservationService; import tech.amak.portbuddy.server.service.TunnelService; @RestController @RequestMapping(path = "/api/expose", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor @Slf4j public class ExposeController { private final AppProperties properties; private final TunnelService tunnelService; private final UserRepository userRepository; private final AccountRepository accountRepository; private final DomainService domainService; private final PortReservationService portReservationService; private final PasswordEncoder passwordEncoder; /** * Creates a public HTTP endpoint to expose a local HTTP service by generating a unique * subdomain and tunnel ID. The method constructs a public URL based on the application * gateway settings and links it to the provided local service details (scheme, host, and port). * * @param request the details of the local HTTP service to expose, including the scheme, * host, and port * @return an {@code ExposeResponse} containing the source (local service details), * the generated public URL, tunnel ID, and subdomain information for the exposed service */ @PostMapping("/http") @Transactional public ExposeResponse exposeHttp(final @AuthenticationPrincipal Jwt jwt, final @RequestBody ExposeRequest request) { final var validatedUser = validateUser(jwt); final var account = validatedUser.account(); final var user = validatedUser.user(); final var domain = domainService.resolveDomain( account, request.domain(), request.host(), request.port()); final var subdomain = domain.getSubdomain(); final var gateway = properties.gateway(); final var publicUrl = gateway.subdomainUrlTemplate().formatted(subdomain); final var source = "%s://%s:%s".formatted(request.scheme(), request.host(), request.port()); final var apiKeyId = extractApiKeyId(jwt); final var tunnel = tunnelService.createHttpTunnel( account, user.getId(), apiKeyId, request, publicUrl, domain); final var tunnelId = tunnel.getId(); // If passcode provided, store temporary passcode hash on the tunnel entity if (request.passcode() != null && !request.passcode().isBlank()) { final var hash = passwordEncoder.encode(request.passcode()); tunnelService.setTempPasscodeHash(tunnelId, hash); } return new ExposeResponse(source, publicUrl, null, null, tunnelId, subdomain); } /** * Allocates a public Net port to expose a local TCP or UDP service using the provided request * details. This method interacts with a TCP proxy client to assign a unique tunnel ID * and configure the TCP exposure. * * @param request the details of the local TCP or UDP service to expose, including the host, * scheme, and port * @return an {@code ExposeResponse} containing the allocated public port, tunnel ID, * and other relevant exposure details * @throws RuntimeException if the allocation of the public TCP or UDP port fails */ @PostMapping("/net") @Transactional public ExposeResponse exposeNet(final @AuthenticationPrincipal Jwt jwt, final @RequestBody ExposeRequest request) { final var validatedUser = validateUser(jwt); final var account = validatedUser.account(); final var user = validatedUser.user(); final var apiKeyId = validatedUser.apiKeyId(); // Pre-create tunnel and use its DB id as tunnelId final var tunnel = tunnelService.createNetTunnel(account, user.getId(), apiKeyId, request); final var tunnelId = tunnel.getId(); // Resolve or validate reservation according to rules final var reservation = portReservationService.resolveForNetExpose( account, user, request.host(), request.port(), request.portReservation()); // Link reservation to tunnel and set public host/port from it tunnelService.assignReservation(tunnelId, reservation); // Do not call net-proxy here. Return allocated details to CLI. return new ExposeResponse( "%s %s:%d".formatted(request.tunnelType().name().toLowerCase(), request.host(), request.port()), null, reservation.getPublicHost(), reservation.getPublicPort(), tunnelId, null); } private String extractApiKeyId(final Jwt jwt) { final var claim = jwt.getClaimAsString("akid"); return claim == null || claim.isBlank() ? null : claim; } private ValidatedUser validateUser(final Jwt jwt) { final var userId = resolveUserId(jwt); final var accountId = resolveAccountId(jwt); final var user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")); if (account.isBlocked()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Account is blocked"); } final var apiKeyId = extractApiKeyId(jwt); return new ValidatedUser(user, account, apiKeyId); } private record ValidatedUser(UserEntity user, AccountEntity account, String apiKeyId) { } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/IngressController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.springframework.http.HttpStatus.TEMPORARY_REDIRECT; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Stream; import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpHeaders; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.AntPathMatcher; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.HandlerMapping; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.tunnel.HttpTunnelMessage; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.DomainRepository; import tech.amak.portbuddy.server.service.TunnelService; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; /** * HTTP ingress that forwards requests to a client tunnel by subdomain. */ @Slf4j @RestController @RequiredArgsConstructor public class IngressController { private static final String PASSCODE_COOKIE_NAME = "pbp"; private final TunnelRegistry registry; private final AppProperties properties; private final DomainRepository domainRepository; private final AccountRepository accountRepository; private final TunnelService tunnelService; private final PasswordEncoder passwordEncoder; private static final Set HOP_BY_HOP_RESPONSE_HEADERS = Set.of( // RFC 7230 hop-by-hop headers + common variants we do not want to relay HttpHeaders.CONNECTION.toLowerCase(), "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", HttpHeaders.TRANSFER_ENCODING.toLowerCase(), HttpHeaders.UPGRADE.toLowerCase(), // Avoid conflicting length management across hops; let container decide HttpHeaders.CONTENT_LENGTH.toLowerCase() ); // HTTP route for subdomain ingress (non-WS traffic) @RequestMapping("/_/{subdomain:.+}/**") @Transactional public void ingressPathBased(final @PathVariable("subdomain") String subdomain, final HttpServletRequest request, final HttpServletResponse response) throws IOException { forwardViaTunnel(subdomain, request, response); } /** * Handles path-based custom domain ingress, mapping requests to the appropriate subdomain. * This endpoint processes requests that use the custom domain format in their URL path. * * @param customDomain The custom domain identifier extracted from the request path. Used to * locate a matching subdomain in the database. * @param request The incoming HTTP request to be forwarded to the matching subdomain's endpoint. * @param response The HTTP response object used to return output or error codes to the client. * @throws IOException If an input or output error occurs during the request forwarding process * or while setting the HTTP response. */ // Path-based custom domain ingress: http://server/_custom/{customDomain}/... @RequestMapping("/_custom/{customDomain:.+}/**") @Transactional public void ingressCustomDomainPathBased(final @PathVariable("customDomain") String customDomain, final HttpServletRequest request, final HttpServletResponse response) throws IOException { var lookupDomain = customDomain.toLowerCase(); final var colonIdx = lookupDomain.indexOf(':'); if (colonIdx > 0) { lookupDomain = lookupDomain.substring(0, colonIdx); } final var domainOpt = domainRepository.findByCustomDomain(lookupDomain); if (domainOpt.isPresent()) { forwardViaTunnel(domainOpt.get().getSubdomain(), request, response); } else { response.sendError(HttpServletResponse.SC_NOT_FOUND, "Custom domain not found: " + lookupDomain); } } private void forwardViaTunnel(final String subdomain, final HttpServletRequest request, final HttpServletResponse response) throws IOException { // If there is no active tunnel for the requested subdomain — redirect users to SPA 404 page final var tunnel = registry.getBySubdomain(subdomain); if (tunnel == null || !tunnel.isOpen()) { final var notFoundUrl = properties.gateway().notFoundPage(); response.setStatus(HttpServletResponse.SC_TEMPORARY_REDIRECT); response.setHeader(HttpHeaders.LOCATION, notFoundUrl); return; } // Check subscription status final var accountOpt = accountRepository.findById(tunnel.accountId()); if (accountOpt.isPresent()) { final var status = accountOpt.get().getSubscriptionStatus(); if (status != null && !"active".equals(status)) { log.warn("Blocked request to subdomain {} because subscription is not active (status: {})", subdomain, status); response.sendError(HttpServletResponse.SC_PAYMENT_REQUIRED, "Subscription is not active. Please check your billing information."); return; } } // Passcode protection check (query param, header, or cookie) if (!isAuthorized(subdomain, tunnel.tunnelId(), request, response)) { final var gateway = properties.gateway(); final var originalDomain = "%s.%s".formatted(subdomain, gateway.domain()); final var redirect = "%s?target_domain=%s".formatted(gateway.passcodePage(), originalDomain); response.setStatus(TEMPORARY_REDIRECT.value()); response.setHeader(HttpHeaders.LOCATION, redirect); return; } final var pathWithin = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); final var bestMatch = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); final var matcher = new AntPathMatcher(); var path = matcher.extractPathWithinPattern(bestMatch, pathWithin); if (!path.startsWith("/")) { path = "/" + path; } final var method = request.getMethod(); final var query = request.getQueryString(); final Map> headers = new HashMap<>(); for (Enumeration en = request.getHeaderNames(); en.hasMoreElements(); ) { final var name = en.nextElement(); // Skip hop-by-hop headers if (name.equalsIgnoreCase(HttpHeaders.HOST) || name.equalsIgnoreCase(HttpHeaders.CONNECTION)) { continue; } final List values = new ArrayList<>(); for (Enumeration headerValues = request.getHeaders(name); headerValues.hasMoreElements(); ) { final var value = headerValues.nextElement(); if (value != null) { values.add(value); } } if (!values.isEmpty()) { headers.put(name, values); } } headers.put("X-Forwarded-Host", List.of(request.getServerName())); headers.put("X-Forwarded-Proto", List.of(request.isSecure() ? "https" : "http")); final var maxRequestBodySize = properties.gateway().maxRequestBodySize(); var limit = maxRequestBodySize == null ? -1 : maxRequestBodySize.toBytes(); if (limit > Integer.MAX_VALUE - 8) { limit = Integer.MAX_VALUE - 8; } final byte[] bodyBytes; try (final var inputStream = request.getInputStream()) { if (limit >= 0) { // Read up to limit + 1 bytes to detect if the body is too large final var result = inputStream.readNBytes((int) limit + 1); if (result.length > limit) { log.warn("Payload Too Large: subdomain {} exceeded max body size of {} (actual length > {})", subdomain, maxRequestBodySize, limit); response.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Payload Too Large: max %s allowed".formatted(maxRequestBodySize)); return; } bodyBytes = result; } else { bodyBytes = inputStream.readAllBytes(); } } final var bodyB64 = bodyBytes.length == 0 ? null : Base64.getEncoder().encodeToString(bodyBytes); final var msg = new HttpTunnelMessage(); msg.setMethod(method); msg.setPath(path); msg.setQuery(query); msg.setHeaders(headers); msg.setBodyB64(bodyB64); msg.setBodyContentType(request.getContentType()); try { final var resp = registry.forwardRequest(subdomain, msg, Duration.ofSeconds(30)).join(); final var status = resp.getStatus() == null ? 502 : resp.getStatus(); response.setStatus(status); if (resp.getRespHeaders() != null) { for (final var header : resp.getRespHeaders().entrySet()) { final var name = header.getKey(); final var values = header.getValue(); if (name == null || values == null) { continue; } final var nameLc = name.toLowerCase(); if (HOP_BY_HOP_RESPONSE_HEADERS.contains(nameLc)) { // Skip hop-by-hop or conflicting headers continue; } values.stream() .filter(Objects::nonNull) .forEach(value -> response.addHeader(name, value)); } } if (resp.getRespBodyB64() != null) { final var bytes = Base64.getDecoder().decode(resp.getRespBodyB64()); response.getOutputStream().write(bytes); } } catch (final Exception ex) { log.warn("Tunnel forward failed for subdomain={}: {}", subdomain, ex.toString()); response.setStatus(HttpServletResponse.SC_BAD_GATEWAY); response.getWriter().write("Bad Gateway: tunnel unavailable"); } } private boolean isAuthorized(final String subdomain, final UUID tunnelId, final HttpServletRequest request, final HttpServletResponse response) { final var passcodeHash = tunnelService.getTempPasscodeHash(tunnelId) .or(() -> domainRepository.findBySubdomain(subdomain) .map(DomainEntity::getPasscodeHash)) .orElse(null); // If there is no passcode configured for either the domain or the tunnel — allow access if (passcodeHash == null) { return true; } final var passcode = StringUtils.firstNonBlank( request.getHeader("X-API-Key"), request.getParameter("passcode")); // If passcode provided via header or query, validate and set cookie on success if (passcode != null) { if (matches(passcode, passcodeHash)) { issueCookie(response, subdomain, passcode); return true; } return false; } return findCookie(request, PASSCODE_COOKIE_NAME) .map(Cookie::getValue) .map(cookiePasscode -> matches(cookiePasscode, passcodeHash)) .orElse(false); } private boolean matches(final String raw, final String hash) { if (hash == null || raw == null) { return false; } try { return passwordEncoder.matches(raw, hash); } catch (final Exception e) { return false; } } private Optional findCookie(final HttpServletRequest request, final String name) { return Stream.ofNullable(request.getCookies()) .flatMap(Arrays::stream) .filter(cookie -> Objects.equals(name, cookie.getName())) .findFirst(); } private void issueCookie(final HttpServletResponse response, final String subdomain, final String value) { final var gateway = properties.gateway(); final var cookie = new Cookie(PASSCODE_COOKIE_NAME, value); cookie.setHttpOnly(true); cookie.setSecure(gateway.url().startsWith("https")); cookie.setPath("/"); // Build a safe cookie domain: strip port and avoid setting Domain for localhost/IP to satisfy RFC6265 final var configuredDomain = gateway.domain(); final var domainWithoutPort = configuredDomain.contains(":") ? configuredDomain.substring(0, configuredDomain.indexOf(':')) : configuredDomain; final var fullDomain = subdomain + "." + domainWithoutPort; final var isLocalhost = "localhost".equalsIgnoreCase(domainWithoutPort) || domainWithoutPort.endsWith('.' + "localhost"); final var isIpv4 = domainWithoutPort.matches("^\\d+\\.\\d+\\.\\d+\\.\\d+$"); // Only set Domain attribute for real registrable domains (no port, not localhost, not IP) final var shouldSetDomain = !(isLocalhost || isIpv4); if (shouldSetDomain) { cookie.setDomain(fullDomain); } cookie.setMaxAge(60 * 60 * 12); // 12 hours response.addCookie(cookie); // Compose manual Set-Cookie with SameSite=Lax; add Domain only when it is valid final var sb = new StringBuilder(); sb.append(PASSCODE_COOKIE_NAME) .append("=") .append(value) .append("; Path=/; Max-Age=43200; HttpOnly; SameSite=Lax"); if (shouldSetDomain) { sb.append("; Domain=").append(fullDomain); } if (cookie.getSecure()) { sb.append("; Secure"); } response.addHeader("Set-Cookie", sb.toString()); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/IngressResolveController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.DomainRepository; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; /** * Lightweight, instance-local endpoint for the API Gateway to resolve which server instance * currently owns an active tunnel for the given subdomain or custom domain. Returns 200 if * this instance has an open tunnel for the subdomain, otherwise 404. * This endpoint is intentionally placed under "/ingress/**" which is already permitted in * {@link tech.amak.portbuddy.server.security.SecurityConfig} so the gateway can probe it * without authentication. */ @RestController @RequestMapping("/ingress") @RequiredArgsConstructor public class IngressResolveController { private final TunnelRegistry registry; private final DomainRepository domainRepository; private final AccountRepository accountRepository; /** * Checks if the given subdomain is owned by an active tunnel. * * @param subdomain the subdomain to check * @return 200 if the subdomain is owned by an active tunnel, 404 otherwise */ @GetMapping("/resolve/{subdomain}") public ResponseEntity resolveOwner(final @PathVariable("subdomain") String subdomain) { final var tunnel = registry.getBySubdomain(subdomain); if (tunnel != null && tunnel.isOpen() && isSubscriptionActive(tunnel)) { return ResponseEntity.ok().build(); } return ResponseEntity.notFound().build(); } /** * Checks if the given custom domain is owned by an active tunnel. * * @param domain the custom domain to check * @return 200 if the custom domain is owned by an active tunnel, 404 otherwise */ @GetMapping("/resolve-custom/{domain}") public ResponseEntity resolveCustomOwner(final @PathVariable("domain") String domain) { return domainRepository.findByCustomDomain(domain) .map(domainEntity -> { final var tunnel = registry.getBySubdomain(domainEntity.getSubdomain()); if (tunnel != null && tunnel.isOpen() && isSubscriptionActive(tunnel)) { return ResponseEntity.ok().build(); } return ResponseEntity.notFound().build(); }) .orElseGet(() -> ResponseEntity.notFound().build()); } private boolean isSubscriptionActive(final TunnelRegistry.Tunnel tunnel) { return accountRepository.findById(tunnel.accountId()) .map(account -> { final var status = account.getSubscriptionStatus(); return status == null || "active".equals(status); }) .orElse(false); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/InternalDomainController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.service.DomainService; /** * Controller for internal domain operations. */ @RestController @RequestMapping(path = "/api/internal/domains", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class InternalDomainController { private final DomainService domainService; /** * Marks the domain as SSL active. * * @param domain the custom domain name */ @PostMapping("/ssl-active") public void markSslActive(@RequestParam("domain") final String domain) { domainService.markSslActive(domain); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/InternalEmailController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.util.HashMap; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.mail.EmailService; /** * Controller for internal email operations. */ @RestController @RequestMapping(path = "/api/internal/email", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor @Slf4j public class InternalEmailController { private final EmailService emailService; private final AppProperties properties; /** * Sends DNS instructions email. * * @param request the DNS instructions email request */ @PostMapping("/dns-instructions") public void sendDnsInstructions(@RequestBody final DnsInstructionsEmailRequest request) { log.info("Sending DNS instructions email for job {} and domain {}", request.getJobId(), request.getDomain()); final var model = new HashMap(); model.put("domain", request.getDomain()); model.put("records", request.getRecords()); model.put("expiresAt", request.getExpiresAt()); model.put("webAppUrl", properties.gateway().url()); model.put("subject", "Action Required: DNS Setup for " + request.getDomain()); emailService.sendTemplate( request.getContactEmail(), "Action Required: DNS Setup for " + request.getDomain(), "email/dns-instructions", model ); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/JwksController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.time.Duration; import java.util.ArrayList; import java.util.List; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.RSAKey; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.common.dto.jwks.JwkKey; import tech.amak.portbuddy.common.dto.jwks.JwksResponse; import tech.amak.portbuddy.server.security.RsaKeyProvider; @RestController @RequiredArgsConstructor public class JwksController { private final RsaKeyProvider rsaKeyProvider; /** * Provides the public JSON Web Key Set (JWKS) for the application. * This endpoint exposes the keys that clients can use to validate * the signatures of issued JSON Web Tokens (JWTs). * The result is cacheable for up to 5 minutes for performance optimization * and convenience of clients consuming this endpoint. * * @return a `ResponseEntity` containing a `JwksResponse` object which * holds a list of the public JSON Web Keys available for verification. */ @GetMapping(value = "/.well-known/jwks.json", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity jwks() { final var set = rsaKeyProvider.getPublicJwkSet(); final List keys = new ArrayList<>(); for (final JWK jwk : set.getKeys()) { if (jwk instanceof RSAKey rsa) { final var pub = rsa.toPublicJWK(); final var json = pub.toJSONObject(); final var dto = new JwkKey(); dto.setKty((String) json.get("kty")); dto.setKid((String) json.get("kid")); dto.setUse((String) json.get("use")); dto.setAlg((String) json.get("alg")); dto.setModulus((String) json.get("n")); dto.setExponent((String) json.get("e")); keys.add(dto); } } final var body = new JwksResponse(keys); return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic()) .body(body); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/PaymentController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static tech.amak.portbuddy.server.security.JwtService.resolveAccountId; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import com.stripe.exception.StripeException; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.StripeService; @Slf4j @RestController @RequestMapping("/api/payments") @RequiredArgsConstructor @PreAuthorize("hasAnyRole('ADMIN', 'ACCOUNT_ADMIN')") public class PaymentController { private final StripeService stripeService; private final UserRepository userRepository; private final AccountRepository accountRepository; /** * Creates a checkout session for the user's account and the requested plan. * * @param jwt the JWT token * @param request the checkout request containing the plan * @return a response containing the checkout session URL * @throws StripeException if Stripe API call fails */ @Transactional @PostMapping("/create-checkout-session") public SessionResponse createCheckoutSession( @AuthenticationPrincipal final Jwt jwt, @RequestBody final CheckoutRequest request) throws StripeException { final var accountId = resolveAccountId(jwt); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")); final var url = stripeService.createCheckoutSession(account, request.getPlan()); return new SessionResponse(url); } /** * Creates a billing portal session for the user's account. * * @param jwt the JWT token * @return a response containing the billing portal session URL * @throws StripeException if Stripe API call fails */ @Transactional @PostMapping("/create-portal-session") public SessionResponse createPortalSession(@AuthenticationPrincipal final Jwt jwt) throws StripeException { final var accountId = resolveAccountId(jwt); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")); final var url = stripeService.createPortalSession(account); return new SessionResponse(url); } /** * Cancels the current subscription for the user's account and resets extra tunnels. * * @param jwt the JWT token * @throws StripeException if Stripe API call fails */ @Transactional @PostMapping("/cancel-subscription") @ResponseStatus(HttpStatus.NO_CONTENT) public void cancelSubscription(@AuthenticationPrincipal final Jwt jwt) throws StripeException { final var accountId = resolveAccountId(jwt); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")); stripeService.cancelSubscription(account); account.setExtraTunnels(0); account.setPlan(Plan.PRO); account.setSubscriptionStatus("active"); account.setStripeSubscriptionId(null); accountRepository.save(account); } @Data public static class CheckoutRequest { private Plan plan; } @Data @RequiredArgsConstructor public static class SessionResponse { private final String url; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/PortsController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.PortReservationService; import tech.amak.portbuddy.server.service.ProxyDiscoveryService; import tech.amak.portbuddy.server.web.dto.PortRangeDto; import tech.amak.portbuddy.server.web.dto.PortReservationDto; import tech.amak.portbuddy.server.web.dto.PortReservationUpdateRequest; @RestController @RequestMapping(path = "/api/ports", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class PortsController { private final PortReservationService reservationService; private final UserRepository userRepository; private final ProxyDiscoveryService proxyDiscoveryService; private final AppProperties properties; /** * Retrieves a list of port reservations for the authenticated user's account. * * @param principal the authenticated user's JWT token, which holds the user information. * @return a list of {@code PortReservationDto} objects representing the port reservations for the user's account. */ @GetMapping public List list(final @AuthenticationPrincipal Jwt principal) { final var account = getAccount(principal); return reservationService.getReservations(account).stream() .map(PortsController::toDto) .toList(); } /** * Creates a new port reservation for the authenticated user's account. * * @param principal the authenticated user's JWT token containing user details. * @return a {@code PortReservationDto} object representing the newly created port reservation. * @throws ResponseStatusException if the user cannot be found or is not authorized. */ @PostMapping public PortReservationDto create(final @AuthenticationPrincipal Jwt principal) { final var userId = UUID.fromString(principal.getSubject()); final var user = userRepository.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); final var account = getAccount(principal); final var reservation = reservationService.createReservation(account, user) .orElseThrow(() -> new RuntimeException("No available ports")); return toDto(reservation); } /** * Deletes a port reservation for the specified ID if it belongs to the authenticated user's account. * Responds with a 204 No Content status on success. * * @param principal the authenticated user's JWT token, used to determine the user's account. * @param id the unique identifier of the port reservation to be deleted. * @throws ResponseStatusException with a 409 Conflict status if the reservation cannot be deleted * due to an illegal state. */ @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(final @AuthenticationPrincipal Jwt principal, final @PathVariable("id") UUID id) { final var account = getAccount(principal); try { reservationService.deleteReservation(id, account); } catch (final IllegalStateException e) { throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage()); } } /** * Updates an existing port reservation. If there is only one available public host, UI may send only port. */ @PutMapping("/{id}") public PortReservationDto update(final @AuthenticationPrincipal Jwt principal, final @PathVariable("id") UUID id, final @RequestBody PortReservationUpdateRequest body) { final var account = getAccount(principal); try { final var updated = reservationService .updateReservation(account, id, body.publicHost(), body.publicPort(), body.name()); return toDto(updated); } catch (final IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); } catch (final IllegalStateException e) { throw new ResponseStatusException(HttpStatus.CONFLICT, e.getMessage()); } } /** * Lists available tcp-proxy public hosts for selection. */ @GetMapping("/hosts") public List hosts() { return proxyDiscoveryService.listPublicHosts(); } /** * Returns allowed port range for a given host. Currently same for all hosts, derived from config. */ @GetMapping("/hosts/{host}/range") public PortRangeDto hostRange(@PathVariable("host") final String host) { // Not validating host existence here; UI should have called /hosts first final var range = properties.portReservations().range(); return new PortRangeDto(range.min(), range.max()); } private AccountEntity getAccount(final Jwt jwt) { final var accountId = tech.amak.portbuddy.server.security.JwtService.resolveAccountId(jwt); return userRepository.findById(UUID.fromString(jwt.getSubject())) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")) .getAccounts().stream() .filter(ua -> ua.getAccount().getId().equals(accountId)) .findFirst() .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Account not found")) .getAccount(); } private static PortReservationDto toDto(final PortReservationEntity e) { return new PortReservationDto( e.getId(), e.getPublicHost(), e.getPublicPort(), e.getName(), e.getCreatedAt(), e.getUpdatedAt() ); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/StripeWebhookController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.time.OffsetDateTime; import java.util.Map; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.stripe.exception.SignatureVerificationException; import com.stripe.model.Event; import com.stripe.model.Invoice; import com.stripe.model.Subscription; import com.stripe.model.checkout.Session; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.StripeEventEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.StripeEventRepository; import tech.amak.portbuddy.server.mail.EmailService; import tech.amak.portbuddy.server.service.StripeService; import tech.amak.portbuddy.server.service.StripeWebhookService; import tech.amak.portbuddy.server.service.TunnelService; @Slf4j @RestController @RequestMapping("/api/webhooks/stripe") @RequiredArgsConstructor public class StripeWebhookController { private final AccountRepository accountRepository; private final StripeEventRepository stripeEventRepository; private final EmailService emailService; private final TunnelService tunnelService; private final StripeService stripeService; private final StripeWebhookService stripeWebhookService; private final AppProperties properties; /** * Handles Stripe webhooks. * * @param payload webhook payload * @param sigHeader Stripe-Signature header * @return response entity */ @Transactional @PostMapping public ResponseEntity handleStripeWebhook( @RequestBody final String payload, @RequestHeader("Stripe-Signature") final String sigHeader) { if (sigHeader == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Missing signature"); } final Event event; try { event = stripeWebhookService.constructEvent(payload, sigHeader, properties.stripe().webhookSecret()); } catch (final SignatureVerificationException e) { log.error("Invalid Stripe signature", e); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature"); } log.info("Received Stripe event: id={}, type={}", event.getId(), event.getType()); if (stripeEventRepository.existsById(event.getId())) { log.info("Stripe event {} already processed, skipping", event.getId()); return ResponseEntity.ok(""); } final var eventEntity = new StripeEventEntity(); eventEntity.setId(event.getId()); eventEntity.setType(event.getType()); eventEntity.setPayload(payload); eventEntity.setStatus("PROCESSING"); eventEntity.setCreatedAt(OffsetDateTime.now()); stripeEventRepository.save(eventEntity); try { switch (event.getType()) { case "checkout.session.completed": handleCheckoutSessionCompleted(event); break; case "customer.subscription.updated": case "customer.subscription.deleted": handleSubscriptionEvent(event); break; case "invoice.payment_failed": handleInvoicePaymentFailed(event); break; default: log.debug("Unhandled event type: {}", event.getType()); } eventEntity.setStatus("PROCESSED"); eventEntity.setProcessedAt(OffsetDateTime.now()); stripeEventRepository.save(eventEntity); log.info("Successfully processed Stripe event: id={}, type={}", event.getId(), event.getType()); } catch (final Exception e) { log.error("Error processing Stripe event: id={}, type={}", event.getId(), event.getType(), e); eventEntity.setStatus("FAILED"); eventEntity.setErrorMessage(e.getMessage()); stripeEventRepository.save(eventEntity); // Return 200 to Stripe to avoid retries if we've successfully stored the failure // or 500 if we want Stripe to retry. Usually for processed failures 200 is better to avoid infinite loops. return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body("Error processing webhook"); } return ResponseEntity.ok(""); } /** * Handles checkout session completed events. * * @param event the event */ private void handleCheckoutSessionCompleted(final Event event) { final var session = (Session) event.getDataObjectDeserializer().getObject().orElseThrow(); log.info("Processing checkout.session.completed: sessionId={}, customerId={}", session.getId(), session.getCustomer()); final var accountIdStr = session.getMetadata().get("accountId"); if (accountIdStr == null) { log.error("No accountId in session metadata for session {}", session.getId()); throw new RuntimeException("No accountId in session metadata"); } final var accountId = UUID.fromString(accountIdStr); final var planStr = session.getMetadata().get("plan"); final var extraTunnelsStr = session.getMetadata().get("extraTunnels"); final var oldSubscriptionId = session.getMetadata().get("oldSubscriptionId"); final var account = accountRepository.findById(accountId) .orElseThrow(() -> new RuntimeException("Account not found: " + accountId)); if (oldSubscriptionId != null) { log.info("Cancelling old subscription {} for account {}", oldSubscriptionId, accountId); try { stripeService.cancelSubscription(oldSubscriptionId); } catch (final Exception e) { log.error("Failed to cancel old subscription {}: {}", oldSubscriptionId, e.getMessage()); // We don't throw here to avoid failing the whole webhook if cancellation fails // although it might lead to two active subscriptions if not handled. // But normally this should work. } } account.setStripeCustomerId(session.getCustomer()); account.setStripeSubscriptionId(session.getSubscription()); account.setSubscriptionStatus("active"); if (planStr != null) { account.setPlan(Plan.valueOf(planStr)); } if (extraTunnelsStr != null) { account.setExtraTunnels(Integer.parseInt(extraTunnelsStr)); } accountRepository.save(account); tunnelService.enforceTunnelLimit(account); log.info("Updated account {} with Stripe customer {} and subscription {}", accountId, session.getCustomer(), session.getSubscription()); sendSubscriptionSuccessEmail(account); } /** * Sends a subscription success email. * * @param account the account */ private void sendSubscriptionSuccessEmail(final AccountEntity account) { final var user = account.getUsers().stream().findFirst().orElse(null); if (user != null) { final var plan = account.getPlan(); final var baseLimit = properties.subscriptions().tunnels().base().get(plan); emailService.sendTemplate(user.getEmail(), "Welcome to " + plan + " - Port Buddy", "email/subscription-success", Map.of("name", user.getFirstName() != null ? user.getFirstName() : "there", "plan", plan.name(), "tunnelLimit", baseLimit, "extraTunnels", account.getExtraTunnels(), "portalUrl", properties.gateway().url() + "/app")); } } /** * Handles subscription events. * * @param event the event */ private void handleSubscriptionEvent(final Event event) { final var subscription = (Subscription) event.getDataObjectDeserializer().getObject().orElseThrow(); final var customerId = subscription.getCustomer(); log.info("Processing {}: subscriptionId={}, customerId={}, status={}", event.getType(), subscription.getId(), customerId, subscription.getStatus()); accountRepository.findByStripeCustomerId(customerId).ifPresentOrElse(account -> { // Only process events for the current subscription. // If the event is for a different subscription, we ignore it to avoid overwriting // active subscription with status from a cancelled old one (e.g. after upgrade). if (account.getStripeSubscriptionId() != null && !account.getStripeSubscriptionId().equals(subscription.getId())) { log.info("Ignoring event for subscription {} as it is not the current subscription {} for account {}", subscription.getId(), account.getStripeSubscriptionId(), account.getId()); return; } final var user = account.getUsers().stream().findFirst().orElse(null); final var oldPlan = account.getPlan(); final var oldStatus = account.getSubscriptionStatus(); account.setSubscriptionStatus(subscription.getStatus()); account.setStripeSubscriptionId(subscription.getId()); // Try to extract plan from subscription items if possible if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { final var priceId = subscription.getItems().getData().get(0).getPrice().getId(); // We could use priceId mapping, but metadata is safer if it's there final var planMeta = subscription.getMetadata().get("plan"); if (planMeta != null) { account.setPlan(Plan.valueOf(planMeta)); } } final var isCanceled = "canceled".equals(account.getSubscriptionStatus()); if (isCanceled && !"canceled".equals(oldStatus)) { log.info("Subscription canceled for account {}, resetting extra tunnels to 0", account.getId()); account.setExtraTunnels(0); account.setPlan(Plan.PRO); account.setSubscriptionStatus("active"); account.setStripeSubscriptionId(null); if (user != null) { emailService.sendTemplate(user.getEmail(), "Subscription Canceled - Port Buddy", "email/subscription-canceled", Map.of("name", user.getFirstName() != null ? user.getFirstName() : "there", "portalUrl", properties.gateway().url() + "/app/billing")); } } accountRepository.save(account); tunnelService.enforceTunnelLimit(account); log.info("Updated subscription status for account {} to {}", account.getId(), subscription.getStatus()); final var isNowActive = "active".equals(account.getSubscriptionStatus()); final var wasActive = "active".equals(oldStatus); final var planChanged = account.getPlan() != oldPlan; if (user != null && !isCanceled) { if (planChanged || (isNowActive && !wasActive)) { final var plan = account.getPlan(); final var baseLimit = properties.subscriptions().tunnels().base().get(plan); emailService.sendTemplate(user.getEmail(), "Plan Updated - Port Buddy", "email/plan-changed", Map.of("name", user.getFirstName() != null ? user.getFirstName() : "there", "plan", plan.name(), "tunnelLimit", baseLimit, "extraTunnels", account.getExtraTunnels(), "portalUrl", properties.gateway().url() + "/app")); } } }, () -> log.warn("Account not found for Stripe customer {}", customerId)); } /** * Handles payment failed events. * * @param event the event */ private void handleInvoicePaymentFailed(final Event event) { final var invoice = (Invoice) event.getDataObjectDeserializer().getObject().orElseThrow(); final var customerId = invoice.getCustomer(); log.warn("Processing invoice.payment_failed: invoiceId={}, customerId={}", invoice.getId(), customerId); accountRepository.findByStripeCustomerId(customerId).ifPresentOrElse(account -> { account.setSubscriptionStatus("past_due"); accountRepository.save(account); final var user = account.getUsers().stream().findFirst().orElse(null); if (user != null) { log.info("Sending payment failed email to user: {}", user.getEmail()); emailService.sendTemplate(user.getEmail(), "Payment Failed - Port Buddy", "email/payment-failed", Map.of("name", user.getFirstName() != null ? user.getFirstName() : "there", "amount", String.format("%.2f %s", invoice.getAmountDue() / 100.0, invoice.getCurrency().toUpperCase()), "portalUrl", properties.gateway().url() + "/app/billing")); } }, () -> log.warn("Account not found for Stripe customer {}", customerId)); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/TeamController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.InvitationEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.TeamService; @RestController @RequestMapping("/api/team") @RequiredArgsConstructor @Transactional public class TeamController { private final TeamService teamService; private final UserRepository userRepository; private final AccountRepository accountRepository; private final UserAccountRepository userAccountRepository; /** * Returns a list of team members for the current user's account. * * @param jwt the JWT token * @return the list of team members */ @GetMapping("/members") public List getMembers(@AuthenticationPrincipal final Jwt jwt) { final var account = getAccount(jwt); return teamService.getMembers(account).stream() .map(member -> toMemberDto(member, account)) .collect(Collectors.toList()); } /** * Returns a list of pending invitations for the current user's account. * * @param jwt the JWT token * @return the list of pending invitations */ @GetMapping("/invitations") @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") public List getInvitations(@AuthenticationPrincipal final Jwt jwt) { final var account = getAccount(jwt); return teamService.getPendingInvitations(account).stream() .map(this::toInvitationDto) .collect(Collectors.toList()); } /** * Invites a new member to the current user's account. * * @param jwt the JWT token * @param request the invite request * @return the created invitation */ @PostMapping("/invitations") @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") public InvitationDto inviteMember(@AuthenticationPrincipal final Jwt jwt, @RequestBody final InviteRequest request) { final var account = getAccount(jwt); final var user = getUser(jwt); final var invitation = teamService.inviteMember(account, user, request.getEmail()); return toInvitationDto(invitation); } @DeleteMapping("/invitations/{id}") @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") @ResponseStatus(HttpStatus.NO_CONTENT) public void cancelInvitation(@AuthenticationPrincipal final Jwt jwt, @PathVariable("id") final UUID id) { final var account = getAccount(jwt); teamService.cancelInvitation(account, id); } @PostMapping("/invitations/{id}/resend") @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") @ResponseStatus(HttpStatus.NO_CONTENT) public void resendInvitation(@AuthenticationPrincipal final Jwt jwt, @PathVariable("id") final UUID id) { final var account = getAccount(jwt); teamService.resendInvitation(account, id); } /** * Removes a member from the team. * * @param jwt the JWT token * @param userId the user id to remove */ @DeleteMapping("/members/{userId}") @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") @ResponseStatus(HttpStatus.NO_CONTENT) public void removeMember(@AuthenticationPrincipal final Jwt jwt, @PathVariable("userId") final UUID userId) { final var account = getAccount(jwt); final var user = getUser(jwt); teamService.removeMember(account, userId, user); } @PostMapping("/accept") @ResponseStatus(HttpStatus.NO_CONTENT) public void acceptInvitation(@AuthenticationPrincipal final Jwt jwt, @RequestParam("token") final String token) { final var user = getUser(jwt); teamService.acceptInvitation(token, user); } private AccountEntity getAccount(final Jwt jwt) { final var accountId = tech.amak.portbuddy.server.security.JwtService.resolveAccountId(jwt); return accountRepository.findById(accountId) .orElseThrow(() -> new IllegalArgumentException("Account not found.")); } private UserEntity getUser(final Jwt jwt) { final var userId = UUID.fromString(jwt.getSubject()); return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found.")); } private MemberDto toMemberDto(final UserEntity user, final AccountEntity account) { final var userAccount = userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId()) .orElseThrow(() -> new IllegalArgumentException("User does not belong to this account.")); return MemberDto.builder() .id(user.getId()) .email(user.getEmail()) .firstName(user.getFirstName()) .lastName(user.getLastName()) .avatarUrl(user.getAvatarUrl()) .roles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet())) .joinedAt(user.getCreatedAt()) .build(); } private InvitationDto toInvitationDto(final InvitationEntity invitation) { return InvitationDto.builder() .id(invitation.getId()) .email(invitation.getEmail()) .invitedBy(invitation.getInvitedBy().getEmail()) .createdAt(invitation.getCreatedAt()) .expiresAt(invitation.getExpiresAt()) .build(); } @Data @Builder public static class MemberDto { private UUID id; private String email; private String firstName; private String lastName; private String avatarUrl; private java.util.Set roles; private OffsetDateTime joinedAt; } @Data @Builder public static class InvitationDto { private UUID id; private String email; private String invitedBy; private OffsetDateTime createdAt; private OffsetDateTime expiresAt; } @Data public static class InviteRequest { private String email; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/TokensController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static tech.amak.portbuddy.server.security.JwtService.resolveAccountId; import static tech.amak.portbuddy.server.security.JwtService.resolveUserId; import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.Data; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.ApiTokenService; @RestController @RequestMapping(path = "/api/tokens", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class TokensController { private final ApiTokenService apiTokenService; private final UserRepository userRepository; /** * Retrieves a list of API tokens belonging to the authenticated user. * * @param principal the authenticated user's principal object, used to extract the user ID * @return a list of {@code ApiTokenService.TokenView} objects representing the user's API tokens * @throws ResponseStatusException if the authenticated user cannot be found in the database */ @GetMapping public List list(@AuthenticationPrincipal final Jwt principal) { final var accountId = resolveAccountId(principal); return apiTokenService.listTokens(accountId); } /** * Creates a new API token for the authenticated user. * * @param principal the authenticated user's principal object, used to extract the user ID * @param req the request payload containing the label for the API token; can be null * @return a {@code CreateTokenResponse} object containing the generated token ID and the token itself */ @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public CreateTokenResponse create(@AuthenticationPrincipal final Jwt principal, @RequestBody final CreateTokenRequest req) { final var userId = resolveUserId(principal); final var accountId = resolveAccountId(principal); final var created = apiTokenService.createToken( accountId, userId, req == null ? null : req.getLabel()); return new CreateTokenResponse(created.id(), created.token()); } /** * Revokes an API token associated with the authenticated user's account. * * @param principal the authenticated user's principal object, used to extract the user ID * @param id the ID of the API token to be revoked * @throws ResponseStatusException if the authenticated user cannot be found in the database */ @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void revoke(@AuthenticationPrincipal final Jwt principal, @PathVariable("id") final String id) { final var accountId = resolveAccountId(principal); apiTokenService.revoke(accountId, id); } @Data public static class CreateTokenRequest { private String label; } public record CreateTokenResponse(String id, String token) { } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/TunnelStatusController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.util.UUID; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.server.service.TunnelService; /** * Endpoints to update tunnel connection status from external components (CLI/net-proxy). * In HTTP mode the status is tracked automatically via the control WebSocket connected to the server. * In TCP mode the control channel is handled by the TCP proxy service, so the CLI reports status here. */ @RestController @RequestMapping(path = "/api/tunnels", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor @Slf4j public class TunnelStatusController { private final TunnelService tunnelService; /** * Marks a tunnel as connected and updates heartbeat timestamp. */ @PostMapping(path = "/{tunnelId}/connected") public void connected(final @PathVariable("tunnelId") UUID tunnelId) { log.debug("Status connected for tunnelId={}", tunnelId); tunnelService.markConnected(tunnelId); } /** * Updates tunnel heartbeat timestamp. */ @PostMapping(path = "/{tunnelId}/heartbeat") public void heartbeat(final @PathVariable("tunnelId") UUID tunnelId) { tunnelService.heartbeat(tunnelId); } /** * Marks a tunnel as closed. */ @PostMapping(path = "/{tunnelId}/closed") public void closed(final @PathVariable("tunnelId") UUID tunnelId) { log.debug("Status closed for tunnelId={}", tunnelId); tunnelService.markClosed(tunnelId); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/TunnelsController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.entity.TunnelEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.security.JwtService; @RestController @RequestMapping(path = "/api/tunnels", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class TunnelsController { private final TunnelRepository tunnelRepository; /** * Retrieves a paginated list of tunnels associated with the authenticated user's account, ordered by * the most recent heartbeat timestamp (nulls last) and creation date. * * @param principal the authenticated user principle, used to extract the user's unique identifier * @param pageable the pagination and sorting parameters * @return a paginated list of {@link TunnelView} objects representing the account's tunnels */ @GetMapping public Page page(final @AuthenticationPrincipal Jwt principal, final Pageable pageable) { final var accountId = JwtService.resolveAccountId(principal); final Page page = tunnelRepository .pageByAccountOrderByLastHeartbeatDescNullsLast(accountId, pageable); return page.map(TunnelsController::toView); } private static TunnelView toView(final TunnelEntity tunnel) { final var local = tunnel.getLocalScheme() == null || tunnel.getLocalHost() == null || tunnel.getLocalPort() == null ? null : "%s://%s:%s".formatted(tunnel.getLocalScheme(), tunnel.getLocalHost(), tunnel.getLocalPort()); final String publicEndpoint; if (tunnel.getType() == TunnelType.HTTP) { publicEndpoint = tunnel.getPublicUrl(); } else { final var host = tunnel.getPublicHost(); final var port = tunnel.getPublicPort(); publicEndpoint = host == null || port == null ? null : host + ":" + port; } final var subdomain = Optional.ofNullable(tunnel.getDomain()) .map(DomainEntity::getSubdomain) .orElse(null); final var portReservationName = Optional.ofNullable(tunnel.getPortReservation()) .map(PortReservationEntity::getName) .orElse(null); return new TunnelView( tunnel.getId().toString(), tunnel.getType(), tunnel.getStatus(), local, publicEndpoint, tunnel.getPublicUrl(), // keep original http URL if any tunnel.getPublicHost(), tunnel.getPublicPort(), subdomain, portReservationName, tunnel.getLastHeartbeatAt() == null ? null : tunnel.getLastHeartbeatAt().toString(), tunnel.getCreatedAt() == null ? null : tunnel.getCreatedAt().toString() ); } public record TunnelView( String id, TunnelType type, TunnelStatus status, String local, String publicEndpoint, String publicUrl, String publicHost, Integer publicPort, String subdomain, String portReservationName, String lastHeartbeatAt, String createdAt ) { } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/UsersController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static tech.amak.portbuddy.server.security.JwtService.resolveUserId; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.stripe.exception.StripeException; import lombok.Data; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.security.Oauth2SuccessHandler; import tech.amak.portbuddy.server.service.StripeService; import tech.amak.portbuddy.server.service.TeamService; import tech.amak.portbuddy.server.service.TunnelService; @RestController @RequestMapping(path = "/api/users/me", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class UsersController { private final UserRepository userRepository; private final AccountRepository accountRepository; private final TunnelRepository tunnelRepository; private final StripeService stripeService; private final TunnelService tunnelService; private final UserAccountRepository userAccountRepository; private final TeamService teamService; private final JwtService jwtService; private final AppProperties properties; /** * User details endpoint. * * @return user details */ @GetMapping("/details") @Transactional public UserDetailsResponse details(@AuthenticationPrincipal final Jwt jwt) { final var user = resolveUser(jwt); final var userAccount = resolveUserAccount(jwt); final var account = userAccount.getAccount(); final var details = new UserDetailsResponse(); final var userDto = new UserDto(); userDto.setId(user.getId().toString()); userDto.setEmail(user.getEmail()); userDto.setFirstName(user.getFirstName()); userDto.setLastName(user.getLastName()); userDto.setAvatarUrl(user.getAvatarUrl()); userDto.setRoles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet())); details.setUser(userDto); details.setAccount(toAccountDto(account)); return details; } /** * Updates user profile. * * @param jwt principal. * @param request request body. * @return updated user profile. */ @PatchMapping(path = "/profile", consumes = MediaType.APPLICATION_JSON_VALUE) @Transactional public UserDto updateProfile(@AuthenticationPrincipal final Jwt jwt, @RequestBody final UpdateProfileRequest request) { final var user = resolveUser(jwt); final var userAccount = resolveUserAccount(jwt); final var firstName = normalizeNullable(request == null ? null : request.getFirstName()); final var lastName = normalizeNullable(request == null ? null : request.getLastName()); user.setFirstName(firstName); user.setLastName(lastName); userRepository.save(user); final var userDto = new UserDto(); userDto.setId(user.getId().toString()); userDto.setEmail(user.getEmail()); userDto.setFirstName(user.getFirstName()); userDto.setLastName(user.getLastName()); userDto.setAvatarUrl(user.getAvatarUrl()); userDto.setRoles(userAccount.getRoles().stream().map(Enum::name).collect(Collectors.toSet())); return userDto; } /** * Updates account name. * * @param jwt principal. * @param request request body. * @return updated account name. */ @PatchMapping(path = "/account", consumes = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") @Transactional public AccountDto updateAccount(@AuthenticationPrincipal final Jwt jwt, @RequestBody final UpdateAccountRequest request) { final var userAccount = resolveUserAccount(jwt); final var account = userAccount.getAccount(); final var name = normalizeNullable(request == null ? null : request.getName()); if (!StringUtils.hasText(name)) { throw new IllegalArgumentException("Account name must not be empty"); } account.setName(name); accountRepository.save(account); return toAccountDto(account); } /** * Updates the number of extra tunnels for the account. * * @param jwt principal. * @param request request body. * @return updated account details. */ @PatchMapping(path = "/account/tunnels", consumes = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("hasAnyRole('ACCOUNT_ADMIN', 'ADMIN')") @Transactional public AccountDto updateExtraTunnels(@AuthenticationPrincipal final Jwt jwt, @RequestBody final UpdateTunnelsRequest request) throws StripeException { final var userAccount = resolveUserAccount(jwt); final var account = userAccount.getAccount(); final int currentExtra = account.getExtraTunnels(); final int requestedExtra = request.getExtraTunnels(); if (requestedExtra < 0) { throw new IllegalArgumentException("Extra tunnels count cannot be negative"); } final int diff = requestedExtra - currentExtra; if (diff == 0) { return toAccountDto(account); } final int increment = properties.subscriptions().tunnels().increment().get(account.getPlan()); if (Math.abs(diff) % increment != 0) { throw new IllegalArgumentException( "For %s plan, tunnels must be changed by multiples of %d" .formatted(account.getPlan(), increment)); } if (requestedExtra > 0 && account.getStripeSubscriptionId() == null) { // If they don't have a subscription yet, we must create a checkout session // to actually create the subscription in Stripe. // We do NOT update the account in the database yet. // It will be updated by the webhook after successful payment. final var url = stripeService.createCheckoutSession(account, account.getPlan(), requestedExtra); final var dto = toAccountDto(account); dto.setCheckoutUrl(url); return dto; } if (requestedExtra == 0 && account.getPlan() == Plan.PRO && account.getStripeSubscriptionId() != null) { stripeService.cancelSubscription(account); account.setSubscriptionStatus("canceled"); account.setStripeSubscriptionId(null); } else { stripeService.updateExtraTunnels(account, requestedExtra); } account.setExtraTunnels(requestedExtra); accountRepository.save(account); tunnelService.enforceTunnelLimit(account); return toAccountDto(account); } /** * Returns the list of accounts the user belongs to. * * @param jwt the JWT token. * @return the list of accounts. */ @Transactional @GetMapping("/accounts") public List getAccounts(@AuthenticationPrincipal final Jwt jwt) { final var userId = resolveUserId(jwt); return userAccountRepository.findAllByUserId(userId).stream() .map(ua -> UserAccountDto.builder() .accountId(ua.getAccount().getId()) .accountName(ua.getAccount().getName()) .plan(ua.getAccount().getPlan()) .roles(ua.getRoles().stream().map(Enum::name).collect(Collectors.toSet())) .lastUsedAt(ua.getLastUsedAt()) .build()) .toList(); } /** * Switches the current account. * * @param jwt the JWT token. * @param accountId the account id to switch to. * @return a new JWT token. */ @Transactional @PostMapping("/accounts/{id}/switch") public Map switchAccount(@AuthenticationPrincipal final Jwt jwt, @PathVariable("id") final UUID accountId) { final var userId = resolveUserId(jwt); final var provisioned = teamService.switchAccount(userId, accountId); final var claims = new java.util.HashMap(); final var email = jwt.getClaimAsString(Oauth2SuccessHandler.EMAIL_CLAIM); if (email != null) { claims.put(Oauth2SuccessHandler.EMAIL_CLAIM, email); } final var name = jwt.getClaimAsString(Oauth2SuccessHandler.NAME_CLAIM); if (name != null) { claims.put(Oauth2SuccessHandler.NAME_CLAIM, name); } final var picture = jwt.getClaimAsString(Oauth2SuccessHandler.PICTURE_CLAIM); if (picture != null) { claims.put(Oauth2SuccessHandler.PICTURE_CLAIM, picture); } claims.put(Oauth2SuccessHandler.ACCOUNT_ID_CLAIM, provisioned.accountId().toString()); claims.put(Oauth2SuccessHandler.ACCOUNT_NAME_CLAIM, provisioned.accountName()); claims.put(Oauth2SuccessHandler.USER_ID_CLAIM, provisioned.userId().toString()); final var token = jwtService.createToken(claims, provisioned.userId().toString(), provisioned.roles()); return Map.of("token", token); } private AccountDto toAccountDto(final AccountEntity account) { final var dto = new AccountDto(); dto.setId(account.getId().toString()); dto.setName(account.getName()); dto.setPlan(account.getPlan()); dto.setExtraTunnels(account.getExtraTunnels()); dto.setSubscriptionStatus(account.getSubscriptionStatus()); dto.setBaseTunnels(properties.subscriptions().tunnels().base().get(account.getPlan())); dto.setActiveTunnels((int) tunnelRepository.countByAccountIdAndStatusIn( account.getId(), TunnelService.ACTIVE_STATUSES)); dto.setStripeCustomerId(account.getStripeCustomerId()); dto.setBlocked(account.isBlocked()); return dto; } private UserEntity resolveUser(final Jwt jwt) { final var userId = resolveUserId(jwt); return userRepository.findById(userId) .orElseThrow(() -> new RuntimeException("User not found. Id: " + userId)); } private UserAccountEntity resolveUserAccount(final Jwt jwt) { final var userId = resolveUserId(jwt); final var accountId = JwtService.resolveAccountId(jwt); return userAccountRepository.findByUserIdAndAccountId(userId, accountId) .orElseThrow(() -> new IllegalArgumentException("User does not belong to this account.")); } private static String normalizeNullable(final String value) { if (value == null) { return null; } final var trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } @Data public static class UserDetailsResponse { private UserDto user; private AccountDto account; } @Data public static class UserDto { private String id; private String email; private String firstName; private String lastName; private String avatarUrl; private Set roles; } @Data public static class AccountDto { private String id; private String name; private Plan plan; private int extraTunnels; private int baseTunnels; private int activeTunnels; private String subscriptionStatus; private String stripeCustomerId; private String checkoutUrl; private boolean blocked; } @Data public static class UpdateProfileRequest { private String firstName; private String lastName; } @Data public static class UpdateAccountRequest { private String name; } @Data public static class UpdateTunnelsRequest { private int extraTunnels; } @Data @lombok.Builder public static class UserAccountDto { private UUID accountId; private String accountName; private Plan plan; private Set roles; private OffsetDateTime lastUsedAt; } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminAccountController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.service.TunnelService; import tech.amak.portbuddy.server.web.admin.dto.AdminAccountRow; @RestController @RequestMapping(path = "/api/admin/accounts", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class AdminAccountController { private final AccountRepository accountRepository; private final TunnelService tunnelService; /** * Returns a list of accounts for the admin page using a single native SQL query. * The list is ordered by number of active tunnels (DESC) and creation time (DESC). * Accessible only for users with ADMIN role. * * @return list of account rows for admin table */ @GetMapping @PreAuthorize("hasRole('ADMIN')") public List listAccounts(final @RequestParam(value = "search", required = false) String search) { return accountRepository.findAdminAccounts(search); } /** * Blocks the specified account. Only users with the ADMIN role can invoke this endpoint. * When an account is blocked, its users will be prevented from logging in or exchanging * API tokens for access tokens. * * @param accountId the unique identifier of the account to block */ @PostMapping("/{accountId}/block") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('ADMIN')") public void blockAccount(final @PathVariable("accountId") UUID accountId) { final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Account not found")); if (!account.isBlocked()) { account.setBlocked(true); accountRepository.save(account); tunnelService.closeAllTunnels(account); } } /** * Unblocks the specified account. Only users with the ADMIN role can invoke this endpoint. * * @param accountId the unique identifier of the account to unblock */ @PostMapping("/{accountId}/unblock") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('ADMIN')") public void unblockAccount(final @PathVariable("accountId") UUID accountId) { final var account = accountRepository.findById(accountId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Account not found")); if (account.isBlocked()) { account.setBlocked(false); accountRepository.save(account); } } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminSystemController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin; import java.util.List; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.web.admin.dto.AdminStatsRow; import tech.amak.portbuddy.server.web.admin.dto.SystemStatsResponse; /** * Administrative system endpoints. */ @RestController @RequestMapping(path = "/api/admin", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class AdminSystemController { private final UserRepository userRepository; private final TunnelRepository tunnelRepository; private final AccountRepository accountRepository; /** * Returns system-wide statistics for the admin control center. * Only users with the ADMIN role can invoke this endpoint. * * @return a {@link SystemStatsResponse} with total users and active tunnels */ @GetMapping("/stats") @PreAuthorize("hasRole('ADMIN')") public SystemStatsResponse getSystemStats() { final var totalUsers = userRepository.count(); final var activeTunnels = tunnelRepository.countByStatusIn(List.of(TunnelStatus.CONNECTED)); final var totalAccounts = accountRepository.count(); return new SystemStatsResponse(totalUsers, activeTunnels, totalAccounts); } /** * Returns daily system statistics for the last 30 days. * Only users with the ADMIN role can invoke this endpoint. * * @return list of {@link AdminStatsRow} sorted by date in descending order */ @GetMapping("/stats/daily") @PreAuthorize("hasRole('ADMIN')") public List getDailyStats() { return accountRepository.findDailyStats(); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminTunnelController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin; import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.web.admin.dto.AdminTunnelRow; @RestController @RequestMapping(path = "/api/admin/tunnels", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class AdminTunnelController { private final TunnelRepository tunnelRepository; /** * Returns a list of active tunnels for the admin page using a single native SQL query. * The list is ordered by last activity (DESC). * Accessible only for users with ADMIN role. * * @param search optional search string to filter tunnels by public address or user * @return list of tunnel rows for admin table */ @GetMapping @PreAuthorize("hasRole('ADMIN')") public List listActiveTunnels( final @RequestParam(value = "search", required = false) String search) { return tunnelRepository.findAdminActiveTunnels(search); } /** * Closes the specified tunnel. Only users with the ADMIN role can invoke this endpoint. * * @param tunnelId the unique identifier of the tunnel to close */ @PostMapping("/{tunnelId}/close") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('ADMIN')") public void closeTunnel(final @PathVariable("tunnelId") UUID tunnelId) { final var tunnel = tunnelRepository.findById(tunnelId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tunnel not found")); tunnel.setStatus(TunnelStatus.CLOSED); tunnelRepository.save(tunnel); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/AdminUserController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin; import java.util.List; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.web.admin.dto.AdminUserRow; @RestController @RequestMapping(path = "/api/admin/users", produces = MediaType.APPLICATION_JSON_VALUE) @RequiredArgsConstructor public class AdminUserController { private final UserRepository userRepository; /** * Returns a list of users for the admin page using a single native SQL query. * The list is ordered by number of active tunnels (DESC) and creation time (DESC). * Accessible only for users with ADMIN role. * * @param search optional search string to filter users by email, name or ID * @return list of user rows for admin table */ @GetMapping @PreAuthorize("hasRole('ADMIN')") public List listUsers(final @RequestParam(value = "search", required = false) String search) { return userRepository.findAdminUsers(search); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminAccountRow.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin.dto; import java.time.Instant; import java.util.UUID; /** * Projection row for admin accounts list. * Used with a native SQL query in {@code AccountRepository}. */ public record AdminAccountRow( UUID accountId, String name, String plan, int extraTunnels, long activeTunnels, boolean blocked, Instant createdAt) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminStatsRow.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin.dto; import java.sql.Date; /** * Daily statistics for the admin control center. * * @param date the date of the statistics * @param newUsersCount number of users created on this date * @param tunnelsCount number of tunnels created on this date * @param paymentEvents number of stripe events created on this date */ public record AdminStatsRow( Date date, long newUsersCount, long tunnelsCount, long paymentEvents ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminTunnelRow.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin.dto; import java.time.Instant; import java.util.UUID; /** * Projection row for admin active tunnels list. * Used with a native SQL query in {@code TunnelRepository}. */ public record AdminTunnelRow( UUID id, String type, String localAddress, String publicAddress, Instant lastActivity, String userName, UUID userId, UUID accountId) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/AdminUserRow.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin.dto; import java.time.Instant; import java.util.UUID; /** * Projection row for admin users list. * Used with a native SQL query in {@code UserRepository}. */ public record AdminUserRow( UUID id, UUID accountId, String name, String email, long activeTunnels, boolean blocked, Instant createdAt) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/admin/dto/SystemStatsResponse.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin.dto; /** * System-wide statistics for the admin control center. * * @param totalUsers total number of registered users in the system * @param activeTunnels total number of currently active (connected) tunnels in the system * @param totalAccounts total number of accounts in the system */ public record SystemStatsResponse(long totalUsers, long activeTunnels, long totalAccounts) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/advice/GlobalExceptionHandler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.advice; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; import lombok.extern.slf4j.Slf4j; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @ExceptionHandler(ResponseStatusException.class) public ProblemDetail handleResponseStatusException(final ResponseStatusException ex) { log.error(ex.getMessage(), ex); return ProblemDetail.forStatusAndDetail(ex.getStatusCode(), ex.getReason()); } @ExceptionHandler(IllegalArgumentException.class) public ProblemDetail handleIllegalArgumentException(final IllegalArgumentException ex) { log.error(ex.getMessage(), ex); return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); } @ExceptionHandler(Exception.class) public ProblemDetail handleGlobalException(final Exception ex) { log.error(ex.getMessage(), ex); return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); } } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/DomainDto.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; import java.time.OffsetDateTime; import java.util.UUID; public record DomainDto( UUID id, String subdomain, String domain, String customDomain, boolean cnameVerified, boolean sslActive, boolean passcodeProtected, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/LoginRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; public record LoginRequest(String email, String password) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/PasswordResetConfirm.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; public record PasswordResetConfirm(String token, String newPassword) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/PasswordResetRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; public record PasswordResetRequest(String email) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/PortRangeDto.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; /** * Port range for a given tcp-proxy host. */ public record PortRangeDto( int min, int max ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/PortReservationDto.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; import java.time.OffsetDateTime; import java.util.UUID; public record PortReservationDto( UUID id, String publicHost, Integer publicPort, String name, OffsetDateTime createdAt, OffsetDateTime updatedAt ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/PortReservationUpdateRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; /** * Request body to update a port reservation. * Both fields are optional; if a field is null, it will not be changed. */ public record PortReservationUpdateRequest( String publicHost, Integer publicPort, String name ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/SetPasscodeRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; /** * Request payload for setting or updating a passcode on a domain. * * @param passcode the raw passcode value to set for the domain */ public record SetPasscodeRequest(String passcode) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/UpdateCustomDomainRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; public record UpdateCustomDomainRequest( String customDomain ) { } ================================================ FILE: server/src/main/java/tech/amak/portbuddy/server/web/dto/UpdateDomainRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.dto; public record UpdateDomainRequest( String subdomain ) { } ================================================ FILE: server/src/main/resources/application.yml ================================================ server: port: 8090 compression: enabled: on eureka: client: registryFetchIntervalSeconds: 2 eurekaServiceUrlPollIntervalSeconds: 60 service-url: defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka} app: gateway: domain: localhost:8443 url: https://${app.gateway.domain} subdomain-url-template: https://%s.${app.gateway.domain} not-found-page: ${app.gateway.url}/404 passcode-page: ${app.gateway.url}/passcode max-request-body-size: 10MB mail: fromAddress: ${MAIL_FROM:no-reply@portbuddy.dev} fromName: ${MAIL_FROM_NAME:Port Buddy} port-reservations: range: min: 40000 max: 60000 tunnels: heartbeat-timeout: 20s check-interval: 5s subscriptions: grace-period: 3d check-interval: 1h tunnels: base: pro: 1 team: 10 increment: pro: 1 team: 5 jwt: issuer: port-buddy ttl: 168h # 1 week rsa: currentKeyId: dev-key keys: - id: dev-key publicKeyPem: ${JWT_PUBLIC_KEY:classpath:keys/dev_jwt.pub} privateKeyPem: ${JWT_PRIVATE_KEY:classpath:keys/dev_jwt.pem} web-socket: max-text-message-size: 1MB max-binary-message-size: 1MB session-idle-timeout: 10m cli: min-version: 1.0 stripe: api-key: ${STRIPE_API_KEY:sk_test_51P...} webhook-secret: ${STRIPE_WEBHOOK_SECRET:whsec_...} price-ids: pro: ${STRIPE_PRICE_PRO:price_...} team: ${STRIPE_PRICE_TEAM:price_...} extra-tunnel: ${STRIPE_PRICE_EXTRA_TUNNEL:price_...} spring: mvc: problem-details: enabled: true servlet: multipart: max-request-size: 1GB max-file-size: 1GB application: name: port-buddy-server datasource: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy} username: ${DB_USER:portbuddy} password: ${DB_PASSWORD:portbuddy} jpa: hibernate: ddl-auto: none open-in-view: false security: oauth2: client: registration: google: client-id: ${OAUTH_GOOGLE_CLIENT_ID:1111111111111-2222222222222.apps.googleusercontent.com} client-secret: ${OAUTH_GOOGLE_CLIENT_SECRET:secret} scope: openid,profile,email redirect-uri: "${app.gateway.url}/login/oauth2/code/{registrationId}" github: client-id: ${OAUTH_GITHUB_CLIENT_ID:github-client-id} client-secret: ${OAUTH_GITHUB_CLIENT_SECRET:github-client-secret} scope: read:user,user:email redirect-uri: "${app.gateway.url}/login/oauth2/code/{registrationId}" provider: google: authorization-uri: https://accounts.google.com/o/oauth2/auth token-uri: https://oauth2.googleapis.com/token jwk-set-uri: https://www.googleapis.com/oauth2/v3/certs user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo user-name-attribute: sub github: authorization-uri: https://github.com/login/oauth/authorize token-uri: https://github.com/login/oauth/access_token user-info-uri: https://api.github.com/user user-name-attribute: id mail: host: ${SMTP_HOST:} port: ${SMTP_PORT:} username: ${SMTP_USERNAME:} password: ${SMTP_PASSWORD:} protocol: smtp default-encoding: UTF-8 properties: mail: smtp: auth: true starttls: enable: true required: true mime: charset: UTF-8 management: endpoints: web: exposure: include: health,info endpoint: health: probes: enabled: true logging: level: root: info tech.amak.portbuddy: debug org.springframework.web: info com.netflix.discovery: warn tech.amak.portbuddy.server.service.StaleTunnelsReaper: info file: name: log/app.log logback: rollingpolicy: max-history: 14 total-size-cap: 1000MB max-file-size: 100MB threatfox: enabled: ${THREATFOX_ENABLED:false} url: https://threatfox-api.abuse.ch auth-key: ${THREATFOX_AUTH_KEY:} fetch-interval: 1h --- spring: config: activate: on-profile: prod app: gateway: domain: ${APP_DOMAIN:portbuddy.dev} ================================================ FILE: server/src/main/resources/db/migration/V10__link_http_tunnels_to_domain.sql ================================================ ALTER TABLE tunnels ADD COLUMN domain_id UUID; ALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_domain_id FOREIGN KEY (domain_id) REFERENCES domains(id); CREATE INDEX idx_tunnels_domain_id ON tunnels(domain_id); -- Backfill domain_id for existing HTTP tunnels UPDATE tunnels t SET domain_id = d.id FROM domains d WHERE t.subdomain = d.subdomain AND t.account_id = d.account_id AND t.type = 'HTTP'; -- Insert missing domains for orphans (if any) INSERT INTO domains (id, subdomain, domain, account_id, created_at, updated_at) SELECT gen_random_uuid(), t.subdomain, 'portbuddy.dev', t.account_id, NOW(), NOW() FROM tunnels t WHERE t.type = 'HTTP' AND t.domain_id IS NULL AND t.subdomain IS NOT NULL GROUP BY t.subdomain, t.account_id; -- Run update again to link newly created domains UPDATE tunnels t SET domain_id = d.id FROM domains d WHERE t.subdomain = d.subdomain AND t.account_id = d.account_id AND t.type = 'HTTP' AND t.domain_id IS NULL; ALTER TABLE tunnels DROP COLUMN subdomain; ================================================ FILE: server/src/main/resources/db/migration/V11__add_deleted_column_to_domains.sql ================================================ ALTER TABLE domains ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: server/src/main/resources/db/migration/V12__drop_tunnel_id_from_tunnels.sql ================================================ -- Drop obsolete tunnel_id column; entity id (UUID) is used as the tunnel identifier now ALTER TABLE IF EXISTS tunnels DROP COLUMN IF EXISTS tunnel_id; ================================================ FILE: server/src/main/resources/db/migration/V13__password_reset_tokens.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ CREATE TABLE password_reset_tokens ( id UUID PRIMARY KEY, token VARCHAR(255) NOT NULL UNIQUE, user_id UUID NOT NULL REFERENCES users(id), expiry_date TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token); ================================================ FILE: server/src/main/resources/db/migration/V14__port_reservations.sql ================================================ CREATE TABLE port_reservations ( id UUID PRIMARY KEY, account_id UUID NOT NULL REFERENCES accounts(id), public_host VARCHAR(255) NOT NULL, public_port INT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT uq_port_reservations_host_port UNIQUE (public_host, public_port) ); CREATE INDEX idx_port_reservations_account_id ON port_reservations(account_id); CREATE INDEX idx_port_reservations_public_host ON port_reservations(public_host); ================================================ FILE: server/src/main/resources/db/migration/V15__add_user_to_port_reservations.sql ================================================ -- Add creator user reference to port_reservations ALTER TABLE port_reservations ADD COLUMN IF NOT EXISTS user_id UUID NULL REFERENCES users (id); -- Index for faster lookups by user CREATE INDEX IF NOT EXISTS idx_port_reservations_user_id ON port_reservations (user_id); ================================================ FILE: server/src/main/resources/db/migration/V16__add_port_reservation_to_tunnels.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ -- Link tunnels to port_reservations ALTER TABLE IF EXISTS tunnels ADD COLUMN IF NOT EXISTS port_reservation_id UUID NULL REFERENCES port_reservations (id); CREATE INDEX IF NOT EXISTS idx_tunnels_port_reservation_id ON tunnels (port_reservation_id); ================================================ FILE: server/src/main/resources/db/migration/V17__soft_delete_port_reservations.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ -- Add soft delete flag and change unique constraint to only apply to non-deleted records ALTER TABLE port_reservations ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE; -- Drop old unique constraint and replace with a partial unique index DO $$ BEGIN IF EXISTS ( SELECT 1 FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid WHERE t.relname = 'port_reservations' AND c.conname = 'uq_port_reservations_host_port' ) THEN ALTER TABLE port_reservations DROP CONSTRAINT uq_port_reservations_host_port; END IF; END $$; CREATE UNIQUE INDEX IF NOT EXISTS uq_port_reservations_host_port_active ON port_reservations(public_host, public_port) WHERE deleted = FALSE; ================================================ FILE: server/src/main/resources/db/migration/V18__add_passcode_to_domains.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ -- Add optional passcode hash column to domains for securing HTTP tunnels by domain ALTER TABLE domains ADD COLUMN IF NOT EXISTS passcode_hash TEXT; ================================================ FILE: server/src/main/resources/db/migration/V19__add_temp_passcode_to_tunnels.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ -- Temporary passcode hash per tunnel (set via CLI during expose) ALTER TABLE tunnels ADD COLUMN IF NOT EXISTS temp_passcode_hash TEXT; ================================================ FILE: server/src/main/resources/db/migration/V1__accounts_and_users.sql ================================================ -- Accounts and Users schema CREATE TABLE IF NOT EXISTS accounts ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, plan VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, email VARCHAR(320), first_name VARCHAR(255), last_name VARCHAR(255), auth_provider VARCHAR(100) NOT NULL, external_id VARCHAR(255) NOT NULL, avatar_url VARCHAR(1024), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_user_provider_ext UNIQUE (auth_provider, external_id) ); CREATE INDEX IF NOT EXISTS idx_users_account_id ON users(account_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email); ================================================ FILE: server/src/main/resources/db/migration/V20__add_custom_domain_to_domains.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ ALTER TABLE domains ADD COLUMN custom_domain VARCHAR(255); ALTER TABLE domains ADD COLUMN cname_verified BOOLEAN NOT NULL DEFAULT FALSE; CREATE INDEX idx_domains_custom_domain ON domains(custom_domain); ================================================ FILE: server/src/main/resources/db/migration/V21__add_roles_to_users.sql ================================================ ALTER TABLE users ADD COLUMN roles VARCHAR(255)[] NOT NULL DEFAULT '{}'; ================================================ FILE: server/src/main/resources/db/migration/V22__update_plans.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ ALTER TABLE accounts ADD COLUMN extra_tunnels INTEGER NOT NULL DEFAULT 0; UPDATE accounts SET plan = 'PRO' WHERE plan IN ('BASIC', 'INDIVIDUAL'); UPDATE accounts SET plan = 'TEAM' WHERE plan = 'PROFESSIONAL'; UPDATE accounts SET plan = 'PRO' WHERE plan NOT IN ('PRO', 'TEAM'); ================================================ FILE: server/src/main/resources/db/migration/V23__add_stripe_fields.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ ALTER TABLE accounts ADD COLUMN stripe_customer_id VARCHAR(255); ALTER TABLE accounts ADD COLUMN stripe_subscription_id VARCHAR(255); ALTER TABLE accounts ADD COLUMN subscription_status VARCHAR(50); ================================================ FILE: server/src/main/resources/db/migration/V24__create_stripe_events_table.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ CREATE TABLE stripe_events ( id VARCHAR(255) PRIMARY KEY, type VARCHAR(255) NOT NULL, payload TEXT NOT NULL, status VARCHAR(50) NOT NULL, error_message TEXT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, processed_at TIMESTAMP WITH TIME ZONE ); ================================================ FILE: server/src/main/resources/db/migration/V25__create_invitations_table.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ CREATE TABLE invitations ( id UUID PRIMARY KEY, account_id UUID NOT NULL REFERENCES accounts(id), email VARCHAR(320) NOT NULL, token VARCHAR(255) NOT NULL UNIQUE, invited_by_id UUID NOT NULL REFERENCES users(id), accepted_at TIMESTAMP WITH TIME ZONE, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_invitations_account_id ON invitations(account_id); CREATE INDEX idx_invitations_token ON invitations(token); CREATE INDEX idx_invitations_email ON invitations(email); ================================================ FILE: server/src/main/resources/db/migration/V26__many_to_many_users_accounts.sql ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ -- V26__many_to_many_users_accounts.sql CREATE TABLE IF NOT EXISTS user_accounts ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, roles VARCHAR(255)[] NOT NULL DEFAULT '{}', last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, account_id) ); -- Migrate data INSERT INTO user_accounts (user_id, account_id, roles, created_at, updated_at) SELECT id, account_id, roles, created_at, updated_at FROM users; -- Drop columns from users ALTER TABLE users DROP COLUMN account_id; ALTER TABLE users DROP COLUMN roles; -- Update indexes CREATE INDEX IF NOT EXISTS idx_user_accounts_user_id ON user_accounts(user_id); CREATE INDEX IF NOT EXISTS idx_user_accounts_account_id ON user_accounts(account_id); ================================================ FILE: server/src/main/resources/db/migration/V27__add_ssl_active_to_domains.sql ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ ALTER TABLE domains ADD COLUMN ssl_active BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: server/src/main/resources/db/migration/V28__add_blocked_to_accounts.sql ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ ALTER TABLE accounts ADD COLUMN blocked BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: server/src/main/resources/db/migration/V29__add_name_to_port_reservations.sql ================================================ -- Add name column to port_reservations ALTER TABLE port_reservations ADD COLUMN name VARCHAR(255); ================================================ FILE: server/src/main/resources/db/migration/V2__api_keys.sql ================================================ -- API Keys persistent storage CREATE TABLE IF NOT EXISTS api_keys ( id UUID PRIMARY KEY, user_id UUID NOT NULL, label VARCHAR(255) NOT NULL, token_hash VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_used_at TIMESTAMPTZ NULL, revoked BOOLEAN NOT NULL DEFAULT FALSE, revoked_at TIMESTAMPTZ NULL ); CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id); CREATE INDEX IF NOT EXISTS idx_api_keys_created_at ON api_keys(created_at); ================================================ FILE: server/src/main/resources/db/migration/V30__unique_port_reservation_name.sql ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ -- Ensure port reservation name is unique within account -- We use a partial index to only enforce uniqueness for non-deleted reservations and non-null names CREATE UNIQUE INDEX idx_port_reservations_account_id_name_unique ON port_reservations (account_id, name) WHERE deleted = false AND name IS NOT NULL; ================================================ FILE: server/src/main/resources/db/migration/V31__lowercase_domains.sql ================================================ UPDATE domains SET subdomain = LOWER(subdomain), domain = LOWER(domain), custom_domain = LOWER(custom_domain); ================================================ FILE: server/src/main/resources/db/migration/V3__tunnels.sql ================================================ -- Tunnels storage CREATE TABLE IF NOT EXISTS tunnels ( id UUID PRIMARY KEY, tunnel_id VARCHAR(255) NOT NULL UNIQUE, type VARCHAR(16) NOT NULL, status VARCHAR(16) NOT NULL, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, api_key_id UUID NULL REFERENCES api_keys(id) ON DELETE SET NULL, local_scheme VARCHAR(16) NULL, local_host VARCHAR(255) NULL, local_port INTEGER NULL, public_url VARCHAR(1024) NULL, public_host VARCHAR(255) NULL, public_port INTEGER NULL, subdomain VARCHAR(255) NULL, last_heartbeat_at TIMESTAMPTZ NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_tunnels_user_id ON tunnels(user_id); CREATE INDEX IF NOT EXISTS idx_tunnels_api_key_id ON tunnels(api_key_id); CREATE INDEX IF NOT EXISTS idx_tunnels_status ON tunnels(status); CREATE INDEX IF NOT EXISTS idx_tunnels_created_at ON tunnels(created_at); ================================================ FILE: server/src/main/resources/db/migration/V4__shedlock_and_heartbeat_indexes.sql ================================================ -- ShedLock table for cluster-safe scheduling CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL, locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) ); -- Helpful index to speed up stale selection/updates CREATE INDEX IF NOT EXISTS idx_tunnels_status_last_heartbeat ON tunnels (status, last_heartbeat_at); ================================================ FILE: server/src/main/resources/db/migration/V5__add_password_and_admin.sql ================================================ -- Add password column ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(255); ================================================ FILE: server/src/main/resources/db/migration/V6__create_domains_table.sql ================================================ CREATE TABLE domains ( id UUID PRIMARY KEY, subdomain VARCHAR(255) NOT NULL UNIQUE, domain VARCHAR(255) NOT NULL, account_id UUID NOT NULL REFERENCES accounts(id), created_at TIMESTAMP WITH TIME ZONE NOT NULL, updated_at TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE INDEX idx_domains_account_id ON domains(account_id); ================================================ FILE: server/src/main/resources/db/migration/V7__link_tunnels_to_account.sql ================================================ ALTER TABLE tunnels ADD COLUMN account_id UUID; UPDATE tunnels t SET account_id = u.account_id FROM users u WHERE t.user_id = u.id; ALTER TABLE tunnels ALTER COLUMN account_id SET NOT NULL; ALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_account_id FOREIGN KEY (account_id) REFERENCES accounts(id); CREATE INDEX idx_tunnels_account_id ON tunnels(account_id); ALTER TABLE tunnels DROP COLUMN user_id; ================================================ FILE: server/src/main/resources/db/migration/V8__add_user_id_to_tunnels.sql ================================================ ALTER TABLE tunnels ADD COLUMN user_id UUID; UPDATE tunnels t SET user_id = ak.user_id FROM api_keys ak WHERE t.api_key_id = ak.id; ALTER TABLE tunnels ADD CONSTRAINT fk_tunnels_user_id FOREIGN KEY (user_id) REFERENCES users(id); CREATE INDEX idx_tunnels_user_id ON tunnels(user_id); ================================================ FILE: server/src/main/resources/db/migration/V9__link_api_keys_to_account.sql ================================================ ALTER TABLE api_keys ADD COLUMN account_id UUID; UPDATE api_keys k SET account_id = u.account_id FROM users u WHERE k.user_id = u.id; ALTER TABLE api_keys ALTER COLUMN account_id SET NOT NULL; ALTER TABLE api_keys ADD CONSTRAINT fk_api_keys_account_id FOREIGN KEY (account_id) REFERENCES accounts(id); CREATE INDEX idx_api_keys_account_id ON api_keys(account_id); ================================================ FILE: server/src/main/resources/keys/dev_jwt.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDZAc8VhagLcZWX I1Aw5TnfV9zlMANKD3+uc8FnEuY1Cpnq2hlV5TN2nDsNEeBXjoe2o9oi0dB/Ipi5 3oCr7smxklCaJE5/ApfIIMzx/jwsKNx9Qz/G3cg2KlOjamLPLhSy4aK2BWGXkyzv O70IlJ1kEaHps+vkh8jsR6CnkcKxboLIzl8enA7P/Pp4UsQ1cqBiGnPxi3m42d1G u1ZcgLv/uhgyTeAdTjomm8e1ysRy/L0oTQ2+zzHmekZxFX7s8xUsYCRS743M7yOd C7dodNMIdu4RHowcc/vpYbHaay+5vZaceZpCQfATsVCzZ7MlFg30u7SCuBNC5BA2 do66jshFAgMBAAECggEABiwt/+jc7BeNJnOM1fTs8A/VT2XgFFzJWolm8aIMao/S NLCnwB5v4QX2DAmNtMa49Tsietx/ieCFVZnX5/nKHgw10UpT8dZY5pzShUOgdImt k+H9YkcmslKVCQcRgwNrQLvmA+6R0xPFoG61213pGncmgJUiSQUddBw7wOh0WWfo VTruvdOHnDnZqXSOsW4ZDJdzypnzuqpzol9+tmLUzCUC68NzZEj8z7CYjABxXeO7 dRBK1N83UCriJysIKoWZvL0gYgcEVHx2J2FhWBGoTaPSvRLVtZfE9vrGSofCisI+ +SnTn88TE0PgQCGGdSuMd97vJcldEpefVSSWvAnFYQKBgQDsYhSeQEgk8SFh1iq6 /2o6I/dAWfqGOlMBo3+RlZEhDEwv42vVbkDanXk0CEzKFyTRVEjwKDgWgyqFRlxM 9YkiEirT57AZ9GebUqb3PQvTiB6Lz7PXp1MUsB/WmjWcDWmvTAfLfPYKO2m+9VCg xfENDbksB40X/+TtMgsVkDx5+QKBgQDrBBZWGWaRzEtgnsqIu21ZODPnK9FzPh1P J63MZ8Vmjbb6EvTps86Ash7qOvYmD+MdaYXTs9q0wDd7W5E+26+ZNCSQHkT0l0Sk 22XjG2a/6qjObP0qimNQMl4l7274YaZvge4PcZFDwCLU7znGNz1rUEkeEfuGPoa6 xySR23HzrQKBgQCL9QqGJENS9B4ywk5sh5vKrs7PIDdP0Cqjdr2qYicarSBS3lFT fkMR7Vj88MkegpN/CWtiHj4PPjwnyuANhPdb3+vRqYU/6NCLS2WmT1O4PAjx+Nlf nyd2wU0okAebzOk9LEQVPHik2EalFLRXbLtrYiu4IQRuKEnQEugzLUJRaQKBgQDL 7OEA1suUqYOilEbD/HZ223jWF8SHzhcajyCU5Fp6kW97cSWJAFeofmaq8nySLGjz JZRVTZPyEXRTGvJea7vkIUW0tD87SWLr9eBj/2vaDeFqNVI8LpbciMf+/NL6vajw yvpp9i6JblgLEoW8RESMML8xU4NASlMYESLfWV54hQKBgQDDGYAkxLpx1kxXrqF2 wh2JSs/CzcA3E45MEDZ2w5mIXELtHs315a8p346Kx0lcaKvOOGO0lBBZnYY6x710 Sa2CaF5vBjbS8c3UQxJ3uziyAboREwWf660owqymBiedZwzRBLb8z8eJgxDws8sR 82ZM1EWj/bvr8o1qIxbsaQbanA== -----END PRIVATE KEY----- ================================================ FILE: server/src/main/resources/keys/dev_jwt.pub ================================================ -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2QHPFYWoC3GVlyNQMOU5 31fc5TADSg9/rnPBZxLmNQqZ6toZVeUzdpw7DRHgV46HtqPaItHQfyKYud6Aq+7J sZJQmiROfwKXyCDM8f48LCjcfUM/xt3INipTo2pizy4UsuGitgVhl5Ms7zu9CJSd ZBGh6bPr5IfI7Eegp5HCsW6CyM5fHpwOz/z6eFLENXKgYhpz8Yt5uNndRrtWXIC7 /7oYMk3gHU46JpvHtcrEcvy9KE0Nvs8x5npGcRV+7PMVLGAkUu+NzO8jnQu3aHTT CHbuER6MHHP76WGx2msvub2WnHmaQkHwE7FQs2ezJRYN9Lu0grgTQuQQNnaOuo7I RQIDAQAB -----END PUBLIC KEY----- ================================================ FILE: server/src/main/resources/templates/email/base.html ================================================ Port Buddy
Port Buddy — share local and private services securely.
================================================ FILE: server/src/main/resources/templates/email/dns-instructions.html ================================================

Action Required: DNS Setup

Please add the following DNS TXT records to proceed with SSL issuance for example.com.

DNS TXT Record
Type
TXT
Name
_acme-challenge.example.com
Value
challenge-value
Challenge Expiration

This challenge will expire at: 2025-12-21

After creating the records and waiting for propagation, please confirm the setup in your dashboard or via CLI.

CLI Confirmation
port-buddy ssl confirm example.com

Need help? Visit the Documentation for docs and examples.

================================================ FILE: server/src/main/resources/templates/email/password-reset-success.html ================================================

Hello!

Your Port Buddy password has been successfully reset.

Go to Login

If you did not perform this action, please contact support immediately.

Thanks,
The Port Buddy Team

================================================ FILE: server/src/main/resources/templates/email/password-reset.html ================================================

Hello!

You recently requested to reset your password for your Port Buddy account. Use the button below to reset it. This password reset is only valid for the next 1 hour.

Reset your password

For security, this request was received from a Port Buddy service. If you did not request a password reset, please ignore this email or contact support if you have questions.

Thanks,
The Port Buddy Team

================================================ FILE: server/src/main/resources/templates/email/payment-failed.html ================================================

Action Required: Payment Failed

Hi there,

We were unable to process your recent payment of $10.00 USD for your Port Buddy subscription.

To keep your tunnels active and prevent any service interruptions, please update your payment information as soon as possible.

Update Payment Method

If you've already updated your payment details, you can ignore this email. We will automatically attempt to charge your card again in a few days.

Need help? Reply to this email or visit our Billing page.

================================================ FILE: server/src/main/resources/templates/email/plan-changed.html ================================================

Your Plan has Been Updated

Hi there,

This is a confirmation that your Port Buddy plan has been updated to Pro.

Your updated limits:
  • 1 active tunnels
  • 0 extra tunnels

Go to Dashboard

If you didn't authorize this change, please contact our support team immediately.

Happy tunneling!
The Port Buddy Team

================================================ FILE: server/src/main/resources/templates/email/subscription-canceled.html ================================================

Subscription Canceled

Hi there,

Your Port Buddy subscription has been canceled. We're sorry to see you go!

Your account has been reverted to the Free plan. Any tunnels exceeding the free limit have been stopped.

If this was a mistake, or if you change your mind, you can re-subscribe at any time from your billing portal.

Go to Billing Portal

Thank you for using Port Buddy. If you have any feedback on how we can improve, please let us know!

================================================ FILE: server/src/main/resources/templates/email/subscription-success.html ================================================

Subscription Successful!

Hi there,

Thank you for subscribing to the Pro plan! Your account has been upgraded, and you now have access to all the features included in your new plan.

Your new limits:
  • 1 active tunnels
  • 0 extra tunnels

Go to Dashboard

If you have any questions about your subscription or need help getting started, just reply to this email.

Happy tunneling!
The Port Buddy Team

================================================ FILE: server/src/main/resources/templates/email/team-invite.html ================================================

You've been invited!

Some User has invited you to join their team Account Name on Port Buddy.

By joining this team, you'll be able to share the team's tunnel limits and collaborate with other members.

Accept Invitation

If you weren't expecting this invitation, you can safely ignore this email. This invitation will expire in 7 days.

================================================ FILE: server/src/main/resources/templates/email/welcome.html ================================================

Welcome to Port Buddy!

Let’s get your local and private services online in seconds.

Port Buddy securely exposes local or private network ports over the public internet. Share your work, demo features, or collaborate with your team — without deploying.

Open My Account

What you can do with Port Buddy
Expose HTTP apps

Share your local web app with a secure public URL (HTTP & WebSocket).

Share TCP services

Give teammates access to databases or any TCP service with a temporary public port.

Simple CLI

One command to expose a port. Auth with API token. Pro plan is free.

Quick start

Expose an HTTP service running on localhost:3000:

portbuddy 3000

Expose a TCP service, e.g. PostgreSQL on 5432:

port-buddy tcp 5432

Tip: generate an API token in your account and initialize the CLI once:

port-buddy init <YOUR_API_TOKEN>

Need help? Visit the Documentation for docs and examples.

================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/security/Oauth2SuccessHandlerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.security; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.ParameterizedTypeReference; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.web.client.RestClient; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.service.user.MissingEmailException; import tech.amak.portbuddy.server.service.user.UserProvisioningService; @ExtendWith(MockitoExtension.class) @org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) class Oauth2SuccessHandlerTest { @Mock private JwtService jwtService; @Mock private AppProperties properties; @Mock private UserProvisioningService userProvisioningService; @Mock private OAuth2AuthorizedClientService authorizedClientService; @Mock private RestClient.Builder restClientBuilder; @Mock private RestClient restClient; private Oauth2SuccessHandler handler; @Mock private HttpServletRequest request; @Mock private HttpServletResponse response; @BeforeEach void setUp() { when(restClientBuilder.build()).thenReturn(restClient); handler = new Oauth2SuccessHandler( jwtService, properties, userProvisioningService, authorizedClientService, restClientBuilder); // AppProperties config final var gateway = mock(AppProperties.Gateway.class); when(properties.gateway()).thenReturn(gateway); when(gateway.url()).thenReturn("http://localhost:8443"); } @Test void onAuthenticationSuccess_WithEmail_ShouldProvisionAndRedirect() throws IOException { final var attributes = new HashMap(); attributes.put("id", "123"); attributes.put("email", "test@example.com"); attributes.put("name", "Test User"); final var principal = new DefaultOAuth2User(List.of(), attributes, "id"); final var authentication = new OAuth2AuthenticationToken(principal, List.of(), "google"); final var userId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); when(userProvisioningService.provision(anyString(), anyString(), anyString(), anyString(), anyString(), any())) .thenReturn(new UserProvisioningService.ProvisionedUser( userId, accountId, "Test Account", Collections.emptySet())); when(jwtService.createToken(any(), anyString(), any())).thenReturn("mock-token"); handler.onAuthenticationSuccess(request, response, authentication); verify(response).sendRedirect("http://localhost:8443/auth/callback?token=mock-token"); } @Test @SuppressWarnings("unchecked") void onAuthenticationSuccess_MissingEmailGithub_ShouldFetchEmail() throws IOException { final var attributes = new HashMap(); attributes.put("id", 123); attributes.put("name", "Github User"); final var principal = new DefaultOAuth2User(List.of(), attributes, "id"); final var authentication = new OAuth2AuthenticationToken(principal, List.of(), "github"); final var client = mock(OAuth2AuthorizedClient.class); final var accessToken = mock(OAuth2AccessToken.class); when(accessToken.getTokenValue()).thenReturn("gh-token"); when(client.getAccessToken()).thenReturn(accessToken); when(authorizedClientService.loadAuthorizedClient(eq("github"), anyString())).thenReturn(client); // Mock RestClient calls final var getSpec = mock(RestClient.RequestHeadersUriSpec.class); final var headersSpec = mock(RestClient.RequestHeadersSpec.class); final var responseSpec = mock(RestClient.ResponseSpec.class); when(restClient.get()).thenReturn(getSpec); when(getSpec.uri("https://api.github.com/user/emails")).thenReturn(headersSpec); when(headersSpec.headers(any(Consumer.class))).thenReturn(headersSpec); when(headersSpec.retrieve()).thenReturn(responseSpec); final var emails = List.of( Map.of("email", "primary@example.com", "primary", true, "verified", true) ); when(responseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(emails); final var userId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); when(userProvisioningService.provision( eq("github"), eq("123"), eq("primary@example.com"), anyString(), anyString(), any())) .thenReturn(new UserProvisioningService.ProvisionedUser( userId, accountId, "Test Account", Collections.emptySet())); when(jwtService.createToken(any(), anyString(), any())).thenReturn("mock-token"); handler.onAuthenticationSuccess(request, response, authentication); verify(response).sendRedirect("http://localhost:8443/auth/callback?token=mock-token"); } @Test void onAuthenticationSuccess_MissingEmailNotGithub_ShouldRedirectWithError() throws IOException { final var attributes = new HashMap(); attributes.put("id", "123"); attributes.put("name", "Other User"); final var principal = new DefaultOAuth2User(List.of(), attributes, "id"); final var authentication = new OAuth2AuthenticationToken(principal, List.of(), "other-provider"); when(userProvisioningService.provision(anyString(), anyString(), eq(null), anyString(), anyString(), any())) .thenThrow(new MissingEmailException("Email is required")); handler.onAuthenticationSuccess(request, response, authentication); verify(response).sendRedirect(org.mockito.ArgumentMatchers.contains("error=missing_email")); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/service/DomainServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.unit.DataSize; import tech.amak.portbuddy.server.client.SslServiceClient; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.DomainRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; @ExtendWith(MockitoExtension.class) class DomainServiceTest { @Mock private DomainRepository domainRepository; @Mock private UserRepository userRepository; @Mock private TunnelRepository tunnelRepository; @Mock private PasswordEncoder passwordEncoder; @Mock private SslServiceClient sslServiceClient; private DomainService domainService; private AccountEntity account; @BeforeEach void setUp() { final var gateway = new AppProperties.Gateway( "url", "portbuddy.dev", "https", "https://portbuddy.dev/404", "https://portbuddy.dev/passcode", DataSize.ofMegabytes(10)); final var mail = new AppProperties.Mail("no-reply@localhost", "Port Buddy"); final var portReservations = new AppProperties.PortReservations(new AppProperties.PortReservations.Range(40000, 60000)); final var appProps = new AppProperties( gateway, null, null, mail, new AppProperties.Cli("1.0"), portReservations, null, null); domainService = new DomainService( domainRepository, tunnelRepository, appProps, passwordEncoder, sslServiceClient, userRepository); account = new AccountEntity(); account.setId(UUID.randomUUID()); } @Test void createDomain_Success() { when(domainRepository.existsBySubdomainGlobal(anyString())).thenReturn(false); when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]); final var domainOpt = domainService.createDomain(account); assertTrue(domainOpt.isPresent()); final var domain = domainOpt.get(); assertEquals("portbuddy.dev", domain.getDomain()); assertNotNull(domain.getSubdomain()); verify(domainRepository).save(any(DomainEntity.class)); } @Test void createDomain_RetryOnCollision() { // First attempt collision, second success when(domainRepository.existsBySubdomainGlobal(anyString())) .thenReturn(true) .thenReturn(false); when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]); final var domain = domainService.createDomain(account); assertTrue(domain.isPresent()); // Called 3 times: 1st loop (collision), 2nd loop (success), post-loop check (success) verify(domainRepository, times(3)).existsBySubdomainGlobal(anyString()); } @Test void updateDomain_Success() { final var id = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setId(id); domain.setSubdomain("old"); domain.setAccount(account); when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain)); when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(false); when(domainRepository.existsBySubdomainGlobal("new")).thenReturn(false); when(domainRepository.save(any(DomainEntity.class))).thenAnswer(i -> i.getArguments()[0]); final var updated = domainService.updateDomain(id, account, "new"); assertEquals("new", updated.getSubdomain()); verify(domainRepository).save(domain); } @Test void updateDomain_ActiveTunnel() { final var id = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setId(id); domain.setSubdomain("old"); when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain)); when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(true); assertThrows(RuntimeException.class, () -> domainService.updateDomain(id, account, "new")); } @Test void deleteDomain_Success() { final var id = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setId(id); domain.setSubdomain("foo"); when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain)); when(tunnelRepository.existsByDomainAndStatusNot(domain, TunnelStatus.CLOSED)).thenReturn(false); domainService.deleteDomain(id, account); verify(domainRepository).delete(domain); } @Test void deleteCustomDomain_Success() { final var id = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setId(id); domain.setCustomDomain("custom.com"); domain.setCnameVerified(true); domain.setAccount(account); when(domainRepository.findByIdAndAccount(id, account)).thenReturn(Optional.of(domain)); domainService.deleteCustomDomain(id, account); assertNull(domain.getCustomDomain()); assertFalse(domain.isCnameVerified()); verify(domainRepository).save(domain); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/service/PaymentCleanupServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; @ExtendWith(MockitoExtension.class) class PaymentCleanupServiceTest { @Mock private AccountRepository accountRepository; @Mock private TunnelService tunnelService; @Mock private AppProperties appProperties; @Mock private AppProperties.Subscriptions subscriptions; private PaymentCleanupService paymentCleanupService; @BeforeEach void setUp() { when(appProperties.subscriptions()).thenReturn(subscriptions); paymentCleanupService = new PaymentCleanupService(accountRepository, tunnelService, appProperties); } @Test void cleanupFailedPayments_FindsAccounts_FreezesTunnels() { final var gracePeriod = Duration.ofDays(3); when(subscriptions.gracePeriod()).thenReturn(gracePeriod); final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setSubscriptionStatus("past_due"); when(accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(any(OffsetDateTime.class))) .thenReturn(List.of(account)); paymentCleanupService.cleanupFailedPayments(); verify(tunnelService).closeAllTunnels(account); } @Test void cleanupFailedPayments_NoAccounts_DoesNothing() { final var gracePeriod = Duration.ofDays(3); when(subscriptions.gracePeriod()).thenReturn(gracePeriod); when(accountRepository.findBySubscriptionStatusNotActiveAndUpdatedAtBefore(any(OffsetDateTime.class))) .thenReturn(List.of()); paymentCleanupService.cleanupFailedPayments(); verify(tunnelService, never()).closeAllTunnels(any()); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/service/PortReservationServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.when; import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.PortReservationEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.PortReservationRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; @ExtendWith(MockitoExtension.class) class PortReservationServiceTest { @Mock private PortReservationRepository repository; @Mock private ProxyDiscoveryService proxyDiscoveryService; @Mock private TunnelRepository tunnelRepository; @Mock private AppProperties properties; private PortReservationService service; private AccountEntity account; private UserEntity user; @BeforeEach void setUp() { service = new PortReservationService(repository, proxyDiscoveryService, tunnelRepository, properties); account = new AccountEntity(); account.setId(UUID.randomUUID()); user = new UserEntity(); user.setId(UUID.randomUUID()); } @Test void resolveForNetExpose_ExplicitHostPort_Success() { final String host = "proxy-1.portbuddy.dev"; final int port = 12345; final String explicit = host + ":" + port; final var reservation = new PortReservationEntity(); reservation.setPublicHost(host); reservation.setPublicPort(port); when(repository.findByAccountAndPublicHostAndPublicPort(account, host, port)) .thenReturn(Optional.of(reservation)); when(tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED)) .thenReturn(false); final var result = service.resolveForNetExpose(account, user, "localhost", 8080, explicit); assertEquals(reservation, result); } @Test void resolveForNetExpose_ExplicitName_Success() { final String name = "my-reservation"; final var reservation = new PortReservationEntity(); reservation.setName(name); when(repository.findByAccountAndNameIgnoreCase(account, name)) .thenReturn(Optional.of(reservation)); when(tunnelRepository.existsByPortReservationAndStatusNot(reservation, TunnelStatus.CLOSED)) .thenReturn(false); final var result = service.resolveForNetExpose(account, user, "localhost", 8080, name); assertEquals(reservation, result); } @Test void resolveForNetExpose_ExplicitPortOnly_MultipleFound_TakesFirst() { final int port = 12345; final String explicit = String.valueOf(port); final var reservation1 = new PortReservationEntity(); reservation1.setPublicHost("proxy-1.portbuddy.dev"); reservation1.setPublicPort(port); final var reservation2 = new PortReservationEntity(); reservation2.setPublicHost("proxy-2.portbuddy.dev"); reservation2.setPublicPort(port); when(repository.findAllByAccountAndPublicPort(account, port)) .thenReturn(List.of(reservation1, reservation2)); when(tunnelRepository.existsByPortReservationAndStatusNot(reservation1, TunnelStatus.CLOSED)) .thenReturn(false); final var result = service.resolveForNetExpose(account, user, "localhost", 8080, explicit); assertEquals(reservation1, result); } @Test void resolveForNetExpose_ExplicitPortOnly_NotFound_ThrowsException() { final int port = 12345; final String explicit = String.valueOf(port); when(repository.findAllByAccountAndPublicPort(account, port)) .thenReturn(List.of()); assertThrows(IllegalArgumentException.class, () -> service.resolveForNetExpose(account, user, "localhost", 8080, explicit)); } @Test void updateReservation_DuplicateName_ThrowsException() { final UUID id = UUID.randomUUID(); final String name = "duplicate-name"; final var existing = new PortReservationEntity(); existing.setName("old-name"); when(repository.findByIdAndAccount(id, account)).thenReturn(Optional.of(existing)); when(repository.existsByAccountAndName(account, name)).thenReturn(true); assertThrows(IllegalArgumentException.class, () -> service.updateReservation(account, id, null, null, name)); } @Test void updateReservation_SameName_Success() { final UUID id = UUID.randomUUID(); final String name = "same-name"; final var existing = new PortReservationEntity(); existing.setName(name); when(repository.findByIdAndAccount(id, account)).thenReturn(Optional.of(existing)); when(repository.saveAndFlush(existing)).thenReturn(existing); final var result = service.updateReservation(account, id, null, null, name); assertEquals(name, result.getName()); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/service/StaleTunnelsReaperTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; import tech.amak.portbuddy.server.config.TunnelsProperties; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; class StaleTunnelsReaperTest { @Test void shouldCloseTunnelsInRegistryWhenReaped() { // Given final var tunnelRepository = mock(TunnelRepository.class); final var tunnelsProperties = new TunnelsProperties(); tunnelsProperties.setHeartbeatTimeout(Duration.ofMinutes(1)); final var tunnelRegistry = mock(TunnelRegistry.class); final var reaper = new StaleTunnelsReaper(tunnelRepository, tunnelsProperties, tunnelRegistry); final var tunnelId = UUID.randomUUID(); when(tunnelRepository.closeStaleConnected(any(OffsetDateTime.class))) .thenReturn(List.of(tunnelId)); // When reaper.closeStaleTunnels(); // Then verify(tunnelRegistry).closeTunnel(tunnelId); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/service/TunnelServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.service; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.common.TunnelType; import tech.amak.portbuddy.common.dto.ExposeRequest; import tech.amak.portbuddy.server.client.NetProxyClient; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.TunnelEntity; import tech.amak.portbuddy.server.db.entity.TunnelStatus; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; @ExtendWith(MockitoExtension.class) class TunnelServiceTest { @Mock private TunnelRepository tunnelRepository; @Mock private AccountRepository accountRepository; @Mock private TunnelRegistry tunnelRegistry; @Mock private NetProxyClient netProxyClient; private TunnelService tunnelService; private AccountEntity account; @BeforeEach void setUp() { final var properties = new AppProperties( null, null, null, null, null, null, new AppProperties.Subscriptions( Duration.ofDays(3), Duration.ofHours(1), new AppProperties.Subscriptions.Tunnels( Map.of(Plan.PRO, 1, Plan.TEAM, 10), Map.of(Plan.PRO, 1, Plan.TEAM, 5))), null ); tunnelService = new TunnelService( tunnelRepository, accountRepository, properties, Optional.empty(), tunnelRegistry, netProxyClient); account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setPlan(Plan.PRO); account.setSubscriptionStatus("active"); } @Test void checkTunnelLimit_ActiveSubscription_Success() { when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(0L); assertDoesNotThrow(() -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); } @Test void checkTunnelLimit_InactiveSubscription_ThrowsException() { account.setSubscriptionStatus("past_due"); assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); } @Test void markConnected_InactiveSubscription_ThrowsException() { final var tunnelId = UUID.randomUUID(); final var tunnel = new TunnelEntity(); tunnel.setId(tunnelId); tunnel.setAccountId(account.getId()); account.setSubscriptionStatus("canceled"); when(tunnelRepository.findById(tunnelId)).thenReturn(Optional.of(tunnel)); when(accountRepository.findById(account.getId())).thenReturn(Optional.of(account)); assertThrows(IllegalStateException.class, () -> tunnelService.markConnected(tunnelId)); } @Test void heartbeat_InactiveSubscription_ThrowsException() { final var tunnelId = UUID.randomUUID(); final var tunnel = new TunnelEntity(); tunnel.setId(tunnelId); tunnel.setAccountId(account.getId()); account.setSubscriptionStatus("past_due"); when(tunnelRepository.findById(tunnelId)).thenReturn(Optional.of(tunnel)); when(accountRepository.findById(account.getId())).thenReturn(Optional.of(account)); assertThrows(IllegalStateException.class, () -> tunnelService.heartbeat(tunnelId)); } @Test void checkTunnelLimit_LimitReached_ThrowsException() { when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(1L); account.setExtraTunnels(0); final var exception = assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); assertEquals("Tunnel limit reached for your plan (1). Please upgrade or add more tunnels.", exception.getMessage()); } @Test void enforceTunnelLimit_ExceedsLimit_ClosesExcessTunnels() { account.setPlan(Plan.PRO); account.setExtraTunnels(0); // Limit is 1 final var t1 = new TunnelEntity(); t1.setId(UUID.randomUUID()); t1.setStatus(TunnelStatus.CONNECTED); final var t2 = new TunnelEntity(); t2.setId(UUID.randomUUID()); t2.setStatus(TunnelStatus.CONNECTED); when(tunnelRepository.findByAccountIdAndStatusInOrderByLastHeartbeatAtAscCreatedAtAsc( eq(account.getId()), any())) .thenReturn(List.of(t1, t2)); tunnelService.enforceTunnelLimit(account); assertEquals(TunnelStatus.CLOSED, t1.getStatus()); // t2 is the second one, so it remains connected if limit is 1 assertEquals(TunnelStatus.CONNECTED, t2.getStatus()); verify(tunnelRepository, times(1)).save(t1); verify(tunnelRepository, times(0)).save(t2); } @Test void checkTunnelLimit_ProPlanNoSubscription_Success() { account.setSubscriptionStatus(null); account.setPlan(Plan.PRO); account.setExtraTunnels(0); when(tunnelRepository.countByAccountIdAndStatusIn(any(), any())).thenReturn(0L); assertDoesNotThrow(() -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); } @Test void checkTunnelLimit_ProPlanWithExtraNoSubscription_ThrowsException() { account.setSubscriptionStatus(null); account.setPlan(Plan.PRO); account.setExtraTunnels(1); assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); } @Test void checkTunnelLimit_TeamPlanNoSubscription_ThrowsException() { account.setSubscriptionStatus(null); account.setPlan(Plan.TEAM); assertThrows(IllegalStateException.class, () -> tunnelService.createHttpTunnel( account, UUID.randomUUID(), null, createRequest(), "http://abc.pb.dev", new DomainEntity())); } private ExposeRequest createRequest() { return new ExposeRequest(TunnelType.HTTP, "http", "localhost", 8080, null, null, null); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/tunnel/TunnelRegistryLeakTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.tunnel; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.web.socket.WebSocketSession; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.server.db.entity.DomainEntity; import tech.amak.portbuddy.server.db.entity.TunnelEntity; class TunnelRegistryLeakTest { @Test void shouldCloseBrowserSessionsWhenTunnelIsClosed() throws Exception { // Given final var mapper = new ObjectMapper(); final var registry = new TunnelRegistry(mapper); final var tunnelId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setSubdomain("test"); final var tunnelEntity = new TunnelEntity(); tunnelEntity.setId(tunnelId); tunnelEntity.setAccountId(accountId); tunnelEntity.setDomain(domain); final var tunnelSession = mock(WebSocketSession.class); when(tunnelSession.isOpen()).thenReturn(true); registry.register(tunnelEntity, tunnelSession); final var browserSession = mock(WebSocketSession.class); when(browserSession.isOpen()).thenReturn(true); final var connectionId = "conn-1"; registry.registerBrowserWs(tunnelId, connectionId, browserSession); // Verify it is registered assert registry.getByTunnelId(tunnelId) != null; assert registry.getBySubdomain("test") != null; // When registry.closeTunnel(tunnelId); // Then - the browser session should be closed verify(browserSession).close(); // And it should be removed from maps assert registry.getByTunnelId(tunnelId) == null; assert registry.getBySubdomain("test") == null; } @Test void shouldCleanupPendingRequestsOnTimeout() throws Exception { // Given final var mapper = new ObjectMapper(); final var registry = new TunnelRegistry(mapper); final var tunnelId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setSubdomain("test"); final var tunnelEntity = new TunnelEntity(); tunnelEntity.setId(tunnelId); tunnelEntity.setAccountId(accountId); tunnelEntity.setDomain(domain); final var tunnelSession = mock(WebSocketSession.class); when(tunnelSession.isOpen()).thenReturn(true); registry.register(tunnelEntity, tunnelSession); final var request = new tech.amak.portbuddy.common.tunnel.HttpTunnelMessage(); request.setId("req-1"); // When final var future = registry.forwardRequest("test", request, java.time.Duration.ofMillis(100)); final var tunnel = registry.getByTunnelId(tunnelId); assert tunnel.pending().containsKey("req-1"); // Wait for timeout Thread.sleep(200); // Then assert future.isCompletedExceptionally(); assert !tunnel.pending().containsKey("req-1"); } @Test void shouldCleanupBrowserReverseMapWhenUnregistered() { // Given final var mapper = new ObjectMapper(); final var registry = new TunnelRegistry(mapper); final var tunnelId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var domain = new DomainEntity(); domain.setSubdomain("test"); final var tunnelEntity = new TunnelEntity(); tunnelEntity.setId(tunnelId); tunnelEntity.setAccountId(accountId); tunnelEntity.setDomain(domain); final var tunnelSession = mock(WebSocketSession.class); when(tunnelSession.isOpen()).thenReturn(true); registry.register(tunnelEntity, tunnelSession); final var browserSession = mock(WebSocketSession.class); final var connectionId = "conn-1"; registry.registerBrowserWs(tunnelId, connectionId, browserSession); final var tunnel = registry.getByTunnelId(tunnelId); assert tunnel.browserByConnection().containsKey(connectionId); assert tunnel.browserReverse().containsKey(browserSession); // When registry.unregisterBrowserWs(browserSession); // Then assert !tunnel.browserByConnection().containsKey(connectionId); assert !tunnel.browserReverse().containsKey(browserSession); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/user/PasswordResetServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.user; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.OffsetDateTime; import java.util.Optional; import java.util.UUID; 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.security.crypto.password.PasswordEncoder; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.PasswordResetTokenEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.PasswordResetTokenRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.mail.EmailService; import tech.amak.portbuddy.server.service.user.PasswordResetService; @ExtendWith(MockitoExtension.class) class PasswordResetServiceTest { @Mock private UserRepository userRepository; @Mock private PasswordResetTokenRepository tokenRepository; @Mock private EmailService emailService; @Mock private PasswordEncoder passwordEncoder; @Mock private AppProperties properties; @Mock private AppProperties.Gateway gateway; @InjectMocks private PasswordResetService service; @Test void requestReset_shouldGenerateTokenAndSendEmail_whenUserExists() { final var email = "test@example.com"; final var user = new UserEntity(); user.setEmail(email); user.setId(UUID.randomUUID()); when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(user)); when(properties.gateway()).thenReturn(gateway); when(gateway.url()).thenReturn("http://localhost"); service.requestReset(email); verify(tokenRepository).deleteByUser(user); verify(tokenRepository).save(any(PasswordResetTokenEntity.class)); verify(emailService).sendTemplate(eq(email), anyString(), eq("email/password-reset"), anyMap()); } @Test void requestReset_shouldDoNothing_whenUserDoesNotExist() { final var email = "unknown@example.com"; when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty()); service.requestReset(email); verify(tokenRepository, org.mockito.Mockito.never()).save(any()); verify(emailService, org.mockito.Mockito.never()).sendTemplate(anyString(), anyString(), anyString(), anyMap()); } @Test void validateToken_shouldReturnTrue_whenTokenIsValid() { final var token = "valid-token"; final var entity = new PasswordResetTokenEntity(); entity.setToken(token); entity.setExpiryDate(OffsetDateTime.now().plusHours(1)); when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity)); assertTrue(service.validateToken(token)); } @Test void validateToken_shouldReturnFalse_whenTokenIsExpired() { final var token = "expired-token"; final var entity = new PasswordResetTokenEntity(); entity.setToken(token); entity.setExpiryDate(OffsetDateTime.now().minusHours(1)); when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity)); assertFalse(service.validateToken(token)); } @Test void resetPassword_shouldUpdatePassword_whenTokenIsValid() { final var token = "valid-token"; final var newPass = "new-password"; final var entity = new PasswordResetTokenEntity(); entity.setToken(token); entity.setExpiryDate(OffsetDateTime.now().plusHours(1)); final var user = new UserEntity(); user.setEmail("user@example.com"); entity.setUser(user); when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity)); when(passwordEncoder.encode(newPass)).thenReturn("encoded-password"); when(properties.gateway()).thenReturn(gateway); when(gateway.url()).thenReturn("http://localhost"); service.resetPassword(token, newPass); verify(userRepository).save(user); verify(tokenRepository).delete(entity); verify(emailService) .sendTemplate(eq(user.getEmail()), anyString(), eq("email/password-reset-success"), anyMap()); } @Test void resetPassword_shouldThrow_whenTokenIsExpired() { final var token = "expired-token"; final var entity = new PasswordResetTokenEntity(); entity.setToken(token); entity.setExpiryDate(OffsetDateTime.now().minusHours(1)); when(tokenRepository.findByToken(token)).thenReturn(Optional.of(entity)); assertThrows(IllegalArgumentException.class, () -> service.resetPassword(token, "new-pass")); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/AuthControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.Optional; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.dto.auth.RegisterRequest; import tech.amak.portbuddy.common.dto.auth.TokenExchangeRequest; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.Role; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.service.ApiTokenService; import tech.amak.portbuddy.server.service.user.PasswordResetService; import tech.amak.portbuddy.server.service.user.UserProvisioningService; import tech.amak.portbuddy.server.service.user.UserProvisioningService.ProvisionedUser; import tech.amak.portbuddy.server.web.dto.LoginRequest; import tech.amak.portbuddy.server.web.dto.PasswordResetConfirm; import tech.amak.portbuddy.server.web.dto.PasswordResetRequest; @WebMvcTest(AuthController.class) @AutoConfigureMockMvc(addFilters = false) // Disable security filters to focus on controller logic class AuthControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @MockitoBean private UserProvisioningService userProvisioningService; @MockitoBean private ApiTokenService apiTokenService; @MockitoBean private JwtService jwtService; @MockitoBean private UserRepository userRepository; @MockitoBean private PasswordEncoder passwordEncoder; @MockitoBean private PasswordResetService passwordResetService; @MockitoBean private AppProperties appProperties; @MockitoBean private UserAccountRepository userAccountRepository; @MockitoBean private AccountRepository accountRepository; @Test void register_shouldReturnApiKey() throws Exception { final var request = new RegisterRequest("test@example.com", "Test User", "password"); final var userId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var apiKey = "test-api-key"; when(userProvisioningService.createLocalUser(any(), any(), any())) .thenReturn(new ProvisionedUser(userId, accountId, "Test Account", Set.of(Role.ACCOUNT_ADMIN))); when(apiTokenService.createToken(accountId, userId, "prtb-client")) .thenReturn(new ApiTokenService.CreatedToken(UUID.randomUUID().toString(), apiKey)); mockMvc.perform(post("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.apiKey").value(apiKey)) .andExpect(jsonPath("$.success").value(true)); } @Test void register_shouldReturnError_whenMissingFields() throws Exception { final var request = new RegisterRequest(null, "Test User", "password"); mockMvc.perform(post("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) // We now return 200 with error details .andExpect(jsonPath("$.success").value(false)) .andExpect(jsonPath("$.message").value("Email is required")) .andExpect(jsonPath("$.statusCode").value(400)); } @Test void requestPasswordReset_shouldReturnNoContent() throws Exception { final var request = new PasswordResetRequest("test@example.com"); mockMvc.perform(post("/api/auth/password-reset/request") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNoContent()); } @Test void tokenExchange_shouldReturnJwt() throws Exception { final var apiToken = "valid-token"; final var clientVersion = "1.0.0"; final var request = new TokenExchangeRequest(apiToken, clientVersion); final var accountId = UUID.randomUUID(); final var userId = UUID.randomUUID(); final var apiKeyId = UUID.randomUUID(); when(appProperties.cli()).thenReturn(new AppProperties.Cli("1.0.0")); when(apiTokenService.validateAndGetApiKey(apiToken)) .thenReturn(Optional.of(new ApiTokenService.ValidatedApiKey(userId, accountId, apiKeyId))); final var account = new AccountEntity(); account.setId(accountId); account.setBlocked(false); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); when(jwtService.createToken(any(), any())).thenReturn("test-jwt"); mockMvc.perform(post("/api/auth/token-exchange") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").value("test-jwt")); } @Test void login_shouldReturnJwt() throws Exception { final var email = "test@example.com"; final var password = "password"; final var request = new LoginRequest(email, password); final var userId = UUID.randomUUID(); final var user = new UserEntity(); user.setId(userId); user.setEmail(email); user.setFirstName("Test"); user.setPassword("hashed-password"); when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(user)); when(passwordEncoder.matches(password, "hashed-password")).thenReturn(true); final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setBlocked(false); final var userAccount = new UserAccountEntity(user, account, Set.of(Role.ACCOUNT_ADMIN)); when(userAccountRepository.findLatestUsedByUserId(userId)).thenReturn(Optional.of(userAccount)); when(jwtService.createToken(any(), any(), any())).thenReturn("login-jwt"); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken").value("login-jwt")); } @Test void confirmPasswordReset_shouldReturnNoContent() throws Exception { final var request = new PasswordResetConfirm("valid-token", "new-password"); mockMvc.perform(post("/api/auth/password-reset/confirm") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNoContent()); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/IngressControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.util.unit.DataSize; import tech.amak.portbuddy.common.tunnel.HttpTunnelMessage; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.DomainRepository; import tech.amak.portbuddy.server.service.ApiTokenService; import tech.amak.portbuddy.server.service.TunnelService; import tech.amak.portbuddy.server.tunnel.TunnelRegistry; import tech.amak.portbuddy.server.tunnel.TunnelRegistry.Tunnel; @WebMvcTest(IngressController.class) @AutoConfigureMockMvc(addFilters = false) class IngressControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private TunnelRegistry registry; @MockitoBean private AppProperties properties; @MockitoBean private DomainRepository domainRepository; @MockitoBean private AccountRepository accountRepository; @MockitoBean private TunnelService tunnelService; @MockitoBean private ApiTokenService apiTokenService; @MockitoBean private PasswordEncoder passwordEncoder; @BeforeEach void setUp() { final var gateway = new AppProperties.Gateway( "http://localhost", "portbuddy.dev", "https://%s.portbuddy.dev", "http://localhost/404", "http://localhost/passcode", DataSize.ofKilobytes(1) ); when(properties.gateway()).thenReturn(gateway); } @Test void forwardViaTunnel_shouldRejectLargeRequest() throws Exception { final var subdomain = "test"; final var tunnelId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var mockTunnel = mock(Tunnel.class); when(mockTunnel.isOpen()).thenReturn(true); when(mockTunnel.tunnelId()).thenReturn(tunnelId); when(mockTunnel.accountId()).thenReturn(accountId); when(registry.getBySubdomain(subdomain)).thenReturn(mockTunnel); final var account = new AccountEntity(); account.setId(accountId); account.setSubscriptionStatus("active"); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); // Content-Length = 2KB (exceeds 1KB limit) final var largeBody = new byte[2048]; mockMvc.perform(post("/_/" + subdomain + "/some-path") .content(largeBody) .contentType(MediaType.APPLICATION_OCTET_STREAM)) .andExpect(status().is(413)); } @Test void forwardViaTunnel_shouldAcceptSmallRequest() throws Exception { final var subdomain = "test"; final var tunnelId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var mockTunnel = mock(Tunnel.class); when(mockTunnel.isOpen()).thenReturn(true); when(mockTunnel.tunnelId()).thenReturn(tunnelId); when(mockTunnel.accountId()).thenReturn(accountId); when(registry.getBySubdomain(subdomain)).thenReturn(mockTunnel); final var account = new AccountEntity(); account.setId(accountId); account.setSubscriptionStatus("active"); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); final var responseMsg = new HttpTunnelMessage(); responseMsg.setStatus(200); when(registry.forwardRequest(anyString(), any(), any())) .thenReturn(CompletableFuture.completedFuture(responseMsg)); // Content-Length = 512B (within 1KB limit) final var smallBody = new byte[512]; mockMvc.perform(post("/_/" + subdomain + "/some-path") .content(smallBody) .contentType(MediaType.APPLICATION_OCTET_STREAM)) .andExpect(status().isOk()); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/PaymentControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; 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.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.ApiTokenAuthFilter; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.security.Oauth2SuccessHandler; import tech.amak.portbuddy.server.service.StripeService; @WebMvcTest(PaymentController.class) @ActiveProfiles("test") public class PaymentControllerTest { private MockMvc mockMvc; @Autowired private PaymentController paymentController; @Autowired private ObjectMapper objectMapper; @MockitoBean private StripeService stripeService; @MockitoBean private UserRepository userRepository; @MockitoBean private AccountRepository accountRepository; @MockitoBean private JwtService jwtService; @MockitoBean private ApiTokenAuthFilter apiTokenAuthFilter; @MockitoBean private Oauth2SuccessHandler oauth2SuccessHandler; private UUID accountId; private UUID userId; private AccountEntity account; @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(paymentController) .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler()) .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.getParameterType().equals(Jwt.class); } @Override public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { final var principal = webRequest.getUserPrincipal(); if (principal instanceof JwtAuthenticationToken jwtToken) { return jwtToken.getToken(); } return null; } }) .build(); accountId = UUID.randomUUID(); userId = UUID.randomUUID(); account = new AccountEntity(); account.setId(accountId); account.setName("Test Account"); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); } @Test void createCheckoutSession_WithAccountAdmin_ShouldSucceed() throws Exception { when(stripeService.createCheckoutSession(any(), any())).thenReturn("https://checkout.stripe.com/test"); final var request = new PaymentController.CheckoutRequest(); request.setPlan(Plan.PRO); mockMvc.perform(post("/api/payments/create-checkout-session") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .principal(new JwtAuthenticationToken(createJwt(List.of("ACCOUNT_ADMIN"))))) .andExpect(status().isOk()); } @Test void createPortalSession_WithAdmin_ShouldSucceed() throws Exception { when(stripeService.createPortalSession(any())).thenReturn("https://billing.stripe.com/test"); mockMvc.perform(post("/api/payments/create-portal-session") .principal(new JwtAuthenticationToken(createJwt(List.of("ADMIN"))))) .andExpect(status().isOk()); } @Test void cancelSubscription_shouldUpdateAccount() throws Exception { account.setExtraTunnels(5); account.setSubscriptionStatus("active"); account.setStripeSubscriptionId("sub_123"); mockMvc.perform(post("/api/payments/cancel-subscription") .principal(new JwtAuthenticationToken(createJwt(List.of("ACCOUNT_ADMIN"))))) .andExpect(status().isNoContent()); org.mockito.Mockito.verify(stripeService).cancelSubscription(account); org.mockito.Mockito.verify(accountRepository).save(account); org.junit.jupiter.api.Assertions.assertEquals(0, account.getExtraTunnels()); org.junit.jupiter.api.Assertions.assertEquals("active", account.getSubscriptionStatus()); org.junit.jupiter.api.Assertions.assertEquals(Plan.PRO, account.getPlan()); org.junit.jupiter.api.Assertions.assertNull(account.getStripeSubscriptionId()); } // Note: standaloneSetup doesn't enforce @PreAuthorize. // To test @PreAuthorize we would need a full integration test or a different setup. // However, since I'm just verifying the controller logic works when authorized, // and I've manually added @PreAuthorize, this is a good start. private Jwt createJwt(final List roles) { return Jwt.withTokenValue("token") .header("alg", "none") .subject(userId.toString()) .claim("aid", accountId.toString()) .claim("roles", roles) .build(); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/StripeWebhookControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.util.unit.DataSize; import com.stripe.model.Event; import com.stripe.model.EventDataObjectDeserializer; import com.stripe.model.Invoice; import com.stripe.model.Subscription; import com.stripe.model.checkout.Session; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.StripeEventRepository; import tech.amak.portbuddy.server.mail.EmailService; import tech.amak.portbuddy.server.security.ApiTokenAuthFilter; import tech.amak.portbuddy.server.security.Oauth2SuccessHandler; import tech.amak.portbuddy.server.service.StripeService; import tech.amak.portbuddy.server.service.StripeWebhookService; import tech.amak.portbuddy.server.service.TunnelService; @WebMvcTest(StripeWebhookController.class) @AutoConfigureMockMvc(addFilters = false) class StripeWebhookControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private AccountRepository accountRepository; @MockitoBean private StripeEventRepository stripeEventRepository; @MockitoBean private EmailService emailService; @MockitoBean private TunnelService tunnelService; @MockitoBean private StripeWebhookService stripeWebhookService; @MockitoBean private StripeService stripeService; @MockitoBean private ApiTokenAuthFilter apiTokenAuthFilter; @MockitoBean private Oauth2SuccessHandler oauth2SuccessHandler; @MockitoBean private AppProperties appProperties; @BeforeEach void setUp() { when(appProperties.gateway()).thenReturn(new AppProperties.Gateway( "http://localhost:8080", "localhost", "http", "/404", "/passcode", DataSize.ofMegabytes(10) )); when(appProperties.stripe()).thenReturn(new AppProperties.Stripe( "whsec_test", "sk_test", new AppProperties.Stripe.PriceIds("pro", "team", "extra") )); } @Test void handleInvoicePaymentFailed_shouldSendEmail() throws Exception { final var customerId = "cus_123"; final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setStripeCustomerId(customerId); account.setPlan(Plan.PRO); final var user = new UserEntity(); user.setId(UUID.randomUUID()); user.setEmail("test@example.com"); user.setFirstName("Test"); final var userAccount = new UserAccountEntity(user, account, java.util.Set.of()); account.setUsers(List.of(userAccount)); when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account)); when(stripeEventRepository.existsById(anyString())).thenReturn(false); final var invoice = new Invoice(); invoice.setCustomer(customerId); invoice.setAmountDue(1000L); invoice.setCurrency("usd"); final var event = mock(Event.class); when(event.getId()).thenReturn("evt_123"); when(event.getType()).thenReturn("invoice.payment_failed"); final var deserializer = mock(EventDataObjectDeserializer.class); when(deserializer.getObject()).thenReturn(Optional.of(invoice)); when(event.getDataObjectDeserializer()).thenReturn(deserializer); when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event); mockMvc.perform(post("/api/webhooks/stripe") .header("Stripe-Signature", "sig") .content("{}") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); verify(emailService).sendTemplate( eq("test@example.com"), eq("Payment Failed - Port Buddy"), eq("email/payment-failed"), anyMap() ); } @Test void handleSubscriptionDeleted_shouldSendEmail() throws Exception { final var customerId = "cus_123"; final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setStripeCustomerId(customerId); account.setPlan(Plan.PRO); account.setSubscriptionStatus("active"); final var user = new UserEntity(); user.setId(UUID.randomUUID()); user.setEmail("test@example.com"); user.setFirstName("Test"); final var userAccount = new UserAccountEntity(user, account, java.util.Set.of()); account.setUsers(List.of(userAccount)); when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account)); when(stripeEventRepository.existsById(anyString())).thenReturn(false); final var subscription = new Subscription(); subscription.setCustomer(customerId); subscription.setStatus("canceled"); final var event = mock(Event.class); when(event.getId()).thenReturn("evt_456"); when(event.getType()).thenReturn("customer.subscription.deleted"); final var deserializer = mock(EventDataObjectDeserializer.class); when(deserializer.getObject()).thenReturn(Optional.of(subscription)); when(event.getDataObjectDeserializer()).thenReturn(deserializer); when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event); mockMvc.perform(post("/api/webhooks/stripe") .header("Stripe-Signature", "sig") .content("{}") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); verify(emailService).sendTemplate( eq("test@example.com"), eq("Subscription Canceled - Port Buddy"), eq("email/subscription-canceled"), anyMap() ); org.junit.jupiter.api.Assertions.assertEquals("active", account.getSubscriptionStatus()); org.junit.jupiter.api.Assertions.assertEquals(Plan.PRO, account.getPlan()); org.junit.jupiter.api.Assertions.assertNull(account.getStripeSubscriptionId()); } @Test void handleCheckoutSessionCompleted_withOldSubscription_shouldCancelOldSubscription() throws Exception { final var accountId = UUID.randomUUID(); final var oldSubId = "sub_old"; final var newSubId = "sub_new"; final var customerId = "cus_123"; final var account = new AccountEntity(); account.setId(accountId); account.setStripeCustomerId(customerId); account.setStripeSubscriptionId(oldSubId); account.setPlan(Plan.PRO); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); when(stripeEventRepository.existsById(anyString())).thenReturn(false); final var session = mock(Session.class); when(session.getId()).thenReturn("cs_123"); when(session.getCustomer()).thenReturn(customerId); when(session.getSubscription()).thenReturn(newSubId); when(session.getMetadata()).thenReturn(java.util.Map.of( "accountId", accountId.toString(), "plan", "TEAM", "extraTunnels", "5", "oldSubscriptionId", oldSubId )); final var event = mock(Event.class); when(event.getId()).thenReturn("evt_789"); when(event.getType()).thenReturn("checkout.session.completed"); final var deserializer = mock(EventDataObjectDeserializer.class); when(deserializer.getObject()).thenReturn(Optional.of(session)); when(event.getDataObjectDeserializer()).thenReturn(deserializer); when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event); mockMvc.perform(post("/api/webhooks/stripe") .header("Stripe-Signature", "sig") .content("{}") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); verify(stripeService).cancelSubscription("sub_old"); verify(accountRepository).save(account); assert account.getStripeSubscriptionId().equals(newSubId); assert account.getPlan() == Plan.TEAM; assert account.getExtraTunnels() == 5; } @Test void handleSubscriptionDeleted_forOldSubscription_shouldNotCancelAccount() throws Exception { final var customerId = "cus_123"; final var currentSubId = "sub_current"; final var oldSubId = "sub_old"; final var account = new AccountEntity(); account.setId(UUID.randomUUID()); account.setStripeCustomerId(customerId); account.setStripeSubscriptionId(currentSubId); account.setPlan(Plan.TEAM); account.setSubscriptionStatus("active"); when(accountRepository.findByStripeCustomerId(customerId)).thenReturn(Optional.of(account)); when(stripeEventRepository.existsById(anyString())).thenReturn(false); final var subscription = new Subscription(); subscription.setId(oldSubId); subscription.setCustomer(customerId); subscription.setStatus("canceled"); final var event = mock(Event.class); when(event.getId()).thenReturn("evt_old_deleted"); when(event.getType()).thenReturn("customer.subscription.deleted"); final var deserializer = mock(EventDataObjectDeserializer.class); when(deserializer.getObject()).thenReturn(Optional.of(subscription)); when(event.getDataObjectDeserializer()).thenReturn(deserializer); when(stripeWebhookService.constructEvent(anyString(), anyString(), any())).thenReturn(event); mockMvc.perform(post("/api/webhooks/stripe") .header("Stripe-Signature", "sig") .content("{}") .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); // The account should still have the current subscription ID and active status assert account.getStripeSubscriptionId().equals(currentSubId); assert account.getSubscriptionStatus().equals("active"); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/TeamControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; 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.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.InvitationEntity; import tech.amak.portbuddy.server.db.entity.Role; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.ApiTokenAuthFilter; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.security.Oauth2SuccessHandler; import tech.amak.portbuddy.server.service.TeamService; @WebMvcTest(TeamController.class) @ActiveProfiles("test") public class TeamControllerTest { private MockMvc mockMvc; @Autowired private TeamController teamController; @Autowired private ObjectMapper objectMapper; @MockitoBean private TeamService teamService; @MockitoBean private UserRepository userRepository; @MockitoBean private AccountRepository accountRepository; @MockitoBean private JwtService jwtService; @MockitoBean private ApiTokenAuthFilter apiTokenAuthFilter; @MockitoBean private Oauth2SuccessHandler oauth2SuccessHandler; private UUID accountId; private UUID userId; private AccountEntity account; private UserEntity user; @MockitoBean private UserAccountRepository userAccountRepository; @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(teamController) .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler()) .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.getParameterType().equals(Jwt.class); } @Override public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { final var principal = webRequest.getUserPrincipal(); if (principal instanceof JwtAuthenticationToken jwtToken) { return jwtToken.getToken(); } return createJwt(); } }) .build(); accountId = UUID.randomUUID(); userId = UUID.randomUUID(); account = new AccountEntity(); account.setId(accountId); account.setName("Test Team"); user = new UserEntity(); user.setId(userId); user.setEmail("admin@example.com"); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); } @Test void getMembers_ShouldReturnList() throws Exception { when(teamService.getMembers(any())).thenReturn(List.of(user)); final var userAccount = new UserAccountEntity(user, account, Set.of(Role.USER)); when(userAccountRepository.findByUserIdAndAccountId(user.getId(), account.getId())) .thenReturn(Optional.of(userAccount)); mockMvc.perform(get("/api/team/members") .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].email").value("admin@example.com")); } @Test void getInvitations_ShouldReturnList() throws Exception { final var invitation = new InvitationEntity(); invitation.setId(UUID.randomUUID()); invitation.setEmail("invited@example.com"); invitation.setInvitedBy(user); invitation.setCreatedAt(OffsetDateTime.now()); invitation.setExpiresAt(OffsetDateTime.now().plusDays(7)); when(teamService.getPendingInvitations(any())).thenReturn(List.of(invitation)); mockMvc.perform(get("/api/team/invitations") .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].email").value("invited@example.com")); } @Test void inviteMember_ShouldCreateInvitation() throws Exception { final var invitation = new InvitationEntity(); invitation.setId(UUID.randomUUID()); invitation.setEmail("new@example.com"); invitation.setInvitedBy(user); invitation.setCreatedAt(OffsetDateTime.now()); invitation.setExpiresAt(OffsetDateTime.now().plusDays(7)); when(teamService.inviteMember(any(), any(), eq("new@example.com"))).thenReturn(invitation); final var request = new TeamController.InviteRequest(); request.setEmail("new@example.com"); mockMvc.perform(post("/api/team/invitations") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("new@example.com")); } @Test void cancelInvitation_ShouldCallService() throws Exception { final var invId = UUID.randomUUID(); mockMvc.perform(delete("/api/team/invitations/" + invId) .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isNoContent()); verify(teamService).cancelInvitation(any(), eq(invId)); } @Test void resendInvitation_ShouldCallService() throws Exception { final var invId = UUID.randomUUID(); mockMvc.perform(post("/api/team/invitations/" + invId + "/resend") .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isNoContent()); verify(teamService).resendInvitation(any(), eq(invId)); } @Test void removeMember_ShouldCallService() throws Exception { final var memberId = UUID.randomUUID(); mockMvc.perform(delete("/api/team/members/" + memberId) .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isNoContent()); verify(teamService).removeMember(any(), eq(memberId), any()); } @Test void removeMember_ShouldReturnBadRequest_WhenRemovingSelf() throws Exception { org.mockito.Mockito.doThrow(new IllegalArgumentException("You cannot remove yourself from the account.")) .when(teamService).removeMember(any(), eq(userId), any()); mockMvc.perform(delete("/api/team/members/" + userId) .principal(new JwtAuthenticationToken(createJwt()))) .andExpect(status().isBadRequest()); } @Test void getMembers_ShouldThrowException_WhenAccountIdClaimIsMissing() throws Exception { final var jwtWithoutAccountId = org.springframework.security.oauth2.jwt.Jwt.withTokenValue("token") .header("alg", "none") .subject(userId.toString()) .build(); mockMvc.perform(get("/api/team/members") .principal(new JwtAuthenticationToken(jwtWithoutAccountId))) .andExpect(status().isBadRequest()); } @Test void removeMember_ShouldCallService_WhenAdmin() throws Exception { final var memberId = UUID.randomUUID(); final var jwt = org.springframework.security.oauth2.jwt.Jwt.withTokenValue("token") .header("alg", "none") .subject(userId.toString()) .claim("aid", accountId.toString()) .claim("roles", List.of("ADMIN", "USER")) .build(); mockMvc.perform(delete("/api/team/members/" + memberId) .principal(new JwtAuthenticationToken(jwt))) .andExpect(status().isNoContent()); verify(teamService).removeMember(any(), eq(memberId), any()); } private org.springframework.security.oauth2.jwt.Jwt createJwt() { return org.springframework.security.oauth2.jwt.Jwt.withTokenValue("token") .header("alg", "none") .subject(userId.toString()) .claim("aid", accountId.toString()) .claim("roles", List.of("ACCOUNT_ADMIN", "USER")) .build(); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/TokensControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import tech.amak.portbuddy.server.db.entity.UserEntity; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.service.ApiTokenService; @WebMvcTest(TokensController.class) @AutoConfigureMockMvc class TokensControllerTest { @Autowired private MockMvc mockMvc; @MockitoBean private ApiTokenService apiTokenService; @MockitoBean private UserRepository userRepository; @Test void revoke_shouldReturnNoContent() throws Exception { final var userId = UUID.randomUUID(); final var accountId = UUID.randomUUID(); final var tokenId = "token-123"; final var user = new UserEntity(); user.setId(userId); mockMvc.perform(delete("/api/tokens/{id}", tokenId) .with(jwt().jwt(builder -> builder .subject(userId.toString()) .claim("aid", accountId.toString())))) .andExpect(status().isNoContent()); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/UsersControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; 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.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; import com.fasterxml.jackson.databind.ObjectMapper; import tech.amak.portbuddy.common.Plan; import tech.amak.portbuddy.server.config.AppProperties; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.entity.UserAccountEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.db.repo.TunnelRepository; import tech.amak.portbuddy.server.db.repo.UserAccountRepository; import tech.amak.portbuddy.server.db.repo.UserRepository; import tech.amak.portbuddy.server.security.ApiTokenAuthFilter; import tech.amak.portbuddy.server.security.JwtService; import tech.amak.portbuddy.server.security.Oauth2SuccessHandler; import tech.amak.portbuddy.server.service.StripeService; import tech.amak.portbuddy.server.service.TeamService; import tech.amak.portbuddy.server.service.TunnelService; @WebMvcTest(UsersController.class) @ActiveProfiles("test") public class UsersControllerTest { private MockMvc mockMvc; @Autowired private UsersController usersController; @Autowired private ObjectMapper objectMapper; @MockitoBean private StripeService stripeService; @MockitoBean private TunnelService tunnelService; @MockitoBean private TeamService teamService; @MockitoBean private JwtService jwtService; @MockitoBean private UserRepository userRepository; @MockitoBean private AccountRepository accountRepository; @MockitoBean private UserAccountRepository userAccountRepository; @MockitoBean private TunnelRepository tunnelRepository; @MockitoBean private ApiTokenAuthFilter apiTokenAuthFilter; @MockitoBean private Oauth2SuccessHandler oauth2SuccessHandler; @MockitoBean private AppProperties properties; private UUID accountId; private UUID userId; private AccountEntity account; private UserAccountEntity userAccount; @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(usersController) .setControllerAdvice(new tech.amak.portbuddy.server.web.advice.GlobalExceptionHandler()) .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.getParameterType().equals(Jwt.class); } @Override public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { final var principal = webRequest.getUserPrincipal(); if (principal instanceof JwtAuthenticationToken jwtToken) { return jwtToken.getToken(); } return null; } }) .build(); accountId = UUID.randomUUID(); userId = UUID.randomUUID(); account = new AccountEntity(); account.setId(accountId); account.setName("Test Account"); account.setPlan(Plan.PRO); account.setExtraTunnels(0); userAccount = new UserAccountEntity(); userAccount.setAccount(account); when(userAccountRepository.findByUserIdAndAccountId(eq(userId), eq(accountId))) .thenReturn(Optional.of(userAccount)); final var subscriptions = new AppProperties.Subscriptions( null, null, new AppProperties.Subscriptions.Tunnels( Map.of(Plan.PRO, 1, Plan.TEAM, 10), Map.of(Plan.PRO, 1, Plan.TEAM, 5) ) ); when(properties.subscriptions()).thenReturn(subscriptions); } @Test void updateExtraTunnels_withoutSubscription_shouldReturnCheckoutUrl() throws Exception { final var request = new UsersController.UpdateTunnelsRequest(); request.setExtraTunnels(1); final var checkoutUrl = "https://checkout.stripe.com/test"; when(stripeService.createCheckoutSession(any(), eq(Plan.PRO), eq(1))).thenReturn(checkoutUrl); mockMvc.perform(patch("/api/users/me/account/tunnels") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .principal(new JwtAuthenticationToken(createJwt(List.of("ACCOUNT_ADMIN"))))) .andExpect(status().isOk()) .andExpect(jsonPath("$.extraTunnels").value(0)) // Should still be 0 in the response as it's not saved .andExpect(jsonPath("$.checkoutUrl").value(checkoutUrl)); verify(accountRepository, org.mockito.Mockito.never()).save(account); } @Test void updateExtraTunnels_withSubscription_shouldUpdateStripe() throws Exception { account.setStripeSubscriptionId("sub_123"); final var request = new UsersController.UpdateTunnelsRequest(); request.setExtraTunnels(1); mockMvc.perform(patch("/api/users/me/account/tunnels") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .principal(new JwtAuthenticationToken(createJwt(List.of("ACCOUNT_ADMIN"))))) .andExpect(status().isOk()) .andExpect(jsonPath("$.extraTunnels").value(1)) .andExpect(jsonPath("$.checkoutUrl").isEmpty()); verify(stripeService).updateExtraTunnels(account, 1); verify(accountRepository).save(account); } @Test void updateExtraTunnels_proPlan_zeroExtra_withSubscription_shouldCancelSubscription() throws Exception { account.setStripeSubscriptionId("sub_123"); account.setPlan(Plan.PRO); account.setExtraTunnels(1); final var request = new UsersController.UpdateTunnelsRequest(); request.setExtraTunnels(0); mockMvc.perform(patch("/api/users/me/account/tunnels") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .principal(new JwtAuthenticationToken(createJwt(List.of("ACCOUNT_ADMIN"))))) .andExpect(status().isOk()) .andExpect(jsonPath("$.extraTunnels").value(0)) .andExpect(jsonPath("$.subscriptionStatus").value("canceled")); verify(stripeService).cancelSubscription(account); verify(accountRepository).save(account); } private Jwt createJwt(final List roles) { return Jwt.withTokenValue("token") .header("alg", "none") .subject(userId.toString()) .claim("aid", accountId.toString()) .claim("roles", roles) .build(); } } ================================================ FILE: server/src/test/java/tech/amak/portbuddy/server/web/admin/AdminAccountControllerTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.server.web.admin; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; 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.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import tech.amak.portbuddy.server.db.entity.AccountEntity; import tech.amak.portbuddy.server.db.repo.AccountRepository; import tech.amak.portbuddy.server.service.TunnelService; @ExtendWith(MockitoExtension.class) class AdminAccountControllerTest { private MockMvc mockMvc; @Mock private AccountRepository accountRepository; @Mock private TunnelService tunnelService; @InjectMocks private AdminAccountController adminAccountController; @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(adminAccountController).build(); } @Test void blockAccount_shouldSetBlockedTrueAndSaveAndCloseTunnels() throws Exception { final var accountId = UUID.randomUUID(); final var account = new AccountEntity(); account.setId(accountId); account.setBlocked(false); when(accountRepository.findById(accountId)).thenReturn(Optional.of(account)); mockMvc.perform(post("/api/admin/accounts/{accountId}/block", accountId)) .andExpect(status().isNoContent()); verify(accountRepository).save(account); verify(tunnelService).closeAllTunnels(account); assert (account.isBlocked()); } } ================================================ FILE: ssl-service/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT ssl-service port-buddy-ssl-service tech.amak common ${project.version} org.springframework.boot spring-boot-starter-web org.springframework.cloud spring-cloud-starter-openfeign org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-actuator org.springframework.boot spring-boot-starter-data-jpa org.springframework.cloud spring-cloud-starter-netflix-eureka-client org.postgresql postgresql 42.7.4 org.flywaydb flyway-core org.flywaydb flyway-database-postgresql org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-oauth2-resource-server org.projectlombok lombok ${lombok.version} provided org.shredzone.acme4j acme4j-client 3.5.1 org.bouncycastle bcpkix-jdk18on 1.83 org.bouncycastle bcprov-jdk18on 1.83 net.javacrumbs.shedlock shedlock-spring 7.2.0 net.javacrumbs.shedlock shedlock-provider-jdbc-template 7.2.0 org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-maven-plugin repackage org.apache.maven.plugins maven-checkstyle-plugin ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/SslServiceApplication.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication @ConfigurationPropertiesScan @EnableFeignClients public class SslServiceApplication { /** * Application entry point. * * @param args the command line arguments */ public static void main(final String[] args) { SpringApplication.run(SslServiceApplication.class, args); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/client/ServerClient.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.client; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest; @FeignClient(name = "port-buddy-server") public interface ServerClient { @PostMapping("/api/internal/email/dns-instructions") void sendDnsInstructions(@RequestBody final DnsInstructionsEmailRequest request); @PostMapping("/api/internal/domains/ssl-active") void markSslActive(@RequestParam("domain") String domain); } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/AppProperties.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.config; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app") public record AppProperties( Jwt jwt, Acme acme, Storage storage ) { public record Jwt( String issuer, String jwkSetUri ) { } public record Acme( String serverUrl, String accountKeyPath, String contactEmail, String accountLocation, Retry retry ) { } public record Retry( int maxAttempts, long initialDelayMs, long maxDelayMs, double multiplier, long jitterMs ) { } public record Storage( String certificatesDir ) { } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/AsyncConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.config; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; @Configuration @EnableAsync public class AsyncConfig { } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/JpaAuditingConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.config; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Optional; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.auditing.DateTimeProvider; import org.springframework.data.domain.AuditorAware; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @Configuration @EnableJpaAuditing(dateTimeProviderRef = "dateTimeProvider") public class JpaAuditingConfig { @Bean public AuditorAware auditorAware() { return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()) .filter(Authentication::isAuthenticated) .map(Authentication::getName) .or(() -> Optional.of("system")); } @Bean public DateTimeProvider dateTimeProvider() { return () -> Optional.of(OffsetDateTime.now(ZoneOffset.UTC)); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/RestConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.config; import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate; @Configuration public class RestConfig { /** * Creates a load-balanced RestTemplate bean. * * @return the RestTemplate */ @Bean @LoadBalanced public RestTemplate loadBalancedRestTemplate() { return new RestTemplate(); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/config/SchedulingConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.config; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.EnableScheduling; import net.javacrumbs.shedlock.core.LockProvider; import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider; import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; /** * Configuration for ShedLock to ensure scheduled tasks run on one instance at a time. */ @Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = "PT5M", defaultLockAtLeastFor = "PT5S") public class SchedulingConfig { /** * Provides a LockProvider based on JDBC Template. * * @param dataSource the data source * @return the lock provider */ @Bean public LockProvider lockProvider(final DataSource dataSource) { return new JdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() .withTableName("shedlock") .build()); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.domain; import java.time.OffsetDateTime; import java.util.UUID; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @Data @AllArgsConstructor @NoArgsConstructor @Builder @Entity @Table(name = "ssl_certificates") @EntityListeners(AuditingEntityListener.class) public class CertificateEntity { @Id @GeneratedValue private UUID id; @Column(name = "domain", nullable = false, unique = true, length = 255) private String domain; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private CertificateStatus status = CertificateStatus.NEW; /** * Indicates that this certificate/domain is managed by the service (auto-renewal enabled). */ @Column(name = "managed", nullable = false) private boolean managed = false; /** * Verification method for ACME challenges. For now: MANUAL_DNS01 or HTTP01. */ @Column(name = "verification_method", length = 64) private String verificationMethod; /** * Optional contact email to notify about actions required (DNS setup) and results. */ @Column(name = "contact_email", length = 255) private String contactEmail; @Column(name = "issued_at") private OffsetDateTime issuedAt; @Column(name = "expires_at") private OffsetDateTime expiresAt; @Column(name = "certificate_path", length = 1024) private String certificatePath; @Column(name = "private_key_path", length = 1024) private String privateKeyPath; @Column(name = "chain_path", length = 1024) private String chainPath; @Column(name = "full_chain_path", length = 1024) private String fullChainPath; @CreatedBy @Column(name = "created_by", length = 100, updatable = false) private String createdBy; @CreatedDate @Column(name = "created_at", updatable = false) private OffsetDateTime createdAt; @LastModifiedBy @Column(name = "updated_by", length = 100) private String updatedBy; @LastModifiedDate @Column(name = "updated_at") private OffsetDateTime updatedAt; } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateJobEntity.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.domain; import java.time.OffsetDateTime; import java.util.UUID; import org.springframework.data.annotation.CreatedBy; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedBy; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; @Getter @Setter @Entity @Table(name = "ssl_certificate_jobs") @EntityListeners(AuditingEntityListener.class) public class CertificateJobEntity { @Id @GeneratedValue private UUID id; @Column(name = "domain", nullable = false, length = 255) private String domain; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 32) private CertificateJobStatus status = CertificateJobStatus.PENDING; @Column(name = "message", length = 2000) private String message; @Column(name = "contact_email", length = 255) private String contactEmail; @Column(name = "challenge_records_json", columnDefinition = "TEXT") private String challengeRecordsJson; @Column(name = "order_location", length = 1024) private String orderLocation; @Column(name = "authorization_urls_json", columnDefinition = "TEXT") private String authorizationUrlsJson; @Column(name = "challenge_expires_at") private OffsetDateTime challengeExpiresAt; @Column(name = "managed", nullable = false) private boolean managed = false; @Column(name = "started_at") private OffsetDateTime startedAt; @Column(name = "finished_at") private OffsetDateTime finishedAt; @CreatedBy @Column(name = "created_by", length = 100, updatable = false) private String createdBy; @CreatedDate @Column(name = "created_at", updatable = false) private OffsetDateTime createdAt; @LastModifiedBy @Column(name = "updated_by", length = 100) private String updatedBy; @LastModifiedDate @Column(name = "updated_at") private OffsetDateTime updatedAt; } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateJobStatus.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.domain; /** * Status of certificate processing job. */ public enum CertificateJobStatus { PENDING, RUNNING, WAITING_DNS_INSTRUCTIONS, AWAITING_ADMIN_CONFIRMATION, VERIFYING_DNS, FINALIZING, SUCCEEDED, FAILED } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/domain/CertificateStatus.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.domain; /** * Status of SSL certificate lifecycle. */ public enum CertificateStatus { NEW, ACTIVE, EXPIRED, FAILED } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/repo/CertificateJobRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.repo; import java.util.Collection; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; import tech.amak.portbuddy.sslservice.domain.CertificateJobStatus; public interface CertificateJobRepository extends JpaRepository { boolean existsByDomainIgnoreCaseAndStatusIn(String domain, Collection statuses); } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/repo/CertificateRepository.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.repo; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import tech.amak.portbuddy.sslservice.domain.CertificateEntity; public interface CertificateRepository extends JpaRepository { /** * Finds a certificate by domain. * * @param domain the domain name * @return optional entity */ Optional findByDomain(String domain); /** * Finds a certificate by domain (case-insensitive). * * @param domain the domain name * @return optional entity */ Optional findByDomainIgnoreCase(String domain); /** * Returns all certificates that are marked as managed by the service. * * @return list of managed certificates */ List findAllByManagedTrue(); /** * Finds all managed certificates that expire before the given date. * * @param dateTime expiration threshold * @return list of expiring certificates */ List findAllByManagedTrueAndExpiresAtBefore(OffsetDateTime dateTime); } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/security/SecurityConfig.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; 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.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.client.RestTemplate; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.config.AppProperties; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final AppProperties appProperties; /** * Configures HTTP security for the SSL service. * * @param http the HttpSecurity * @param jwtDecoder the JwtDecoder * @return the security filter chain * @throws Exception if configuration fails */ @Bean public SecurityFilterChain securityFilterChain(final HttpSecurity http, final JwtDecoder jwtDecoder) throws Exception { http .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health**").permitAll() .requestMatchers(HttpMethod.GET, "/.well-known/acme-challenge/**").permitAll() .requestMatchers("/internal/api/**").permitAll() .requestMatchers("/api/**").authenticated() .anyRequest().permitAll() ) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .decoder(jwtDecoder) .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ); return http.build(); } /** * JWT decoder configured with remote JWK Set URI and issuer validation. * * @param restTemplate the RestTemplate to use for fetching JWK set * @return the JwtDecoder */ @Bean public JwtDecoder jwtDecoder(final RestTemplate restTemplate) { final var decoder = NimbusJwtDecoder .withJwkSetUri(appProperties.jwt().jwkSetUri()) .restOperations(restTemplate) .build(); final var withIssuer = new JwtIssuerValidator(appProperties.jwt().issuer()); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( new JwtTimestampValidator(), withIssuer )); return decoder; } /** * Converter for JWT authentication. * * @return JwtAuthenticationConverter instance */ @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { return new JwtAuthenticationConverter(); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeAccountService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Files; import java.security.KeyPair; import java.security.Security; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.shredzone.acme4j.util.KeyPairUtils; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.sslservice.config.AppProperties; /** * Loads or creates ACME account key pair from configured location. */ @Service @RequiredArgsConstructor @Slf4j public class AcmeAccountService { static { Security.addProvider(new BouncyCastleProvider()); } private final AppProperties properties; private final ResourceLoader resourceLoader; /** * Loads the ACME account {@link KeyPair} from {@code app.acme.accountKeyPath}. * The key must be in PEM format (PKCS#1 or PKCS#8). If the file does not exist, * a new key pair is generated and saved. * * @return account key pair */ public KeyPair loadAccountKeyPair() { final var pathString = properties.acme().accountKeyPath(); final var resource = resourceLoader.getResource(pathString); if (resource.exists()) { try (final var reader = new InputStreamReader(resource.getInputStream())) { return KeyPairUtils.readKeyPair(reader); } catch (final IOException e) { log.error("Failed to load ACME account key pair from {}", pathString, e); throw new IllegalStateException("Failed to load ACME account key pair", e); } } else { final var keyPair = KeyPairUtils.createKeyPair(); try { final var file = resource.getFile(); final var parentFile = file.getParentFile(); if (parentFile != null && !parentFile.exists() && !parentFile.mkdirs()) { throw new IOException("Failed to create directory: " + parentFile); } try (final var writer = Files.newBufferedWriter(file.toPath())) { KeyPairUtils.writeKeyPair(keyPair, writer); } } catch (final IOException e) { log.error("Failed to save ACME account key pair to {}", pathString, e); throw new IllegalStateException("Failed to save ACME account key pair", e); } return keyPair; } } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeCertificateService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import static java.time.ZoneOffset.UTC; import static org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers.pkcs_9_at_extensionRequest; import static org.bouncycastle.asn1.x509.GeneralName.dNSName; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.cert.X509Certificate; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; import org.shredzone.acme4j.Account; import org.shredzone.acme4j.Authorization; import org.shredzone.acme4j.Order; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.Status; import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.sslservice.client.ServerClient; import tech.amak.portbuddy.sslservice.domain.CertificateEntity; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; import tech.amak.portbuddy.sslservice.domain.CertificateJobStatus; import tech.amak.portbuddy.sslservice.domain.CertificateStatus; import tech.amak.portbuddy.sslservice.repo.CertificateJobRepository; import tech.amak.portbuddy.sslservice.repo.CertificateRepository; import tech.amak.portbuddy.sslservice.work.ChallengeTokenStore; @Service @RequiredArgsConstructor @Slf4j public class AcmeCertificateService { private final CertificateRepository certificateRepository; private final CertificateJobRepository jobRepository; private final ChallengeTokenStore challengeTokenStore; private final AcmeAccountService acmeAccountService; private final AcmeClientService acmeClientService; private final CertificateStorageService storageService; private final RetryExecutor retryExecutor; private final DnsResolverService dnsResolverService; private final EmailService emailService; private final ObjectMapper objectMapper; private final ServerClient serverClient; private final ObjectProvider self; /** * Submits an asynchronous job to issue or renew a certificate for the given domain. * * @param domain the domain to issue or renew certificate for * @param requestedBy username of requester * @param managed whether the certificate should be managed (auto-renewed) * @return persisted job entity */ @Transactional public CertificateJobEntity submitJob(final String domain, final String requestedBy, final boolean managed) { final var normalizedDomain = domain.toLowerCase(); // Prevent duplicate jobs for the same domain when a job is already pending or running final var activeStatuses = Set.of(CertificateJobStatus.PENDING, CertificateJobStatus.RUNNING); final var existsActive = jobRepository.existsByDomainIgnoreCaseAndStatusIn(normalizedDomain, activeStatuses); if (existsActive) { throw new IllegalStateException("A certificate job is already in progress for domain: " + normalizedDomain); } final var job = new CertificateJobEntity(); job.setDomain(normalizedDomain); job.setStatus(CertificateJobStatus.PENDING); job.setManaged(managed); // If we have a managed certificate record, inherit contact email for notifications, // otherwise use the one provided by the requester final var existingCert = certificateRepository.findByDomainIgnoreCase(normalizedDomain); if (existingCert.isPresent()) { job.setContactEmail(existingCert.get().getContactEmail()); } else { job.setContactEmail(requestedBy); } final var savedJob = jobRepository.save(job); // Fire and forget async processing after transaction commit to avoid race condition if (TransactionSynchronizationManager.isActualTransactionActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { self.getIfAvailable().processJobAsync(savedJob.getId()); } }); } else { self.getIfAvailable().processJobAsync(savedJob.getId()); } return savedJob; } /** * Processes the job asynchronously. This method encapsulates ACME/Let’s Encrypt logic. * In this initial implementation, it simulates success and updates DB records. * * @param jobId the job identifier */ @Async @Transactional public void processJobAsync(final UUID jobId) { final var job = jobRepository.findById(jobId).orElseThrow(); try { MDC.put("jobId", String.valueOf(jobId)); MDC.put("domain", job.getDomain()); job.setStatus(CertificateJobStatus.RUNNING); job.setStartedAt(OffsetDateTime.now()); jobRepository.save(job); // If the requested domain contains a wildcard, use manual DNS-01 flow with admin confirmation. if (job.getDomain().contains("*")) { performAcmeDns01Initiate(job); } else { performAcmeHttp01Issuance(job); } } catch (final Exception e) { log.error("Certificate job failed", e); job.setStatus(CertificateJobStatus.FAILED); job.setFinishedAt(OffsetDateTime.now()); job.setMessage(e.getMessage()); jobRepository.save(job); } finally { MDC.clear(); } } /** * Performs the full ACME HTTP-01 issuance flow for the job's domain. * * @param job the job to process */ private void performAcmeHttp01Issuance(final CertificateJobEntity job) throws Exception { final var domain = job.getDomain(); updateJobMessage(job, "Starting issuance for '%s'", domain); // 1) Create session and load/login account final Session session = acmeClientService.newSession(); updateJobMessage(job, "ACME session created"); final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair(); updateJobMessage(job, "Account key loaded"); final Account account = retryExecutor.callWithRetry("acme.login", () -> acmeClientService.loginOrRegister(session, accountKeyPair)); updateJobMessage(job, "Logged into ACME account"); // 2) Create a new order for the domain final Order order = retryExecutor.callWithRetry("acme.order.create", () -> account .newOrder().domains(domain).create()); updateJobMessage(job, "ACME order created"); // 3) Complete HTTP-01 challenge for each authorization for (final Authorization auth : order.getAuthorizations()) { if (auth.getStatus() == Status.VALID) { continue; } final Http01Challenge httpChallenge = auth.getChallenges().stream() .filter(challenge -> challenge.getType().equals(Http01Challenge.TYPE)) .map(challenge -> (Http01Challenge) challenge) .findFirst() .orElse(null); if (httpChallenge == null) { throw new IllegalStateException("HTTP-01 challenge not available for domain: " + domain); } final var token = httpChallenge.getToken(); final var challengeContent = httpChallenge.getAuthorization(); challengeTokenStore.putToken(token, challengeContent); try { updateJobMessage(job, "HTTP-01 challenge published (token=%s)", token); retryExecutor.callWithRetry("acme.challenge.trigger", () -> { httpChallenge.trigger(); return Boolean.TRUE; }); // poll until VALID or failure with backoff pollAuthorizationValidWithRetry(auth, 90, 2_000); updateJobMessage(job, "HTTP-01 challenge validated"); } finally { challengeTokenStore.removeToken(token); } } // 4) Generate domain key and CSR final KeyPair domainKeyPair = storageService.generateRsaKeyPair(); final var csr = buildCsrDer(domain, domainKeyPair); updateJobMessage(job, "CSR generated"); // 5) Finalize order retryExecutor.callWithRetry("acme.order.finalize", () -> { order.execute(csr); return Boolean.TRUE; }); updateJobMessage(job, "Order finalized, waiting for issuance"); pollOrderValidWithRetry(order, 120, 2_000); // 6) Download certificate: acme4j returns X509Certificate(s); convert to PEM strings final var downloaded = retryExecutor.callWithRetry("acme.cert.download", order::getCertificate); final var certChain = downloaded.getCertificateChain(); // Convert primary certificate to PEM string final var leafCertPem = toPem(downloaded.getCertificate()); final var chainPem = certChain == null ? "" : certChain.stream() .skip(1) .map(this::toPem) .reduce("", (a, b) -> a + b); // 7) Store files final var keyPath = storageService.writePrivateKeyPem(domain, domainKeyPair); final var certPath = storageService.writeCertPem(domain, leafCertPem); final var chainPath = storageService.writeChainPem(domain, chainPem); final var fullChainPath = storageService.writeFullChainPem(domain, leafCertPem + chainPem); // 8) Update DB var certificate = certificateRepository.findByDomain(domain).orElse(null); if (certificate == null) { certificate = new CertificateEntity(); certificate.setDomain(domain); } certificate.setManaged(job.isManaged()); certificate.setContactEmail(job.getContactEmail()); // Try to extract validity from leaf certificate final var x509 = downloaded.getCertificate(); certificate.setStatus(CertificateStatus.ACTIVE); certificate.setIssuedAt(OffsetDateTime.ofInstant(x509.getNotBefore().toInstant(), UTC)); certificate.setExpiresAt(OffsetDateTime.ofInstant(x509.getNotAfter().toInstant(), UTC)); certificate.setPrivateKeyPath(keyPath.toAbsolutePath().toString()); certificate.setCertificatePath(certPath.toAbsolutePath().toString()); certificate.setChainPath(chainPath.toAbsolutePath().toString()); certificate.setFullChainPath(fullChainPath.toAbsolutePath().toString()); certificateRepository.save(certificate); // Notify server module about successful issuance try { serverClient.markSslActive(domain); } catch (final Exception e) { log.warn("Failed to notify server module about SSL activation for {}", domain, e); } // Single-entity model: no separate root-domain metadata to update job.setStatus(CertificateJobStatus.SUCCEEDED); job.setFinishedAt(OffsetDateTime.now()); job.setMessage("Certificate issued/renewed successfully."); jobRepository.save(job); } /** * Initiates ACME DNS-01 flow for wildcard domains and pauses awaiting admin DNS confirmation. * Stores ACME order and authorization info as well as required TXT records in the job. * * @param job the job to process */ private void performAcmeDns01Initiate(final CertificateJobEntity job) throws Exception { final var requestedDomain = job.getDomain(); updateJobMessage(job, "Starting DNS-01 issuance for '%s'", requestedDomain); final Session session = acmeClientService.newSession(); updateJobMessage(job, "ACME session created"); final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair(); final Account account = retryExecutor .callWithRetry("acme.login", () -> acmeClientService.loginOrRegister(session, accountKeyPair)); updateJobMessage(job, "Logged into ACME account"); // Create order for apex + wildcard when wildcard requested, otherwise single domain final var domains = new ArrayList(); if (requestedDomain.startsWith("*.")) { final var apex = requestedDomain.substring(2); domains.add(apex); } domains.add(requestedDomain); final Order order = retryExecutor.callWithRetry("acme.order.create", () -> account .newOrder().domains(domains.toArray(String[]::new)).create()); job.setOrderLocation(order.getLocation().toString()); // Collect DNS-01 challenges and build instruction payload final var authorizations = order.getAuthorizations(); final var authUrls = new ArrayList(); final var records = new ArrayList>(); OffsetDateTime authExpiresAt = null; for (final Authorization auth : authorizations) { authUrls.add(auth.getLocation().toString()); final var idDomain = auth.getIdentifier().getDomain(); final var recordHost = idDomain.startsWith("*.") ? idDomain.substring(2) : idDomain; final var recordName = "_acme-challenge." + recordHost; final var dns01 = auth.getChallenges().stream() .filter(challenge -> challenge.getType().equals(Dns01Challenge.TYPE)) .map(challenge -> (Dns01Challenge) challenge) .findFirst() .orElse(null); if (dns01 == null) { throw new IllegalStateException("DNS-01 challenge not available for domain: " + idDomain); } final var txtValue = dns01.getDigest(); final var map = new HashMap(); map.put("name", recordName); map.put("value", txtValue); records.add(map); // Track the earliest authorization expiration across all identifiers final var expiresOpt = auth.getExpires(); if (expiresOpt != null && expiresOpt.isPresent()) { final var expiresAt = OffsetDateTime.ofInstant(expiresOpt.get(), UTC); if (authExpiresAt == null || expiresAt.isBefore(authExpiresAt)) { authExpiresAt = expiresAt; } } } job.setAuthorizationUrlsJson(objectMapper.writeValueAsString(authUrls)); job.setChallengeRecordsJson(objectMapper.writeValueAsString(records)); job.setChallengeExpiresAt(authExpiresAt); job.setStatus(CertificateJobStatus.WAITING_DNS_INSTRUCTIONS); jobRepository.save(job); // Send email to admin with instructions (best-effort) emailService.sendDnsInstructions(job, records, authExpiresAt); job.setStatus(CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION); updateJobMessage(job, "Awaiting admin DNS TXT creation for %s", requestedDomain); } /** * Confirms DNS TXT records are in place and continues ACME DNS-01 flow to issuance. * * @param jobId certificate job id */ @Transactional public void confirmDnsAndContinue(final UUID jobId) { final var job = jobRepository.findById(jobId).orElseThrow(); MDC.put("jobId", String.valueOf(jobId)); MDC.put("domain", job.getDomain()); try { if (job.getStatus() != CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION) { throw new IllegalStateException("Job is not awaiting admin confirmation"); } job.setStatus(CertificateJobStatus.VERIFYING_DNS); jobRepository.save(job); final var records = objectMapper.readValue(job.getChallengeRecordsJson(), new TypeReference>>() { }); // Verify TXT visibility for each record for (final var record : records) { final var name = record.get("name"); final var value = record.get("value"); updateJobMessage(job, "Checking TXT %s", name); retryExecutor.callWithRetry("dns.check." + name, () -> { final var ok = dnsResolverService.isTxtRecordVisible(name, value); if (!ok) { throw new IllegalStateException("TXT record not visible yet: " + name); } return Boolean.TRUE; }); } // Re-bind order and trigger challenges final Session session = acmeClientService.newSession(); final KeyPair accountKeyPair = acmeAccountService.loadAccountKeyPair(); final Account account = retryExecutor .callWithRetry("acme.login", () -> acmeClientService.loginOrRegister(session, accountKeyPair)); final var orderLocation = job.getOrderLocation(); final Order order = acmeClientService.bindOrder(session, account, accountKeyPair, orderLocation); final var authorizations = order.getAuthorizations(); for (final Authorization auth : authorizations) { if (auth.getStatus() == Status.VALID) { continue; } final var dns01 = auth.getChallenges().stream() .filter(challenge -> challenge.getType().equals(Dns01Challenge.TYPE)) .map(challenge -> (Dns01Challenge) challenge) .findFirst() .orElse(null); if (dns01 == null) { throw new IllegalStateException("DNS-01 challenge not available: " + auth.getIdentifier()); } retryExecutor.callWithRetry("acme.challenge.trigger", () -> { dns01.trigger(); return Boolean.TRUE; }); pollAuthorizationValidWithRetry(auth, 180, 2_000); } // Generate key and CSR for apex + wildcard (or single domain) final var domain = job.getDomain(); final KeyPair domainKeyPair = storageService.generateRsaKeyPair(); final byte[] csr; if (domain.startsWith("*.")) { final var apex = domain.substring(2); csr = buildCsrDer(List.of(apex, domain), domainKeyPair); } else { csr = buildCsrDer(List.of(domain), domainKeyPair); } retryExecutor.callWithRetry("acme.order.finalize", () -> { order.execute(csr); return Boolean.TRUE; }); updateJobMessage(job, "Order finalized, waiting for issuance"); pollOrderValidWithRetry(order, 180, 2_000); final var downloaded = retryExecutor.callWithRetry("acme.cert.download", order::getCertificate); final var certChain = downloaded.getCertificateChain(); final var leafCertPem = toPem(downloaded.getCertificate()); final var chainPem = certChain == null ? "" : certChain.stream() .skip(1) .map(this::toPem) .reduce("", (a, b) -> a + b); final var keyPath = storageService.writePrivateKeyPem(domain, domainKeyPair); final var certPath = storageService.writeCertPem(domain, leafCertPem); final var chainPath = storageService.writeChainPem(domain, chainPem); final var fullChainPath = storageService.writeFullChainPem(domain, leafCertPem + chainPem); var certificate = certificateRepository.findByDomain(domain).orElse(null); if (certificate == null) { certificate = new CertificateEntity(); certificate.setDomain(domain); } certificate.setManaged(job.isManaged()); certificate.setContactEmail(job.getContactEmail()); final var x509 = downloaded.getCertificate(); certificate.setStatus(CertificateStatus.ACTIVE); certificate.setIssuedAt(OffsetDateTime.ofInstant(x509.getNotBefore().toInstant(), UTC)); certificate.setExpiresAt(OffsetDateTime.ofInstant(x509.getNotAfter().toInstant(), UTC)); certificate.setPrivateKeyPath(keyPath.toAbsolutePath().toString()); certificate.setCertificatePath(certPath.toAbsolutePath().toString()); certificate.setChainPath(chainPath.toAbsolutePath().toString()); certificate.setFullChainPath(fullChainPath.toAbsolutePath().toString()); certificateRepository.save(certificate); // Notify server module about successful issuance try { serverClient.markSslActive(domain); } catch (final Exception e) { log.warn("Failed to notify server module about SSL activation for {}", domain, e); } // Single-entity model: no separate root-domain metadata to update job.setStatus(CertificateJobStatus.SUCCEEDED); job.setFinishedAt(OffsetDateTime.now()); job.setMessage("Certificate issued successfully."); jobRepository.save(job); } catch (final Exception e) { log.error("confirmDnsAndContinue failed", e); job.setStatus(CertificateJobStatus.FAILED); job.setFinishedAt(OffsetDateTime.now()); job.setMessage(e.getMessage()); jobRepository.save(job); throw new IllegalStateException("DNS confirmation/issuance failed", e); } finally { MDC.clear(); } } private void pollAuthorizationValidWithRetry(final Authorization auth, final int maxSeconds, final long sleepMillis) throws InterruptedException { final long deadline = System.currentTimeMillis() + maxSeconds * 1000L; while (System.currentTimeMillis() < deadline) { try { retryExecutor.callWithRetry("acme.auth.update", () -> { auth.update(); return Boolean.TRUE; }); } catch (final Exception e) { // If a non-transient error bubbles up, rethrow as IllegalStateException throw new IllegalStateException("Authorization update failed", e); } final var status = auth.getStatus(); if (status == Status.VALID) { return; } if (status == Status.INVALID) { throw new IllegalStateException("Authorization invalid: " + auth.getLocation()); } Thread.sleep(sleepMillis); } throw new IllegalStateException("Authorization validation timed out for " + auth.getIdentifier()); } private void pollOrderValidWithRetry(final Order order, final int maxSeconds, final long sleepMillis) throws InterruptedException { final long deadline = System.currentTimeMillis() + maxSeconds * 1000L; while (System.currentTimeMillis() < deadline) { try { retryExecutor.callWithRetry("acme.order.update", () -> { order.update(); return Boolean.TRUE; }); } catch (final Exception e) { throw new IllegalStateException("Order update failed", e); } final var status = order.getStatus(); if (status == Status.VALID) { return; } if (status == Status.INVALID) { throw new IllegalStateException("Order became INVALID"); } Thread.sleep(sleepMillis); } throw new IllegalStateException("Order finalization timed out"); } private byte[] buildCsrDer(final List domains, final KeyPair keyPair) throws OperatorCreationException, java.io.IOException { final var primary = domains.getFirst(); final X500Name subject = new X500Name("CN=" + primary); final PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(subject, keyPair.getPublic()); // Add SAN extension with all domains final var genNames = domains.stream() .map(d -> new org.bouncycastle.asn1.x509.GeneralName(dNSName, d)) .toArray(org.bouncycastle.asn1.x509.GeneralName[]::new); final var subjectAltName = new org.bouncycastle.asn1.x509.GeneralNames(genNames); final var extGen = new org.bouncycastle.asn1.x509.ExtensionsGenerator(); extGen.addExtension(org.bouncycastle.asn1.x509.Extension.subjectAlternativeName, false, subjectAltName); final var extensions = extGen.generate(); p10Builder.addAttribute(pkcs_9_at_extensionRequest, extensions); final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); final PKCS10CertificationRequest csr = p10Builder.build(signer); return csr.getEncoded(); } private byte[] buildCsrDer(final String domain, final KeyPair keyPair) throws OperatorCreationException, java.io.IOException { return buildCsrDer(List.of(domain), keyPair); } private String toPem(final X509Certificate cert) { try { final var base64 = java.util.Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)) .encodeToString(cert.getEncoded()); return "-----BEGIN CERTIFICATE-----\n" + base64 + "\n-----END CERTIFICATE-----\n"; } catch (final Exception e) { throw new IllegalStateException("Failed to encode certificate to PEM", e); } } private void updateJobMessage(final CertificateJobEntity job, final String template, final Object... args) { final var message = String.format(template, args); job.setMessage(message); jobRepository.save(job); log.info(message); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/AcmeClientService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.net.URL; import java.security.KeyPair; import org.shredzone.acme4j.Account; import org.shredzone.acme4j.AccountBuilder; import org.shredzone.acme4j.Login; import org.shredzone.acme4j.Order; import org.shredzone.acme4j.Session; import org.shredzone.acme4j.exception.AcmeException; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.config.AppProperties; /** * Provides ACME Session and Account using configured ACME directory and contact email. */ @Service @RequiredArgsConstructor public class AcmeClientService { private final AppProperties properties; /** * Creates an ACME {@link Session} for the configured server URL. * * @return a new Session */ public Session newSession() { return new Session(properties.acme().serverUrl()); } /** * Logs into an existing ACME account using the provided key, or creates it on-the-fly if not found. * * @param session the ACME session * @param keyPair the ACME account key pair * @return logged-in or newly created {@link Account} * @throws AcmeException on ACME failures */ public Account loginOrRegister(final Session session, final KeyPair keyPair) throws AcmeException { // Try to log in to an existing account identified by the key pair try { return new AccountBuilder() .onlyExisting() .useKeyPair(keyPair) .create(session); } catch (final AcmeException ex) { // Not found -> create a new account below } final var contactEmail = properties.acme().contactEmail(); final var builder = new AccountBuilder() .agreeToTermsOfService() .useKeyPair(keyPair); if (contactEmail != null && !contactEmail.isEmpty()) { builder.addContact("mailto:" + contactEmail); } return builder.create(session); } /** * Binds an existing ACME order by its location URL using configured account location and the provided key. * * @param session ACME session * @param keyPair ACME account key pair * @param orderLocation order URL as string * @return bound {@link Order} * @throws AcmeException on ACME failures */ public Order bindOrder(final Session session, final Account account, final KeyPair keyPair, final String orderLocation) throws AcmeException { try { final Login login = session.login(account.getLocation(), keyPair); return login.bindOrder(new URL(orderLocation)); } catch (final Exception e) { throw new AcmeException("Failed to bind order: " + e.getMessage(), e); } } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/CertificateStorageService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.io.IOException; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.config.AppProperties; /** * Handles certificate and key file storage in PEM format. */ @Service @RequiredArgsConstructor public class CertificateStorageService { private final AppProperties properties; /** * Generates a new RSA key pair. * * @return generated key pair */ public KeyPair generateRsaKeyPair() { try { final var kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048, SecureRandom.getInstanceStrong()); return kpg.generateKeyPair(); } catch (final NoSuchAlgorithmException e) { throw new IllegalStateException("RSA not available", e); } } /** * Writes a private key to PEM file. * * @param domain domain name (used for file names) * @param keyPair key pair * @return path to written private key file */ public Path writePrivateKeyPem(final String domain, final KeyPair keyPair) { final var baseDir = resolveBaseDir(); final var file = baseDir.resolve(safe(domain) + ".key.pem"); writePem(file, keyPair.getPrivate()); return file; } /** * Writes certificate chain to PEM file. * * @param domain domain * @param chainPem full chain in PEM string * @return path to chain file */ public Path writeChainPem(final String domain, final String chainPem) { final var baseDir = resolveBaseDir(); final var file = baseDir.resolve(safe(domain) + ".chain.pem"); writeString(file, chainPem); return file; } /** * Writes leaf certificate to PEM file. * * @param domain domain * @param certPem certificate PEM * @return path to cert file */ public Path writeCertPem(final String domain, final String certPem) { final var baseDir = resolveBaseDir(); final var file = baseDir.resolve(safe(domain) + ".cert.pem"); writeString(file, certPem); return file; } /** * Writes full certificate chain to PEM file. * * @param domain domain * @param fullChainPem full chain PEM * @return path to full chain file */ public Path writeFullChainPem(final String domain, final String fullChainPem) { final var baseDir = resolveBaseDir(); final var file = baseDir.resolve(safe(domain) + ".fullchain.pem"); writeString(file, fullChainPem); return file; } private Path resolveBaseDir() { final var resource = properties.storage().certificatesDir(); // Expecting formats like "file:/abs/path" or just a directory. Normalize to Path. final Path dir; if (resource.startsWith("file:")) { dir = Path.of(resource.substring("file:".length())); } else { dir = Path.of(resource); } try { Files.createDirectories(dir); } catch (final IOException e) { throw new IllegalStateException("Cannot create certificates directory: " + dir, e); } return dir; } private void writePem(final Path file, final Object pemObject) { try { try (var os = Files.newOutputStream(file); var writer = new OutputStreamWriter(os, StandardCharsets.UTF_8); var pemWriter = new JcaPEMWriter(writer)) { pemWriter.writeObject(pemObject); } } catch (final IOException e) { throw new IllegalStateException("Failed to write PEM file: " + file, e); } } private void writeString(final Path file, final String content) { try { Files.writeString(file, content, StandardCharsets.UTF_8); } catch (final IOException e) { throw new IllegalStateException("Failed to write file: " + file, e); } } private String safe(final String domain) { return domain.toLowerCase().replaceAll("[^a-z0-9_.-]", "_"); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/DnsResolverService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; /** * Resolves DNS TXT records and verifies visibility of ACME DNS-01 tokens. */ public interface DnsResolverService { /** * Checks whether a TXT record with the expected value is visible at the given FQDN. * Implementations may query multiple public resolvers. * * @param fqdn fully-qualified domain name of the TXT record * @param expectedValue expected TXT value * @return true if visible, false otherwise */ boolean isTxtRecordVisible(String fqdn, String expectedValue); } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/EmailService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; /** * Sends notification emails to administrators about DNS setup and results. */ public interface EmailService { /** * Sends DNS TXT record setup instructions to the administrator. * * @param job certificate job * @param records list of maps with keys: name, value * @param expiresAt optional expiration of the ACME authorization */ void sendDnsInstructions(CertificateJobEntity job, List> records, OffsetDateTime expiresAt); } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/RenewalScheduler.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.time.OffsetDateTime; import java.util.Set; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import tech.amak.portbuddy.sslservice.domain.CertificateJobStatus; import tech.amak.portbuddy.sslservice.domain.CertificateStatus; import tech.amak.portbuddy.sslservice.repo.CertificateJobRepository; import tech.amak.portbuddy.sslservice.repo.CertificateRepository; /** * Periodically scans managed root domains and creates certificate jobs * for wildcard domains when missing or nearing expiry. */ @Component @RequiredArgsConstructor @Slf4j public class RenewalScheduler { private static final Set ACTIVE_JOB_STATUSES = Set.of( CertificateJobStatus.PENDING, CertificateJobStatus.RUNNING, CertificateJobStatus.WAITING_DNS_INSTRUCTIONS, CertificateJobStatus.AWAITING_ADMIN_CONFIRMATION, CertificateJobStatus.VERIFYING_DNS, CertificateJobStatus.FINALIZING ); private static final int RENEW_DAYS_BEFORE = 30; private final CertificateRepository certificateRepository; private final CertificateJobRepository jobRepository; private final AcmeCertificateService acmeCertificateService; /** * Runs every 5 minutes with 5 seconds initial delay. */ @Scheduled(initialDelay = 5_000, fixedDelay = 300_000) @SchedulerLock(name = "RenewalScheduler_scheduleRenewals", lockAtMostFor = "PT10M", lockAtLeastFor = "PT1M") public void scheduleRenewals() { final var managed = certificateRepository.findAllByManagedTrue(); if (managed.isEmpty()) { return; } final var cutoff = OffsetDateTime.now().plusDays(RENEW_DAYS_BEFORE); for (final var certMeta : managed) { final var wildcardDomain = certMeta.getDomain(); // Skip if there is an active job already if (jobRepository.existsByDomainIgnoreCaseAndStatusIn(wildcardDomain, ACTIVE_JOB_STATUSES)) { continue; } final var needsRenewal = certificateRepository.findByDomainIgnoreCase(wildcardDomain) .filter(cert -> cert.getStatus() == CertificateStatus.ACTIVE) .filter(cert -> cert.getExpiresAt() != null) .filter(cert -> cert.getExpiresAt().isAfter(cutoff)) .isEmpty(); if (needsRenewal) { try { log.info("Scheduling certificate job for {}", wildcardDomain); acmeCertificateService.submitJob(wildcardDomain, "renewal-scheduler", true); } catch (final Exception e) { log.warn("Failed to schedule job for {}: {}", wildcardDomain, e.getMessage()); } } } } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/RetryExecutor.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.util.concurrent.Callable; import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.sslservice.config.AppProperties; /** * Simple exponential backoff retry executor for transient errors. */ @Component @RequiredArgsConstructor @Slf4j public class RetryExecutor { private final AppProperties properties; /** * Executes the given {@code action} with retry/backoff for transient errors. * * @param stepName a human readable step name for logs * @param action the action to execute * @param return type * @return result of action * @throws Exception last thrown error if all attempts fail or a non-transient error occurs */ public T callWithRetry(final String stepName, final Callable action) throws Exception { final var retry = properties.acme().retry(); final int maxAttempts = Math.max(1, retry.maxAttempts()); long delay = Math.max(0L, retry.initialDelayMs()); final long maxDelay = Math.max(delay, retry.maxDelayMs()); final double multiplier = Math.max(1.0, retry.multiplier()); final long jitter = Math.max(0L, retry.jitterMs()); Exception last = null; for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { if (attempt > 1) { final long sleep = Math.min(maxDelay, delay + jitterRandom(jitter)); log.info("Retry step='{}' attempt={} sleepingMs={}", stepName, attempt, sleep); Thread.sleep(sleep); delay = Math.min(maxDelay, (long) (delay * multiplier)); } return action.call(); } catch (final Exception e) { last = e; final boolean transientErr = TransientErrorClassifier.isTransient(e); log.warn("Step '{}' attempt {} failed (transient={})", stepName, attempt, transientErr, e); if (!transientErr || attempt >= maxAttempts) { break; } } } if (last != null) { throw last; } throw new IllegalStateException("Unknown failure in retry executor for step=" + stepName); } private long jitterRandom(final long jitter) { if (jitter <= 0L) { return 0L; } // simple symmetric jitter in range [0, jitter] return (long) (Math.random() * jitter); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/TransientErrorClassifier.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import java.io.IOException; import java.net.ConnectException; import java.net.SocketException; import java.net.SocketTimeoutException; import java.net.UnknownHostException; import org.shredzone.acme4j.exception.AcmeException; import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.exception.AcmeServerException; import lombok.experimental.UtilityClass; /** * Classifies exceptions into transient vs permanent for retry purposes. */ @UtilityClass public class TransientErrorClassifier { /** * Returns true if the exception is likely transient and worth retrying. * * @param e exception * @return true if transient */ public static boolean isTransient(final Exception e) { if (e == null) { return false; } // Network related issues: retry if (e instanceof SocketTimeoutException || e instanceof UnknownHostException || e instanceof ConnectException || e instanceof SocketException || e instanceof IOException) { return true; } // ACME specific retriable conditions if (e instanceof AcmeRetryAfterException) { return true; } if (e instanceof AcmeServerException) { // Treat ACME server-side errors as transient; specific status access may vary by version. return true; } // Unknown AcmeException without details: treat as transient conservatively if (e instanceof AcmeException) { return true; } return false; } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/impl/ServerEmailService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service.impl; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.common.dto.DnsInstructionsEmailRequest; import tech.amak.portbuddy.sslservice.client.ServerClient; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; import tech.amak.portbuddy.sslservice.service.EmailService; /** * Implementation of {@link EmailService} that sends email via server API. */ @Service @Primary @RequiredArgsConstructor public class ServerEmailService implements EmailService { private final ServerClient serverClient; @Override public void sendDnsInstructions( final CertificateJobEntity job, final List> records, final OffsetDateTime expiresAt ) { final var request = DnsInstructionsEmailRequest.builder() .jobId(job.getId()) .domain(job.getDomain()) .contactEmail(job.getContactEmail()) .records(records) .expiresAt(expiresAt) .build(); serverClient.sendDnsInstructions(request); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/service/impl/SimpleDnsResolverService.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service.impl; import java.util.Hashtable; import javax.naming.Context; import javax.naming.directory.Attribute; import javax.naming.directory.Attributes; import javax.naming.directory.DirContext; import javax.naming.directory.InitialDirContext; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; import tech.amak.portbuddy.sslservice.service.DnsResolverService; /** * Simple DNS resolver that checks TXT record visibility using JNDI DNS provider. */ @Service @Slf4j public class SimpleDnsResolverService implements DnsResolverService { @Override public boolean isTxtRecordVisible(final String fqdn, final String expectedValue) { try { final var env = new Hashtable(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); env.put("com.sun.jndi.dns.timeout.initial", "2000"); env.put("com.sun.jndi.dns.timeout.retries", "1"); final DirContext ctx = new InitialDirContext(env); final Attributes attrs = ctx.getAttributes(fqdn, new String[] {"TXT"}); final Attribute txt = attrs.get("TXT"); if (txt == null) { return false; } final var values = txt.getAll(); while (values.hasMoreElements()) { final var raw = String.valueOf(values.nextElement()); final var normalized = raw.replaceAll("^\"|\"$", ""); if (normalized.contains(expectedValue)) { return true; } } return false; } catch (final Exception e) { log.debug("TXT lookup failed for {}: {}", fqdn, e.getMessage()); return false; } } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/CertificatesController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.domain.CertificateEntity; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; import tech.amak.portbuddy.sslservice.repo.CertificateRepository; import tech.amak.portbuddy.sslservice.service.AcmeCertificateService; import tech.amak.portbuddy.sslservice.web.dto.CreateCertificateRequest; import tech.amak.portbuddy.sslservice.web.dto.CreateManagedCertificateRequest; @RestController @RequestMapping("/api/certificates") @RequiredArgsConstructor public class CertificatesController { private final AcmeCertificateService acmeCertificateService; private final CertificateRepository certificateRepository; /** * Creates a new certificate issuance/renewal job for the given domain. * * @param request the request containing domain * @param authentication current user authentication * @return job summary */ @PostMapping public ResponseEntity createCertificate( @Valid @RequestBody final CreateCertificateRequest request, final Authentication authentication ) { final var username = authentication == null ? "system" : authentication.getName(); final var job = acmeCertificateService.submitJob(request.domain(), username, false); return ResponseEntity.accepted().body(job); } /** * Retrieves certificate metadata for a given domain. * * @param domain domain name * @return 200 with certificate or 404 if not found */ @GetMapping("/{domain}") public ResponseEntity getCertificateByDomain(@PathVariable("domain") final String domain) { final var normalized = domain.toLowerCase(); final var entity = certificateRepository.findByDomainIgnoreCase(normalized); return entity.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } /** * Lists certificates with pagination support. * * @return page of certificates */ @GetMapping public Page listCertificates(final Pageable pageable) { return certificateRepository.findAll(pageable); } /** * Onboards or updates a managed certificate entry for a domain. Managed entries are * scanned by the renewal scheduler and automatically renewed. * * @param request request payload * @return created/updated certificate metadata */ @PostMapping("/managed") public CertificateEntity createManagedCertificate( @Valid @RequestBody final CreateManagedCertificateRequest request ) { final var domain = request.domain().toLowerCase(); final var certificate = certificateRepository.findByDomainIgnoreCase(domain) .orElseGet(() -> CertificateEntity.builder() .domain(domain) .build()); certificate.setManaged(true); certificate.setVerificationMethod( Optional.ofNullable(request.verificationMethod()) .orElse("MANUAL_DNS01")); certificate.setContactEmail(request.contactEmail()); return certificateRepository.save(certificate); } /** * Lists managed certificate entries. * * @return page of managed certificates */ @GetMapping("/managed") public List listManagedCertificates() { return certificateRepository.findAllByManagedTrue(); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/ChallengeController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web; import java.util.concurrent.TimeUnit; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.work.ChallengeTokenStore; @RestController @RequiredArgsConstructor public class ChallengeController { private final ChallengeTokenStore challengeTokenStore; /** * Serves HTTP-01 ACME challenge tokens. * * @param token token name * @return token content or empty string if not found */ @GetMapping(value = "/.well-known/acme-challenge/{token}", produces = MediaType.TEXT_PLAIN_VALUE) public ResponseEntity getChallengeToken(@PathVariable("token") final String token) { final var content = challengeTokenStore.getTokenContent(token); final var body = content == null ? "" : content; return ResponseEntity.ok() .cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS).cachePublic()) .body(body); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/InternalController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web; import java.util.UUID; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.domain.CertificateEntity; import tech.amak.portbuddy.sslservice.repo.CertificateRepository; import tech.amak.portbuddy.sslservice.service.AcmeCertificateService; @RestController @RequestMapping("/internal/api/certificates") @RequiredArgsConstructor public class InternalController { private final AcmeCertificateService acmeCertificateService; private final CertificateRepository certificateRepository; /** * Retrieves certificate metadata for a given domain. * * @param domain domain name * @return 200 with certificate or 404 if not found */ @GetMapping("/{domain}") public ResponseEntity getCertificateByDomain(@PathVariable("domain") final String domain) { final var normalized = domain.toLowerCase(); final var entity = certificateRepository.findByDomainIgnoreCase(normalized); return entity.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } /** * Confirms that DNS TXT records were added for the job and continues issuance. * * @param id job id * @return 202 Accepted on success */ @PostMapping("/jobs/{id}/confirm-dns") public ResponseEntity confirmDns(@PathVariable("id") final UUID id) { acmeCertificateService.confirmDnsAndContinue(id); return ResponseEntity.accepted().build(); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/JobsController.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web; import java.util.UUID; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; import tech.amak.portbuddy.sslservice.domain.CertificateJobEntity; import tech.amak.portbuddy.sslservice.repo.CertificateJobRepository; import tech.amak.portbuddy.sslservice.service.AcmeCertificateService; @RestController @RequestMapping("/api/certificates/jobs") @RequiredArgsConstructor public class JobsController { private final CertificateJobRepository jobRepository; private final AcmeCertificateService acmeCertificateService; /** * Submits a new certificate job. * * @param domain domain name * @param requestedBy who requested the job * @param managed whether the certificate should be managed (auto-renewed) * @return created job entity */ @PostMapping public ResponseEntity submitJob( @RequestParam("domain") final String domain, @RequestParam("requestedBy") final String requestedBy, @RequestParam(value = "managed", defaultValue = "false") final boolean managed) { return ResponseEntity.ok(acmeCertificateService.submitJob(domain, requestedBy, managed)); } /** * Returns job by id. * * @param id job id * @return job entity or 404 */ @GetMapping("/{id}") public ResponseEntity getJob(@PathVariable("id") final UUID id) { final var job = jobRepository.findById(id); return job.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); } /** * Lists jobs with pagination. * * @return page of jobs */ @GetMapping public Page listJobs(final Pageable pageable) { return jobRepository.findAll(pageable); } /** * Confirms that DNS TXT records were added for the job and continues issuance. * * @param id job id * @return 202 Accepted on success */ @PostMapping("/{id}/confirm-dns") public ResponseEntity confirmDns(@PathVariable("id") final UUID id) { acmeCertificateService.confirmDnsAndContinue(id); return ResponseEntity.accepted().build(); } } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateCertificateRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; /** * Request payload to create or renew an SSL certificate for a domain. */ public record CreateCertificateRequest( @NotBlank @Pattern( // Allows domains like example.com or *.example.com regexp = "^(?:\\*\\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[A-Za-z]{2,}$", message = "Invalid domain name" ) String domain ) { } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateManagedCertificateRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web.dto; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; /** * Request to create or update a managed certificate entry. */ public record CreateManagedCertificateRequest( @NotBlank @Pattern( // Allows domains like example.com or *.example.com regexp = "^(?:\\*\\.)?(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[A-Za-z]{2,}$", message = "Invalid domain name" ) String domain, @Email String contactEmail, // For now supports values like MANUAL_DNS01 or HTTP01. Optional. String verificationMethod ) { } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/web/dto/CreateRootDomainRequest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.web.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; /** * Request to onboard a managed root domain for wildcard certificate automation. */ public record CreateRootDomainRequest( @NotBlank @Pattern( // Root domain (no wildcard) regexp = "^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[A-Za-z]{2,}$", message = "Invalid root domain" ) String rootDomain, Boolean wildcardManaged ) { } ================================================ FILE: ssl-service/src/main/java/tech/amak/portbuddy/sslservice/work/ChallengeTokenStore.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.work; import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Component; /** * In-memory storage for ACME HTTP-01 challenge tokens. */ @Component public class ChallengeTokenStore { private final ConcurrentHashMap tokens = new ConcurrentHashMap<>(); /** * Adds or updates a challenge token value. * * @param token token name * @param content token content */ public void putToken(final String token, final String content) { tokens.put(token, content); } /** * Retrieves challenge token content by token name. * * @param token token name * @return token content or null */ public String getTokenContent(final String token) { return tokens.get(token); } /** * Removes a token from the store. * * @param token token name */ public void removeToken(final String token) { tokens.remove(token); } } ================================================ FILE: ssl-service/src/main/resources/application.yml ================================================ server: port: ${SSL_SERVICE_PORT:8050} spring: application: name: ssl-service datasource: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy} username: ${DB_USER:portbuddy} password: ${DB_PASSWORD:portbuddy} jpa: hibernate: ddl-auto: validate properties: hibernate: jdbc: time_zone: UTC default_schema: certificates flyway: enabled: true locations: classpath:db/migration default-schema: certificates eureka: client: registryFetchIntervalSeconds: 2 eurekaServiceUrlPollIntervalSeconds: 60 service-url: defaultZone: ${EUREKA_ZONE:http://portbuddy:portbuddy@localhost:8761/eureka} management: endpoints: web: exposure: include: health,info app: jwt: issuer: port-buddy jwk-set-uri: lb://port-buddy-server/.well-known/jwks.json acme: serverUrl: https://acme-v02.api.letsencrypt.org/directory accountKeyPath: ${ACME_ACCOUNT_KEY_PATH:file:config/acme/account.key} contactEmail: admin@portbuddy.dev retry: maxAttempts: 6 initialDelayMs: 1000 maxDelayMs: 10000 multiplier: 2.0 jitterMs: 500 storage: certificatesDir: file:certs logging: level: root: info reactor.netty.http.client: warn io.netty.handler.codec.http.websocketx: warn org.springframework.cloud.gateway: warn org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator: warn com.netflix.discovery: warn file: name: log/app.log logback: rollingpolicy: max-history: 14 total-size-cap: 1000MB max-file-size: 100MB ================================================ FILE: ssl-service/src/main/resources/db/migration/V1__init.sql ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ -- Create tables for SSL certificates and jobs CREATE TABLE IF NOT EXISTS ssl_certificates ( id UUID PRIMARY KEY, domain VARCHAR(255) NOT NULL UNIQUE, status VARCHAR(32) NOT NULL, managed BOOLEAN NOT NULL DEFAULT FALSE, verification_method VARCHAR(64) NULL, contact_email VARCHAR(255) NULL, issued_at TIMESTAMPTZ NULL, expires_at TIMESTAMPTZ NULL, certificate_path VARCHAR(1024) NULL, private_key_path VARCHAR(1024) NULL, chain_path VARCHAR(1024) NULL, created_by VARCHAR(100) NULL, created_at TIMESTAMPTZ NULL, updated_by VARCHAR(100) NULL, updated_at TIMESTAMPTZ NULL ); CREATE TABLE IF NOT EXISTS ssl_certificate_jobs ( id UUID PRIMARY KEY, domain VARCHAR(255) NOT NULL, status VARCHAR(32) NOT NULL, message VARCHAR(2000) NULL, contact_email VARCHAR(255) NULL, challenge_records_json TEXT NULL, order_location VARCHAR(1024) NULL, authorization_urls_json TEXT NULL, challenge_expires_at TIMESTAMPTZ NULL, managed BOOLEAN NOT NULL DEFAULT FALSE, started_at TIMESTAMPTZ NULL, finished_at TIMESTAMPTZ NULL, created_by VARCHAR(100) NULL, created_at TIMESTAMPTZ NULL, updated_by VARCHAR(100) NULL, updated_at TIMESTAMPTZ NULL ); CREATE INDEX IF NOT EXISTS idx_ssl_certificate_jobs_domain ON ssl_certificate_jobs(domain); ================================================ FILE: ssl-service/src/main/resources/db/migration/V2__shedlock_table.sql ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ -- ShedLock table for cluster-safe scheduling CREATE TABLE IF NOT EXISTS shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL, locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) ); ================================================ FILE: ssl-service/src/main/resources/db/migration/V3__add_full_chain_path.sql ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ ALTER TABLE ssl_certificates ADD COLUMN full_chain_path VARCHAR(1024); ================================================ FILE: ssl-service/src/test/java/tech/amak/portbuddy/sslservice/service/CertificateRenewalServiceTest.java ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package tech.amak.portbuddy.sslservice.service; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; 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 tech.amak.portbuddy.sslservice.domain.CertificateEntity; import tech.amak.portbuddy.sslservice.domain.CertificateStatus; import tech.amak.portbuddy.sslservice.repo.CertificateJobRepository; import tech.amak.portbuddy.sslservice.repo.CertificateRepository; @ExtendWith(MockitoExtension.class) class CertificateRenewalServiceTest { @Mock private CertificateRepository certificateRepository; @Mock private CertificateJobRepository jobRepository; @Mock private AcmeCertificateService acmeCertificateService; @InjectMocks private RenewalScheduler renewalService; @Test void checkAndRenewCertificates_ShouldTriggerRenewalForExpiringCerts() { // Given final var cert1 = new CertificateEntity(); cert1.setDomain("expiring.com"); cert1.setManaged(true); cert1.setExpiresAt(OffsetDateTime.now().plusDays(10)); cert1.setStatus(CertificateStatus.ACTIVE); when(certificateRepository.findAllByManagedTrue()).thenReturn(List.of(cert1)); when(certificateRepository.findByDomainIgnoreCase("expiring.com")).thenReturn(Optional.of(cert1)); // When renewalService.scheduleRenewals(); // Then verify(acmeCertificateService, times(1)).submitJob(eq("expiring.com"), eq("renewal-scheduler"), eq(true)); } @Test void checkAndRenewCertificates_NoExpiringCerts_ShouldDoNothing() { // Given final var cert1 = new CertificateEntity(); cert1.setDomain("not-expiring.com"); cert1.setManaged(true); cert1.setExpiresAt(OffsetDateTime.now().plusDays(40)); cert1.setStatus(CertificateStatus.ACTIVE); when(certificateRepository.findAllByManagedTrue()).thenReturn(List.of(cert1)); when(certificateRepository.findByDomainIgnoreCase("not-expiring.com")).thenReturn(Optional.of(cert1)); // When renewalService.scheduleRenewals(); // Then verify(acmeCertificateService, never()).submitJob(any(), any(), any(Boolean.class)); } } ================================================ FILE: ssl-service/src/test/resources/application-test.yml ================================================ spring: datasource: url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portbuddy} username: ${DB_USER:portbuddy} password: ${DB_PASSWORD:portbuddy} jpa: hibernate: ddl-auto: validate properties: hibernate: default_schema: certificates flyway: enabled: true default-schema: certificates create-schemas: true eureka: client: enabled: false ================================================ FILE: web/index.html ================================================ Port Buddy
================================================ FILE: web/package.json ================================================ { "name": "port-buddy-web", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "vite --host", "build": "vite build", "preview": "vite preview" }, "dependencies": { "@heroicons/react": "^2.1.5", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "jwt-decode": "^4.0.0", "react": "18.3.1", "react-dom": "18.3.1", "react-helmet-async": "^2.0.5", "react-router-dom": "6.28.0" }, "devDependencies": { "@prerenderer/renderer-puppeteer": "^1.2.4", "@prerenderer/rollup-plugin": "^0.3.12", "@types/react-helmet-async": "^1.0.1", "@vitejs/plugin-react": "4.3.3", "autoprefixer": "10.4.20", "postcss": "8.4.49", "puppeteer": "^24.35.0", "tailwindcss": "3.4.14", "typescript": "5.6.3", "vite": "5.4.10" } } ================================================ FILE: web/pom.xml ================================================ 4.0.0 tech.amak port-buddy 1.0-SNAPSHOT web port-buddy-web jar React+TypeScript SPA built with Vite and Tailwind (packaged into META-INF/resources) com.github.eirslett frontend-maven-plugin 1.15.0 ${project.basedir} ${project.build.directory} install-node-and-npm generate-resources install-node-and-npm ${node.version} ${npm.version} npm-ci generate-resources npm ci npm-build generate-resources npm run build src/main/resources false ${project.basedir}/dist false META-INF/resources **/* ================================================ FILE: web/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: web/public/install.ps1 ================================================ $owner = "amak-tech" $repo = "port-buddy" Write-Host "Detecting latest version..." -ForegroundColor Cyan $releaseUrl = "https://api.github.com/repos/$owner/$repo/releases/latest" $release = Invoke-RestMethod -Uri $releaseUrl $version = $release.tag_name if (-not $version) { Write-Error "Could not determine the latest version." exit 1 } $url = "https://github.com/$owner/$repo/releases/download/$version/portbuddy-windows-x64.exe" $installDir = Join-Path $HOME ".portbuddy" $exePath = Join-Path $installDir "portbuddy.exe" if (-not (Test-Path $installDir)) { New-Item -ItemType Directory -Path $installDir | Out-Null } Write-Host "Downloading Port Buddy $version..." -ForegroundColor Cyan Invoke-WebRequest -Uri $url -OutFile $exePath $path = [Environment]::GetEnvironmentVariable("Path", "User") if ($path -notlike "*$installDir*") { Write-Host "Adding $installDir to PATH..." -ForegroundColor Cyan [Environment]::SetEnvironmentVariable("Path", "$path;$installDir", "User") $env:Path += ";$installDir" } Write-Host "Port Buddy installed successfully!" -ForegroundColor Green Write-Host "Please restart your terminal to start using 'portbuddy'." -ForegroundColor Yellow ================================================ FILE: web/public/install.sh ================================================ #!/bin/bash # # Copyright (c) 2026 AMAK Inc. All rights reserved. # set -e # Port Buddy Installation Script # This script detects the platform and architecture, then downloads and installs # the latest version of Port Buddy from GitHub releases. OWNER="amak-tech" REPO="port-buddy" BINARY_NAME="portbuddy" INSTALL_DIR="/usr/local/bin" # Detect OS OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "${OS}" in linux*) PLATFORM="linux" ;; darwin*) PLATFORM="macos" ;; *) echo "Unsupported OS: ${OS}"; exit 1 ;; esac # Detect Architecture ARCH_RAW="$(uname -m)" case "${ARCH_RAW}" in x86_64) ARCH="x64" ;; aarch64|arm64) ARCH="arm64" ;; *) echo "Unsupported architecture: ${ARCH_RAW}"; exit 1 ;; esac ASSET_NAME="${BINARY_NAME}-${PLATFORM}-${ARCH}" echo "Detecting latest version..." LATEST_RELEASE_URL="https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" VERSION=$(curl -s "${LATEST_RELEASE_URL}" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') if [ -z "${VERSION}" ]; then echo "Error: Could not determine the latest version." exit 1 fi echo "Latest version: ${VERSION}" DOWNLOAD_URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${ASSET_NAME}" echo "Downloading ${ASSET_NAME}..." curl -L "${DOWNLOAD_URL}" -o "${BINARY_NAME}" echo "Installing to ${INSTALL_DIR}..." chmod +x "${BINARY_NAME}" sudo mv "${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" echo "Successfully installed Port Buddy ${VERSION} to ${INSTALL_DIR}/${BINARY_NAME}" echo "Run 'portbuddy --help' to get started." ================================================ FILE: web/public/pages/contacts.html ================================================ Contact Us - Port Buddy
================================================ FILE: web/public/pages/docs/guides/hytale-server.html ================================================ How to Expose a Hytale Server to the Internet | Port Buddy
================================================ FILE: web/public/pages/docs/guides/minecraft-server.html ================================================ How to Expose a Minecraft Server to the Internet | Port Buddy
================================================ FILE: web/public/pages/docs.html ================================================ Documentation - Port Buddy
================================================ FILE: web/public/pages/index.html ================================================ Port Buddy - Expose Localhost to the Internet | Ngrok Alternative
================================================ FILE: web/public/pages/install.html ================================================ Install Port Buddy - Windows, macOS, Linux, Docker | Port Buddy CLI
================================================ FILE: web/public/pages/privacy.html ================================================ Privacy Policy - Port Buddy
================================================ FILE: web/public/pages/terms.html ================================================ Terms and Conditions - Port Buddy
================================================ FILE: web/public/robots.txt ================================================ User-agent: * Allow: / Disallow: /app/ Sitemap: https://portbuddy.dev/sitemap.xml ================================================ FILE: web/public/setup-portbuddy-service.ps1 ================================================ <# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. #> # Check if running as Administrator $currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Warning "Please run as Administrator to register a service (Scheduled Task)." exit 1 } param( [Parameter(Position=0, Mandatory=$true)] [string]$Mode, [Parameter(Position=1, Mandatory=$true)] [string]$Port, [Parameter(Position=2)] [string]$HostName, [Parameter(Mandatory=$false)] [string]$Name ) # Construct target argument if ([string]::IsNullOrEmpty($HostName)) { $Target = "$Port" } else { $Target = "$HostName`:$Port" } # Determine Service Name if ([string]::IsNullOrEmpty($Name)) { $ServiceName = "portbuddy-$Mode-$Port" } else { $ServiceName = $Name } # Locate portbuddy executable $PortBuddyBin = Get-Command "portbuddy" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source if (-not $PortBuddyBin) { # Try common location installed by install.ps1 $CommonPath = Join-Path $env:USERPROFILE ".portbuddy\portbuddy.exe" if (Test-Path $CommonPath) { $PortBuddyBin = $CommonPath } else { Write-Error "Error: portbuddy executable not found in PATH or $CommonPath" Write-Error "Please make sure portbuddy is installed and available." exit 1 } } Write-Host "Setting up Port Buddy service..." -ForegroundColor Cyan Write-Host "Service Name: $ServiceName" Write-Host "Binary: $PortBuddyBin" Write-Host "Command: $PortBuddyBin $Mode $Target -n" Write-Host "User Profile: $env:USERPROFILE" # Create Scheduled Task Action # We use cmd /c to ensure environment variables (specifically USERPROFILE) are set correctly for the context # or rely on the fact that if we run as SYSTEM, we need to point it to the config. # Port Buddy likely uses 'user.home' to find config. # We set USERPROFILE to the current user's profile so it finds the config. $UserHome = $env:USERPROFILE $ActionCmd = "cmd.exe" $ActionArg = "/c set USERPROFILE=$UserHome && `"$PortBuddyBin`" $Mode $Target" $Action = New-ScheduledTaskAction -Execute $ActionCmd -Argument $ActionArg # Create Trigger (At Startup) $Trigger = New-ScheduledTaskTrigger -AtStartup # Create Settings # Restart if it fails, don't stop on battery, etc. $Settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RestartCount 3 ` -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit (New-TimeSpan -Days 3650) # Effectively infinite # Create Principal (SYSTEM account) $Principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest # Register Task try { Register-ScheduledTask -TaskName $ServiceName -Action $Action -Trigger $Trigger -Principal $Principal -Settings $Settings -Force | Out-Null Write-Host "----------------------------------------" Write-Host "Service $ServiceName has been installed and started (scheduled)." -ForegroundColor Green Write-Host "To start immediately: Start-ScheduledTask -TaskName $ServiceName" Write-Host "Check status with: Get-ScheduledTask -TaskName $ServiceName" # Attempt to start it now Start-ScheduledTask -TaskName $ServiceName } catch { Write-Error "Failed to register task: $_" exit 1 } ================================================ FILE: web/public/setup-portbuddy-service.sh ================================================ #!/bin/bash # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # # Check if running as root if [ "$EUID" -ne 0 ]; then echo "Please run as root (sudo)" exit 1 fi # Parse arguments POSITIONAL_ARGS=() CUSTOM_NAME="" while [[ $# -gt 0 ]]; do case $1 in --name) CUSTOM_NAME="$2" shift # past argument shift # past value ;; *) POSITIONAL_ARGS+=("$1") # save positional arg shift # past argument ;; esac done set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters if [ "$#" -lt 2 ]; then echo "Usage: $0 [options] [host]" echo "Options:" echo " --name Custom name for the service" echo "Example: $0 tcp 22" echo "Example: $0 --name my-ssh-service tcp 22" exit 1 fi MODE=$1 PORT=$2 HOST=$3 # Construct the argument for portbuddy if [ -z "$HOST" ]; then TARGET="$PORT" else TARGET="$HOST:$PORT" fi if [ -n "$CUSTOM_NAME" ]; then SERVICE_NAME="$CUSTOM_NAME" else SERVICE_NAME="portbuddy-${MODE}-${PORT}" fi # Get the real user if running under sudo REAL_USER=${SUDO_USER:-$USER} # Get home dir of the real user REAL_USER_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) # Locate portbuddy binary PORTBUDDY_BIN=$(which portbuddy) if [ -z "$PORTBUDDY_BIN" ]; then # Try common location if [ -f "/usr/local/bin/portbuddy" ]; then PORTBUDDY_BIN="/usr/local/bin/portbuddy" else echo "Error: portbuddy binary not found in PATH or /usr/local/bin" echo "Please make sure portbuddy is installed and available." exit 1 fi fi echo "Setting up Port Buddy service..." echo "User: $REAL_USER" echo "Binary: $PORTBUDDY_BIN" echo "Command: $PORTBUDDY_BIN $MODE $TARGET" # Create systemd service file cat < /etc/systemd/system/$SERVICE_NAME.service [Unit] Description=Port Buddy Service After=network.target [Service] Type=simple User=$REAL_USER ExecStart=$PORTBUDDY_BIN $MODE $TARGET -n Restart=on-failure RestartSec=5 Environment=HOME=$REAL_USER_HOME [Install] WantedBy=multi-user.target EOF # Reload systemd, enable and start service systemctl daemon-reload systemctl enable $SERVICE_NAME systemctl restart $SERVICE_NAME echo "----------------------------------------" echo "Service $SERVICE_NAME has been installed and started." echo "Check status with: systemctl status $SERVICE_NAME" echo "View logs with: journalctl -u $SERVICE_NAME -f" ================================================ FILE: web/public/site.webmanifest ================================================ { "name" : "Port Buddy", "short_name" : "PortBuddy", "start_url": "https://portbuddy.dev", "icons" : [ { "src" : "/favicon-192x192.png", "sizes" : "192x192", "type" : "image/png" }, { "src" : "/favicon-512x512.png", "sizes" : "512x512", "type" : "image/png" } ], "theme_color" : "#ffffff", "background_color" : "#ffffff", "display" : "standalone" } ================================================ FILE: web/public/sitemap.xml ================================================ https://portbuddy.dev/ 1.0 daily https://portbuddy.dev/install 0.8 monthly https://portbuddy.dev/docs 0.8 monthly https://portbuddy.dev/login 0.5 monthly https://portbuddy.dev/register 0.5 monthly https://portbuddy.dev/forgot-password 0.1 monthly https://portbuddy.dev/terms 0.3 monthly https://portbuddy.dev/privacy 0.3 monthly https://portbuddy.dev/contacts 0.3 monthly https://portbuddy.dev/docs/guides/minecraft-server 0.8 monthly https://portbuddy.dev/docs/guides/hytale-server 0.8 monthly ================================================ FILE: web/src/App.tsx ================================================ import { Link, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom' import { useEffect, useState } from 'react' import { Bars3Icon, ChevronDownIcon, XMarkIcon } from '@heroicons/react/24/outline' import Landing from './pages/Landing' import Installation from './pages/Installation' import DocsLayout from './pages/docs/DocsLayout' import DocsOverview from './pages/docs/DocsOverview' import MinecraftGuide from './pages/docs/guides/MinecraftGuide' import HytaleGuide from './pages/docs/guides/HytaleGuide' import AcceptInvite from './pages/AcceptInvite' import Login from './pages/Login' import Register from './pages/Register' import ForgotPassword from './pages/ForgotPassword' import ResetPassword from './pages/ResetPassword' import Billing from './pages/app/Billing' import BillingSuccess from './pages/app/BillingSuccess' import BillingCancel from './pages/app/BillingCancel' import ProtectedRoute from './components/ProtectedRoute' import { useAuth } from './auth/AuthContext' import AppLayout from './components/AppLayout' import { useLoading } from './components/LoadingContext' import ProgressBar from './components/ProgressBar' import { setLoadingCallbacks } from './lib/api' import Tunnels from './pages/app/Tunnels' import Tokens from './pages/app/Tokens' import Domains from './pages/app/Domains' import Settings from './pages/app/Settings' import Team from './pages/app/Team' import Ports from './pages/app/Ports' import Profile from './pages/app/Profile' import AdminPanel from './pages/app/AdminPanel' import AdminAccounts from './pages/app/AdminAccounts' import AdminUsers from './pages/app/AdminUsers' import AdminTunnels from './pages/app/AdminTunnels' import Terms from './pages/Terms' import Privacy from './pages/Privacy' import Contacts from './pages/Contacts' import NotFound from './pages/NotFound' import ServerError from './pages/ServerError' import Passcode from './pages/Passcode' function ScrollToTop() { const { pathname } = useLocation() useEffect(() => { window.scrollTo(0, 0) }, [pathname]) return null } function ScrollToHash() { const location = useLocation() useEffect(() => { if (!location.hash) { return } const id = location.hash.replace('#', '') const scroll = () => { const el = document.getElementById(id) if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } // Try immediately and once more on next tick to ensure target is mounted scroll() const t = setTimeout(scroll, 0) return () => clearTimeout(t) }, [location.hash]) return null } export default function App() { const { user, logout } = useAuth() const { startLoading, stopLoading } = useLoading() const [menuOpen, setMenuOpen] = useState(false) const [communityOpen, setCommunityOpen] = useState(false) const location = useLocation() useEffect(() => { setLoadingCallbacks(startLoading, stopLoading) // Signal to pre-renderer that the page is ready const timer = setTimeout(() => { document.dispatchEvent(new Event('render-event')) }, 100); return () => clearTimeout(timer); }, [startLoading, stopLoading, location.pathname]) const isApp = location.pathname.startsWith('/app') const showHeader = !isApp && !['/login', '/register', '/forgot-password', '/reset-password'].includes(location.pathname) // const [currentYear, setCurrentYear] = useState(null); // // useEffect(() => { // setCurrentYear(new Date().getFullYear()); // }, []); return (
{showHeader && (
Port Buddy
)}
} /> } /> } /> }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* App area with sidebar layout */} }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> {/* Unknown app routes redirect to dashboard */} } /> {/* Backward-compat for old links */} } /> {/* Global 404 */} } /> } />
{showHeader && ( )}
) } ================================================ FILE: web/src/auth/AuthContext.tsx ================================================ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { jwtDecode } from 'jwt-decode' import { API_BASE, apiJson, getToken } from '../lib/api' export type User = { id: string accountId: string email: string name?: string avatarUrl?: string roles?: string[] plan?: 'pro' | 'team' accountName?: string extraTunnels?: number baseTunnels?: number activeTunnels?: number subscriptionStatus?: string stripeCustomerId?: string blocked?: boolean } const ACCOUNT_NAME_CLAIM = 'aname' const ACCOUNT_ID_CLAIM = 'aid' type AuthState = { user: User | null loading: boolean loginWithGoogle: () => void loginWithGithub: () => void loginWithEmail: (email: string, pass: string) => Promise register: (email: string, pass: string, name?: string) => Promise logout: () => Promise refresh: () => Promise switchAccount: (accountId: string) => Promise } const AuthContext = createContext(undefined) const APP_ORIGIN = window.location.origin const OAUTH_REDIRECT_URI = `${APP_ORIGIN}/auth/callback` function storeTokenFromUrlIfPresent(): string | null { // Only capture token if we are on the auth callback path or similar // to avoid stealing tokens from other pages (like /accept-invite?token=...) if (!window.location.pathname.startsWith('/auth/callback') && !window.location.pathname.startsWith('/login')) { return localStorage.getItem('pb_token') } // Support either hash or query param token from backend callback, e.g. /auth/callback#token=... or ?token=... const hash = new URLSearchParams(window.location.hash.replace(/^#/, '')) const query = new URLSearchParams(window.location.search) const token = hash.get('token') || query.get('token') if (token) { localStorage.setItem('pb_token', token) // Clean URL const url = new URL(window.location.href) url.hash = '' url.searchParams.delete('token') window.history.replaceState({}, '', url.toString()) return token } return localStorage.getItem('pb_token') } export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const refresh = useCallback(async () => { setLoading(true) const token = getToken() if (!token) { setUser(null) setLoading(false) return } const decoded: any = jwtDecode(token) const currentUserId = decoded.uid const currentAccountName = decoded[ACCOUNT_NAME_CLAIM] const currentAccountId = decoded[ACCOUNT_ID_CLAIM] try { const details = await apiJson<{ user: { id: string, email: string, firstName?: string, lastName?: string, avatarUrl?: string, roles?: string[] } account?: { name?: string, plan?: string, extraTunnels?: number, baseTunnels?: number, activeTunnels?: number, subscriptionStatus?: string, stripeCustomerId?: string, blocked?: boolean } }>('/api/users/me/details', undefined, { skipRedirectOn401: true }) const firstName = details?.user?.firstName?.trim() || '' const lastName = details?.user?.lastName?.trim() || '' const name = [firstName, lastName].filter(Boolean).join(' ') || undefined // Map server response to SPA User shape const mapped: User = { id: details.user.id, accountId: currentAccountId, email: details.user.email, name, avatarUrl: details.user.avatarUrl || undefined, roles: details.user.roles, plan: details.account?.plan?.toLowerCase() as any, accountName: details.account?.name || currentAccountName, extraTunnels: details.account?.extraTunnels, baseTunnels: details.account?.baseTunnels, activeTunnels: details.account?.activeTunnels, subscriptionStatus: details.account?.subscriptionStatus, stripeCustomerId: details.account?.stripeCustomerId, blocked: details.account?.blocked, } setUser(mapped) } catch (e: any) { if (e.status === 401) { try { localStorage.removeItem('pb_token') } catch (_) { // ignore } } setUser(null) } finally { setLoading(false) } }, []) useEffect(() => { // On first load, capture token if backend sent it in URL and then fetch profile storeTokenFromUrlIfPresent() void refresh() }, [refresh]) const loginWithGoogle = useCallback(() => { const redirect = encodeURIComponent(OAUTH_REDIRECT_URI) // Typical Spring Security OAuth2 endpoint const url = `${API_BASE}/oauth2/authorization/google?redirect_uri=${redirect}` window.location.href = url }, []) const loginWithGithub = useCallback(() => { const redirect = encodeURIComponent(OAUTH_REDIRECT_URI) // Typical Spring Security OAuth2 endpoint const url = `${API_BASE}/oauth2/authorization/github?redirect_uri=${redirect}` window.location.href = url }, []) const loginWithEmail = useCallback(async (email: string, pass: string) => { const res = await apiJson<{ accessToken: string, tokenType: string }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ email, password: pass }) }, { skipAuth: true }) localStorage.setItem('pb_token', res.accessToken) await refresh() }, [refresh]) const register = useCallback(async (email: string, pass: string, name?: string) => { const res = await apiJson<{ success: boolean, message?: string }>('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password: pass, name }) }, { skipAuth: true }) if (!res.success) { throw new Error(res.message || 'Registration failed') } await loginWithEmail(email, pass) }, [loginWithEmail]) const logout = useCallback(async () => { // Stateless logout: just drop the JWT from localStorage client-side try { localStorage.removeItem('pb_token') } catch (_) { // ignore storage errors } setUser(null) // Redirect to landing page after logout window.location.assign('/') }, []) const switchAccount = useCallback(async (accountId: string) => { try { const res = await apiJson<{ token: string }>(`/api/users/me/accounts/${accountId}/switch`, { method: 'POST' }) localStorage.setItem('pb_token', res.token) await refresh() } catch (e) { console.error('Failed to switch account', e) throw e } }, [refresh]) const value = useMemo(() => ({ user, loading, loginWithGoogle, loginWithGithub, loginWithEmail, register, logout, refresh, switchAccount }), [user, loading, loginWithGoogle, loginWithGithub, loginWithEmail, register, logout, refresh, switchAccount]) return ( {children} ) } export function useAuth(): AuthState { const ctx = useContext(AuthContext) if (!ctx) throw new Error('useAuth must be used within AuthProvider') return ctx } ================================================ FILE: web/src/components/AppLayout.tsx ================================================ import { Link, NavLink, Outlet } from 'react-router-dom' import { ComponentType, SVGProps, useEffect, useState } from 'react' import { useAuth } from '../auth/AuthContext' import { PageHeaderProvider, usePageHeader } from './PageHeader' import { apiJson } from '../lib/api' import { AcademicCapIcon, ArrowsRightLeftIcon, ChevronUpDownIcon, Cog8ToothIcon, GlobeAltIcon, LinkIcon, LockClosedIcon, WalletIcon, UserGroupIcon, PowerIcon, ShieldCheckIcon, XMarkIcon, Bars3Icon, } from '@heroicons/react/24/outline' type UserAccount = { accountId: string accountName: string plan: string roles: string[] lastUsedAt: string } export default function AppLayout() { const { user, logout, switchAccount } = useAuth() const [accounts, setAccounts] = useState([]) const [showAccountSwitcher, setShowAccountSwitcher] = useState(false) const [isSidebarOpen, setIsSidebarOpen] = useState(false) useEffect(() => { if (user) { void apiJson('/api/users/me/accounts').then(setAccounts) } }, [user]) const otherAccounts = accounts.filter(a => a.accountId !== user?.accountId) return (
{/* Mobile Sidebar Overlay */} {isSidebarOpen && (
setIsSidebarOpen(false)} /> )} {/* Sidebar */} {/* Main content */}
{/* Page Header (sticky at top) */}
{/* Page body */}
{user?.blocked && (

Account Blocked

Your account is currently blocked. Access to services is restricted. Please contact support for assistance.

)}
) } type IconType = ComponentType> function SideLink({ to, label, end = false, Icon, onClick }: { to: string, label: string, end?: boolean, Icon?: IconType, onClick?: () => void }) { return ( `flex items-center gap-3 px-3 py-3 rounded-xl transition-all duration-200 ${ isActive ? 'bg-jb-blue/10 text-jb-blue font-bold shadow-[inset_4px_0_0_0_#33ccff]' : 'text-slate-400 hover:text-slate-200 hover:bg-white/5' }` } > {Icon ? ) } function HeaderTitle() { const { title } = usePageHeader() return (
{title}
) } ================================================ FILE: web/src/components/CodeBlock.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ export default function CodeBlock({ code }: { code: string }) { return (
{code}
) } ================================================ FILE: web/src/components/LoadingContext.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import React, { createContext, useContext, useState, useCallback } from 'react'; interface LoadingContextType { isLoading: boolean; startLoading: () => void; stopLoading: () => void; } const LoadingContext = createContext(undefined); export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [activeRequests, setActiveRequests] = useState(0); const startLoading = useCallback(() => { setActiveRequests((prev) => prev + 1); }, []); const stopLoading = useCallback(() => { setActiveRequests((prev) => Math.max(0, prev - 1)); }, []); const isLoading = activeRequests > 0; return ( {children} ); }; export const useLoading = () => { const context = useContext(LoadingContext); if (!context) { throw new Error('useLoading must be used within a LoadingProvider'); } return context; }; ================================================ FILE: web/src/components/Modal.tsx ================================================ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { XMarkIcon } from '@heroicons/react/24/outline' interface ModalProps { isOpen: boolean onClose: () => void title: string children: React.ReactNode } export function Modal({ isOpen, onClose, title, children }: ModalProps) { const overlayRef = useRef(null) useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } if (isOpen) { document.addEventListener('keydown', handleEscape) document.body.style.overflow = 'hidden' } return () => { document.removeEventListener('keydown', handleEscape) document.body.style.overflow = 'unset' } }, [isOpen, onClose]) if (!isOpen) return null const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === overlayRef.current) { onClose() } } return createPortal(

{title}

{children}
, document.body ) } interface ConfirmModalProps { isOpen: boolean onClose: () => void onConfirm: () => void title: string message: string confirmText?: string cancelText?: string isDangerous?: boolean } export function ConfirmModal({ isOpen, onClose, onConfirm, title, message, confirmText = 'Confirm', cancelText = 'Cancel', isDangerous = false }: ConfirmModalProps) { return (

{message}

) } interface AlertModalProps { isOpen: boolean onClose: () => void title: string message: string } export function AlertModal({ isOpen, onClose, title, message }: AlertModalProps) { return (

{message}

) } ================================================ FILE: web/src/components/PageHeader.tsx ================================================ import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react' type PageHeaderContextValue = { title: string setTitle: (title: string) => void } const PageHeaderContext = createContext(undefined) export function PageHeaderProvider({ children }: PropsWithChildren) { const [title, setTitle] = useState('') const value = useMemo(() => ({ title, setTitle }), [title]) useEffect(() => { document.title = title ? `${title} | Port Buddy` : 'Port Buddy' }, [title]) return ( {children} ) } export function usePageHeader() { const ctx = useContext(PageHeaderContext) if (!ctx) throw new Error('usePageHeader must be used within PageHeaderProvider') return ctx } export function usePageTitle(title: string) { const { setTitle } = usePageHeader() useEffect(() => { setTitle(title) }, [setTitle, title]) } ================================================ FILE: web/src/components/PlanComparison.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { CheckIcon, XMarkIcon } from '@heroicons/react/24/outline' import { Link } from 'react-router-dom' const comparisonData = [ { feature: 'HTTP Tunnels', pro: true, team: true }, { feature: 'TCP Tunnels', pro: true, team: true }, { feature: 'UDP Tunnels', pro: true, team: true }, { feature: 'SSL for HTTP', pro: true, team: true }, { feature: 'Static Subdomains', pro: true, team: true }, { feature: 'Custom Domains', pro: true, team: true }, { feature: 'Private Tunnels', pro: true, team: true }, { feature: 'Web Socket Support', pro: true, team: true }, { feature: 'Free Tunnels', pro: '1 tunnel', team: '10 tunnels' }, { feature: 'Extra Tunnels', pro: '$1/mo each', team: '$1/mo each' }, { feature: 'Team Members', pro: false, team: true }, { feature: 'SSO', pro: false, team: 'Coming soon' }, { feature: 'Support', pro: 'Community', team: 'Priority' }, ] export default function PlanComparison() { return (
{/* Pricing Cards */}
{/* Pro Plan */}

Pro

$0 /month

Perfect for hobbyists and individual developers working on side projects.

1 free tunnel included
Unlimited custom domains
HTTP, TCP & UDP support
$1/mo per extra tunnel
Get Started
{/* Team Plan */}
Most Popular

Team

$10 /month

For growing teams that need more resources and collaboration features.

10 free tunnels included
Team member management
Priority email support
$1/mo per extra tunnel
Upgrade to Team

Detailed Comparison

Choose the plan that fits your development needs.

{comparisonData.map((item, idx) => ( ))}
Feature Pro Team
{item.feature} {typeof item.pro === 'boolean' ? ( item.pro ? : ) : ( {item.pro} )} {typeof item.team === 'boolean' ? ( item.team ? : ) : ( {item.team} )}
) } ================================================ FILE: web/src/components/ProgressBar.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import React, { useEffect } from 'react'; import { useLoading } from './LoadingContext'; const ProgressBar: React.FC = () => { const { isLoading } = useLoading(); if (!isLoading) return null; return (
); }; export default ProgressBar; ================================================ FILE: web/src/components/ProtectedRoute.tsx ================================================ import React from 'react' import { Navigate, useLocation } from 'react-router-dom' import { useAuth } from '../auth/AuthContext' export default function ProtectedRoute({ children, role }: { children: React.ReactNode, role?: string }) { const { user, loading } = useAuth() const location = useLocation() if (loading) { return (
{/* Sidebar shell */} {/* Main shell */}
) } if (!user) { return } if (role && !user.roles?.includes(role)) { return } return <>{children} } ================================================ FILE: web/src/index.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; :root { color-scheme: dark; } html, body, #root { height: 100%; } body { margin: 0; font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-optical-sizing: auto; font-weight: 300; font-style: normal; background: #0b1020; color: #e2e8f0; background-image: radial-gradient(at 0% 0%, rgba(204, 51, 255, 0.05) 0px, transparent 50%), radial-gradient(at 100% 100%, rgba(34, 211, 238, 0.05) 0px, transparent 50%); background-attachment: fixed; } a { color: #67e8f9; text-decoration: none; transition: color 0.2s; } a:hover { color: #22d3ee; text-decoration: none; } .container { @apply max-w-7xl mx-auto px-6; } .btn { @apply inline-flex items-center gap-2 bg-slate-800 border border-slate-700 px-6 py-2.5 rounded-lg hover:bg-slate-700 hover:border-slate-600 transition-all font-medium; } .btn-primary { @apply bg-indigo-600 border-indigo-500 text-white hover:bg-indigo-500 shadow-lg shadow-indigo-500/20; } .badge { @apply text-[10px] uppercase tracking-wider font-bold border border-slate-700 bg-slate-800/50 rounded-full px-2.5 py-0.5 text-slate-400; } .glass { @apply bg-slate-900/40 backdrop-blur-md border border-white/5; } .text-gradient { @apply text-transparent bg-clip-text bg-gradient-to-r from-jb-blue via-jb-purple to-jb-pink; } .jb-border { position: relative; } .jb-border::after { content: ''; position: absolute; bottom: -1px; left: 0; right: 0; height: 1px; background: linear-gradient(90deg, transparent, #cc33ff, #ff3399, #ff9933, transparent); opacity: 0.5; } ================================================ FILE: web/src/lib/api.ts ================================================ // Centralized API client that attaches Authorization: Bearer // and avoids sending cookies. Works in dev and prod using VITE_API_BASE. let startLoadingCallback: (() => void) | null = null; let stopLoadingCallback: (() => void) | null = null; export function setLoadingCallbacks(start: () => void, stop: () => void) { startLoadingCallback = start; stopLoadingCallback = stop; } function startLoading() { if (startLoadingCallback) startLoadingCallback(); } function stopLoading() { if (stopLoadingCallback) stopLoadingCallback(); } export const API_BASE: string = (() => { const env = (import.meta as any).env?.VITE_API_BASE?.toString() if (env) return env if (window.location.hostname === 'localhost' && window.location.port === '5173') { return 'http://localhost:8080' } return '' // same-origin in production })() export function getToken(): string | null { try { return localStorage.getItem('pb_token') } catch { return null } } function redirectToLogin(withFrom: boolean = true): void { try { localStorage.removeItem('pb_token') } catch { // ignore } const { pathname, search, hash } = window.location const here = `${pathname}${search}${hash}` const onLogin = pathname.startsWith('/login') || pathname.startsWith('/auth/callback') // Prevent multi-trigger redirects const flag = '__pbRedirectingToLogin' if ((window as any)[flag]) return ;(window as any)[flag] = true // If we are already on a login-related page, do not navigate again to avoid reload loops if (onLogin) { return } const to = !withFrom ? '/login' : `/login?from=${encodeURIComponent(here)}` // Use assign to create a fresh navigation (clears any stale protected UI) window.location.assign(to) } function withAuth(init?: RequestInit, skipToken: boolean = false): RequestInit { const token = !skipToken ? getToken() : null const headers: Record = { ...(init?.headers as Record | undefined), } if (token) { headers['Authorization'] = `Bearer ${token}` } return { ...init, // Explicitly avoid sending cookies for stateless JWT API credentials: 'omit', headers, } } export async function apiJson(path: string, init?: RequestInit, options?: { skipRedirectOn401?: boolean, skipAuth?: boolean }): Promise { startLoading() try { const res = await fetch(`${API_BASE}${path}`, withAuth({ headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, ...init, }, options?.skipAuth)) if (res.status === 401) { if (!options?.skipRedirectOn401) { redirectToLogin(true) } // Throw to stop any further processing by callers const err: any = new Error('Unauthorized') err.status = 401 throw err } if (!res.ok) { let errorMessage = `HTTP ${res.status}` try { const text = await res.text() if (text) { try { const data = JSON.parse(text) if (data && typeof data === 'object') { if (data.detail) { errorMessage = data.detail } else if (data.title) { errorMessage = data.title } else if (data.message) { errorMessage = data.message } else { errorMessage = text } } else { errorMessage = text } } catch { errorMessage = text } } } catch { // ignore } const err: any = new Error(errorMessage) err.status = res.status throw err } // 204 No Content has no body if (res.status === 204) return undefined as unknown as T return res.json() as Promise } finally { stopLoading() } } export async function apiRaw(path: string, init?: RequestInit): Promise { startLoading() try { const res = await fetch(`${API_BASE}${path}`, withAuth(init)) if (res.status === 401) { redirectToLogin(true) // Throw to ensure callers do not proceed under unauthorized state const err: any = new Error('Unauthorized') err.status = 401 throw err } return res } finally { stopLoading() } } ================================================ FILE: web/src/lib/utils.ts ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ export function formatDateTime(dateString: string): string { const date = new Date(dateString) if (isNaN(date.getTime())) return dateString const dd = String(date.getDate()).padStart(2, '0') const MM = String(date.getMonth() + 1).padStart(2, '0') const yy = String(date.getFullYear()).slice(-2) const hh = String(date.getHours()).padStart(2, '0') const mm = String(date.getMinutes()).padStart(2, '0') return `${dd}.${MM}.${yy} ${hh}:${mm}` } ================================================ FILE: web/src/main.tsx ================================================ import React from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import App from './App' import './index.css' import { AuthProvider } from './auth/AuthContext' import { LoadingProvider } from './components/LoadingContext' const rootElement = document.getElementById('root')!; const app = ( ); const prerenderedRoute = rootElement.getAttribute('data-prerendered-route'); const currentRoute = window.location.pathname.replace(/\/$/, '') || '/'; const shouldHydrate = rootElement.hasChildNodes() && ( prerenderedRoute === currentRoute || (currentRoute === '/' && prerenderedRoute === '/index') || (currentRoute === '/index' && prerenderedRoute === '/') ); if (shouldHydrate) { ReactDOM.hydrateRoot(rootElement, app); } else { ReactDOM.createRoot(rootElement).render(app); } ================================================ FILE: web/src/pages/AcceptInvite.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { apiJson } from '../lib/api' import { useAuth } from '../auth/AuthContext' import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline' export default function AcceptInvite() { const [searchParams] = useSearchParams() const token = searchParams.get('token') const { user, loading, refresh } = useAuth() const navigate = useNavigate() const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'unauthenticated'>('loading') const [error, setError] = useState(null) useEffect(() => { if (loading) { return } if (!token) { setStatus('error') setError('Missing invitation token.') return } if (!user) { setStatus('unauthenticated') return } void handleAccept() }, [token, user, loading]) async function handleAccept() { setStatus('loading') try { await apiJson(`/api/team/accept?token=${token}`, { method: 'POST' }) setStatus('success') await refresh() setTimeout(() => navigate('/app/team'), 3000) } catch (err: any) { setStatus('error') setError(err.message || 'Failed to accept invitation.') } } if (status === 'unauthenticated') { return (

Login required

You must be logged in to accept a team invitation.

) } return (
{status === 'loading' && (

Accepting invitation...

)} {status === 'success' && (

Welcome to the team!

You've successfully joined the team. Redirecting you to the dashboard...

)} {status === 'error' && (

Invitation failed

{error}

)}
) } ================================================ FILE: web/src/pages/Contacts.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ export default function Contacts() { return (

Contact Us

Have questions, need assistance, or want to report an issue? We're here to help. Reach out to us via the appropriate email address below.

Support

For any technical questions, issues with your account, subscription inquiries, or general help using Port Buddy.

support@portbuddy.dev

Abuse

To report any misuse of our service, phishing attempts, or content that violates our terms of service.

abuse@portbuddy.dev

Other Ways to Connect

You can also find us on our community channels:

  • Discord - Join our community for real-time help and discussions.
  • Telegram - Follow us for updates and news.
  • GitHub - Report bugs or contribute to the project.
) } ================================================ FILE: web/src/pages/ForgotPassword.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useState } from 'react' import { Link } from 'react-router-dom' import { ArrowLeftIcon } from '@heroicons/react/24/outline' import { apiJson } from '../lib/api' export default function ForgotPassword() { const [email, setEmail] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) setSubmitting(true) try { await apiJson('/api/auth/password-reset/request', { method: 'POST', body: JSON.stringify({ email }) }, { skipAuth: true }) setSuccess(true) } catch (err: any) { setError(err.message || 'Failed to request password reset') } finally { setSubmitting(false) } } return (
{/* Background gradients */}
Port Buddy

Reset Password

Enter your email to receive a reset link.

{success ? (
A link to reset password is sent to your email.
Back to Login
) : (
{error && (
{error}
)}
setEmail(e.target.value)} required className="w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all" placeholder="name@example.com" />
)}
Back to Login
) } ================================================ FILE: web/src/pages/Installation.tsx ================================================ import { useState } from 'react' import { Link } from 'react-router-dom' import { CommandLineIcon, ClipboardDocumentIcon, CheckIcon, ComputerDesktopIcon } from '@heroicons/react/24/outline' export default function Installation() { const [activeTab, setActiveTab] = useState<'macos' | 'linux' | 'windows' | 'docker'>('macos') return (
{/* Background gradients */}

Install Port Buddy CLI

Get up and running in seconds. Our CLI is a single static binary with zero dependencies.

{/* Tab Navigation */}
setActiveTab('macos')} label="macOS" /> setActiveTab('linux')} label="Linux" /> setActiveTab('windows')} label="Windows" /> setActiveTab('docker')} label="Docker" />
{/* Content Area */}
{activeTab === 'macos' && (
)} {activeTab === 'linux' && (

Need to run in background? Run as a Service guide →

)} {activeTab === 'windows' && (

Need to run in background? Run as a Service guide →

)} {activeTab === 'docker' && (

First, authenticate on your host machine:

Then, run with the token mounted:

)}
) } function TabButton({ isActive, onClick, label }: { isActive: boolean, onClick: () => void, label: string }) { return ( ) } function Step({ title, description, children }: { title: string, description: string, children: React.ReactNode }) { return (

{title}

{description}

{children}
) } function CodeBlock({ code }: { code: string }) { const [copied, setCopied] = useState(false) const copy = () => { navigator.clipboard.writeText(code) setCopied(true) setTimeout(() => setCopied(false), 2000) } return (
{code}
) } function InfoCard({ title, description }: { title: string, description: string }) { return (

{title}

{description}

) } function PlanLimitCard({ plan, limit, description, isPro }: { plan: string, limit: string, description: string, isPro?: boolean }) { return (

{plan}

{limit}

{description}

) } ================================================ FILE: web/src/pages/Landing.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { Link } from 'react-router-dom' import { CommandLineIcon, GlobeAltIcon, ShieldCheckIcon, ServerIcon, BoltIcon, LockClosedIcon, CodeBracketIcon, ArrowRightIcon, CheckIcon, UserIcon, CloudIcon, ComputerDesktopIcon, ChatBubbleLeftRightIcon, CpuChipIcon } from '@heroicons/react/24/outline' import React, { useState, useEffect } from 'react' import PlanComparison from '../components/PlanComparison' // --- Helper Components --- function FeatureCard({ icon, title, description }: { icon: React.ReactNode, title: string, description: string }) { return (
{icon}

{title}

{description}

) } function Step({ number, title, description }: { number: string, title: string, description: string }) { return (
{number}

{title}

{description}

) } function StatCard({ label, value, icon }: { label: string, value: string, icon: React.ReactNode }) { return (
{icon}
{value}
{label}
) } function TestimonialCard({ quote, author, role }: { quote: string, author: string, role: string }) { return (
{[1, 2, 3, 4, 5].map((star) => ( ))}

"{quote}"

{author.charAt(0)}
{author}
{role}
) } function FaqItem({ question, answer }: { question: string, answer: string }) { const [isOpen, setIsOpen] = useState(false) return (

{answer}

) } function ChevronDownIcon({ className }: { className?: string }) { return ( ) } function TypewriterText() { const words = ["Localhost", "Databases", "Webhooks", "APIs", "Game Servers", "Everything"] const [index, setIndex] = useState(0) const [subIndex, setSubIndex] = useState(0) const [reverse, setReverse] = useState(false) const [blink, setBlink] = useState(true) // Blinking cursor useEffect(() => { const timeout2 = setTimeout(() => setBlink((prev) => !prev), 500) return () => clearTimeout(timeout2) }, [blink]) // Typewriter logic useEffect(() => { if (subIndex === words[index].length + 1 && !reverse) { setTimeout(() => setReverse(true), 1000) return } if (subIndex === 0 && reverse) { setReverse(false) setIndex((prev) => (prev + 1) % words.length) return } const timeout = setTimeout(() => { setSubIndex((prev) => prev + (reverse ? -1 : 1)) }, reverse ? 75 : 150) return () => clearTimeout(timeout) }, [subIndex, index, reverse, words]) return ( {words[index].substring(0, subIndex)} | ) } // --- Main Component --- export default function Landing() { return (
{/* Hero Section */}
{/* Background Decorative Elements */}

Secure Tunnels for

Expose your local server to the internet in seconds.
Production-ready security, developer-friendly experience.

Install CLI Get Started
No credit card required
Free tier available
{/* Terminal Preview */}
user@machine:~
{/* Spacer for center alignment */}
~ portbuddy 3000
Port Buddy HTTP mode
Status Online
Forwarding
Local: http://localhost:3000 Public: https://app.portbuddy.dev
14:32:01 200 OK GET /api/users 12ms
14:32:05 201 OK POST /api/webhooks/stripe 45ms
14:32:12 401 GET /admin/settings 8ms
{/* Stats Section (Trust Builder) */} {/*
*/} {/*
*/} {/* }*/} {/* value="50+"*/} {/* label="Regions"*/} {/* />*/} {/* }*/} {/* value="99.9%"*/} {/* label="Uptime"*/} {/* />*/} {/* }*/} {/* value="10k+"*/} {/* label="Users"*/} {/* />*/} {/* }*/} {/* value="AES-256"*/} {/* label="Encryption"*/} {/* />*/} {/*
*/} {/*
*/} {/* Features Grid */}

Everything you need for
local development

Port Buddy is packed with features to help you develop, test, and demo your applications faster without compromising on security.

} title="Custom Domains" description="Bring your own domain name. We automatically provision and manage SSL certificates for you." /> } title="TCP & UDP Tunnels" description="Expose any TCP or UDP service. Databases, SSH, RDP, game servers, IoT protocols, and more." /> } title="Secure by Default" description="Automatic HTTPS for all HTTP tunnels. End-to-end encryption keeps your data safe." /> } title="WebSockets Support" description="Full support for WebSockets. Perfect for real-time applications, chat apps, and game servers." /> } title="Static Subdomains" description="Reserve your own subdomains on our platform. Keep your URLs consistent across restarts." /> } title="Private Tunnels" description="Protect your tunnels with basic auth or IP allowlisting. Control who can access your local apps." />
{/* Architecture Section */}
{/* Animated Background */}

How it works

A high-performance edge network that routes traffic securely to your machine.

{/* Public Client */}

Public Visitor

Accesses your app via
*.portbuddy.dev

{/* Arrow 1 */}
HTTPS/TCP
{/* Port Buddy Cloud */}
Edge Node

Port Buddy Cloud

Auth, SSL Termination &
Request Routing

{/* Arrow 2 (The Tunnel) */}
Secure Tunnel
{/* Local Environment */}

Your Machine

Port Buddy CLI
Localhost:3000
{/* Testimonials (Trust Builder) */}

Loved by developers

{/* Use Cases */}

Built for modern workflows

From webhooks to client demos, Port Buddy streamlines your development workflow. Stop deploying just to test a small change.

Test Webhooks

Receive webhooks from Stripe, GitHub, or Twilio directly to your localhost.

Demo to Clients

Share your work in progress with clients or colleagues instantly.

Mobile Testing

Test your responsive designs on real mobile devices via public URL.

webhook-handler.js
                 
{`app.post('/webhook', (req, res) => {
  const event = req.body;
  
  // Handle the event locally
  console.log('Received event:', event.type);
  
  if (event.type === 'payment_succeeded') {
    fulfillOrder(event.data);
  }

  res.json({received: true});
});`}
                 
               
{/* Pricing */}

Simple, transparent pricing

Start for free, upgrade as you grow.

{/* FAQ */}

Frequently Asked Questions

{/* Final CTA */}

Ready to code from anywhere?

Join thousands of developers who trust Port Buddy for their local development.

Get Started for Free Read Documentation
) } ================================================ FILE: web/src/pages/Login.tsx ================================================ import { useEffect, useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '../auth/AuthContext' import { ArrowLeftIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline' export default function Login() { const { user, loading, loginWithGoogle, loginWithGithub, loginWithEmail } = useAuth() const navigate = useNavigate() const location = useLocation() as any const [showEmail, setShowEmail] = useState(false) const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) useEffect(() => { // After refresh completes and user exists, redirect to intended page or /app. if (!loading && user) { const params = new URLSearchParams(location?.search || '') const fromQuery = params.get('from') const fromState = location?.state?.from?.pathname const fromStorage = localStorage.getItem('pb_login_from') const to = (fromQuery && typeof fromQuery === 'string') ? fromQuery : (fromState || fromStorage || '/app') // Clean up storage localStorage.removeItem('pb_login_from') navigate(to, { replace: true }) } }, [user, loading, navigate, location]) const handleGoogleLogin = () => { // Save current 'from' to localStorage because OAuth redirect will lose React state const from = location?.state?.from?.pathname if (from) { localStorage.setItem('pb_login_from', from) } loginWithGoogle() } const handleGithubLogin = () => { // Save current 'from' to localStorage because OAuth redirect will lose React state const from = location?.state?.from?.pathname if (from) { localStorage.setItem('pb_login_from', from) } loginWithGithub() } const handleEmailLogin = async (e: React.FormEvent) => { e.preventDefault() setError(null) setSubmitting(true) try { await loginWithEmail(email, password) } catch (err: any) { setError(err.message || 'Login failed') } finally { setSubmitting(false) } } return (
{/* Background gradients */}
Port Buddy

Welcome back

Manage your tunnels and domains.

Or continue with
{showEmail && (
{error && (
{error}
)}
setEmail(e.target.value)} required className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3.5 text-white focus:outline-none focus:ring-2 focus:ring-jb-blue/50 focus:border-jb-blue/50 transition-all font-mono" placeholder="name@example.com" />
setPassword(e.target.value)} required className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3.5 text-white focus:outline-none focus:ring-2 focus:ring-jb-blue/50 focus:border-jb-blue/50 transition-all font-mono" placeholder="••••••••" />
Forgot password?

Don't have an account?{' '} Sign up

)}

By continuing, you agree to our{' '} Terms of Service {' '}and{' '} Privacy Policy.

Back to home
) } ================================================ FILE: web/src/pages/NotFound.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { Link } from 'react-router-dom' import { ArrowRightIcon, HomeIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline' import React from 'react' export default function NotFound() { return (
404 — Not Found

Oops, this tunnel seems closed

The page you’re trying to reach doesn’t exist or has moved. Double‑check the URL, or head back to a safe place.

Go to Home Installation Guide
request — 404
GET /unknown 404 Not Found
Hints
  • Check the URL spelling.
  • Use the navigation above to find what you need.
  • Start with our Installation guide to expose a local port.
) } ================================================ FILE: web/src/pages/Passcode.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { FormEvent, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' export default function Passcode() { const [sp] = useSearchParams() const [passcode, setPasscode] = useState('') const targetDomain = sp.get('target_domain') || '' const protocol = useMemo(() => window.location.protocol, []) const onSubmit = (e: FormEvent) => { e.preventDefault() if (!targetDomain || !passcode) return const url = `${protocol}//${targetDomain}/?passcode=${encodeURIComponent(passcode)}` window.location.href = url } return (

Enter passcode

This tunnel is protected. Enter the passcode to proceed to {targetDomain ? ' ' : ''} {targetDomain && ( {targetDomain} )}.

setPasscode(e.target.value)} className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" placeholder="Enter passcode" autoFocus />
) } ================================================ FILE: web/src/pages/Privacy.tsx ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ export default function Privacy() { return (

Privacy Policy

1. Information We Collect

We collect information you provide directly to us when you create an account, such as your name, email address, and authentication details from Google or GitHub.

We also collect technical data related to your use of our service, including IP addresses, browser type, and usage statistics of the tunnels you create.

2. How We Use Your Information

We use the information we collect to:

  • Provide, maintain, and improve our services.
  • Process transactions and manage your subscription.
  • Communicate with you about updates, security alerts, and support.
  • Monitor and analyze trends, usage, and activities.

3. Data Sharing and Disclosure

We do not share your personal information with third parties except as described in this policy:

  • With your consent or at your direction.
  • With vendors and service providers who perform services for us (e.g., payment processing).
  • To comply with legal obligations.
  • To protect the rights and safety of Port Buddy and our users.

4. Data Security

We take reasonable measures to protect your personal information from loss, theft, misuse, and unauthorized access. However, no internet transmission is 100% secure.

5. Your Choices

You can access, update, or delete your account information at any time through your account settings. You may also contact us to request data deletion.

6. Cookies

We use cookies and similar technologies to enhance your experience and collect usage data. You can manage cookie preferences through your browser settings.

Last updated: January 8, 2026
) } ================================================ FILE: web/src/pages/Register.tsx ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ import { useEffect, useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '../auth/AuthContext' import { ArrowLeftIcon } from '@heroicons/react/24/outline' export default function Register() { const { loading, user, loginWithGoogle, loginWithGithub, register } = useAuth() const navigate = useNavigate() const location = useLocation() as any const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (!loading && user) { const params = new URLSearchParams(location?.search || '') const fromQuery = params.get('from') const fromState = location?.state?.from?.pathname const to = (fromQuery && typeof fromQuery === 'string') ? fromQuery : (fromState || '/app') navigate(to, { replace: true }) } }, [user, loading, navigate, location]) const handleRegister = async (e: React.FormEvent) => { e.preventDefault() setError(null) setSubmitting(true) try { await register(email, password, name) } catch (err: any) { setError(err.message || 'Registration failed') } finally { setSubmitting(false) } } const handleGoogleLogin = () => { loginWithGoogle() } const handleGithubLogin = () => { loginWithGithub() } return (
{/* Background gradients */}
Port Buddy

Create an account

Start sharing your local ports securely.

{error && (
{error}
)}
setName(e.target.value)} className="w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all" placeholder="John Doe" />
setEmail(e.target.value)} required className="w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all" placeholder="name@example.com" />
setPassword(e.target.value)} required className="w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all" placeholder="••••••••" />
Or register with

Already have an account?{' '} Log in

By continuing, you agree to our{' '} Terms of Service {' '}and{' '} Privacy Policy.

Back to home
) } ================================================ FILE: web/src/pages/ResetPassword.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useEffect, useState } from 'react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { ArrowLeftIcon } from '@heroicons/react/24/outline' import { apiJson } from '../lib/api' export default function ResetPassword() { const [searchParams] = useSearchParams() const token = searchParams.get('token') const navigate = useNavigate() const [password, setPassword] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const [validToken, setValidToken] = useState(null) // null = loading, true = valid, false = invalid useEffect(() => { if (!token) { setValidToken(false) return } apiJson(`/api/auth/password-reset/validate?token=${token}`, undefined, { skipAuth: true }) .then(() => setValidToken(true)) .catch(() => setValidToken(false)) }, [token]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) if (password.length < 8) { setError('Password must be at least 8 characters long') return } setSubmitting(true) try { await apiJson('/api/auth/password-reset/confirm', { method: 'POST', body: JSON.stringify({ token, newPassword: password }) }, { skipAuth: true }) navigate('/login', { state: { message: 'Password reset successfully. Please login.' } }) } catch (err: any) { setError(err.message || 'Failed to reset password') } finally { setSubmitting(false) } } if (validToken === null) { return (
) } if (!validToken) { return (

Invalid or Expired Token

This password reset link is invalid or has expired.

Request new link
) } return (
{/* Background gradients */}
Port Buddy

Set New Password

Create a secure password for your account.

{error && (
{error}
)}
setPassword(e.target.value)} required minLength={8} className="w-full bg-slate-950/50 border border-slate-800 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all" placeholder="At least 8 characters" /> {password.length > 0 && password.length < 8 && (

Password is too short (min 8 chars)

)}
Back to Login
) } ================================================ FILE: web/src/pages/ServerError.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { Link, useSearchParams } from 'react-router-dom' import { ArrowPathIcon, HomeIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline' import React, { useEffect, useState } from 'react' export default function ServerError() { const [searchParams] = useSearchParams() const [retryUrl, setRetryUrl] = useState(null) // Extract and decode retry param once useEffect(() => { const raw = searchParams.get('retry')?.trim() if (raw) { try { const decoded = decodeURIComponent(raw) setRetryUrl(decoded) } catch (_err) { setRetryUrl(null) } } // run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Clean retry from address bar without navigation useEffect(() => { if (searchParams.has('retry')) { const params = new URLSearchParams(searchParams as unknown as URLSearchParams) params.delete('retry') const queryString = params.toString() const hash = window.location.hash || '' const newUrl = window.location.pathname + (queryString ? `?${queryString}` : '') + hash window.history.replaceState({}, document.title, newUrl) } }, [searchParams]) return (
5xx — Server Error

Something went wrong on our side

We are experiencing an internal issue. Please try again in a moment. If the problem persists, head back home or check your connection.

Go to Home
response — 5xx
GET /some-endpoint 500 Internal Server Error
You can try
  • Reload the page.
  • Check your internet connection.
  • Return to the homepage and try again.
) } ================================================ FILE: web/src/pages/Terms.tsx ================================================ export default function Terms() { return (

Terms and Conditions

1. Acceptance of Terms

By accessing or using Port Buddy, you agree to be bound by these Terms and Conditions. If you do not agree with any part of these terms, you may not use our services.

2. Description of Service

Port Buddy provides a tool that allows you to share a port opened on your local host or private network to the public network. It is a proxy service that facilitates remote access to local development environments.

3. User Accounts

To use certain features of Port Buddy, you must register for an account. You are responsible for maintaining the confidentiality of your account credentials and for all activities that occur under your account.

4. Prohibited Use

You agree not to use Port Buddy for any illegal or unauthorized purpose. Prohibited activities include, but are not limited to:

  • Sharing content that violates any laws or regulations.
  • Distributing malware or performing malicious attacks.
  • Attempting to circumvent any security features of the service.
  • Using the service for high-traffic production workloads beyond your subscription limits.

5. Subscription and Billing

Port Buddy offers both free and paid subscription plans. By subscribing to a paid plan, you agree to pay the specified fees. Fees are non-refundable except as required by law.

6. Limitation of Liability

Port Buddy is provided "as is" without any warranties. We shall not be liable for any indirect, incidental, or consequential damages arising out of your use of the service.

7. Changes to Terms

We reserve the right to modify these terms at any time. We will notify users of any significant changes by posting the new terms on our website.

Last updated: January 8, 2026
) } ================================================ FILE: web/src/pages/app/AdminAccounts.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { EllipsisHorizontalIcon, LockClosedIcon, LockOpenIcon, CheckCircleIcon, XCircleIcon, ClipboardIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import { usePageTitle } from '../../components/PageHeader' import { formatDateTime } from '../../lib/utils' import { ConfirmModal } from '../../components/Modal' export type AdminAccountRow = { accountId: string name: string plan: string extraTunnels: number activeTunnels: number blocked: boolean createdAt: string } export default function AdminAccounts() { usePageTitle('Admin • Accounts') const [rows, setRows] = useState(null) const [openMenuId, setOpenMenuId] = useState(null) const [search, setSearch] = useState('') const [confirmBlock, setConfirmBlock] = useState(null) const refresh = (s?: string) => { const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : '' void apiJson(`/api/admin/accounts${qs}`) .then(setRows) .catch(() => setRows([])) } useEffect(() => { refresh() const onDocClick = (e: MouseEvent) => { const target = e.target as HTMLElement if (!target.closest('[data-menu-root]')) setOpenMenuId(null) } document.addEventListener('click', onDocClick) return () => document.removeEventListener('click', onDocClick) }, []) useEffect(() => { const h = setTimeout(() => refresh(search), 300) return () => clearTimeout(h) }, [search]) const onToggleBlock = async (row: AdminAccountRow) => { const path = row.blocked ? `/api/admin/accounts/${row.accountId}/unblock` : `/api/admin/accounts/${row.accountId}/block` try { await apiJson(path, { method: 'POST' }) refresh() } finally { setOpenMenuId(null) } } const copyToClipboard = (text: string) => { void navigator.clipboard.writeText(text) setOpenMenuId(null) } const data = useMemo(() => rows ?? [], [rows]) return (

Accounts

Back to Admin
Accounts ({data.length})
setSearch(e.target.value)} placeholder="Search by name or ID..." className="w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
{data.length === 0 && ( )} {data.map((r) => ( ))}
Account Name Plan Extra tunnels Active tunnels Blocked Created at Actions
No accounts
{r.name} {r.plan} {r.extraTunnels} {r.activeTunnels} {r.blocked ? (
Yes
) : (
No
)}
{formatDateTime(r.createdAt)}
{openMenuId === r.accountId && (
)}
{confirmBlock && ( setConfirmBlock(null)} onConfirm={() => onToggleBlock(confirmBlock)} title="Block account?" message={`Are you sure you want to block account \"${confirmBlock.name}\". Users will lose access until it is unblocked.`} confirmText="Block account" isDangerous /> )}
) } ================================================ FILE: web/src/pages/app/AdminPanel.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useEffect, useState } from 'react' import { usePageTitle } from '../../components/PageHeader' import { apiJson } from '../../lib/api' import { Link } from 'react-router-dom' type SystemStats = { totalUsers: number, activeTunnels: number, totalAccounts: number } type DailyStat = { date: string, newUsersCount: number, tunnelsCount: number, paymentEvents: number } export default function AdminPanel() { usePageTitle('Admin Control Center') const [stats, setStats] = useState(null) const [daily, setDaily] = useState(null) useEffect(() => { void Promise.all([ apiJson('/api/admin/stats').catch(() => null), apiJson('/api/admin/stats/daily').catch(() => null), ]).then(([s, d]) => { setStats(s) setDaily(d) }) }, []) const formatDay = (iso: string): string => { const dt = new Date(iso) const dd = dt.getDate().toString().padStart(2, '0') const mm = (dt.getMonth() + 1).toString().padStart(2, '0') const yy = dt.getFullYear().toString().slice(-2) return `${dd}.${mm}.${yy}` } return (

Total Accounts

{stats ? stats.totalAccounts : '---'}

Total Users

{stats ? stats.totalUsers : '---'}

Active Tunnels

{stats ? stats.activeTunnels : '---'}

Last 30 days stats

{daily && daily.length > 0 ? ( daily.map((r, idx) => ( )) ) : ( )}
Date New users Tunnels Payment events
{formatDay(r.date)} {r.newUsersCount} {r.tunnelsCount} {r.paymentEvents}
{daily === null ? 'Loading...' : 'No data'}
) } ================================================ FILE: web/src/pages/app/AdminTunnels.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { EllipsisHorizontalIcon, GlobeAltIcon, ServerIcon, XCircleIcon, ClipboardIcon, ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import { usePageTitle } from '../../components/PageHeader' import { formatDateTime } from '../../lib/utils' import { ConfirmModal } from '../../components/Modal' export type AdminTunnelRow = { id: string type: 'HTTP' | 'TCP' localAddress: string publicAddress: string lastActivity: string | null userName: string userId: string accountId: string } export default function AdminTunnels() { usePageTitle('Admin • Active Tunnels') const [rows, setRows] = useState(null) const [openMenuId, setOpenMenuId] = useState(null) const [search, setSearch] = useState('') const [confirmClose, setConfirmClose] = useState(null) const refresh = (s?: string) => { const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : '' void apiJson(`/api/admin/tunnels${qs}`) .then(setRows) .catch(() => setRows([])) } useEffect(() => { refresh() const onDocClick = (e: MouseEvent) => { const target = e.target as HTMLElement if (!target.closest('[data-menu-root]')) setOpenMenuId(null) } document.addEventListener('click', onDocClick) return () => document.removeEventListener('click', onDocClick) }, []) useEffect(() => { const h = setTimeout(() => refresh(search), 300) return () => clearTimeout(h) }, [search]) const onCloseTunnel = async (row: AdminTunnelRow) => { try { await apiJson(`/api/admin/tunnels/${row.id}/close`, { method: 'POST' }) refresh() } finally { setOpenMenuId(null) setConfirmClose(null) } } const copyToClipboard = (text: string) => { void navigator.clipboard.writeText(text) setOpenMenuId(null) } const data = useMemo(() => rows ?? [], [rows]) return (

Active Tunnels

Back to Admin
Active Tunnels ({data.length})
setSearch(e.target.value)} placeholder="Search by user or public address..." className="w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
{data.length === 0 && ( )} {data.map((r) => { const isHttp = r.type === 'HTTP' const publicUrl = isHttp && r.publicAddress.startsWith('http') ? r.publicAddress : null return ( ) })}
Type Local Address Public Address Last Activity User Name Actions
No active tunnels found
{isHttp ? : } {r.type}
{r.localAddress} {publicUrl ? ( {r.publicAddress} ) : ( {r.publicAddress} )} {r.lastActivity ? formatDateTime(r.lastActivity) : '-'} {r.userName || 'Unknown'}
{openMenuId === r.id && (
)}
{confirmClose && ( setConfirmClose(null)} onConfirm={() => onCloseTunnel(confirmClose)} title="Close tunnel?" message={`Are you sure you want to close tunnel from \"${confirmClose.userName}\" exposing \"${confirmClose.localAddress}\"?`} confirmText="Close tunnel" isDangerous /> )}
) } ================================================ FILE: web/src/pages/app/AdminUsers.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { EllipsisHorizontalIcon, LockClosedIcon, LockOpenIcon, CheckCircleIcon, XCircleIcon, ClipboardIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import { usePageTitle } from '../../components/PageHeader' import { formatDateTime } from '../../lib/utils' import { ConfirmModal } from '../../components/Modal' export type AdminUserRow = { id: string accountId: string name: string email: string activeTunnels: number blocked: boolean createdAt: string } export default function AdminUsers() { usePageTitle('Admin • Users') const [rows, setRows] = useState(null) const [openMenuId, setOpenMenuId] = useState(null) const [search, setSearch] = useState('') const [confirmBlock, setConfirmBlock] = useState(null) const refresh = (s?: string) => { const qs = s && s.trim().length > 0 ? `?search=${encodeURIComponent(s.trim())}` : '' void apiJson(`/api/admin/users${qs}`) .then(setRows) .catch(() => setRows([])) } useEffect(() => { refresh() const onDocClick = (e: MouseEvent) => { const target = e.target as HTMLElement if (!target.closest('[data-menu-root]')) setOpenMenuId(null) } document.addEventListener('click', onDocClick) return () => document.removeEventListener('click', onDocClick) }, []) useEffect(() => { const h = setTimeout(() => refresh(search), 300) return () => clearTimeout(h) }, [search]) const onToggleBlock = async (row: AdminUserRow) => { const path = row.blocked ? `/api/admin/accounts/${row.accountId}/unblock` : `/api/admin/accounts/${row.accountId}/block` try { await apiJson(path, { method: 'POST' }) refresh() } finally { setOpenMenuId(null) } } const copyToClipboard = (text: string) => { void navigator.clipboard.writeText(text) setOpenMenuId(null) } const data = useMemo(() => rows ?? [], [rows]) return (

Users

Back to Admin
Users ({data.length})
setSearch(e.target.value)} placeholder="Search by name, email or ID..." className="w-72 rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" />
{data.length === 0 && ( )} {data.map((r) => ( ))}
User Name Email Active tunnels Created at Actions
No users
{r.name} {r.email} {r.activeTunnels} {formatDateTime(r.createdAt)}
{openMenuId === r.id && (
)}
{confirmBlock && ( setConfirmBlock(null)} onConfirm={() => onToggleBlock(confirmBlock)} title="Block account?" message={`Are you sure you want to block the account for user \"${confirmBlock.name}\". Users will lose access until it is unblocked.`} confirmText="Block account" isDangerous /> )}
) } ================================================ FILE: web/src/pages/app/Billing.tsx ================================================ import { useState, useEffect } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { CheckIcon, ArrowLeftIcon, PlusIcon, MinusIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import PlanComparison from '../../components/PlanComparison' import { ConfirmModal } from '../../components/Modal' export default function Billing() { usePageTitle('Billing') const { user, refresh } = useAuth() const [searchParams, setSearchParams] = useSearchParams() const [updating, setUpdating] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(false) const [pendingExtra, setPendingExtra] = useState(null) const [loading, setLoading] = useState(false) const [confirmConfig, setConfirmConfig] = useState<{ isOpen: boolean, title: string, message: string, onConfirm: () => void, isDangerous?: boolean }>({ isOpen: false, title: '', message: '', onConfirm: () => {}, isDangerous: false }) useEffect(() => { if (searchParams.get('success')) { setSuccess(true) refresh() // Remove query param const newParams = new URLSearchParams(searchParams) newParams.delete('success') setSearchParams(newParams, { replace: true }) } if (searchParams.get('canceled')) { setError('Payment was canceled.') const newParams = new URLSearchParams(searchParams) newParams.delete('canceled') setSearchParams(newParams, { replace: true }) } }, [searchParams, refresh, setSearchParams]) const plans: { key: 'pro' | 'team', name: string, price: string, period?: string, description: string, features: string[] }[] = [ { key: 'pro', name: 'Pro', price: '$0', description: 'Everything you need for personal exposure.', features: [ 'HTTP, TCP, UDP tunnels', 'SSL for HTTP tunnels', 'Static subdomains', 'Custom domains', 'Private tunnels', 'Web socket support', '1 free tunnel at a time', '$1/mo per extra tunnel' ] }, { key: 'team', name: 'Team', price: '$10', period: '/mo', description: 'For teams and collaborative projects.', features: [ 'Everything in Pro', 'Team members', 'SSO (Coming soon)', 'Priority support', '10 free tunnels at a time', '$1/mo per extra tunnel' ] }, ] const currentPlanKey = user?.plan || 'pro' const extraTunnels = user?.extraTunnels || 0 const baseTunnels = user?.baseTunnels || 1 const activeTunnels = user?.activeTunnels || 0 const subscriptionStatus = user?.subscriptionStatus const effectiveExtra = pendingExtra !== null ? pendingExtra : extraTunnels const planPrice = currentPlanKey === 'team' ? 10 : 0 const extraCost = effectiveExtra * 1 const totalMonthly = planPrice + extraCost const increment = currentPlanKey === 'team' ? 5 : 1 const getLimitForPlan = (planKey: string) => { return planKey === 'team' ? 10 : 1; }; const handleUpdate = async () => { if (pendingExtra === null || pendingExtra === extraTunnels) return const newLimit = baseTunnels + pendingExtra; if (pendingExtra < extraTunnels && activeTunnels > newLimit) { setConfirmConfig({ isOpen: true, title: 'Reduce Tunnel Limit', message: `Reducing extra tunnels will lower your total limit to ${newLimit}. You currently have ${activeTunnels} active tunnels. Excess tunnels will be automatically closed. Do you want to proceed?`, onConfirm: () => performUpdate(), isDangerous: true }); return; } performUpdate(); } const performUpdate = async () => { if (pendingExtra === null) return; setError(null) setUpdating(true) setLoading(true) try { const response = await apiJson('/api/users/me/account/tunnels', { method: 'PATCH', body: JSON.stringify({ extraTunnels: pendingExtra }) }) if (response.checkoutUrl) { window.location.href = response.checkoutUrl return } await refresh() setPendingExtra(null) setSuccess(true) } catch (e: any) { setError(e.message || 'Failed to update tunnels') } finally { setUpdating(false) setLoading(false) } } const changePending = (newExtra: number) => { if (newExtra < 0) return setPendingExtra(newExtra) } const handleUpgrade = async (planKey: string) => { const isDowngrade = currentPlanKey === 'team' && planKey === 'pro'; const newLimit = getLimitForPlan(planKey) + (isDowngrade ? 0 : extraTunnels); if (isDowngrade) { setConfirmConfig({ isOpen: true, title: 'Downgrade to PRO', message: 'Cancelling your TEAM subscription will reset your extra tunnels to 0 and downgrade your account to PRO. Do you want to proceed?', onConfirm: () => checkTunnelLimitAndUpgrade(planKey, newLimit), isDangerous: true }); return; } else if (subscriptionStatus === 'active') { setConfirmConfig({ isOpen: true, title: 'Switch Plan', message: `Switching to ${planKey.toUpperCase()} will cancel your current subscription and reset extra tunnels to 0. You will be redirected to payment for the new plan. Do you want to proceed?`, onConfirm: () => checkTunnelLimitAndUpgrade(planKey, newLimit), isDangerous: false }); return; } checkTunnelLimitAndUpgrade(planKey, newLimit); } const checkTunnelLimitAndUpgrade = (planKey: string, newLimit: number) => { if (activeTunnels > newLimit) { setConfirmConfig({ isOpen: true, title: 'Reduce Tunnel Limit', message: `This change will reduce your tunnel limit to ${newLimit}. You currently have ${activeTunnels} active tunnels. Excess tunnels will be automatically closed. Do you want to proceed?`, onConfirm: () => performUpgrade(planKey), isDangerous: true }); return; } performUpgrade(planKey); } const performUpgrade = async (planKey: string) => { setError(null) setLoading(true) try { if (subscriptionStatus === 'active') { await apiJson('/api/payments/cancel-subscription', { method: 'POST' }) await refresh() } if (planKey === 'pro') { setSuccess(true) return } const { url } = await apiJson('/api/payments/create-checkout-session', { method: 'POST', body: JSON.stringify({ plan: planKey.toUpperCase() }) }) window.location.href = url } catch (e: any) { setError(e.message || 'Failed to initiate checkout') } finally { setLoading(false) } } const handleManageBilling = async () => { setError(null) setLoading(true) try { const { url } = await apiJson('/api/payments/create-portal-session', { method: 'POST' }) window.location.href = url } catch (e: any) { setError(e.message || 'Failed to initiate portal session') } finally { setLoading(false) } } return (

Billing & Plans

Choose the plan that fits your needs.

{subscriptionStatus && (
Subscription Status: {subscriptionStatus.replace('_', ' ')}
)}
{success && (
Success! Your subscription has been updated.
)}
{plans.map((p) => { const isCurrent = p.key === currentPlanKey const isPopular = p.key === 'team' return (
{isPopular && (
Most Popular
)}

{p.name}

{p.price} {p.period && {p.period}}

{p.description}

    {p.features.map((f, i) => (
  • {f}
  • ))}
{isCurrent && (
Total Tunnels
{baseTunnels + effectiveExtra}
Extra Tunnels
+{effectiveExtra} (${extraCost}/mo)
Total Monthly
${totalMonthly}/mo
{effectiveExtra < extraTunnels ? `Remove ${increment}` : `Add ${increment}`}
{pendingExtra !== null && pendingExtra !== extraTunnels && (
)} {error &&

{error}

}
)}
) })}
{user?.stripeCustomerId && ( )} {error &&

{error}

}

Need a custom enterprise plan? Contact us

Back to dashboard
setConfirmConfig({ ...confirmConfig, isOpen: false })} onConfirm={confirmConfig.onConfirm} title={confirmConfig.title} message={confirmConfig.message} isDangerous={confirmConfig.isDangerous} />
) } ================================================ FILE: web/src/pages/app/BillingCancel.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { Link } from 'react-router-dom' import { XCircleIcon } from '@heroicons/react/24/outline' import { usePageTitle } from '../../components/PageHeader' export default function BillingCancel() { usePageTitle('Payment Canceled') return (

Payment Canceled

Your payment process was canceled. No charges were made to your account. If you encountered any issues, please feel free to contact our support.

Back to Billing Return to Dashboard
) } ================================================ FILE: web/src/pages/app/BillingSuccess.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { Link } from 'react-router-dom' import { CheckCircleIcon } from '@heroicons/react/24/outline' import { usePageTitle } from '../../components/PageHeader' export default function BillingSuccess() { usePageTitle('Payment Successful') return (

Payment Successful!

Thank you for your purchase. Your subscription has been updated successfully. It might take a few moments for the changes to reflect in your account.

Return to Dashboard View Billing
) } ================================================ FILE: web/src/pages/app/Domains.tsx ================================================ import { useEffect, useState } from 'react' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { GlobeAltIcon, PlusIcon, TrashIcon, PencilIcon, CheckIcon, XMarkIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import { AlertModal, ConfirmModal, Modal } from '../../components/Modal' interface Domain { id: string subdomain: string domain: string customDomain: string | null cnameVerified: boolean sslActive: boolean passcodeProtected: boolean createdAt: string updatedAt: string } export default function Domains() { const { user } = useAuth() usePageTitle('Domains') const [domains, setDomains] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [editingId, setEditingId] = useState(null) const [editValue, setEditValue] = useState('') const [creating, setCreating] = useState(false) // Custom Domain state const [customDomainDomainId, setCustomDomainDomainId] = useState(null) const [customDomainValue, setCustomDomainValue] = useState('') const [customDomainSaving, setCustomDomainSaving] = useState(false) const [customDomainRemoving, setCustomDomainRemoving] = useState(false) const [verifyingCname, setVerifyingCname] = useState(false) // Passcode modal state const [passcodeDomainId, setPasscodeDomainId] = useState(null) const [pass1, setPass1] = useState('') const [passSaving, setPassSaving] = useState(false) const [passRemoving, setPassRemoving] = useState(false) // Dialog states const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({ isOpen: false, title: '', message: '' }) const [deleteId, setDeleteId] = useState(null) const fetchDomains = async () => { try { const data = await apiJson('/api/domains') setDomains(data) } catch (err) { setError('Failed to load domains') } finally { setLoading(false) } } useEffect(() => { fetchDomains() }, []) const handleAdd = async () => { setCreating(true) try { const newDomain = await apiJson('/api/domains', { method: 'POST' }) setDomains([...domains, newDomain]) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to add domain' }) } finally { setCreating(false) } } const handleEditStart = (domain: Domain) => { setEditingId(domain.id) setEditValue(domain.subdomain) } const handleEditCancel = () => { setEditingId(null) setEditValue('') } const handleEditSave = async (id: string) => { try { const updated = await apiJson(`/api/domains/${id}`, { method: 'PUT', body: JSON.stringify({ subdomain: editValue }) }) setDomains(domains.map(d => d.id === id ? updated : d)) setEditingId(null) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to update domain' }) } } const handleDeleteClick = (id: string) => { setDeleteId(id) } const handleConfirmDelete = async () => { if (!deleteId) return try { await apiJson(`/api/domains/${deleteId}`, { method: 'DELETE' }) setDomains(domains.filter(d => d.id !== deleteId)) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to delete domain' }) } finally { setDeleteId(null) } } const handleCustomDomainClick = (domain: Domain) => { setCustomDomainDomainId(domain.id) setCustomDomainValue(domain.customDomain || '') } const handleCustomDomainSave = async () => { if (!customDomainDomainId) return setCustomDomainSaving(true) try { const updated = await apiJson(`/api/domains/${customDomainDomainId}/custom-domain`, { method: 'PUT', body: JSON.stringify({ customDomain: customDomainValue }) }) setDomains(domains.map(d => d.id === customDomainDomainId ? updated : d)) setCustomDomainDomainId(null) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to update custom domain' }) } finally { setCustomDomainSaving(false) } } const handleCustomDomainRemove = async () => { if (!customDomainDomainId) return setCustomDomainRemoving(true) try { await apiJson(`/api/domains/${customDomainDomainId}/custom-domain`, { method: 'DELETE' }) setDomains(domains.map(d => d.id === customDomainDomainId ? { ...d, customDomain: null, cnameVerified: false } : d)) setCustomDomainDomainId(null) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to remove custom domain' }) } finally { setCustomDomainRemoving(false) } } const handleVerifyCname = async (id: string) => { setVerifyingCname(true) try { const updated = await apiJson(`/api/domains/${id}/verify-cname`, { method: 'POST' }) setDomains(domains.map(d => d.id === id ? updated : d)) setAlertState({ isOpen: true, title: 'Success', message: 'CNAME verified successfully! SSL certificate issuance has been triggered.' }) } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'CNAME verification failed' }) } finally { setVerifyingCname(false) } } const openSetPasscode = (id: string) => { setPasscodeDomainId(id) setPass1('') } const closePasscodeModal = () => { setPasscodeDomainId(null) setPass1('') setPassSaving(false) setPassRemoving(false) } const savePasscode = async () => { if (!passcodeDomainId) return if (pass1.length < 4) { setAlertState({ isOpen: true, title: 'Invalid passcode', message: 'Passcode must be at least 4 characters long.' }) return } setPassSaving(true) try { const updated = await apiJson(`/api/domains/${passcodeDomainId}/passcode`, { method: 'PUT', body: JSON.stringify({ passcode: pass1 }) }) setDomains(domains.map(d => d.id === updated.id ? updated : d)) closePasscodeModal() } catch (err: any) { setPassSaving(false) setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to set passcode' }) } } const removePasscode = async (id: string) => { try { setPassRemoving(true) await apiJson(`/api/domains/${id}/passcode`, { method: 'DELETE' }) setDomains(domains.map(d => d.id === id ? { ...d, passcodeProtected: false } : d)) closePasscodeModal() } catch (err: any) { setPassRemoving(false) setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to remove passcode' }) } } return (
setAlertState({ ...alertState, isOpen: false })} title={alertState.title} message={alertState.message} /> setDeleteId(null)} onConfirm={handleConfirmDelete} title="Delete Domain" message="Are you sure you want to delete this domain? This action cannot be undone." confirmText="Delete" isDangerous /> {/* Set/Change Passcode Modal */} d.id === passcodeDomainId)?.passcodeProtected ? 'Change' : 'Set') + ' Domain Passcode'} >
setPass1(e.target.value)} className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white focus:outline-none focus:border-indigo-500" placeholder="Enter passcode" />
{/* Left side: Remove Passcode (only if currently protected) */} {domains.find(d => d.id === passcodeDomainId)?.passcodeProtected && ( )} {/* Right side: Cancel + Save */}
{/* Custom Domain Modal */} setCustomDomainDomainId(null)} title="Custom Domain" >

Bind your own custom domain (e.g., app.mycompany.com) to your Port Buddy subdomain. Ensure you have a CNAME record pointing to your subdomain first.

setCustomDomainValue(e.target.value)} className="w-full bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white focus:outline-none focus:border-indigo-500" placeholder="e.g. app.mycompany.com" />
{domains.find(d => d.id === customDomainDomainId)?.customDomain && ( )}

Domains

Manage your custom domains and static subdomains.

{loading ? (
{[1, 2].map(i => (
))}
) : error ? (
{error}
) : domains.length === 0 ? (

No domains yet

Create your first static subdomain to get started.

) : (
{domains.map(domain => (
{editingId === domain.id ? (
setEditValue(e.target.value)} className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-white focus:outline-none focus:border-indigo-500" autoFocus /> .{domain.domain}
) : (
{domain.subdomain}.{domain.domain}
)}
Created on {new Date(domain.createdAt).toLocaleDateString()}
{domain.customDomain && (
Custom Domain: {domain.customDomain} {domain.cnameVerified ? ( domain.sslActive ? ( Verified & SSL Active ) : ( Verified. SSL Provisioning... ) ) : ( Pending Verification )}
{!domain.cnameVerified && ( )}
)}
{editingId === domain.id ? ( <> ) : ( <> {/* Passcode control icon */} )}
))}
)}
) } ================================================ FILE: web/src/pages/app/Ports.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useEffect, useMemo, useState } from 'react' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { GlobeAltIcon, PlusIcon, TrashIcon, PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline' import { apiJson } from '../../lib/api' import { AlertModal, ConfirmModal } from '../../components/Modal' interface PortReservation { id: string publicHost: string publicPort: number name: string createdAt: string updatedAt: string } export default function Ports() { const { user } = useAuth() usePageTitle('Port Reservations') const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [creating, setCreating] = useState(false) const [error, setError] = useState('') // Edit state const [editingId, setEditingId] = useState(null) const [hosts, setHosts] = useState([]) const [selectedHost, setSelectedHost] = useState('') const [portRange, setPortRange] = useState<{ min: number, max: number } | null>(null) const [editPort, setEditPort] = useState('') const [editName, setEditName] = useState('') // Dialogs const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({ isOpen: false, title: '', message: '' }) const [deleteId, setDeleteId] = useState(null) const load = async () => { try { const data = await apiJson('/api/ports') setItems(data) } catch (e) { setError('Failed to load port reservations') } finally { setLoading(false) } } useEffect(() => { load() }, []) const fetchHosts = async () => { const hs = await apiJson('/api/ports/hosts') setHosts(hs) return hs } const fetchRange = async (host: string) => { const r = await apiJson<{ min: number, max: number }>(`/api/ports/hosts/${encodeURIComponent(host)}/range`) setPortRange(r) return r } const startEdit = async (res: PortReservation) => { setEditingId(res.id) setEditName(res.name || '') try { const hs = await fetchHosts() if (hs.length <= 1) { // Only port is editable setSelectedHost(res.publicHost) const r = await fetchRange(res.publicHost) setEditPort(String(res.publicPort)) } else { setSelectedHost(res.publicHost) await fetchRange(res.publicHost) setEditPort(String(res.publicPort)) } } catch (e: any) { setAlertState({ isOpen: true, title: 'Error', message: e.message || 'Failed to start editing' }) setEditingId(null) } } const cancelEdit = () => { setEditingId(null) setHosts([]) setPortRange(null) setSelectedHost('') setEditPort('') setEditName('') } const saveEdit = async (id: string) => { try { const body: any = {} if (hosts.length > 1) body.publicHost = selectedHost body.publicPort = Number(editPort) body.name = editName const updated = await apiJson(`/api/ports/${id}`, { method: 'PUT', body: JSON.stringify(body) }) setItems(items.map(i => i.id === id ? updated : i)) cancelEdit() } catch (e: any) { let msg = e.message || 'Failed to update reservation' if (e.status === 400 && msg.includes('already exists')) { msg = 'This name is already taken. Please choose a different name.' } setAlertState({ isOpen: true, title: 'Update failed', message: msg }) } } const addReservation = async () => { setCreating(true) try { const created = await apiJson('/api/ports', { method: 'POST' }) setItems([...items, created]) } catch (e: any) { setAlertState({ isOpen: true, title: 'Error', message: e.message || 'Failed to create reservation' }) } finally { setCreating(false) } } const confirmDelete = async () => { if (!deleteId) return try { await apiJson(`/api/ports/${deleteId}`, { method: 'DELETE' }) setItems(items.filter(i => i.id !== deleteId)) setDeleteId(null) } catch (e: any) { const msg = (e.status === 409) ? 'Cannot delete this reservation because there are active TCP tunnels using it. Close them first and try again.' : (e.message || 'Failed to delete reservation') setAlertState({ isOpen: true, title: 'Delete failed', message: msg }) } } const singleHost = hosts.length <= 1 const portHint = portRange ? `Allowed range: ${portRange.min} - ${portRange.max}` : '' return (
setAlertState({ ...alertState, isOpen: false })} /> setDeleteId(null)} onConfirm={() => void confirmDelete()} />

Port Reservations

Reserve public TCP ports on available proxy hosts.

{loading ? (
{[1, 2].map(i => (
))}
) : items.length === 0 ? (

No reservations yet

Create your first TCP port reservation to get started.

) : (
{items.map(item => (
{editingId === item.id ? (
setEditName(e.target.value)} className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white w-full" placeholder="Reservation name (optional)" />
{hosts.length > 1 && ( )} setEditPort(e.target.value)} className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white w-32" placeholder={portHint} /> {portRange &&
{portHint}
}
) : ( <>
{item.name ? {item.name} : null} {item.publicHost}:{item.publicPort}
)}
Created on {new Date(item.createdAt).toLocaleDateString()}
{editingId === item.id ? ( <> ) : ( <> )}
))}
)}
) } ================================================ FILE: web/src/pages/app/Profile.tsx ================================================ /* * Copyright (c) 2026 AMAK Inc. All rights reserved. */ import { useEffect, useMemo, useState } from 'react' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { apiJson } from '../../lib/api' import { UserCircleIcon } from '@heroicons/react/24/outline' export default function Profile() { const { user, refresh } = useAuth() usePageTitle('Personal Details') const [firstName, setFirstName] = useState('') const [lastName, setLastName] = useState('') const [saving, setSaving] = useState(false) const [message, setMessage] = useState(null) const [loading, setLoading] = useState(true) const [orig, setOrig] = useState<{ firstName: string, lastName: string } | null>(null) useEffect(() => { let cancelled = false async function load() { setLoading(true) setMessage(null) try { const details = await apiJson<{ user: { firstName?: string, lastName?: string } }>( '/api/users/me/details' ) if (cancelled) return const first = details.user?.firstName || '' const last = details.user?.lastName || '' setFirstName(first) setLastName(last) setOrig({ firstName: first, lastName: last }) } catch { if (!firstName && !lastName && (user?.name || '')) { const parts = (user?.name || '').trim().split(/\s+/) if (parts.length > 0) setFirstName(parts[0]) if (parts.length > 1) setLastName(parts.slice(1).join(' ')) } } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.id]) const isChanged = useMemo(() => { if (!orig) return true return ( (firstName || '').trim() !== (orig.firstName || '').trim() || (lastName || '').trim() !== (orig.lastName || '').trim() ) }, [orig, firstName, lastName]) async function onSave() { setSaving(true) setMessage(null) try { await apiJson('/api/users/me/profile', { method: 'PATCH', body: JSON.stringify({ firstName: firstName.trim(), lastName: lastName.trim() }) }) await refresh() setOrig({ firstName, lastName }) setMessage('Profile updated.') } catch { setMessage('Failed to update profile. Please try again later.') } finally { setSaving(false) } } return (

Personal Details

Manage your personal information.

{user?.avatarUrl ? ( avatar ) : (
{user?.name?.[0] || user?.email?.[0] || '?'}
)}
{user?.name || 'Unknown User'}
{user?.email}
setFirstName(e.target.value)} className="block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all" placeholder="John" disabled={loading} />
setLastName(e.target.value)} className="block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all" placeholder="Doe" disabled={loading} />
{message && (
{message}
)}
) } ================================================ FILE: web/src/pages/app/Settings.tsx ================================================ import { useEffect, useMemo, useState } from 'react' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { apiJson } from '../../lib/api' import { BuildingOfficeIcon } from '@heroicons/react/24/outline' export default function Settings() { const { user, refresh } = useAuth() usePageTitle('Account Settings') const [accountName, setAccountName] = useState('') const [saving, setSaving] = useState(false) const [message, setMessage] = useState(null) const [loading, setLoading] = useState(true) const [orig, setOrig] = useState<{ accountName: string } | null>(null) useEffect(() => { let cancelled = false async function load() { setLoading(true) setMessage(null) try { const details = await apiJson<{ account: { name: string } }>( '/api/users/me/details' ) if (cancelled) return const acc = details.account?.name || '' setAccountName(acc || defaultAccountName(user?.name, user?.email)) setOrig({ accountName: acc }) } catch { // Fallback to best-effort defaults if backend not available const defAcc = defaultAccountName(user?.name, user?.email) if (!accountName) setAccountName(defAcc) } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.id]) function defaultAccountName(name?: string, email?: string): string { const base = (name && name.trim()) || (email ? email.split('@')[0] : '') return base ? `${base}'s account` : 'My account' } const isChanged = useMemo(() => { if (!orig) return true return accountName.trim() !== (orig.accountName || '').trim() }, [orig, accountName]) async function onSave() { setSaving(true) setMessage(null) try { // Update account name if changed if (!orig || accountName.trim() !== (orig.accountName || '').trim()) { await apiJson('/api/users/me/account', { method: 'PATCH', body: JSON.stringify({ name: accountName.trim() }) }) } await refresh() setOrig({ accountName }) setMessage('Settings saved.') } catch { setMessage('Failed to save settings. Please try again later.') } finally { setSaving(false) } } return (

Account Settings

Manage your account preferences.

{/* Decorative gradient */}
setAccountName(e.target.value)} className="block w-full pl-10 bg-slate-950 border border-slate-800 rounded-lg py-2.5 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all" placeholder="e.g. Acme Inc" disabled={loading} />
{message && (
{message}
)}
) } ================================================ FILE: web/src/pages/app/Team.tsx ================================================ /* * Copyright (c) 2025 AMAK Inc. All rights reserved. */ import { useEffect, useState } from 'react' import { apiJson } from '../../lib/api' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { UserGroupIcon, UserPlusIcon, XMarkIcon, TrashIcon, ArrowPathIcon } from '@heroicons/react/24/outline' import { AlertModal, ConfirmModal } from '../../components/Modal' type Member = { id: string email: string firstName: string | null lastName: string | null avatarUrl: string | null roles: string[] joinedAt: string } type Invitation = { id: string email: string invitedBy: string createdAt: string expiresAt: string } export default function Team() { const { user } = useAuth() usePageTitle('Team Management') const [members, setMembers] = useState([]) const [invitations, setInvitations] = useState([]) const [loading, setLoading] = useState(true) const [email, setEmail] = useState('') const [inviting, setInviting] = useState(false) const [error, setError] = useState(null) // Dialogs const [alertState, setAlertState] = useState<{ isOpen: boolean, title: string, message: string }>({ isOpen: false, title: '', message: '' }) const [cancelId, setCancelId] = useState(null) const [removeMemberId, setRemoveMemberId] = useState(null) const isTeamPlan = user?.plan === 'team' const isAccountAdmin = user?.roles?.some(r => r === 'ACCOUNT_ADMIN' || r === 'ADMIN') useEffect(() => { if (isTeamPlan) { void loadData() } else { setLoading(false) } }, [isTeamPlan]) async function loadData() { setLoading(true) try { const promises: [Promise, Promise | null] = [ apiJson('/api/team/members'), isAccountAdmin ? apiJson('/api/team/invitations') : Promise.resolve([]) ] const [m, i] = await Promise.all(promises) setMembers(m) if (i) setInvitations(i) } catch (err) { console.error('Failed to load team data', err) } finally { setLoading(false) } } async function handleInvite(e: React.FormEvent) { e.preventDefault() if (!email) return setInviting(true) setError(null) try { await apiJson('/api/team/invitations', { method: 'POST', body: JSON.stringify({ email }) }) setEmail('') void loadData() } catch (err: any) { setError(err.message || 'Failed to send invitation') } finally { setInviting(false) } } async function confirmCancelInvitation() { if (!cancelId) return try { await apiJson(`/api/team/invitations/${cancelId}`, { method: 'DELETE' }) setCancelId(null) void loadData() } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to cancel invitation' }) } } async function handleResendInvitation(id: string) { try { await apiJson(`/api/team/invitations/${id}/resend`, { method: 'POST' }) setAlertState({ isOpen: true, title: 'Success', message: 'Invitation has been resent successfully.' }) void loadData() } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to resend invitation' }) } } async function confirmRemoveMember() { if (!removeMemberId) return try { await apiJson(`/api/team/members/${removeMemberId}`, { method: 'DELETE' }) setRemoveMemberId(null) void loadData() } catch (err: any) { setAlertState({ isOpen: true, title: 'Error', message: err.message || 'Failed to remove member' }) } } if (!isTeamPlan) { return (

Team plan required

Team features like inviting members and shared tunnel limits are only available on the Team plan.

Upgrade to Team Plan
) } return (
setAlertState({ ...alertState, isOpen: false })} /> setCancelId(null)} onConfirm={() => void confirmCancelInvitation()} isDangerous={true} /> setRemoveMemberId(null)} onConfirm={() => void confirmRemoveMember()} isDangerous={true} /> {/* Invite Section */} {isAccountAdmin && (

Invite Team Member

setEmail(e.target.value)} placeholder="colleague@example.com" required className="flex-1 bg-slate-950 border border-slate-800 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50" />
{error &&

{error}

}
)} {/* Members List */}

Team Members

{loading ? ( ) : members.map((m) => ( ))}
User Roles Joined Actions
Loading members...
{m.avatarUrl ? ( {`${m.firstName ) : (
{m.firstName?.[0] || m.email[0].toUpperCase()}
)}
{m.firstName} {m.lastName} {m.id === user?.id && You}
{m.email}
{m.roles.map(r => ( {r.replace('_', ' ')} ))}
{new Date(m.joinedAt).toLocaleDateString()} {isAccountAdmin && m.id !== user?.id && ( )}
{/* Pending Invitations */} {isAccountAdmin && invitations.length > 0 && (

Pending Invitations

{invitations.map((i) => ( ))}
Email Invited By Expires
{i.email} {i.invitedBy} {new Date(i.expiresAt).toLocaleDateString()} {isAccountAdmin && (
)}
)}
) } ================================================ FILE: web/src/pages/app/Tokens.tsx ================================================ import { useEffect, useMemo, useState } from 'react' import { apiJson } from '../../lib/api' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { ConfirmModal } from '../../components/Modal' import { KeyIcon, TrashIcon, ClipboardDocumentIcon, CheckIcon, PlusIcon } from '@heroicons/react/24/outline' type TokenItem = { id: string, label: string, createdAt: string, revoked: boolean, lastUsedAt?: string } export default function Tokens() { const { user } = useAuth() usePageTitle('Access Tokens') const hasUser = useMemo(() => !!user, [user]) const [tokens, setTokens] = useState([]) const [loading, setLoading] = useState(false) const [isInitialLoad, setIsInitialLoad] = useState(true) const [newLabel, setNewLabel] = useState('cli') const [justCreatedToken, setJustCreatedToken] = useState(null) // Dialog state const [revokeId, setRevokeId] = useState(null) useEffect(() => { if (!hasUser) return void loadTokens().finally(() => setIsInitialLoad(false)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasUser]) async function loadTokens() { setLoading(true) try { const list = await apiJson('/api/tokens') setTokens(list) } catch { setTokens([]) } finally { setLoading(false) } } async function createToken() { setLoading(true) try { const resp = await apiJson<{ token: string }>('/api/tokens', { method: 'POST', body: JSON.stringify({ label: newLabel || 'cli' }) }) setJustCreatedToken(resp.token as string) await loadTokens() } catch { // noop } finally { setLoading(false) } } async function revokeToken(id: string) { setLoading(true) try { await apiJson(`/api/tokens/${id}`, { method: 'DELETE' }) setTokens(current => current.map(t => t.id === id ? { ...t, revoked: true } : t )) } catch (err) { console.error('Failed to revoke token', err) } finally { setLoading(false) setRevokeId(null) } } return (
setRevokeId(null)} onConfirm={() => { if (revokeId) void revokeToken(revokeId) }} title="Revoke Token" message="Are you sure you want to revoke this token? This action cannot be undone and any applications using this token will lose access." confirmText="Revoke" isDangerous />

Access Tokens

Generate and manage API tokens to authenticate the CLI.

Generate New Token

setNewLabel(e.target.value)} className="block w-full bg-slate-950 border border-slate-800 rounded-lg py-2.5 px-4 text-slate-200 placeholder-slate-600 focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all" placeholder="e.g. MacBook Pro" />
{justCreatedToken && (
Token generated successfully
Make sure to copy your token now. You won't be able to see it again!
{justCreatedToken}
port-buddy init {justCreatedToken}
)}
{isInitialLoad ? (
) : tokens.length === 0 ? (

No tokens yet

Generate your first token to start using the CLI.

) : (
{tokens.map(t => (
{t.label}
{t.revoked && Revoked}
Created {new Date(t.createdAt).toLocaleDateString()} {t.lastUsedAt && ( <> Last used {new Date(t.lastUsedAt).toLocaleDateString()} )}
{!t.revoked && ( )}
))}
)}
) } function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false) const copy = () => { navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) } return ( ) } ================================================ FILE: web/src/pages/app/Tunnels.tsx ================================================ import { useEffect, useMemo, useRef, useState } from 'react' import { apiJson } from '../../lib/api' import { formatDateTime } from '../../lib/utils' import { useAuth } from '../../auth/AuthContext' import { usePageTitle } from '../../components/PageHeader' import { ArrowTopRightOnSquareIcon, GlobeAltIcon, ServerIcon } from '@heroicons/react/24/outline' type TunnelView = { id: string tunnelId: string type: 'HTTP' | 'TCP' | 'UDP' status: 'PENDING' | 'CONNECTED' | 'CLOSED' local: string | null publicEndpoint: string | null publicUrl: string | null publicHost: string | null publicPort: number | null subdomain: string | null portReservationName: string | null lastHeartbeatAt: string | null createdAt: string | null } export default function Tunnels() { const { user } = useAuth() usePageTitle('Tunnels') const hasUser = useMemo(() => !!user, [user]) const [tunnels, setTunnels] = useState([]) const [page, setPage] = useState(0) const [totalPages, setTotalPages] = useState(0) const [loading, setLoading] = useState(false) const [isInitialLoad, setIsInitialLoad] = useState(true) const sentinelRef = useRef(null) useEffect(() => { if (!hasUser) return void loadTunnels(0, false).finally(() => setIsInitialLoad(false)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasUser]) // Infinite scroll using IntersectionObserver useEffect(() => { if (!hasUser) return const el = sentinelRef.current if (!el) return const rootEl = document.querySelector('[data-scroll-root]') as Element | null const observer = new IntersectionObserver((entries) => { const entry = entries[0] const hasNext = page < totalPages - 1 if (entry.isIntersecting && hasNext && !loading) { void loadTunnels(page + 1, true) } }, { root: rootEl ?? null, rootMargin: '0px', threshold: 0.1 }) observer.observe(el) return () => observer.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasUser, page, totalPages, loading]) // Ensure we fill the viewport useEffect(() => { if (!hasUser || loading) return const hasNext = page < totalPages - 1 if (!hasNext) return const el = sentinelRef.current if (!el) return const rect = el.getBoundingClientRect() const rootEl = document.querySelector('[data-scroll-root]') as Element | null if (rootEl) { const rootRect = rootEl.getBoundingClientRect() if (rect.top <= rootRect.bottom) { void loadTunnels(page + 1, true) } } else { if (rect.top <= window.innerHeight) { void loadTunnels(page + 1, true) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasUser, loading, page, totalPages]) async function loadTunnels(nextPage: number, append: boolean) { setLoading(true) try { const res = await apiJson<{ content: TunnelView[], number: number, totalPages: number }>(`/api/tunnels?page=${nextPage}&size=30`) const nextContent = res.content || [] if (append) { setTunnels((prev) => [...prev, ...nextContent]) } else { setTunnels(nextContent) } setPage(res.number ?? nextPage) setTotalPages(res.totalPages ?? 0) } catch { setTunnels([]) setPage(0) setTotalPages(0) } finally { setLoading(false) } } function formatDate(iso: string | null | undefined): string { if (!iso) return '-' return formatDateTime(iso) } return (

Active Tunnels

Monitor and manage your HTTP and TCP tunnels.

{isInitialLoad ? (
) : tunnels.length === 0 ? (

No tunnels found

Start your first tunnel using the CLI.

port-buddy 8080
) : (
{tunnels.map((t) => { const canOpen = t.type === 'HTTP' && t.status === 'CONNECTED' && !!t.publicUrl const publicText = t.type === 'HTTP' ? (t.publicUrl || '-') : (t.publicEndpoint || '-') return ( ) })}
Type Name Local Address Public URL Status Last Activity
{t.type === 'HTTP' ? : } {t.type}
{t.type === 'HTTP' && t.subdomain ? ( {t.subdomain} ) : t.portReservationName ? ( {t.portReservationName} ) : (t.type === 'TCP' || t.type === 'UDP') && t.publicPort ? ( {t.publicPort} ) : ( unnamed )} {t.local || '-'} {canOpen ? ( {publicText} ) : ( {publicText} )}
{t.status}
{formatDate(t.lastHeartbeatAt)}
)}
{/* Infinite scroll sentinel */}
{/* Loading indicator for next page */} {loading && tunnels.length > 0 ? (
Loading more tunnels...
) : null}
) } ================================================ FILE: web/src/pages/docs/DocsLayout.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { Link, Outlet, useLocation } from 'react-router-dom' export default function DocsLayout() { const location = useLocation() return (
{/* Sidebar Navigation */} {/* Main Content */}
) } function SidebarLink({ to, label, active }: { to: string, label: string, active: boolean }) { // If we are on a different page and the link is an anchor on /docs, we need to make sure we go to /docs first. // The 'to' prop should be the full path (e.g., "/docs#introduction"). // However, ScrollToHash in App.tsx handles the scrolling. return ( {label} ) } ================================================ FILE: web/src/pages/docs/DocsOverview.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { Link } from 'react-router-dom' import { CommandLineIcon, GlobeAltIcon, ShieldCheckIcon, BoltIcon, LockClosedIcon, BookOpenIcon, InformationCircleIcon } from '@heroicons/react/24/outline' import CodeBlock from '../../components/CodeBlock' export default function DocsOverview() { return ( <>
Documentation

Introduction

Port Buddy is a tool that allows you to share a port opened on your local host or private network to the public network. It's perfect for testing webhooks, sharing your work with clients, or exposing any local service securely.

How it works

Port Buddy works as a reverse proxy. When you run the CLI, it establishes a secure connection to our edge servers. Any traffic sent to your unique Port Buddy URL is then proxied through this connection to your local machine.

Key Features

    } label="HTTP/HTTPS Tunnels" /> } label="TCP & UDP Support" /> } label="WebSocket Support" /> } label="Custom Domains" />

Installation

Before you can start using Port Buddy, you need to install the CLI on your machine. We provide binaries for macOS, Linux, and Windows.

View Installation Guide

Authentication

To use Port Buddy, you must be authenticated. This allows us to manage your tunnels and respect your subscription limits.

  1. Log in to your account at portbuddy.dev.
  2. Go to the Tokens page and generate a new API token.
  3. Run the following command in your terminal:

HTTP Tunnels

HTTP is the default mode for Port Buddy. It's used for web applications and APIs.

Usage

This command will expose your local web server running on port 3000. You will receive a public URL like https://abc123.portbuddy.dev.

TCP Tunnels

TCP mode allows you to expose any TCP-based service, such as databases or SSH.

Usage

This command exposes your local PostgreSQL database on port 5432. You will get an address like net-proxy-3.portbuddy.dev:43452.

UDP Tunnels

UDP mode is useful for game servers, VoIP, and other UDP-based protocols.

Usage

Run as Service

You can configure Port Buddy to run as a background service. This ensures that your tunnel starts automatically on system boot and restarts if it fails.

We provide helper scripts to set this up easily. You can run these scripts multiple times to set up different tunnels.

Linux (systemd)

Windows (Scheduled Task)

Run as Administrator:

[host]`} />

Options

  • --name <name> (Linux) or -Name <name> (Windows) - Custom name for the service.

Example

To expose port 22 (SSH) over TCP and run it as a service:

Linux:

Windows:

By default, the service name follows the pattern portbuddy-<mode>-<port>.

Custom Service Name

Linux:

Windows:

Managing the Service

Linux (systemctl):

Windows (PowerShell):

Custom Domains

With a Pro or Team plan, you can use your own domain name for your tunnels. We handle the SSL certificate issuance and renewal for you automatically.

Configure your custom domains in the Domains section of the dashboard.

Pricing & Limits

Port Buddy offers two simple plans to suit your needs.

Pro

$0 / mo

  • • 1 free tunnel at a time
  • • Custom domains
  • • Static subdomains
  • • $1/mo per extra tunnel

Team

$10 / mo

  • • 10 free tunnels at a time
  • • Team members
  • • Priority support
  • • $1/mo per extra tunnel
) } function FeatureItem({ icon, label }: { icon: React.ReactNode, label: string }) { return (
  • {icon}
    {label}
  • ) } ================================================ FILE: web/src/pages/docs/guides/HytaleGuide.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { BookOpenIcon } from '@heroicons/react/24/outline' import CodeBlock from '../../../components/CodeBlock' export default function HytaleGuide() { return ( <>
    How-to Guides

    Hosting a Hytale Server

    Learn how to expose your local Hytale server to the internet using Port Buddy, allowing your friends to join your adventure without complex network configuration.

    Prerequisites

    • A running Hytale Server on your local machine.
    • Port Buddy CLI installed and authenticated.

    Exposing the Server

    Hytale servers use UDP port 5520 by default.

    1. Start your Hytale Server

    Launch your Hytale server and ensure it is running locally.

    2. Expose the port

    Run the following command in your terminal:

    You will see output similar to this:

    udp localhost:5520 exposed to: net-proxy-2.portbuddy.dev:54321

    3. Connect

    Share the address (e.g., net-proxy-2.portbuddy.dev:54321) with your friends. They can use this address to connect to your server from the game client.

    ) } ================================================ FILE: web/src/pages/docs/guides/MinecraftGuide.tsx ================================================ /* * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import { BookOpenIcon } from '@heroicons/react/24/outline' import CodeBlock from '../../../components/CodeBlock' export default function MinecraftGuide() { return ( <>
    How-to Guides

    Hosting a Minecraft Server

    Learn how to expose your local Minecraft server to the internet using Port Buddy, allowing your friends to join without port forwarding or configuring your router.

    Prerequisites

    • A running Minecraft Server (Java or Bedrock Edition) on your local machine.
    • Port Buddy CLI installed and authenticated.

    Java Edition

    Minecraft Java Edition uses TCP port 25565 by default.

    1. Start your Minecraft Server

    Ensure your server is running and accessible locally (usually at localhost:25565).

    2. Expose the port

    Run the following command in your terminal:

    You will see output similar to this:

    tcp localhost:25565 exposed to: net-proxy-1.portbuddy.dev:42123

    3. Connect

    Share the address (e.g., net-proxy-1.portbuddy.dev:42123) with your friends. They can enter this address in the Multiplayer menu under "Direct Connection" or "Add Server".

    Bedrock Edition

    Minecraft Bedrock Edition uses UDP port 19132 by default.

    1. Start your Bedrock Server

    Ensure your server is running locally.

    2. Expose the port

    Run the following command:

    3. Connect

    Share the generated address and port with your friends. They can add it to their server list.

    ) } ================================================ FILE: web/tailwind.config.js ================================================ /**** @type {import('tailwindcss').Config} ****/ export default { content: [ './index.html', './src/**/*.{ts,tsx}', ], theme: { extend: { fontFamily: { mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'] }, colors: { primary: { 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#0b1020', }, accent: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', }, jb: { purple: '#cc33ff', pink: '#ff3399', orange: '#ff9933', blue: '#33ccff', } }, backgroundImage: { 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 'mesh-gradient': 'radial-gradient(at 0% 0%, rgba(204, 51, 255, 0.15) 0, transparent 50%), radial-gradient(at 50% 0%, rgba(34, 211, 238, 0.15) 0, transparent 50%), radial-gradient(at 100% 0%, rgba(255, 51, 153, 0.15) 0, transparent 50%)', }, keyframes: { progress: { '0%': { transform: 'translateX(-100%)' }, '50%': { transform: 'translateX(-30%)' }, '100%': { transform: 'translateX(0%)' }, }, flow: { '0%': { left: '-10%' }, '100%': { left: '110%' }, }, }, animation: { progress: 'progress 2s ease-in-out infinite', flow: 'flow 3s linear infinite', 'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite', }, }, }, plugins: [], } ================================================ FILE: web/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "baseUrl": "." }, "include": ["src"] } ================================================ FILE: web/vite.config.ts ================================================ import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import Prerender from '@prerenderer/rollup-plugin' import Renderer from '@prerenderer/renderer-puppeteer' import path from 'path' import fs from 'fs' // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); return { define: { 'process.env.NODE_ENV': JSON.stringify('development') }, plugins: [ react(), Prerender({ routes: ['/', '/index', '/install', '/docs', '/docs/guides/minecraft-server', '/docs/guides/hytale-server', '/privacy', '/terms', '/contacts'], renderer: new Renderer({ renderAfterDocumentEvent: 'render-event', }), staticDir: path.join(__dirname, 'dist'), postProcess(renderedRoute) { const route = renderedRoute.route; renderedRoute.html = renderedRoute.html.replace( '
    ', `
    ` ); let templatePath = ''; // Check for specific guide html files if (route.startsWith('/docs/guides/')) { const guideName = route.substring('/docs/guides/'.length); const potentialPath = `public/pages/docs/guides/${guideName}.html`; if (fs.existsSync(path.join(__dirname, potentialPath))) { templatePath = potentialPath; } } if (!templatePath) { if (route === '/' || route === '/index') templatePath = 'public/pages/index.html'; else if (route === '/install') templatePath = 'public/pages/install.html'; else if (route === '/docs' || route.startsWith('/docs/')) templatePath = 'public/pages/docs.html'; else if (route === '/privacy') templatePath = 'public/pages/privacy.html'; else if (route === '/terms') templatePath = 'public/pages/terms.html'; else if (route === '/contacts') templatePath = 'public/pages/contacts.html'; } if (templatePath) { let template = fs.readFileSync(path.join(__dirname, templatePath), 'utf8'); // Replace environment variables Object.keys(env).forEach((key) => { if (key.startsWith('VITE_')) { template = template.replace(new RegExp(`%${key}%`, 'g'), env[key]); } }); const titleMatch = template.match(/(.*?)<\/title>/); const headContent = template.match(/<head>([\s\S]*?)<\/head>/); if (titleMatch) { renderedRoute.html = renderedRoute.html.replace(/<title>.*?<\/title>/, titleMatch[0]); } if (headContent) { // Extract meta and link tags from template head const tags = headContent[1].match(/<(meta|link|script type="application\/ld\+json")[\s\S]*?>([\s\S]*?<\/(script)>)?/g); if (tags) { const headEndIndex = renderedRoute.html.indexOf('</head>'); let newHead = renderedRoute.html.substring(0, headEndIndex); tags.forEach(tag => { // Avoid duplicating tags if they already exist with same name/property/rel // Simple check to avoid some common duplicates const nameMatch = tag.match(/(name|property|rel|type)="([^"]+)"/); if (nameMatch) { const attr = nameMatch[1]; const val = nameMatch[2]; if (renderedRoute.html.includes(`${attr}="${val}"`)) { // Replace existing tag if found (simplified) const existingTagRegex = new RegExp(`<[^>]*${attr}="${val}"[^>]*>`, 'g'); newHead = newHead.replace(existingTagRegex, tag); return; } } newHead += `\n ${tag}`; }); renderedRoute.html = newHead + renderedRoute.html.substring(headEndIndex); } } } return renderedRoute; } }), ], build: { minify: false, sourcemap: true, }, }; });