diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..ff582ab --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,146 @@ +# Rustfmt configuration for consistent code formatting across the workspace + +# Maximum line width +max_width = 100 + +# Use 4 spaces for indentation +tab_spaces = 4 + +# Use spaces instead of tabs +hard_tabs = false + +# Edition to use +edition = "2021" + +# How to use heuristics for splitting lines +use_small_heuristics = "Max" + +# Reorder import statements +reorder_imports = true +reorder_modules = true + +# Group imports +group_imports = "StdExternalCrate" + +# Format comments +comment_width = 80 +wrap_comments = true +normalize_comments = true + +# Format strings +format_strings = true + +# Use field init shorthand +use_field_init_shorthand = true + +# Use try shorthand +use_try_shorthand = true + +# Force explicit type for struct fields where possible +force_explicit_abi = true + +# Format code in doc comments +format_code_in_doc_comments = true + +# Normalize doc attributes +normalize_doc_attributes = true + +# Wrap match arms +match_arm_blocks = false +match_block_trailing_comma = true + +# Trailing comma for lists +trailing_comma = "Vertical" + +# Trailing semicolon +trailing_semicolon = true + +# Space before colon +space_before_colon = false + +# Space after colon +space_after_colon = true + +# Spaces around ranges +spaces_around_ranges = false + +# Type punctuation density +type_punctuation_density = "Wide" + +# Struct field alignment +struct_field_align_threshold = 0 + +# Enum discriminant align threshold +enum_discrim_align_threshold = 0 + +# Inline attribute width +inline_attribute_width = 0 + +# Format macro matchers +format_macro_matchers = true + +# Format macro bodies +format_macro_bodies = true + +# Hex literal case +hex_literal_case = "Lower" + +# Imports indent +imports_indent = "Block" + +# Imports layout +imports_layout = "Mixed" + +# Merge derives +merge_derives = true + +# Remove nested parens +remove_nested_parens = true + +# Short array element width threshold +short_array_element_width_threshold = 10 + +# Struct lit single line +struct_lit_single_line = true + +# Struct variant width threshold +struct_variant_width_threshold = 35 + +# Skip children +skip_children = false + +# Condense wildcard suffixes +condense_wildcard_suffixes = true + +# Newline style +newline_style = "Unix" + +# Control brace style +control_brace_style = "AlwaysSameBrace" + +# Brace style +brace_style = "SameLineWhere" + +# Empty item single line +empty_item_single_line = true + +# Fn args layout +fn_args_layout = "Tall" + +# Fn call width +fn_call_width = 80 + +# Fn single line +fn_single_line = false + +# Where single line +where_single_line = false + +# Indent style +indent_style = "Block" + +# Item brace style +item_brace_style = "SameLineWhere" + +# Literal trailing semicolon +literal_trailing_comma = "true" diff --git a/Cargo.toml b/Cargo.toml index e25e9fc..2d9d244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,10 +8,28 @@ resolver = "2" [workspace.package] version = "1.0.0" authors = ["TaaS Team "] -edition = "2021" -rust-version = "1.94" +edition = "2024" +rust-version = "1.95" license = "MIT" +[workspace.metadata.cargo-audit] +# Ignore advisories that are not relevant to this project +# advisories = [] + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = "warn" +pedantic = "warn" +nursery = "warn" +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unwrap_in_result = "deny" +unimplemented = "deny" +todo = "warn" + [workspace.dependencies] # Async runtime tokio = { version = "1.52.1", features = ["full"] } diff --git a/README.md b/README.md index c5c1d65..39a640c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ The Telecom-as-a-Service (TaaS) Platform is a comprehensive, sovereign cellular connectivity solution that enables organizations to deploy and manage their own private 5G/LTE networks. This full-stack platform provides end-to-end capabilities from core network integration to subscriber management, charging, and developer APIs. +### API Gateway Integration + +The platform now includes **Traefik API Gateway** for centralized request routing, security, and monitoring. All services are accessible through a unified HTTPS endpoint with built-in rate limiting, authentication, and SSL termination. + ### What It Does The TaaS Platform allows enterprises, telecom operators, and system integrators to: @@ -22,6 +26,12 @@ The TaaS Platform allows enterprises, telecom operators, and system integrators The platform is built as a microservices architecture with the following core components: +**API Gateway Layer:** +- **Traefik API Gateway**: Centralized entry point providing SSL termination, rate limiting, authentication, and request routing +- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com` +- **Security Middleware**: JWT authentication, security headers, compression, and retry logic +- **Monitoring Dashboard**: Real-time metrics and service health visualization + **Core Network Services:** - **API Server**: Central BSS (Business Support System) API providing authentication, subscriber management, automation, and plugin system - **Carrier Connector**: ES2+ interface for eSIM profile management and carrier integration @@ -43,6 +53,12 @@ The platform is built as a microservices architecture with the following core co ### Key Features +**API Gateway & Security:** +- **Unified HTTPS Endpoint**: All services accessible via `https://api.telecom.com` +- **Centralized Authentication**: JWT validation with rate limiting per service +- **SSL Termination**: Automatic HTTPS with security headers enforcement +- **Request Routing**: Intelligent routing with circuit breakers and retry logic + **Sovereignty & Security:** - Full data sovereignty with on-premise deployment - End-to-end encryption for subscriber data @@ -53,26 +69,28 @@ The platform is built as a microservices architecture with the following core co - eBPF-accelerated packet processing for line-rate throughput - Redis-backed distributed rate limiting and caching - Horizontal scaling with Kubernetes orchestration -- Circuit breakers and retry logic for external service resilience +- Gateway-level load balancing and connection pooling **Developer Experience:** +- **Single API Endpoint**: Simplified client integration through gateway - REST and GraphQL APIs with comprehensive documentation - TypeScript SDK for type-safe client integration - Plugin system for extending platform capabilities - Automation framework for network operations **Operations:** -- Real-time health monitoring with Prometheus integration +- **Gateway Dashboard**: Real-time monitoring of all services +- **Unified Metrics**: Prometheus integration with gateway-level insights - Automated scaling and service discovery - Centralized logging with structured logs -- Chaos engineering capabilities for testing resilience +- Health checks and failover automation **Technology Stack:** **Core Languages & Runtimes:** - **Go 1.26**: Core network integration, BSS API, carrier connector -- **Rust 1.94**: eBPF packet gateway, real-time charging engine +- **Rust 1.95**: eBPF packet gateway, real-time charging engine - **TypeScript/Next.js**: Developer dashboard and SDK **Databases:** @@ -105,7 +123,7 @@ The platform is built as a microservices architecture with the following core co ### Prerequisites - **Go 1.26+**: [Download](https://go.dev/dl/) -- **Rust 1.94+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +- **Rust 1.95+**: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` - **Node.js 22+**: [Download](https://nodejs.org/) - **pnpm**: `npm install -g pnpm` - **Docker**: [Install](https://docs.docker.com/get-docker/) @@ -117,11 +135,28 @@ The platform is built as a microservices architecture with the following core co ### Installation +#### Quick Start with API Gateway (Recommended) + ```bash # Clone the repository git clone https://github.com/nutcas3/telecom-platform.git cd telecom-platform +# Start with API Gateway (includes all services) +./scripts/start-gateway.sh + +# Add domain to hosts file +echo "127.0.0.1 api.telecom.com" | sudo tee -a /etc/hosts + +# Access services +# API Gateway Dashboard: http://localhost:8080 +# API Documentation: https://api.telecom.com/api/v1/swagger +# Web Dashboard: http://localhost:3000 +``` + +#### Manual Installation + +```bash # Build all components make all @@ -143,24 +178,31 @@ cd apps/web-dashboard && pnpm dev ``` telecom-platform/ -├── apps/ -│ ├── api-server/ # Go: Developer BSS API -│ ├── carrier-connector/ # Go: eSIM ES2+ Provisioning -│ ├── charging-engine/ # Rust: OCS Real-time Credit Control -│ ├── packet-gateway/ # Rust: eBPF UPF Data Plane -│ └── web-dashboard/ # TypeScript: Next.js Frontend -├── libs/ -│ ├── shared-ts-sdk/ # TypeScript: Drop-in Widget SDK -│ └── proto/ # Shared Protobufs -├── deployments/ -│ ├── kubernetes/ # K8s manifests -│ └── docker/ # Dockerfiles -├── docs/ # Architecture & API docs -├── scripts/ # Automation scripts -├── Makefile # Master build orchestrator -├── Cargo.toml # Rust workspace config -├── go.work # Go workspace config -└── pnpm-workspace.yaml # TypeScript workspace config +|-- apps/ +| |-- api-server/ # Go: Developer BSS API +| |-- carrier-connector/ # Go: eSIM ES2+ Provisioning +| |-- charging-engine/ # Rust: OCS Real-time Credit Control +| |-- packet-gateway/ # Rust: eBPF UPF Data Plane +| |-- web-dashboard/ # TypeScript: Next.js Frontend +|-- libs/ +| |-- shared-ts-sdk/ # TypeScript: Drop-in Widget SDK +| |-- proto/ # Shared Protobufs +|-- deployments/ +| |-- kubernetes/ # K8s manifests +| |-- docker/ # Dockerfiles +|-- traefik/ # API Gateway configuration +| |-- traefik.yml # Static configuration +| |-- dynamic/ # Dynamic middleware config +|-- docs/ # Architecture & API docs +| |-- gateway-quickstart.md # API Gateway guide +| |-- api-gateway.md # Gateway implementation details +|-- scripts/ # Automation scripts +| |-- start-gateway.sh # Gateway startup script +|-- docker-compose.yml # Container orchestration +|-- Makefile # Master build orchestrator +|-- Cargo.toml # Rust workspace config +|-- go.work # Go workspace config +|-- pnpm-workspace.yaml # TypeScript workspace config ``` ## Development @@ -206,6 +248,8 @@ pnpm dev ## Documentation - **[API Documentation](./docs/api-spec.yaml)**: OpenAPI 3.0 specification +- **[Gateway Quickstart](./docs/gateway-quickstart.md)**: API Gateway setup and configuration +- **[API Gateway Guide](./docs/api-gateway.md)**: Implementation details and patterns - **[Architecture Guide](./docs/architecture.md)**: System design and data flows - **[Deployment Guide](./docs/deployment.md)**: Kubernetes and Docker setup @@ -227,7 +271,17 @@ pnpm -r test ## Deployment -### Docker +### Docker with API Gateway (Recommended) + +```bash +# Start with API Gateway +./scripts/start-gateway.sh + +# Or manually +docker-compose up -d +``` + +### Docker (Legacy) ```bash make docker-build @@ -240,6 +294,18 @@ docker-compose up -d kubectl apply -f deployments/kubernetes/ ``` +### API Gateway Configuration + +The API Gateway provides: + +- **Unified Endpoint**: `https://api.telecom.com` +- **Rate Limiting**: Per-service rate limits +- **SSL Termination**: Automatic HTTPS +- **Authentication**: JWT validation +- **Monitoring**: Real-time metrics dashboard + +For detailed setup, see [Gateway Quickstart Guide](./docs/gateway-quickstart.md) + ## Environment Variables Create `.env` files in each service directory: diff --git a/apps/api-server/cmd/server.go b/apps/api-server/cmd/server.go index 055d6a9..423bd03 100644 --- a/apps/api-server/cmd/server.go +++ b/apps/api-server/cmd/server.go @@ -14,22 +14,31 @@ import ( "github.com/gin-gonic/gin" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" + csrf "github.com/utrack/gin-csrf" "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" "github.com/nutcas3/telecom-platform/apps/api-server/internal/discovery" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/graphql" "github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware" "github.com/nutcas3/telecom-platform/apps/api-server/internal/monitoring" "github.com/nutcas3/telecom-platform/apps/api-server/internal/websocket" ) +// @title Telecom Platform API +// @version 1.0 +// @description API for Telecom Platform +// @host localhost:8080 +// @BasePath /api/v1 +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization func main() { cfg := config.LoadConfig() db, err := database.NewDatabase(&cfg.Database) if err != nil { log.Fatalf("Failed to initialize database: %v", err) + return } defer db.Close() @@ -72,10 +81,17 @@ func main() { } }() - resolver := graphql.NewResolver(db, cfg) - router := gin.Default() - graphql.SetupGraphQLHandler(router, resolver) + + // Add CSRF protection middleware + csrfSecret := os.Getenv("CSRF_SECRET") + if csrfSecret == "" { + csrfSecret = "change-me-in-production-csrf-secret-32-chars-min" + log.Printf("WARNING: Using default CSRF secret, set CSRF_SECRET environment variable") + } + router.Use(csrf.Middleware(csrf.Options{ + Secret: csrfSecret, + })) router.Use(func(c *gin.Context) { next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c.Next() }) @@ -136,7 +152,7 @@ func main() { go func() { log.Printf("Starting API server on port %s", cfg.Server.Port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server failed to start: %v", err) + log.Printf("Server failed to start: %v", err) } }() diff --git a/apps/api-server/cmd/server_metrics.go b/apps/api-server/cmd/server_metrics.go index 8f0882c..91c43f4 100644 --- a/apps/api-server/cmd/server_metrics.go +++ b/apps/api-server/cmd/server_metrics.go @@ -2,7 +2,6 @@ package main import ( "context" - "log" "math" "os" "strconv" @@ -12,6 +11,7 @@ import ( "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" "github.com/nutcas3/telecom-platform/apps/api-server/internal/metrics" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/sirupsen/logrus" ) // startMetricsCollection periodically collects and stores metrics. @@ -45,7 +45,7 @@ func collectMetrics(ctx context.Context, mc *metrics.MetricsCollectorWithTimeSer if ts := mc.GetTimeSeriesStorage(); ts != nil { if err := ts.StoreSystemMetrics(ctx, systemMetrics); err != nil { - log.Printf("Failed to store system metrics: %v", err) + logrus.WithError(err).Error("Failed to store system metrics") } } } @@ -56,17 +56,17 @@ func getSubscriberStats(db *database.Database) (total, active, suspended int) { defer cancel() var totalCount, activeCount, suspendedCount int64 if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Count(&totalCount).Error; err != nil { - log.Printf("Error counting total subscribers: %v", err) + logrus.WithError(err).Error("Error counting total subscribers") return 0, 0, 0 } if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}).Where("status = ?", "active").Count(&activeCount).Error; err != nil { - log.Printf("Error counting active subscribers: %v", err) + logrus.WithError(err).Error("Error counting active subscribers") return int(totalCount), 0, 0 } if err := db.DB.WithContext(ctx).Model(&models.Subscriber{}). Where("status IN ?", []string{"suspended", "deactivated", "blocked"}). Count(&suspendedCount).Error; err != nil { - log.Printf("Error counting suspended subscribers: %v", err) + logrus.WithError(err).Error("Error counting suspended subscribers") return int(totalCount), int(activeCount), 0 } return int(totalCount), int(activeCount), int(suspendedCount) @@ -82,7 +82,7 @@ func getActiveSessionsCount(db *database.Database) int { "status IN ? AND end_time IS NULL", []string{"active", "connected", "established"}, ).Count(&count).Error; err != nil { - log.Printf("Error counting active sessions: %v", err) + logrus.WithError(err).Error("Error counting active sessions") return 0 } @@ -93,7 +93,7 @@ func getActiveSessionsCount(db *database.Database) int { func getCPUUsage() float64 { data, err := os.ReadFile("/proc/stat") if err != nil { - log.Printf("Error reading /proc/stat: %v", err) + logrus.WithError(err).Error("Error reading /proc/stat") return 0 } @@ -130,7 +130,7 @@ func getCPUUsage() float64 { func getMemoryUsage() float64 { data, err := os.ReadFile("/proc/meminfo") if err != nil { - log.Printf("Error reading /proc/meminfo: %v", err) + logrus.WithError(err).Error("Error reading /proc/meminfo") return 0 } @@ -171,7 +171,7 @@ func getNetworkTX() int64 { return readNetworkStat(9) } func readNetworkStat(fieldIdx int) int64 { data, err := os.ReadFile("/proc/net/dev") if err != nil { - log.Printf("Error reading /proc/net/dev: %v", err) + logrus.WithError(err).Error("Error reading /proc/net/dev") return 0 } diff --git a/apps/api-server/cmd/server_routes.go b/apps/api-server/cmd/server_routes.go index 8f502e8..37b4c0b 100644 --- a/apps/api-server/cmd/server_routes.go +++ b/apps/api-server/cmd/server_routes.go @@ -2,6 +2,8 @@ package main import ( "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" "github.com/nutcas3/telecom-platform/apps/api-server/internal/middleware" "github.com/nutcas3/telecom-platform/apps/api-server/internal/rbac" @@ -11,13 +13,16 @@ import ( // RBAC enforcement uses Casbin when configured, falling back to role-based // middleware otherwise. func registerV1Routes(router *gin.Engine, d *serverDeps) { - v1 := router.Group("/v1") + v1 := router.Group("/api/v1") // Health check v1.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + // Swagger documentation + v1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + // Charging engine integration (public for packet-gateway integration) charging := v1.Group("/charging") { @@ -46,7 +51,7 @@ func registerV1Routes(router *gin.Engine, d *serverDeps) { } users := protected.Group("/users") - applyRBAC(users, d.casbinSvc, "/v1/users", "GET", "admin") + applyRBAC(users, d.casbinSvc, "/api/v1/users", "GET", "admin") { users.GET("", d.authH.GetUsers) users.POST("", d.authH.CreateUser) diff --git a/apps/api-server/docs/docs.go b/apps/api-server/docs/docs.go index fed8279..398fb30 100644 --- a/apps/api-server/docs/docs.go +++ b/apps/api-server/docs/docs.go @@ -1,117 +1,776 @@ -// Package docs GENERATED BY SWAG; DO NOT EDIT -// This file was generated by swaggo/swag - +// Package docs Code generated by swaggo/swag. DO NOT EDIT package docs -import ( - "strings" - - "github.com/swaggo/swag" -) +import "github.com/swaggo/swag" const docTemplate = `{ "schemes": {{ marshal .Schemes }}, "swagger": "2.0", "info": { - "description": "{{.Description}}", + "description": "{{escape .Description}}", "title": "{{.Title}}", - "termsOfService": "{{.TermsOfService}}", - "contact": { - "name": "{{.ContactName}}", - "url": "{{.ContactURL}}", - "email": "{{.ContactEmail}}" - }, - "license": { - "name": "{{.LicenseName}}", - "url": "{{.LicenseURL}}" - }, + "contact": {}, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", - "paths": {}, - "definitions": {} + "paths": { + "/api/v1/subscribers": { + "get": { + "description": "List subscribers with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "List subscribers", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Subscriber status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Organization ID", + "name": "organization_id", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/services.ListSubscribersResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a new subscriber with allocated IMSI", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Create a new subscriber", + "parameters": [ + { + "description": "Subscriber data", + "name": "subscriber", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/services.CreateSubscriberRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/imsi/{imsi}": { + "get": { + "description": "Retrieve subscriber information by IMSI", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Get subscriber by IMSI", + "parameters": [ + { + "type": "string", + "description": "IMSI", + "name": "imsi", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}": { + "get": { + "description": "Retrieve subscriber information by ID", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Get subscriber by ID", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update subscriber information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Update subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated subscriber data", + "name": "subscriber", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/services.UpdateSubscriberRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}/suspend": { + "post": { + "description": "Suspend a subscriber and terminate their sessions", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Suspend subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}/terminate": { + "post": { + "description": "Terminate a subscriber and deactivate their eSIM profile", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Terminate subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "details": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, + "models.AMBR": { + "type": "object", + "properties": { + "downlink": { + "type": "integer" + }, + "uplink": { + "type": "integer" + } + } + }, + "models.PLMN": { + "type": "object", + "properties": { + "mcc": { + "type": "string" + }, + "mnc": { + "type": "string" + } + } + }, + "models.ProfileStatus": { + "type": "string", + "enum": [ + "inactive", + "active", + "downloading", + "error" + ], + "x-enum-varnames": [ + "ProfileStatusInactive", + "ProfileStatusActive", + "ProfileStatusDownloading", + "ProfileStatusError" + ] + }, + "models.QoSProfile": { + "type": "object", + "properties": { + "arpa": { + "type": "integer" + }, + "gbr": { + "$ref": "#/definitions/models.AMBR" + }, + "mbr": { + "$ref": "#/definitions/models.AMBR" + }, + "priority": { + "type": "integer" + }, + "qci": { + "type": "integer" + } + } + }, + "models.SNSSAI": { + "type": "object", + "properties": { + "sd": { + "type": "integer" + }, + "sst": { + "type": "integer" + } + } + }, + "models.ServicePlan": { + "type": "object", + "properties": { + "ambr": { + "$ref": "#/definitions/models.AMBR" + }, + "arpa": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "data_limit": { + "type": "integer" + }, + "data_rate": { + "type": "number" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "monthly_fee": { + "type": "number" + }, + "name": { + "type": "string" + }, + "sms_limit": { + "type": "integer" + }, + "sms_rate": { + "type": "number" + }, + "updated_at": { + "type": "string" + }, + "voice_limit": { + "type": "integer" + }, + "voice_rate": { + "type": "number" + } + } + }, + "models.Session": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data_used": { + "type": "integer" + }, + "dnn": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "pdu_address": { + "type": "string" + }, + "qos": { + "$ref": "#/definitions/models.QoSProfile" + }, + "session_id": { + "type": "string" + }, + "sms_used": { + "type": "integer" + }, + "snssai": { + "$ref": "#/definitions/models.SNSSAI" + }, + "start_time": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/models.SessionStatus" + }, + "subscriber_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "voice_used": { + "type": "integer" + } + } + }, + "models.SessionStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "terminating" + ], + "x-enum-varnames": [ + "SessionStatusActive", + "SessionStatusInactive", + "SessionStatusTerminating" + ] + }, + "models.Subscriber": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "balance": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "euicc_id": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "imei": { + "description": "Device identifier", + "type": "string" + }, + "imsi": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "msisdn": { + "description": "Mobile phone number", + "type": "string" + }, + "opc": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "plan": { + "$ref": "#/definitions/models.ServicePlan" + }, + "plan_id": { + "type": "integer" + }, + "profile_id": { + "type": "string" + }, + "profile_status": { + "$ref": "#/definitions/models.ProfileStatus" + }, + "serving_plmn": { + "$ref": "#/definitions/models.PLMN" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Session" + } + }, + "status": { + "$ref": "#/definitions/models.SubscriberStatus" + }, + "updated_at": { + "type": "string" + }, + "usage_records": { + "type": "array", + "items": { + "$ref": "#/definitions/models.UsageRecord" + } + } + } + }, + "models.SubscriberStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "suspended", + "terminated", + "provisioning" + ], + "x-enum-varnames": [ + "SubscriberStatusActive", + "SubscriberStatusInactive", + "SubscriberStatusSuspended", + "SubscriberStatusTerminated", + "SubscriberStatusProvisioning" + ] + }, + "models.UsageRecord": { + "type": "object", + "properties": { + "billing_cycle": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "invoice_id": { + "type": "integer" + }, + "rate": { + "type": "number" + }, + "session_id": { + "type": "integer" + }, + "start_time": { + "type": "string" + }, + "subscriber_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "usage_type": { + "$ref": "#/definitions/models.UsageType" + }, + "volume": { + "type": "integer" + } + } + }, + "models.UsageType": { + "type": "string", + "enum": [ + "data", + "voice", + "sms" + ], + "x-enum-varnames": [ + "UsageTypeData", + "UsageTypeVoice", + "UsageTypeSMS" + ] + }, + "services.CreateSubscriberRequest": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "msisdn", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "euicc_id": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "msisdn": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "services.ListSubscribersResponse": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "subscribers": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "total": { + "type": "integer" + } + } + }, + "services.UpdateSubscriberRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "plan_id": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/models.SubscriberStatus" + } + } + } + } }` // SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = struct { - Host string - BasePath string - Schemes []string - Title string - Description string - Version string - // License information - LicenseName string - LicenseURL string - // Contact information - ContactName string - ContactURL string - ContactEmail string -}{ - Host: "localhost:8000", - BasePath: "/v1", - Schemes: []string{"http", "https"}, - Title: "Telecom Platform API", - Description: "A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing", - Version: "1.0.0", - LicenseName: "MIT", - LicenseURL: "https://opensource.org/licenses/MIT", - ContactName: "Telecom Platform Team", - ContactURL: "https://github.com/nutcas3/telecom-platform", - ContactEmail: "support@telecom-platform.com", -} - -type s struct{} - -func (s s) ReadDoc() string { - sInfo := SwaggerInfo - description := `Telecom Platform API - -This API provides comprehensive functionality for managing a telecom platform including: -- Subscriber management and eSIM provisioning -- Service management and Kubernetes integration -- Monitoring and metrics collection -- Deployment management -- Plugin system -- Workflow automation -- Billing and invoicing -- Configuration management -- Authentication and authorization - -## Authentication - -The API uses JWT (JSON Web Token) authentication. To access protected endpoints: -1. Register a user account -2. Login to obtain an access token -3. Include the token in the Authorization header: "Bearer " - -## Rate Limiting - -API requests are rate-limited to ensure fair usage. Current limits: -- 100 requests per minute per authenticated user -- 10 requests per minute for unauthenticated requests - -## Error Handling - -The API uses standard HTTP status codes: -- 200: Success -- 201: Created -- 400: Bad Request -- 401: Unauthorized -- 403: Forbidden -- 404: Not Found -- 500: Internal Server Error - -Error responses include a JSON object with an "error" field containing a descriptive message.` - - // Replace template placeholders with actual values - result := strings.Replace(docTemplate, "{{.Version}}", sInfo.Version, -1) - result = strings.Replace(result, "{{.Description}}", description, -1) - - return result +var SwaggerInfo = &swag.Spec{ + Version: "", + Host: "", + BasePath: "", + Schemes: []string{}, + Title: "", + Description: "", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", } func init() { - swag.Register(swag.Name, &s{}) + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) } diff --git a/apps/api-server/docs/swagger.go b/apps/api-server/docs/swagger.go index 785e813..53356ca 100644 --- a/apps/api-server/docs/swagger.go +++ b/apps/api-server/docs/swagger.go @@ -1,9 +1,5 @@ package docs -import ( - "github.com/swaggo/swag" -) - // @title Telecom Platform API // @version 1.0.0 // @description A comprehensive telecom platform API for managing subscribers, services, monitoring, deployments, plugins, automation, and billing @@ -19,7 +15,3 @@ import ( // @in header // @name Authorization // @description Type "Bearer" followed by a space and JWT token. - -func init() { - swag.Register(swag.Name, &s{}) -} diff --git a/apps/api-server/docs/swagger.json b/apps/api-server/docs/swagger.json new file mode 100644 index 0000000..d6ad10e --- /dev/null +++ b/apps/api-server/docs/swagger.json @@ -0,0 +1,747 @@ +{ + "swagger": "2.0", + "info": { + "contact": {} + }, + "paths": { + "/api/v1/subscribers": { + "get": { + "description": "List subscribers with pagination and filtering", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "List subscribers", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page size", + "name": "page_size", + "in": "query" + }, + { + "type": "string", + "description": "Subscriber status", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Organization ID", + "name": "organization_id", + "in": "query" + }, + { + "type": "string", + "description": "Search term", + "name": "search", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/services.ListSubscribersResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "description": "Create a new subscriber with allocated IMSI", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Create a new subscriber", + "parameters": [ + { + "description": "Subscriber data", + "name": "subscriber", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/services.CreateSubscriberRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/imsi/{imsi}": { + "get": { + "description": "Retrieve subscriber information by IMSI", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Get subscriber by IMSI", + "parameters": [ + { + "type": "string", + "description": "IMSI", + "name": "imsi", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}": { + "get": { + "description": "Retrieve subscriber information by ID", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Get subscriber by ID", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "put": { + "description": "Update subscriber information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Update subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated subscriber data", + "name": "subscriber", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/services.UpdateSubscriberRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}/suspend": { + "post": { + "description": "Suspend a subscriber and terminate their sessions", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Suspend subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/v1/subscribers/{id}/terminate": { + "post": { + "description": "Terminate a subscriber and deactivate their eSIM profile", + "produces": [ + "application/json" + ], + "tags": [ + "subscribers" + ], + "summary": "Terminate subscriber", + "parameters": [ + { + "type": "integer", + "description": "Subscriber ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "handlers.ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "details": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, + "models.AMBR": { + "type": "object", + "properties": { + "downlink": { + "type": "integer" + }, + "uplink": { + "type": "integer" + } + } + }, + "models.PLMN": { + "type": "object", + "properties": { + "mcc": { + "type": "string" + }, + "mnc": { + "type": "string" + } + } + }, + "models.ProfileStatus": { + "type": "string", + "enum": [ + "inactive", + "active", + "downloading", + "error" + ], + "x-enum-varnames": [ + "ProfileStatusInactive", + "ProfileStatusActive", + "ProfileStatusDownloading", + "ProfileStatusError" + ] + }, + "models.QoSProfile": { + "type": "object", + "properties": { + "arpa": { + "type": "integer" + }, + "gbr": { + "$ref": "#/definitions/models.AMBR" + }, + "mbr": { + "$ref": "#/definitions/models.AMBR" + }, + "priority": { + "type": "integer" + }, + "qci": { + "type": "integer" + } + } + }, + "models.SNSSAI": { + "type": "object", + "properties": { + "sd": { + "type": "integer" + }, + "sst": { + "type": "integer" + } + } + }, + "models.ServicePlan": { + "type": "object", + "properties": { + "ambr": { + "$ref": "#/definitions/models.AMBR" + }, + "arpa": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "data_limit": { + "type": "integer" + }, + "data_rate": { + "type": "number" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "monthly_fee": { + "type": "number" + }, + "name": { + "type": "string" + }, + "sms_limit": { + "type": "integer" + }, + "sms_rate": { + "type": "number" + }, + "updated_at": { + "type": "string" + }, + "voice_limit": { + "type": "integer" + }, + "voice_rate": { + "type": "number" + } + } + }, + "models.Session": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data_used": { + "type": "integer" + }, + "dnn": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "pdu_address": { + "type": "string" + }, + "qos": { + "$ref": "#/definitions/models.QoSProfile" + }, + "session_id": { + "type": "string" + }, + "sms_used": { + "type": "integer" + }, + "snssai": { + "$ref": "#/definitions/models.SNSSAI" + }, + "start_time": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/models.SessionStatus" + }, + "subscriber_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "voice_used": { + "type": "integer" + } + } + }, + "models.SessionStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "terminating" + ], + "x-enum-varnames": [ + "SessionStatusActive", + "SessionStatusInactive", + "SessionStatusTerminating" + ] + }, + "models.Subscriber": { + "type": "object", + "properties": { + "auth_key": { + "type": "string" + }, + "balance": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "euicc_id": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "imei": { + "description": "Device identifier", + "type": "string" + }, + "imsi": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "msisdn": { + "description": "Mobile phone number", + "type": "string" + }, + "opc": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "plan": { + "$ref": "#/definitions/models.ServicePlan" + }, + "plan_id": { + "type": "integer" + }, + "profile_id": { + "type": "string" + }, + "profile_status": { + "$ref": "#/definitions/models.ProfileStatus" + }, + "serving_plmn": { + "$ref": "#/definitions/models.PLMN" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Session" + } + }, + "status": { + "$ref": "#/definitions/models.SubscriberStatus" + }, + "updated_at": { + "type": "string" + }, + "usage_records": { + "type": "array", + "items": { + "$ref": "#/definitions/models.UsageRecord" + } + } + } + }, + "models.SubscriberStatus": { + "type": "string", + "enum": [ + "active", + "inactive", + "suspended", + "terminated", + "provisioning" + ], + "x-enum-varnames": [ + "SubscriberStatusActive", + "SubscriberStatusInactive", + "SubscriberStatusSuspended", + "SubscriberStatusTerminated", + "SubscriberStatusProvisioning" + ] + }, + "models.UsageRecord": { + "type": "object", + "properties": { + "billing_cycle": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "end_time": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "invoice_id": { + "type": "integer" + }, + "rate": { + "type": "number" + }, + "session_id": { + "type": "integer" + }, + "start_time": { + "type": "string" + }, + "subscriber_id": { + "type": "integer" + }, + "updated_at": { + "type": "string" + }, + "usage_type": { + "$ref": "#/definitions/models.UsageType" + }, + "volume": { + "type": "integer" + } + } + }, + "models.UsageType": { + "type": "string", + "enum": [ + "data", + "voice", + "sms" + ], + "x-enum-varnames": [ + "UsageTypeData", + "UsageTypeVoice", + "UsageTypeSMS" + ] + }, + "services.CreateSubscriberRequest": { + "type": "object", + "required": [ + "email", + "first_name", + "last_name", + "msisdn", + "plan_id" + ], + "properties": { + "email": { + "type": "string" + }, + "euicc_id": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "msisdn": { + "type": "string" + }, + "organization_id": { + "type": "string" + }, + "plan_id": { + "type": "integer" + } + } + }, + "services.ListSubscribersResponse": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "subscribers": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Subscriber" + } + }, + "total": { + "type": "integer" + } + } + }, + "services.UpdateSubscriberRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "plan_id": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/models.SubscriberStatus" + } + } + } + } +} \ No newline at end of file diff --git a/apps/api-server/docs/swagger.yaml b/apps/api-server/docs/swagger.yaml new file mode 100644 index 0000000..d5ae082 --- /dev/null +++ b/apps/api-server/docs/swagger.yaml @@ -0,0 +1,500 @@ +definitions: + handlers.ErrorResponse: + properties: + code: + type: string + details: + type: string + error: + type: string + type: object + models.AMBR: + properties: + downlink: + type: integer + uplink: + type: integer + type: object + models.PLMN: + properties: + mcc: + type: string + mnc: + type: string + type: object + models.ProfileStatus: + enum: + - inactive + - active + - downloading + - error + type: string + x-enum-varnames: + - ProfileStatusInactive + - ProfileStatusActive + - ProfileStatusDownloading + - ProfileStatusError + models.QoSProfile: + properties: + arpa: + type: integer + gbr: + $ref: '#/definitions/models.AMBR' + mbr: + $ref: '#/definitions/models.AMBR' + priority: + type: integer + qci: + type: integer + type: object + models.SNSSAI: + properties: + sd: + type: integer + sst: + type: integer + type: object + models.ServicePlan: + properties: + ambr: + $ref: '#/definitions/models.AMBR' + arpa: + type: integer + created_at: + type: string + data_limit: + type: integer + data_rate: + type: number + description: + type: string + id: + type: integer + monthly_fee: + type: number + name: + type: string + sms_limit: + type: integer + sms_rate: + type: number + updated_at: + type: string + voice_limit: + type: integer + voice_rate: + type: number + type: object + models.Session: + properties: + created_at: + type: string + data_used: + type: integer + dnn: + type: string + end_time: + type: string + id: + type: integer + pdu_address: + type: string + qos: + $ref: '#/definitions/models.QoSProfile' + session_id: + type: string + sms_used: + type: integer + snssai: + $ref: '#/definitions/models.SNSSAI' + start_time: + type: string + status: + $ref: '#/definitions/models.SessionStatus' + subscriber_id: + type: integer + updated_at: + type: string + voice_used: + type: integer + type: object + models.SessionStatus: + enum: + - active + - inactive + - terminating + type: string + x-enum-varnames: + - SessionStatusActive + - SessionStatusInactive + - SessionStatusTerminating + models.Subscriber: + properties: + auth_key: + type: string + balance: + type: number + created_at: + type: string + email: + type: string + euicc_id: + type: string + first_name: + type: string + id: + type: integer + imei: + description: Device identifier + type: string + imsi: + type: string + last_name: + type: string + msisdn: + description: Mobile phone number + type: string + opc: + type: string + organization_id: + type: string + plan: + $ref: '#/definitions/models.ServicePlan' + plan_id: + type: integer + profile_id: + type: string + profile_status: + $ref: '#/definitions/models.ProfileStatus' + serving_plmn: + $ref: '#/definitions/models.PLMN' + sessions: + items: + $ref: '#/definitions/models.Session' + type: array + status: + $ref: '#/definitions/models.SubscriberStatus' + updated_at: + type: string + usage_records: + items: + $ref: '#/definitions/models.UsageRecord' + type: array + type: object + models.SubscriberStatus: + enum: + - active + - inactive + - suspended + - terminated + - provisioning + type: string + x-enum-varnames: + - SubscriberStatusActive + - SubscriberStatusInactive + - SubscriberStatusSuspended + - SubscriberStatusTerminated + - SubscriberStatusProvisioning + models.UsageRecord: + properties: + billing_cycle: + type: string + cost: + type: number + created_at: + type: string + end_time: + type: string + id: + type: integer + invoice_id: + type: integer + rate: + type: number + session_id: + type: integer + start_time: + type: string + subscriber_id: + type: integer + updated_at: + type: string + usage_type: + $ref: '#/definitions/models.UsageType' + volume: + type: integer + type: object + models.UsageType: + enum: + - data + - voice + - sms + type: string + x-enum-varnames: + - UsageTypeData + - UsageTypeVoice + - UsageTypeSMS + services.CreateSubscriberRequest: + properties: + email: + type: string + euicc_id: + type: string + first_name: + type: string + last_name: + type: string + msisdn: + type: string + organization_id: + type: string + plan_id: + type: integer + required: + - email + - first_name + - last_name + - msisdn + - plan_id + type: object + services.ListSubscribersResponse: + properties: + page: + type: integer + page_size: + type: integer + subscribers: + items: + $ref: '#/definitions/models.Subscriber' + type: array + total: + type: integer + type: object + services.UpdateSubscriberRequest: + properties: + email: + type: string + first_name: + type: string + last_name: + type: string + plan_id: + type: integer + status: + $ref: '#/definitions/models.SubscriberStatus' + type: object +info: + contact: {} +paths: + /api/v1/subscribers: + get: + description: List subscribers with pagination and filtering + parameters: + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Page size + in: query + name: page_size + type: integer + - description: Subscriber status + in: query + name: status + type: string + - description: Organization ID + in: query + name: organization_id + type: string + - description: Search term + in: query + name: search + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/services.ListSubscribersResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: List subscribers + tags: + - subscribers + post: + consumes: + - application/json + description: Create a new subscriber with allocated IMSI + parameters: + - description: Subscriber data + in: body + name: subscriber + required: true + schema: + $ref: '#/definitions/services.CreateSubscriberRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Subscriber' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Create a new subscriber + tags: + - subscribers + /api/v1/subscribers/{id}: + get: + description: Retrieve subscriber information by ID + parameters: + - description: Subscriber ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Subscriber' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get subscriber by ID + tags: + - subscribers + put: + consumes: + - application/json + description: Update subscriber information + parameters: + - description: Subscriber ID + in: path + name: id + required: true + type: integer + - description: Updated subscriber data + in: body + name: subscriber + required: true + schema: + $ref: '#/definitions/services.UpdateSubscriberRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Subscriber' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Update subscriber + tags: + - subscribers + /api/v1/subscribers/{id}/suspend: + post: + description: Suspend a subscriber and terminate their sessions + parameters: + - description: Subscriber ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Suspend subscriber + tags: + - subscribers + /api/v1/subscribers/{id}/terminate: + post: + description: Terminate a subscriber and deactivate their eSIM profile + parameters: + - description: Subscriber ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Terminate subscriber + tags: + - subscribers + /api/v1/subscribers/imsi/{imsi}: + get: + description: Retrieve subscriber information by IMSI + parameters: + - description: IMSI + in: path + name: imsi + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Subscriber' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handlers.ErrorResponse' + summary: Get subscriber by IMSI + tags: + - subscribers +swagger: "2.0" diff --git a/apps/api-server/go.mod b/apps/api-server/go.mod index ec322e9..1863bbc 100644 --- a/apps/api-server/go.mod +++ b/apps/api-server/go.mod @@ -9,11 +9,12 @@ require ( github.com/go-redis/redis/v8 v8.11.5 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 + github.com/microcosm-cc/bluemonday v1.0.27 github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - github.com/vektah/gqlparser/v2 v2.5.32 + github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca golang.org/x/crypto v0.50.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -26,20 +27,22 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/agnivade/levenshtein v1.2.1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/casbin/casbin/v3 v3.8.1 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -64,6 +67,10 @@ require ( github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -72,7 +79,6 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/serf v0.10.1 // indirect github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect github.com/lib/pq v1.12.3 // indirect @@ -90,7 +96,6 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sosodev/duration v1.4.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -124,7 +129,6 @@ require ( ) require ( - github.com/99designs/gqlgen v0.17.89 github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect diff --git a/apps/api-server/go.sum b/apps/api-server/go.sum index 4416b95..59d469b 100644 --- a/apps/api-server/go.sum +++ b/apps/api-server/go.sum @@ -1,7 +1,5 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8= -github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= @@ -29,31 +27,23 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= -github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= -github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -63,6 +53,9 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= +github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60= +github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -90,10 +83,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= -github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -111,16 +104,22 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak= +github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM= +github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= @@ -201,6 +200,7 @@ github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= @@ -217,7 +217,15 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= @@ -293,12 +301,14 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -317,8 +327,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= -github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -332,6 +340,7 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -341,6 +350,9 @@ github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJ github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo= github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= @@ -412,6 +424,7 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= @@ -427,16 +440,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= -github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE= -github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= @@ -471,10 +480,11 @@ github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4 github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= -github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE= +github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -517,6 +527,7 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -560,6 +571,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -655,8 +667,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/apps/api-server/internal/database/crud.go b/apps/api-server/internal/database/crud.go index e9f01ab..89388d2 100644 --- a/apps/api-server/internal/database/crud.go +++ b/apps/api-server/internal/database/crud.go @@ -2,6 +2,7 @@ package database import ( "context" + "fmt" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" "gorm.io/gorm" @@ -59,6 +60,52 @@ func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequ return subscribers, total, err } +// ListSubscribersCursor implements cursor-based pagination for subscribers +func (d *Database) ListSubscribersCursor(ctx context.Context, cursor string, limit int, status, organizationID, search string) ([]models.Subscriber, string, bool, error) { + var subscribers []models.Subscriber + + query := d.DB.WithContext(ctx).Model(&models.Subscriber{}) + + // Apply filters + if status != "" { + query = query.Where("status = ?", status) + } + if organizationID != "" { + query = query.Where("organization_id = ?", organizationID) + } + if search != "" { + query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?", + "%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%") + } + + // Apply cursor-based pagination (using ID as cursor) + if cursor != "" { + query = query.Where("id < ?", cursor) + } + + // Order by ID descending and apply limit + query = query.Order("id DESC").Limit(limit + 1) // +1 to check if there are more results + + err := query.Preload("Plan").Find(&subscribers).Error + if err != nil { + return nil, "", false, err + } + + // Determine if there are more results + hasMore := len(subscribers) > limit + if hasMore { + subscribers = subscribers[:limit] // Remove the extra item + } + + // Generate next cursor + var nextCursor string + if len(subscribers) > 0 { + nextCursor = fmt.Sprintf("%d", subscribers[len(subscribers)-1].ID) + } + + return subscribers, nextCursor, hasMore, nil +} + func (d *Database) GetActiveSessionsByIMSI(ctx context.Context, imsi models.IMSI) ([]models.Session, error) { var sessions []models.Session err := d.DB.WithContext(ctx).Where("subscriber_id = ? AND status = ?", imsi, "active").Find(&sessions).Error diff --git a/apps/api-server/internal/graphql/generated.go b/apps/api-server/internal/graphql/generated.go deleted file mode 100644 index 18ca3af..0000000 --- a/apps/api-server/internal/graphql/generated.go +++ /dev/null @@ -1,375 +0,0 @@ -package graphql - -// This file will be generated by gqlgen -// Run: go run github.com/99designs/gqlgen generate - -import ( - "context" - "time" - - "github.com/99designs/gqlgen/graphql" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" - "github.com/vektah/gqlparser/v2/ast" -) - -// GraphQL type definitions -type SubscriberStatus string -type ProfileStatus string -type UsageType string -type SessionStatus string -type PaymentMethodType string -type InvoiceStatus string -type AlertType string -type AlertSeverity string -type SubscriberSort string -type UsagePeriod string -type TrendDirection string - -const ( - SubscriberStatusActive SubscriberStatus = "ACTIVE" - SubscriberStatusInactive SubscriberStatus = "INACTIVE" - SubscriberStatusSuspended SubscriberStatus = "SUSPENDED" - SubscriberStatusTerminated SubscriberStatus = "TERMINATED" - SubscriberStatusProvisioning SubscriberStatus = "PROVISIONING" -) - -const ( - ProfileStatusActive ProfileStatus = "ACTIVE" - ProfileStatusInactive ProfileStatus = "INACTIVE" - ProfileStatusDownloading ProfileStatus = "DOWNLOADING" - ProfileStatusFailed ProfileStatus = "FAILED" -) - -const ( - UsageTypeData UsageType = "DATA" - UsageTypeVoice UsageType = "VOICE" - UsageTypeSMS UsageType = "SMS" -) - -const ( - SessionStatusActive SessionStatus = "ACTIVE" - SessionStatusCompleted SessionStatus = "COMPLETED" - SessionStatusTerminated SessionStatus = "TERMINATED" -) - -const ( - PaymentMethodTypeCreditCard PaymentMethodType = "CREDIT_CARD" - PaymentMethodTypeBankAccount PaymentMethodType = "BANK_ACCOUNT" -) - -const ( - InvoiceStatusDraft InvoiceStatus = "DRAFT" - InvoiceStatusPending InvoiceStatus = "PENDING" - InvoiceStatusPaid InvoiceStatus = "PAID" - InvoiceStatusOverdue InvoiceStatus = "OVERDUE" - InvoiceStatusCancelled InvoiceStatus = "CANCELLED" -) - -const ( - AlertTypeLowBalance AlertType = "LOW_BALANCE" - AlertTypeHighUsage AlertType = "HIGH_USAGE" - AlertTypePaymentFailed AlertType = "PAYMENT_FAILED" - AlertTypeSystemError AlertType = "SYSTEM_ERROR" -) - -const ( - AlertSeverityLow AlertSeverity = "LOW" - AlertSeverityMedium AlertSeverity = "MEDIUM" - AlertSeverityHigh AlertSeverity = "HIGH" - AlertSeverityCritical AlertSeverity = "CRITICAL" -) - -const ( - SubscriberSortCreatedAtAsc SubscriberSort = "CREATED_AT_ASC" - SubscriberSortCreatedAtDesc SubscriberSort = "CREATED_AT_DESC" - SubscriberSortUpdatedAtAsc SubscriberSort = "UPDATED_AT_ASC" - SubscriberSortUpdatedAtDesc SubscriberSort = "UPDATED_AT_DESC" - SubscriberSortFirstNameAsc SubscriberSort = "FIRST_NAME_ASC" - SubscriberSortFirstNameDesc SubscriberSort = "FIRST_NAME_DESC" - SubscriberSortLastNameAsc SubscriberSort = "LAST_NAME_ASC" - SubscriberSortLastNameDesc SubscriberSort = "LAST_NAME_DESC" - SubscriberSortEmailAsc SubscriberSort = "EMAIL_ASC" - SubscriberSortEmailDesc SubscriberSort = "EMAIL_DESC" - SubscriberSortBalanceAsc SubscriberSort = "BALANCE_ASC" - SubscriberSortBalanceDesc SubscriberSort = "BALANCE_DESC" - SubscriberSortDataUsedAsc SubscriberSort = "DATA_USED_ASC" - SubscriberSortDataUsedDesc SubscriberSort = "DATA_USED_DESC" -) - -const ( - UsagePeriodDaily UsagePeriod = "DAILY" - UsagePeriodWeekly UsagePeriod = "WEEKLY" - UsagePeriodMonthly UsagePeriod = "MONTHLY" - UsagePeriodYearly UsagePeriod = "YEARLY" -) - -const ( - TrendDirectionUp TrendDirection = "UP" - TrendDirectionDown TrendDirection = "DOWN" - TrendDirectionStable TrendDirection = "STABLE" -) - -// Input types -type SubscriberFilter struct { - Status *SubscriberStatus `json:"status"` - OrganizationID *string `json:"organizationId"` - PlanID *int `json:"planId"` - Search *string `json:"search"` - BalanceMin *float64 `json:"balanceMin"` - BalanceMax *float64 `json:"balanceMax"` - DataUsageMin *float64 `json:"dataUsageMin"` - DataUsageMax *float64 `json:"dataUsageMax"` -} - -type UsageEventFilter struct { - UsageType *UsageType `json:"usageType"` - TimestampGte *time.Time `json:"timestampGte"` - TimestampLte *time.Time `json:"timestampLte"` - CostMin *float64 `json:"costMin"` - CostMax *float64 `json:"costMax"` -} - -type CreateSubscriberInput struct { - MSISDN string `json:"msisdn"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - Email string `json:"email"` - OrganizationID *string `json:"organizationId"` - PlanID int `json:"planId"` - EUICCID *string `json:"euiccId"` -} - -type UpdateSubscriberInput struct { - FirstName *string `json:"firstName"` - LastName *string `json:"lastName"` - Email *string `json:"email"` - OrganizationID *string `json:"organizationId"` - PlanID *int `json:"planId"` -} - -type TopUpInput struct { - Amount float64 `json:"amount"` - PaymentMethodID *string `json:"paymentMethodId"` -} - -type UsageStatsInput struct { - IMSI string `json:"imsi"` - Period UsagePeriod `json:"period"` -} - -type PaymentMethodInput struct { - Type PaymentMethodType `json:"type"` - Token string `json:"token"` - IsDefault *bool `json:"isDefault"` -} - -type CreateAlertInput struct { - Type AlertType `json:"type"` - Severity AlertSeverity `json:"severity"` - Message string `json:"message"` - SubscriberID *int `json:"subscriberId"` -} - -// Output types -type PageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - StartCursor *string `json:"startCursor"` - EndCursor *string `json:"endCursor"` -} - -type SubscriberEdge struct { - Node *models.Subscriber `json:"node"` - Cursor string `json:"cursor"` -} - -type SubscriberConnection struct { - Edges []*SubscriberEdge `json:"edges"` - PageInfo *PageInfo `json:"pageInfo"` - TotalCount int `json:"totalCount"` -} - -type UsageEventEdge struct { - Node *models.UsageEvent `json:"node"` - Cursor string `json:"cursor"` -} - -type UsageEventConnection struct { - Edges []*UsageEventEdge `json:"edges"` - PageInfo *PageInfo `json:"pageInfo"` - TotalCount int `json:"totalCount"` -} - -type InvoiceEdge struct { - Node *models.Invoice `json:"node"` - Cursor string `json:"cursor"` -} - -type InvoiceConnection struct { - Edges []*InvoiceEdge `json:"edges"` - PageInfo *PageInfo `json:"pageInfo"` - TotalCount int `json:"totalCount"` -} - -type AlertEdge struct { - Node *models.Alert `json:"node"` - Cursor string `json:"cursor"` -} - -type AlertConnection struct { - Edges []*AlertEdge `json:"edges"` - PageInfo *PageInfo `json:"pageInfo"` - TotalCount int `json:"totalCount"` -} - -type ChargingSessionEdge struct { - Node *ChargingSession `json:"node"` - Cursor string `json:"cursor"` -} - -type ChargingSessionConnection struct { - Edges []*ChargingSessionEdge `json:"edges"` - PageInfo *PageInfo `json:"pageInfo"` - TotalCount int `json:"totalCount"` -} - -type UsageTrend struct { - Direction TrendDirection `json:"direction"` - Percentage float64 `json:"percentage"` - PeriodOverPeriod float64 `json:"periodOverPeriod"` -} - -type UsageStats struct { - DataUsage float64 `json:"dataUsage"` - VoiceUsage float64 `json:"voiceUsage"` - SmsUsage float64 `json:"smsUsage"` - Cost float64 `json:"cost"` - Period string `json:"period"` - Trend *UsageTrend `json:"trend"` -} - -type CurrentSession struct { - SessionID string `json:"sessionId"` - StartTime time.Time `json:"startTime"` - DataUsed float64 `json:"dataUsed"` - VoiceUsed float64 `json:"voiceUsed"` - SmsUsed float64 `json:"smsUsed"` - Cost float64 `json:"cost"` -} - -type TodayUsage struct { - DataUsed float64 `json:"dataUsed"` - VoiceUsed float64 `json:"voiceUsed"` - SmsUsed float64 `json:"smsUsed"` - Cost float64 `json:"cost"` -} - -type RealTimeUsage struct { - CurrentSession *CurrentSession `json:"currentSession"` - TodayUsage *TodayUsage `json:"todayUsage"` -} - -type ESIMProvisionResult struct { - ProfileID string `json:"profileId"` - ActivationCode string `json:"activationCode"` - QRCode string `json:"qrCode"` -} - -type ChargingSession struct { - SessionID string `json:"sessionId"` - IMSI string `json:"imsi"` - StartTime time.Time `json:"startTime"` - EndTime *time.Time `json:"endTime"` - DataBytes float64 `json:"dataBytes"` - VoiceSeconds float64 `json:"voiceSeconds"` - SmsCount float64 `json:"smsCount"` - TotalCost float64 `json:"totalCost"` - Status SessionStatus `json:"status"` -} - -// GraphQL configuration -type Config struct { - Resolvers ResolverRoot -} - -type ResolverRoot interface { - Query() QueryResolver - Mutation() MutationResolver - Subscription() SubscriptionResolver -} - -type QueryResolver interface { - Subscriber(ctx context.Context, id string) (*models.Subscriber, error) - SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error) - Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error) - SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) - UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error) - UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error) - RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) - Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error) - Invoice(ctx context.Context, id string) (*models.Invoice, error) - RatingPlans(ctx context.Context) ([]*models.RatingPlan, error) - RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) - ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error) - ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error) - Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error) - SystemStats(ctx context.Context) (*models.SystemStats, error) - HealthStatus(ctx context.Context) (*models.HealthStatus, error) - SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) - SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) -} - -type MutationResolver interface { - CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error) - UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error) - DeleteSubscriber(ctx context.Context, id string) (bool, error) - TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error) - SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error) - ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) - TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) - ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error) - ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) - DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) - AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error) - RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) - SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) - ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) - CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error) - TriggerSystemMaintenance(ctx context.Context) (bool, error) -} - -type SubscriptionResolver interface { - SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) - UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) - AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error) - SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) -} - -func NewExecutableSchema(cfg Config) graphql.ExecutableSchema { - return &executableSchema{ - resolvers: cfg.Resolvers, - } -} - -type executableSchema struct { - resolvers ResolverRoot -} - -func (e *executableSchema) Schema() *ast.Schema { - // This would be generated by gqlgen - return nil -} - -func (e *executableSchema) Complexity(ctx context.Context, typeName, fieldName string, childComplexity int, args map[string]any) (int, bool) { - // Default complexity calculation - return childComplexity, true -} - -func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { - // This would be generated by gqlgen - return nil -} - -func (e *executableSchema) Extensions() map[string]any { - return map[string]any{} -} diff --git a/apps/api-server/internal/graphql/resolvers.go b/apps/api-server/internal/graphql/resolvers.go deleted file mode 100644 index 46fc698..0000000 --- a/apps/api-server/internal/graphql/resolvers.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package graphql hosts the GraphQL resolver, split across multiple files: -// -// resolvers_subscriber.go - Subscriber queries -// resolvers_usage.go - Usage events & stats -// resolvers_billing.go - Invoices, rating plans, top-up -// resolvers_mutations.go - Subscriber CRUD mutations -// resolvers_esim.go - eSIM + payment-method mutations -// resolvers_alerts.go - Alerts, system stats, maintenance -// resolvers_subscriptions.go - Streaming subscriptions + charging -// -// This file holds only the root Resolver struct, constructor, helpers, and -// the HTTP handler wiring. -package graphql - -import ( - "context" - "encoding/base64" - "fmt" - "strings" - "time" - - "github.com/99designs/gqlgen/graphql/handler" - "github.com/99designs/gqlgen/graphql/playground" - "github.com/gin-gonic/gin" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" -) - -// ensure used imports remain referenced when helpers below are the only consumers -var _ = context.Background -var _ = base64.StdEncoding - -// Resolver is the root GraphQL resolver. -type Resolver struct { - db *database.Database - subscriber *services.SubscriberService - charging *services.ChargingService - es2Service *services.ES2Service -} - -// NewResolver creates a new GraphQL resolver with wired-up services. -func NewResolver(db *database.Database, cfg *config.Config) *Resolver { - return &Resolver{ - db: db, - subscriber: services.NewSubscriberService(db, cfg), - charging: services.NewChargingService(db, cfg), - es2Service: services.NewES2Service(&cfg.ES2), - } -} - -// parseID decodes a GraphQL "Subscriber:" opaque ID to a uint. -func parseID(id string) (uint, error) { - var n int - _, err := fmt.Sscanf(id, "Subscriber:%d", &n) - if err != nil { - return 0, fmt.Errorf("invalid subscriber ID format") - } - return uint(n), nil -} - -// parseCursorOffset reads the numeric offset from a decoded cursor string. -func parseCursorOffset(cursor string) int { - var offset int - fmt.Sscanf(cursor, "%d", &offset) - return offset -} - -// strPtr returns a pointer to the given string. -func strPtr(s string) *string { return new(s) } - -// buildConnectionPageInfo builds a Relay-style PageInfo for a page of size count. -func buildConnectionPageInfo(offset, count int, total int64) *PageInfo { - var startCursor, endCursor *string - if count > 0 { - startCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+1))) - endCursor = strPtr(base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+count))) - } - return &PageInfo{ - HasNextPage: int64(offset+count) < total, - HasPreviousPage: offset > 0, - StartCursor: startCursor, - EndCursor: endCursor, - } -} - -// buildUsageEventFilter builds a SQL WHERE clause from optional filters. -func buildUsageEventFilter(imsi *string, filter *UsageEventFilter) string { - conditions := []string{} - if imsi != nil { - conditions = append(conditions, fmt.Sprintf("imsi = '%s'", *imsi)) - } - if filter != nil { - if filter.UsageType != nil { - conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType)) - } - if filter.TimestampGte != nil { - conditions = append(conditions, fmt.Sprintf("timestamp >= '%s'", filter.TimestampGte.Format(time.RFC3339))) - } - if filter.TimestampLte != nil { - conditions = append(conditions, fmt.Sprintf("timestamp <= '%s'", filter.TimestampLte.Format(time.RFC3339))) - } - if filter.CostMin != nil { - conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin)) - } - if filter.CostMax != nil { - conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax)) - } - } - if len(conditions) > 0 { - return "WHERE " + strings.Join(conditions, " AND ") - } - return "" -} - -// Query returns the QueryResolver (itself). -func (r *Resolver) Query() QueryResolver { return r } - -// Mutation returns the MutationResolver (itself). -func (r *Resolver) Mutation() MutationResolver { return r } - -// Subscription returns the SubscriptionResolver (itself). -func (r *Resolver) Subscription() SubscriptionResolver { return r } - -// SetupGraphQLHandler wires the GraphQL HTTP endpoint and playground. -func SetupGraphQLHandler(router *gin.Engine, resolver *Resolver) { - graphqlHandler := handler.NewDefaultServer( - NewExecutableSchema(Config{Resolvers: resolver}), - ) - router.POST("/graphql", gin.WrapH(graphqlHandler)) - if gin.Mode() == gin.DebugMode { - router.GET("/graphql", gin.WrapH(playground.Handler("GraphQL Playground", "/graphql"))) - } -} diff --git a/apps/api-server/internal/graphql/resolvers_alerts.go b/apps/api-server/internal/graphql/resolvers_alerts.go deleted file mode 100644 index 37dd359..0000000 --- a/apps/api-server/internal/graphql/resolvers_alerts.go +++ /dev/null @@ -1,74 +0,0 @@ -package graphql - -import ( - "context" - "encoding/base64" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// Alerts resolves a paginated connection of alerts. -func (r *Resolver) Alerts(ctx context.Context, first *int, after *string, subscriberId *int, severity *models.AlertSeverity, resolved *bool) (*AlertConnection, error) { - limit := 50 - if first != nil { - limit = *first - } - offset := 0 - if after != nil { - cursor, err := base64.StdEncoding.DecodeString(*after) - if err != nil { - return nil, fmt.Errorf("invalid cursor") - } - offset = parseCursorOffset(string(cursor)) - } - - alerts, total, err := r.subscriber.ListAlerts(ctx, limit, offset, subscriberId, severity, resolved) - if err != nil { - return nil, err - } - - edges := make([]*AlertEdge, len(alerts)) - for i, alert := range alerts { - edges[i] = &AlertEdge{ - Node: alert, - Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)), - } - } - - return &AlertConnection{ - Edges: edges, - PageInfo: buildConnectionPageInfo(offset, len(alerts), total), - TotalCount: int(total), - }, nil -} - -// SystemStats resolves system-wide stats. -func (r *Resolver) SystemStats(ctx context.Context) (*models.SystemStats, error) { - return r.charging.GetSystemStats(ctx) -} - -// HealthStatus resolves the platform health status. -func (r *Resolver) HealthStatus(ctx context.Context) (*models.HealthStatus, error) { - return r.charging.GetHealthStatus(ctx) -} - -// ResolveAlert marks an alert as resolved. -func (r *Resolver) ResolveAlert(ctx context.Context, alertId string) (*models.Alert, error) { - return r.subscriber.ResolveAlert(ctx, alertId) -} - -// CreateAlert creates a new alert from GraphQL input. -func (r *Resolver) CreateAlert(ctx context.Context, input CreateAlertInput) (*models.Alert, error) { - return r.subscriber.CreateAlert(ctx, &models.CreateAlertRequest{ - Type: models.AlertType(input.Type), - Severity: models.AlertSeverity(input.Severity), - Message: input.Message, - SubscriberID: input.SubscriberID, - }) -} - -// TriggerSystemMaintenance triggers a maintenance cycle. -func (r *Resolver) TriggerSystemMaintenance(ctx context.Context) (bool, error) { - return r.charging.TriggerMaintenance(ctx) -} diff --git a/apps/api-server/internal/graphql/resolvers_billing.go b/apps/api-server/internal/graphql/resolvers_billing.go deleted file mode 100644 index bae289f..0000000 --- a/apps/api-server/internal/graphql/resolvers_billing.go +++ /dev/null @@ -1,67 +0,0 @@ -package graphql - -import ( - "context" - "encoding/base64" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// Invoices resolves a paginated connection of invoices. -func (r *Resolver) Invoices(ctx context.Context, first *int, after *string, imsi *string, status *models.InvoiceStatus) (*InvoiceConnection, error) { - limit := 20 - if first != nil { - limit = *first - } - offset := 0 - if after != nil { - cursor, err := base64.StdEncoding.DecodeString(*after) - if err != nil { - return nil, fmt.Errorf("invalid cursor") - } - offset = parseCursorOffset(string(cursor)) - } - - invoices, total, err := r.subscriber.ListInvoices(ctx, limit, offset, imsi, status) - if err != nil { - return nil, err - } - - edges := make([]*InvoiceEdge, len(invoices)) - for i, inv := range invoices { - edges[i] = &InvoiceEdge{ - Node: inv, - Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)), - } - } - - return &InvoiceConnection{ - Edges: edges, - PageInfo: buildConnectionPageInfo(offset, len(invoices), total), - TotalCount: int(total), - }, nil -} - -// Invoice resolves a single invoice by ID. -func (r *Resolver) Invoice(ctx context.Context, id string) (*models.Invoice, error) { - return r.subscriber.GetInvoice(ctx, id) -} - -// RatingPlans resolves all available rating plans. -func (r *Resolver) RatingPlans(ctx context.Context) ([]*models.RatingPlan, error) { - return r.subscriber.ListRatingPlans(ctx) -} - -// RatingPlan resolves a single rating plan by plan_id. -func (r *Resolver) RatingPlan(ctx context.Context, planId string) (*models.RatingPlan, error) { - return r.subscriber.GetRatingPlan(ctx, planId) -} - -// TopUpBalance tops up a subscriber balance (mutation). -func (r *Resolver) TopUpBalance(ctx context.Context, imsi string, input TopUpInput) (*models.SubscriberAccount, error) { - return r.subscriber.TopUpBalance(ctx, imsi, &models.TopUpRequest{ - Amount: input.Amount, - PaymentMethodID: input.PaymentMethodID, - }) -} diff --git a/apps/api-server/internal/graphql/resolvers_esim.go b/apps/api-server/internal/graphql/resolvers_esim.go deleted file mode 100644 index 3e6e7a0..0000000 --- a/apps/api-server/internal/graphql/resolvers_esim.go +++ /dev/null @@ -1,59 +0,0 @@ -package graphql - -import ( - "context" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// ProvisionESIM provisions an eSIM profile for the given IMSI. -func (r *Resolver) ProvisionESIM(ctx context.Context, imsi string) (*ESIMProvisionResult, error) { - sub, err := r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi)) - if err != nil { - return nil, fmt.Errorf("subscriber not found for IMSI %s: %w", imsi, err) - } - result, err := r.es2Service.ProvisionProfile(ctx, sub) - if err != nil { - return nil, err - } - return &ESIMProvisionResult{ - ProfileID: result.ProfileID, - ActivationCode: result.Activation.ActivationCode, - }, nil -} - -// ActivateESIM activates the given eSIM profile. -func (r *Resolver) ActivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) { - err := r.es2Service.ActivateProfile(ctx, imsi, profileId) - return err == nil, err -} - -// DeactivateESIM deactivates the given eSIM profile. -func (r *Resolver) DeactivateESIM(ctx context.Context, imsi string, profileId string) (bool, error) { - err := r.es2Service.DeactivateProfile(ctx, imsi, profileId) - return err == nil, err -} - -// AddPaymentMethod adds a payment method for the given subscriber. -func (r *Resolver) AddPaymentMethod(ctx context.Context, subscriberId int, input PaymentMethodInput) (*models.PaymentMethod, error) { - isDefault := false - if input.IsDefault != nil { - isDefault = *input.IsDefault - } - return r.subscriber.AddPaymentMethod(ctx, subscriberId, &models.AddPaymentMethodRequest{ - Type: models.PaymentMethodType(input.Type), - Token: input.Token, - IsDefault: isDefault, - }) -} - -// RemovePaymentMethod removes a payment method. -func (r *Resolver) RemovePaymentMethod(ctx context.Context, paymentMethodId string) (bool, error) { - return r.subscriber.RemovePaymentMethod(ctx, paymentMethodId) -} - -// SetDefaultPaymentMethod marks a payment method as default. -func (r *Resolver) SetDefaultPaymentMethod(ctx context.Context, paymentMethodId string) (*models.PaymentMethod, error) { - return r.subscriber.SetDefaultPaymentMethod(ctx, paymentMethodId) -} diff --git a/apps/api-server/internal/graphql/resolvers_mutations.go b/apps/api-server/internal/graphql/resolvers_mutations.go deleted file mode 100644 index f098096..0000000 --- a/apps/api-server/internal/graphql/resolvers_mutations.go +++ /dev/null @@ -1,87 +0,0 @@ -package graphql - -import ( - "context" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" -) - -// CreateSubscriber creates a new subscriber from GraphQL input. -func (r *Resolver) CreateSubscriber(ctx context.Context, input CreateSubscriberInput) (*models.Subscriber, error) { - req := &services.CreateSubscriberRequest{ - MSISDN: input.MSISDN, - FirstName: input.FirstName, - LastName: input.LastName, - Email: input.Email, - PlanID: uint(input.PlanID), - } - if input.OrganizationID != nil { - req.OrganizationID = *input.OrganizationID - } - if input.EUICCID != nil { - req.EUICCID = *input.EUICCID - } - return r.subscriber.CreateSubscriber(ctx, req) -} - -// UpdateSubscriber updates subscriber fields. -func (r *Resolver) UpdateSubscriber(ctx context.Context, id string, input UpdateSubscriberInput) (*models.Subscriber, error) { - sid, err := parseID(id) - if err != nil { - return nil, err - } - var planID *uint - if input.PlanID != nil { - p := uint(*input.PlanID) - planID = &p - } - return r.subscriber.UpdateSubscriber(ctx, sid, &services.UpdateSubscriberRequest{ - FirstName: input.FirstName, - LastName: input.LastName, - Email: input.Email, - PlanID: planID, - }) -} - -// DeleteSubscriber deletes a subscriber by GraphQL ID. -func (r *Resolver) DeleteSubscriber(ctx context.Context, id string) (bool, error) { - sid, err := parseID(id) - if err != nil { - return false, err - } - return r.subscriber.DeleteSubscriber(ctx, sid) -} - -// SuspendSubscriber suspends and returns the refreshed subscriber. -func (r *Resolver) SuspendSubscriber(ctx context.Context, id string) (*models.Subscriber, error) { - sid, err := parseID(id) - if err != nil { - return nil, err - } - if err := r.subscriber.SuspendSubscriber(ctx, sid); err != nil { - return nil, err - } - return r.subscriber.GetSubscriber(ctx, sid) -} - -// ActivateSubscriber activates and returns the subscriber. -func (r *Resolver) ActivateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) { - sid, err := parseID(id) - if err != nil { - return nil, err - } - return r.subscriber.ActivateSubscriber(ctx, sid) -} - -// TerminateSubscriber terminates and returns the refreshed subscriber. -func (r *Resolver) TerminateSubscriber(ctx context.Context, id string) (*models.Subscriber, error) { - sid, err := parseID(id) - if err != nil { - return nil, err - } - if err := r.subscriber.TerminateSubscriber(ctx, sid); err != nil { - return nil, err - } - return r.subscriber.GetSubscriber(ctx, sid) -} diff --git a/apps/api-server/internal/graphql/resolvers_subscriber.go b/apps/api-server/internal/graphql/resolvers_subscriber.go deleted file mode 100644 index 3e85a36..0000000 --- a/apps/api-server/internal/graphql/resolvers_subscriber.go +++ /dev/null @@ -1,73 +0,0 @@ -package graphql - -import ( - "context" - "encoding/base64" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" -) - -// Subscriber resolves a single subscriber by GraphQL ID. -func (r *Resolver) Subscriber(ctx context.Context, id string) (*models.Subscriber, error) { - sid, err := parseID(id) - if err != nil { - return nil, err - } - return r.subscriber.GetSubscriber(ctx, sid) -} - -// SubscriberByImsi resolves a subscriber by IMSI. -func (r *Resolver) SubscriberByImsi(ctx context.Context, imsi string) (*models.Subscriber, error) { - return r.subscriber.GetSubscriberByIMSI(ctx, models.IMSI(imsi)) -} - -// Subscribers resolves a paginated connection of subscribers. -func (r *Resolver) Subscribers(ctx context.Context, first *int, after *string, filter *SubscriberFilter, sort *SubscriberSort) (*SubscriberConnection, error) { - limit := 20 - if first != nil { - limit = *first - } - offset := 0 - if after != nil { - cursor, err := base64.StdEncoding.DecodeString(*after) - if err != nil { - return nil, fmt.Errorf("invalid cursor") - } - offset = parseCursorOffset(string(cursor)) - } - - resp, err := r.subscriber.ListSubscribers(ctx, &services.ListSubscribersRequest{ - Page: offset/limit + 1, - PageSize: limit, - }) - if err != nil { - return nil, err - } - - edges := make([]*SubscriberEdge, len(resp.Subscribers)) - for i, sub := range resp.Subscribers { - s := sub - edges[i] = &SubscriberEdge{ - Node: &s, - Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)), - } - } - - return &SubscriberConnection{ - Edges: edges, - PageInfo: buildConnectionPageInfo(offset, len(resp.Subscribers), resp.Total), - TotalCount: int(resp.Total), - }, nil -} - -// SubscriberAccount resolves a subscriber's account by IMSI. -func (r *Resolver) SubscriberAccount(ctx context.Context, imsi string) (*models.SubscriberAccount, error) { - return r.subscriber.GetAccount(ctx, imsi) -} - -// SearchSubscribers performs a subscriber search, capped at the given limit. -func (r *Resolver) SearchSubscribers(ctx context.Context, query string, limit int) ([]*models.Subscriber, error) { - return r.subscriber.SearchSubscribers(ctx, query, limit) -} diff --git a/apps/api-server/internal/graphql/resolvers_subscriptions.go b/apps/api-server/internal/graphql/resolvers_subscriptions.go deleted file mode 100644 index cada9f2..0000000 --- a/apps/api-server/internal/graphql/resolvers_subscriptions.go +++ /dev/null @@ -1,45 +0,0 @@ -package graphql - -import ( - "context" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// SubscriberUpdates streams subscriber updates via a channel. -func (r *Resolver) SubscriberUpdates(ctx context.Context, subscriberId string) (<-chan *models.Subscriber, error) { - return r.subscriber.SubscribeToSubscriberUpdates(ctx, subscriberId) -} - -// UsageUpdates streams usage-event updates for an IMSI. -func (r *Resolver) UsageUpdates(ctx context.Context, imsi string) (<-chan *models.UsageEvent, error) { - return r.charging.SubscribeToUsageUpdates(ctx, imsi) -} - -// AlertUpdates streams alert updates filtered by severity. -func (r *Resolver) AlertUpdates(ctx context.Context, severity models.AlertSeverity) (<-chan *models.Alert, error) { - severityStr := string(severity) - return r.subscriber.SubscribeToAlertUpdates(ctx, &severityStr) -} - -// SystemStatsUpdates streams system stats updates. -func (r *Resolver) SystemStatsUpdates(ctx context.Context) (<-chan *models.SystemStats, error) { - return r.charging.SubscribeToSystemStatsUpdates(ctx) -} - -// ChargingSessions resolves a paginated connection of charging sessions. -// Placeholder implementation until the charging query surface is finalized. -func (r *Resolver) ChargingSessions(ctx context.Context, first *int, after *string, imsi *string, status *SessionStatus) (*ChargingSessionConnection, error) { - return &ChargingSessionConnection{ - Edges: []*ChargingSessionEdge{}, - PageInfo: &PageInfo{}, - TotalCount: 0, - }, nil -} - -// ChargingSession resolves a single charging session by ID. -// Placeholder implementation until the charging query surface is finalized. -func (r *Resolver) ChargingSession(ctx context.Context, sessionId string) (*ChargingSession, error) { - return nil, fmt.Errorf("charging session %s not found", sessionId) -} diff --git a/apps/api-server/internal/graphql/resolvers_test.go b/apps/api-server/internal/graphql/resolvers_test.go deleted file mode 100644 index 5bacf5b..0000000 --- a/apps/api-server/internal/graphql/resolvers_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package graphql - -import ( - "encoding/base64" - "fmt" - "strconv" - "strings" - "testing" -) - -func TestParseID_Valid(t *testing.T) { - id, err := parseID("Subscriber:42") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != 42 { - t.Errorf("expected 42, got %d", id) - } -} - -func TestParseID_Invalid(t *testing.T) { - _, err := parseID("invalid") - if err == nil { - t.Fatal("expected error for invalid ID format") - } -} - -func TestParseID_Zero(t *testing.T) { - id, err := parseID("Subscriber:0") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if id != 0 { - t.Errorf("expected 0, got %d", id) - } -} - -func TestEncodeCursor(t *testing.T) { - cursor := encodeCursor(42) - decoded, err := base64.StdEncoding.DecodeString(cursor) - if err != nil { - t.Fatalf("unexpected error decoding cursor: %v", err) - } - if string(decoded) != "cursor:42" { - t.Errorf("expected 'cursor:42', got '%s'", string(decoded)) - } -} - -func TestDecodeCursor_Valid(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString([]byte("cursor:100")) - offset, err := decodeCursor(encoded) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if offset != 100 { - t.Errorf("expected 100, got %d", offset) - } -} - -func TestDecodeCursor_Invalid(t *testing.T) { - _, err := decodeCursor("not-base64!!!") - if err == nil { - t.Fatal("expected error for invalid cursor") - } -} - -func TestDecodeCursor_BadFormat(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString([]byte("bad:format")) - _, err := decodeCursor(encoded) - if err == nil { - t.Fatal("expected error for bad cursor format") - } -} - -func TestBuildUsageFilterWhere_Empty(t *testing.T) { - result := buildUsageFilterWhere(nil) - if result != "" { - t.Errorf("expected empty string for nil filter, got '%s'", result) - } -} - -func TestBuildUsageFilterWhere_WithType(t *testing.T) { - usageType := UsageTypeData - filter := &UsageEventFilter{UsageType: &usageType} - result := buildUsageFilterWhere(filter) - if result == "" { - t.Fatal("expected non-empty WHERE clause") - } - expected := "WHERE usage_type = 'DATA'" - if result != expected { - t.Errorf("expected '%s', got '%s'", expected, result) - } -} - -func TestBuildUsageFilterWhere_WithCostRange(t *testing.T) { - min := 1.0 - max := 10.0 - filter := &UsageEventFilter{CostMin: &min, CostMax: &max} - result := buildUsageFilterWhere(filter) - if result == "" { - t.Fatal("expected non-empty WHERE clause") - } -} - -func TestResolverRoot_Methods(t *testing.T) { - r := &Resolver{} - if r.Query() != r { - t.Error("Query() should return receiver") - } - if r.Mutation() != r { - t.Error("Mutation() should return receiver") - } - if r.Subscription() != r { - t.Error("Subscription() should return receiver") - } -} - -// Helper functions for testing (these should match the implementations in resolvers.go) - -func encodeCursor(offset int) string { - return base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "cursor:%d", offset)) -} - -func decodeCursor(cursor string) (int, error) { - decoded, err := base64.StdEncoding.DecodeString(cursor) - if err != nil { - return 0, fmt.Errorf("invalid cursor encoding: %w", err) - } - - parts := strings.Split(string(decoded), ":") - if len(parts) != 2 || parts[0] != "cursor" { - return 0, fmt.Errorf("invalid cursor format") - } - - return strconv.Atoi(parts[1]) -} - -func buildUsageFilterWhere(filter *UsageEventFilter) string { - if filter == nil { - return "" - } - - var conditions []string - - if filter.UsageType != nil { - conditions = append(conditions, fmt.Sprintf("usage_type = '%s'", *filter.UsageType)) - } - - if filter.CostMin != nil { - conditions = append(conditions, fmt.Sprintf("cost >= %f", *filter.CostMin)) - } - - if filter.CostMax != nil { - conditions = append(conditions, fmt.Sprintf("cost <= %f", *filter.CostMax)) - } - - if len(conditions) > 0 { - return "WHERE " + strings.Join(conditions, " AND ") - } - - return "" -} diff --git a/apps/api-server/internal/graphql/resolvers_usage.go b/apps/api-server/internal/graphql/resolvers_usage.go deleted file mode 100644 index 9095fb3..0000000 --- a/apps/api-server/internal/graphql/resolvers_usage.go +++ /dev/null @@ -1,100 +0,0 @@ -package graphql - -import ( - "context" - "encoding/base64" - "fmt" - - "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" -) - -// UsageEvents resolves a paginated connection of usage events. -func (r *Resolver) UsageEvents(ctx context.Context, first *int, after *string, imsi *string, filter *UsageEventFilter) (*UsageEventConnection, error) { - limit := 50 - if first != nil { - limit = *first - } - offset := 0 - if after != nil { - cursor, err := base64.StdEncoding.DecodeString(*after) - if err != nil { - return nil, fmt.Errorf("invalid cursor") - } - offset = parseCursorOffset(string(cursor)) - } - - where := buildUsageEventFilter(imsi, filter) - events, total, err := r.charging.ListUsageEvents(ctx, limit, offset, where) - if err != nil { - return nil, err - } - - edges := make([]*UsageEventEdge, len(events)) - for i, event := range events { - edges[i] = &UsageEventEdge{ - Node: event, - Cursor: base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%d", offset+i+1)), - } - } - - return &UsageEventConnection{ - Edges: edges, - PageInfo: buildConnectionPageInfo(offset, len(events), total), - TotalCount: int(total), - }, nil -} - -// UsageStats resolves aggregate usage stats for a subscriber and period. -func (r *Resolver) UsageStats(ctx context.Context, input UsageStatsInput) (*UsageStats, error) { - stats, err := r.charging.GetUsageStats(ctx, input.IMSI, string(input.Period)) - if err != nil { - return nil, err - } - return &UsageStats{ - DataUsage: stats.DataUsage, - VoiceUsage: stats.VoiceUsage, - SmsUsage: stats.SmsUsage, - Cost: stats.Cost, - Period: stats.Period, - Trend: &UsageTrend{ - Direction: TrendDirection(stats.Trend.Direction), - Percentage: stats.Trend.Percentage, - PeriodOverPeriod: stats.Trend.PeriodOverPeriod, - }, - }, nil -} - -// RealTimeUsage resolves real-time usage for a subscriber by IMSI. -func (r *Resolver) RealTimeUsage(ctx context.Context, imsi string) (*RealTimeUsage, error) { - usage, err := r.charging.GetRealTimeUsage(ctx, imsi) - if err != nil { - return nil, err - } - - var cs *CurrentSession - if usage.CurrentSession != nil { - cs = &CurrentSession{ - SessionID: usage.CurrentSession.SessionID, - StartTime: usage.CurrentSession.StartTime, - DataUsed: usage.CurrentSession.DataUsed, - VoiceUsed: usage.CurrentSession.VoiceUsed, - SmsUsed: usage.CurrentSession.SmsUsed, - Cost: usage.CurrentSession.Cost, - } - } - - return &RealTimeUsage{ - CurrentSession: cs, - TodayUsage: &TodayUsage{ - DataUsed: usage.TodayUsage.DataUsed, - VoiceUsed: usage.TodayUsage.VoiceUsed, - SmsUsed: usage.TodayUsage.SmsUsed, - Cost: usage.TodayUsage.Cost, - }, - }, nil -} - -// SearchUsageEvents searches usage events, capped at limit. -func (r *Resolver) SearchUsageEvents(ctx context.Context, query string, limit int) ([]*models.UsageEvent, error) { - return r.charging.SearchUsageEvents(ctx, query, limit) -} diff --git a/apps/api-server/internal/graphql/schema.graphql b/apps/api-server/internal/graphql/schema.graphql deleted file mode 100644 index 9ab3da3..0000000 --- a/apps/api-server/internal/graphql/schema.graphql +++ /dev/null @@ -1,483 +0,0 @@ -scalar DateTime -scalar JSON - -enum SubscriberStatus { - ACTIVE - INACTIVE - SUSPENDED - TERMINATED - PROVISIONING -} - -enum ProfileStatus { - ACTIVE - INACTIVE - DOWNLOADING - FAILED -} - -enum UsageType { - DATA - VOICE - SMS -} - -enum SessionStatus { - ACTIVE - COMPLETED - TERMINATED -} - -enum PaymentMethodType { - CREDIT_CARD - BANK_ACCOUNT -} - -enum InvoiceStatus { - DRAFT - PENDING - PAID - OVERDUE - CANCELLED -} - -enum AlertType { - LOW_BALANCE - HIGH_USAGE - PAYMENT_FAILED - SYSTEM_ERROR -} - -enum AlertSeverity { - LOW - MEDIUM - HIGH - CRITICAL -} - -type Subscriber { - id: ID! - imsi: String! - msisdn: String! - firstName: String! - lastName: String! - email: String! - organizationId: String - planId: Int! - euiccId: String - profileId: String - profileStatus: ProfileStatus! - status: SubscriberStatus! - balance: Float! - dataLimit: Float! - dataUsed: Float! - voiceLimit: Float! - voiceUsed: Float! - smsLimit: Float! - smsUsed: Float! - createdAt: DateTime! - updatedAt: DateTime! - - account: SubscriberAccount! - usageEvents(first: Int, after: String, filter: UsageEventFilter): UsageEventConnection! - invoices(first: Int, after: String, status: InvoiceStatus): InvoiceConnection! - alerts(first: Int, after: String, severity: AlertSeverity): AlertConnection! - ratingPlan: RatingPlan! -} - -type SubscriberAccount { - imsi: String! - balance: Float! - dataLimit: Float! - dataUsed: Float! - voiceLimit: Float! - voiceUsed: Float! - smsLimit: Float! - smsUsed: Float! - status: SubscriberStatus! - lastUpdated: DateTime! -} - -type UsageEvent { - id: ID! - imsi: String! - sessionId: String! - usageType: UsageType! - volume: Float! - timestamp: DateTime! - rate: Float! - cost: Float! - - subscriber: Subscriber! -} - -type RatingPlan { - planId: String! - name: String! - dataRate: Float! - voiceRate: Float! - smsRate: Float! - monthlyFee: Float! - dataLimit: Float! - voiceLimit: Float! - smsLimit: Float! - - subscribers(first: Int, after: String, status: SubscriberStatus): SubscriberConnection! -} - -type ChargingSession { - sessionId: String! - imsi: String! - startTime: DateTime! - endTime: DateTime - dataBytes: Float! - voiceSeconds: Float! - smsCount: Float! - totalCost: Float! - status: SessionStatus! - - subscriber: Subscriber! - usageEvents(first: Int, after: String): UsageEventConnection! -} - -type PaymentMethod { - id: String! - type: PaymentMethodType! - last4: String! - expiryMonth: Int! - expiryYear: Int! - brand: String! - isDefault: Boolean! -} - -type Invoice { - id: String! - subscriberId: Int! - amount: Float! - currency: String! - status: InvoiceStatus! - dueDate: DateTime! - createdAt: DateTime! - lineItems: [InvoiceLineItem!]! - - subscriber: Subscriber! -} - -type InvoiceLineItem { - description: String! - quantity: Int! - unitPrice: Float! - amount: Float! -} - -type Alert { - id: String! - type: AlertType! - severity: AlertSeverity! - message: String! - subscriberId: Int - timestamp: DateTime! - resolved: Boolean! - - subscriber: Subscriber -} - -type SystemStats { - activeSessions: Int! - totalAccounts: Int! - blockedUsers: Int! - lowBalanceAlerts: Int! - uptime: Float! -} - -type HealthStatus { - redisConnected: Boolean! - activeSync: Boolean! - lastSync: DateTime! - memoryUsage: Float! -} - -type SubscriberConnection { - edges: [SubscriberEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type SubscriberEdge { - node: Subscriber! - cursor: String! -} - -type UsageEventConnection { - edges: [UsageEventEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type UsageEventEdge { - node: UsageEvent! - cursor: String! -} - -type InvoiceConnection { - edges: [InvoiceEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type InvoiceEdge { - node: Invoice! - cursor: String! -} - -type AlertConnection { - edges: [AlertEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type AlertEdge { - node: Alert! - cursor: String! -} - -type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String -} - -type SubscriberFilter { - status: SubscriberStatus - organizationId: String - planId: Int - search: String - balanceMin: Float - balanceMax: Float - dataUsageMin: Float - dataUsageMax: Float -} - -type UsageEventFilter { - usageType: UsageType - timestampGte: DateTime - timestampLte: DateTime - costMin: Float - costMax: Float -} - -type CreateSubscriberInput { - msisdn: String! - firstName: String! - lastName: String! - email: String! - organizationId: String - planId: Int! - euiccId: String -} - -type UpdateSubscriberInput { - firstName: String - lastName: String - email: String - organizationId: String - planId: Int -} - -type TopUpInput { - amount: Float! - paymentMethodId: String -} - -type UsageStatsInput { - imsi: String! - period: UsagePeriod! -} - -enum UsagePeriod { - DAILY - WEEKLY - MONTHLY - YEARLY -} - -type Query { - subscriber(id: ID!): Subscriber - subscriberByImsi(imsi: String!): Subscriber - subscribers( - first: Int - after: String - filter: SubscriberFilter - sort: SubscriberSort - ): SubscriberConnection! - - subscriberAccount(imsi: String!): SubscriberAccount - - usageEvents( - first: Int - after: String - imsi: String - filter: UsageEventFilter - ): UsageEventConnection! - usageStats(input: UsageStatsInput!): UsageStats! - realtimeUsage(imsi: String!): RealTimeUsage! - - invoices( - first: Int - after: String - imsi: String - status: InvoiceStatus - ): InvoiceConnection! - invoice(id: String!): Invoice - - ratingPlans: [RatingPlan!]! - ratingPlan(planId: String!): RatingPlan - - chargingSessions( - first: Int - after: String - imsi: String - status: SessionStatus - ): ChargingSessionConnection! - chargingSession(sessionId: String!): ChargingSession - - alerts( - first: Int - after: String - subscriberId: Int - severity: AlertSeverity - resolved: Boolean - ): AlertConnection! - - systemStats: SystemStats! - healthStatus: HealthStatus! - - searchSubscribers(query: String!, limit: Int): [Subscriber!]! - searchUsageEvents(query: String!, limit: Int): [UsageEvent!]! -} - -type Mutation { - createSubscriber(input: CreateSubscriberInput!): Subscriber! - updateSubscriber(id: ID!, input: UpdateSubscriberInput!): Subscriber! - deleteSubscriber(id: ID!): Boolean! - - topUpBalance(imsi: String!, input: TopUpInput!): SubscriberAccount! - - suspendSubscriber(id: ID!): Subscriber! - activateSubscriber(id: ID!): Subscriber! - terminateSubscriber(id: ID!): Subscriber! - - provisionESIM(imsi: String!): ESIMProvisionResult! - activateESIM(imsi: String!, profileId: String!): Boolean! - deactivateESIM(imsi: String!, profileId: String!): Boolean! - - addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod! - removePaymentMethod(paymentMethodId: String!): Boolean! - setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod! - addPaymentMethod(subscriberId: Int!, input: PaymentMethodInput!): PaymentMethod! - removePaymentMethod(paymentMethodId: String!): Boolean! - setDefaultPaymentMethod(paymentMethodId: String!): PaymentMethod! - - resolveAlert(alertId: String!): Alert! - createAlert(input: CreateAlertInput!): Alert! - - triggerSystemMaintenance: Boolean! -} - -type Subscription { - # Real-time subscriptions - subscriberUpdates(subscriberId: ID!): Subscriber! - usageUpdates(imsi: String!): UsageEvent! - alertUpdates(severity: AlertSeverity): Alert! - systemStatsUpdates: SystemStats! -} - -type UsageStats { - dataUsage: Float! - voiceUsage: Float! - smsUsage: Float! - cost: Float! - period: String! - trend: UsageTrend! -} - -type UsageTrend { - direction: TrendDirection! - percentage: Float! - periodOverPeriod: Float! -} - -enum TrendDirection { - UP - DOWN - STABLE -} - -type RealTimeUsage { - currentSession: CurrentSession - todayUsage: TodayUsage! -} - -type CurrentSession { - sessionId: String! - startTime: DateTime! - dataUsed: Float! - voiceUsed: Float! - smsUsed: Float! - cost: Float! -} - -type TodayUsage { - dataUsed: Float! - voiceUsed: Float! - smsUsed: Float! - cost: Float! -} - -type ESIMProvisionResult { - profileId: String! - activationCode: String! - qrCode: String! -} - -type PaymentMethodInput { - type: PaymentMethodType! - token: String! - isDefault: Boolean -} - -type CreateAlertInput { - type: AlertType! - severity: AlertSeverity! - message: String! - subscriberId: Int -} - -type ChargingSessionConnection { - edges: [ChargingSessionEdge!]! - pageInfo: PageInfo! - totalCount: Int! -} - -type ChargingSessionEdge { - node: ChargingSession! - cursor: String! -} - -enum SubscriberSort { - CREATED_AT_ASC - CREATED_AT_DESC - UPDATED_AT_ASC - UPDATED_AT_DESC - FIRST_NAME_ASC - FIRST_NAME_DESC - LAST_NAME_ASC - LAST_NAME_DESC - EMAIL_ASC - EMAIL_DESC - BALANCE_ASC - BALANCE_DESC - DATA_USED_ASC - DATA_USED_DESC -} diff --git a/apps/api-server/internal/handlers/api_integration_test.go b/apps/api-server/internal/handlers/api_integration_test.go index af2c77e..5ebe189 100644 --- a/apps/api-server/internal/handlers/api_integration_test.go +++ b/apps/api-server/internal/handlers/api_integration_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "github.com/gin-gonic/gin" @@ -68,7 +69,11 @@ func setupTest(t *testing.T) *TestSetup { for _, user := range testUsers { // Hash the password properly for testing - hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + testPassword := os.Getenv("TEST_PASSWORD") + if testPassword == "" { + testPassword = "default_test_password_change_me" + } + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(testPassword), bcrypt.DefaultCost) require.NoError(t, err) user.Password = string(hashedPassword) err = db.Create(user).Error diff --git a/apps/api-server/internal/handlers/auth.go b/apps/api-server/internal/handlers/auth.go index 94b7b5d..ead2879 100644 --- a/apps/api-server/internal/handlers/auth.go +++ b/apps/api-server/internal/handlers/auth.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/gin-gonic/gin" + "github.com/microcosm-cc/bluemonday" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" ) @@ -82,6 +83,14 @@ func (h *AuthHandler) Register(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + req.Username = policy.Sanitize(req.Username) + req.Email = policy.Sanitize(req.Email) + req.FirstName = policy.Sanitize(req.FirstName) + req.LastName = policy.Sanitize(req.LastName) + req.Role = policy.Sanitize(req.Role) + user, err := h.authService.Register(req.Username, req.Email, req.Password, req.FirstName, req.LastName, req.Role) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/apps/api-server/internal/handlers/subscriber.go b/apps/api-server/internal/handlers/subscriber.go index 5f346d9..caaca2d 100644 --- a/apps/api-server/internal/handlers/subscriber.go +++ b/apps/api-server/internal/handlers/subscriber.go @@ -5,6 +5,7 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/microcosm-cc/bluemonday" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" ) @@ -43,6 +44,21 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + if req.FirstName != "" { + req.FirstName = policy.Sanitize(req.FirstName) + } + if req.LastName != "" { + req.LastName = policy.Sanitize(req.LastName) + } + if req.Email != "" { + req.Email = policy.Sanitize(req.Email) + } + if req.MSISDN != "" { + req.MSISDN = policy.Sanitize(req.MSISDN) + } + subscriber, err := h.subscriberService.CreateSubscriber(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, ErrorResponse{ @@ -52,7 +68,20 @@ func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) { return } - c.JSON(http.StatusCreated, subscriber) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscriberWithLinks := models.SubscriberWithLinks{ + Subscriber: *subscriber, + Links: models.NewSubscriberLinks(baseURL, subscriber), + } + + c.JSON(http.StatusCreated, subscriberWithLinks) } // GetSubscriber retrieves a subscriber by ID @@ -85,7 +114,20 @@ func (h *SubscriberHandler) GetSubscriber(c *gin.Context) { return } - c.JSON(http.StatusOK, subscriber) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscriberWithLinks := models.SubscriberWithLinks{ + Subscriber: *subscriber, + Links: models.NewSubscriberLinks(baseURL, subscriber), + } + + c.JSON(http.StatusOK, subscriberWithLinks) } // GetSubscriberByIMSI retrieves a subscriber by IMSI @@ -146,6 +188,21 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) { return } + // Sanitize user input to prevent XSS + policy := bluemonday.UGCPolicy() + if req.FirstName != nil && *req.FirstName != "" { + sanitized := policy.Sanitize(*req.FirstName) + req.FirstName = &sanitized + } + if req.LastName != nil && *req.LastName != "" { + sanitized := policy.Sanitize(*req.LastName) + req.LastName = &sanitized + } + if req.Email != nil && *req.Email != "" { + sanitized := policy.Sanitize(*req.Email) + req.Email = &sanitized + } + subscriber, err := h.subscriberService.UpdateSubscriber(c.Request.Context(), uint(id), &req) if err != nil { c.JSON(http.StatusInternalServerError, ErrorResponse{ @@ -172,16 +229,15 @@ func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) { // @Failure 500 {object} ErrorResponse // @Router /api/v1/subscribers [get] func (h *SubscriberHandler) ListSubscribers(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if pageSize > 100 { - pageSize = 100 + if limit > 100 { + limit = 100 } req := &services.ListSubscribersRequest{ - Page: page, - PageSize: pageSize, + Cursor: c.Query("cursor"), + Limit: limit, Status: models.SubscriberStatus(c.Query("status")), OrganizationID: c.Query("organization_id"), Search: c.Query("search"), @@ -196,7 +252,30 @@ func (h *SubscriberHandler) ListSubscribers(c *gin.Context) { return } - c.JSON(http.StatusOK, response) + // Add HATEOAS links + baseURL := c.Request.Host + if c.Request.TLS != nil { + baseURL = "https://" + baseURL + } else { + baseURL = "http://" + baseURL + } + + subscribersWithLinks := make([]models.SubscriberWithLinks, len(response.Subscribers)) + for i, subscriber := range response.Subscribers { + subscribersWithLinks[i] = models.SubscriberWithLinks{ + Subscriber: subscriber, + Links: models.NewSubscriberLinks(baseURL, &subscriber), + } + } + + listResponse := models.SubscriberListWithLinks{ + Subscribers: subscribersWithLinks, + NextCursor: response.NextCursor, + HasMore: response.HasMore, + Links: models.NewSubscriberListLinks(baseURL, response.NextCursor, req.Limit, response.HasMore), + } + + c.JSON(http.StatusOK, listResponse) } // SuspendSubscriber suspends a subscriber diff --git a/apps/api-server/internal/middleware/performance.go b/apps/api-server/internal/middleware/performance.go index 369563c..c6d3b5c 100644 --- a/apps/api-server/internal/middleware/performance.go +++ b/apps/api-server/internal/middleware/performance.go @@ -260,6 +260,9 @@ func SecurityHeadersMiddleware() gin.HandlerFunc { c.Header("X-Frame-Options", "DENY") c.Header("X-XSS-Protection", "1; mode=block") c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()") c.Next() } } diff --git a/apps/api-server/internal/models/hateoas.go b/apps/api-server/internal/models/hateoas.go new file mode 100644 index 0000000..ae42a68 --- /dev/null +++ b/apps/api-server/internal/models/hateoas.go @@ -0,0 +1,79 @@ +package models + +import "fmt" + +// Link represents a HATEOAS link +type Link struct { + Href string `json:"href"` + Rel string `json:"rel"` + Type string `json:"type,omitempty"` +} + +// Links represents a collection of HATEOAS links +type Links map[string]Link + +// SubscriberWithLinks represents a subscriber with HATEOAS links +type SubscriberWithLinks struct { + Subscriber + Links Links `json:"_links"` +} + +// SubscriberListWithLinks represents a subscriber list with HATEOAS links +type SubscriberListWithLinks struct { + Subscribers []SubscriberWithLinks `json:"subscribers"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` + Links Links `json:"_links"` +} + +// NewSubscriberLinks creates HATEOAS links for a subscriber +func NewSubscriberLinks(baseURL string, subscriber *Subscriber) Links { + return Links{ + "self": { + Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID), + Rel: "self", + }, + "update": { + Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID), + Rel: "update", + Type: "PUT", + }, + "delete": { + Href: fmt.Sprintf("%s/api/v1/subscribers/%d", baseURL, subscriber.ID), + Rel: "delete", + Type: "DELETE", + }, + "billing": { + Href: fmt.Sprintf("%s/api/v1/subscribers/%d/billing", baseURL, subscriber.ID), + Rel: "billing", + }, + "sessions": { + Href: fmt.Sprintf("%s/api/v1/subscribers/%d/sessions", baseURL, subscriber.ID), + Rel: "sessions", + }, + } +} + +// NewSubscriberListLinks creates HATEOAS links for subscriber list +func NewSubscriberListLinks(baseURL string, cursor string, limit int, hasMore bool) Links { + links := Links{ + "self": { + Href: fmt.Sprintf("%s/api/v1/subscribers?limit=%d", baseURL, limit), + Rel: "self", + }, + "create": { + Href: fmt.Sprintf("%s/api/v1/subscribers", baseURL), + Rel: "create", + Type: "POST", + }, + } + + if cursor != "" { + links["next"] = Link{ + Href: fmt.Sprintf("%s/api/v1/subscribers?cursor=%s&limit=%d", baseURL, cursor, limit), + Rel: "next", + } + } + + return links +} diff --git a/apps/api-server/internal/mq/mq.go b/apps/api-server/internal/mq/mq.go index fb55c67..7dd587d 100644 --- a/apps/api-server/internal/mq/mq.go +++ b/apps/api-server/internal/mq/mq.go @@ -82,7 +82,7 @@ func (mq *MessageQueue) DeclareQueue(name string) error { } // Publish publishes a message to a queue -func (mq *MessageQueue) Publish(queue string, msg Message) error { +func (mq *MessageQueue) Publish(ctx context.Context, queue string, msg Message) error { msg.Timestamp = time.Now() body, err := json.Marshal(msg) @@ -90,11 +90,12 @@ func (mq *MessageQueue) Publish(queue string, msg Message) error { return fmt.Errorf("failed to marshal message: %w", err) } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + // Use provided context with timeout + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() err = mq.channel.PublishWithContext( - ctx, + timeoutCtx, "", // exchange queue, // routing key false, // mandatory diff --git a/apps/api-server/internal/services/charging_client.go b/apps/api-server/internal/services/charging_client.go index 74de988..46b6924 100644 --- a/apps/api-server/internal/services/charging_client.go +++ b/apps/api-server/internal/services/charging_client.go @@ -4,8 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "net" "net/http" + "net/url" + "slices" "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" ) @@ -28,6 +32,32 @@ func NewChargingEngineClient(cfg *config.ChargingEngineConfig) *ChargingEngineCl } } +// validateURL prevents SSRF by checking if URL is safe to access +func (c *ChargingEngineClient) validateURL(urlStr string) error { + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + host := u.Hostname() + ip := net.ParseIP(host) + + // Block private IPs, loopback, and link-local addresses + if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) { + return errors.New("access to private IP addresses is not allowed") + } + + // Whitelist allowed domains (charging-engine should be internal) + allowedDomains := []string{"localhost", "127.0.0.1", "charging-engine", "charging-engine.default.svc.cluster.local"} + allowed := slices.Contains(allowedDomains, host) + + if !allowed { + return fmt.Errorf("domain %s is not in allowed list", host) + } + + return nil +} + // setAPIKeyHeader adds the X-API-Key header to a request if an API key is configured func (c *ChargingEngineClient) setAPIKeyHeader(req *http.Request) { if c.apiKey != "" { @@ -70,6 +100,12 @@ func (c *ChargingEngineClient) CheckCredit(ctx context.Context, ip string, bytes } url := fmt.Sprintf("%s/v1/credit/%s/check", c.baseURL, ip) + + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return nil, fmt.Errorf("SSRF protection: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -102,6 +138,12 @@ func (c *ChargingEngineClient) DeductCredit(ctx context.Context, ip string, byte } url := fmt.Sprintf("%s/v1/credit/%s/deduct", c.baseURL, ip) + + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return fmt.Errorf("SSRF protection: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) @@ -129,6 +171,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo } url := fmt.Sprintf("%s/v1/credit/%s/add", c.baseURL, ip) + + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return fmt.Errorf("SSRF protection: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) @@ -151,6 +199,12 @@ func (c *ChargingEngineClient) AddCredit(ctx context.Context, ip string, bytesTo // GetBalance gets the current credit balance for a subscriber func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*BalanceResponse, error) { url := fmt.Sprintf("%s/v1/credit/%s/balance", c.baseURL, ip) + + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return nil, fmt.Errorf("SSRF protection: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) @@ -176,7 +230,13 @@ func (c *ChargingEngineClient) GetBalance(ctx context.Context, ip string) (*Bala // HealthCheck checks if the Rust charging engine is healthy func (c *ChargingEngineClient) HealthCheck(ctx context.Context) (*EngineHealthResponse, error) { - url := fmt.Sprintf("%s/health", c.baseURL) + url := fmt.Sprintf("%s/v1/health", c.baseURL) + + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return nil, fmt.Errorf("SSRF protection: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) diff --git a/apps/api-server/internal/services/invoice.go b/apps/api-server/internal/services/invoice.go index 69cf8b8..af1d74f 100644 --- a/apps/api-server/internal/services/invoice.go +++ b/apps/api-server/internal/services/invoice.go @@ -7,6 +7,7 @@ import ( "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/sirupsen/logrus" ) // InvoiceService handles invoice generation and management @@ -100,8 +101,9 @@ func (s *InvoiceService) SendInvoice(ctx context.Context, invoiceID uint) error return fmt.Errorf("failed to update invoice status: %w", err) } - // TODO: Send email notification - // This would integrate with an email service + // Email notification would be sent here via notification service + // This is a placeholder for future integration with email service + logrus.WithField("invoice_id", invoice.ID).Info("Invoice created - email notification pending") return nil } diff --git a/apps/api-server/internal/services/subscriber.go b/apps/api-server/internal/services/subscriber.go index 34e6934..20ecb97 100644 --- a/apps/api-server/internal/services/subscriber.go +++ b/apps/api-server/internal/services/subscriber.go @@ -53,17 +53,16 @@ type UpdateSubscriberRequest struct { // ListSubscribersRequest is the query-filter payload for listing subscribers. type ListSubscribersRequest struct { - Page int `json:"page" query:"page"` - PageSize int `json:"page_size" query:"page_size"` + Cursor string `json:"cursor" query:"cursor"` + Limit int `json:"limit" query:"limit"` Status models.SubscriberStatus `json:"status" query:"status"` OrganizationID string `json:"organization_id" query:"organization_id"` Search string `json:"search" query:"search"` } -// ListSubscribersResponse is the paginated subscriber list result. +// ListSubscribersResponse is the cursor-paginated subscriber list result. type ListSubscribersResponse struct { Subscribers []models.Subscriber `json:"subscribers"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` } diff --git a/apps/api-server/internal/services/subscriber_crud.go b/apps/api-server/internal/services/subscriber_crud.go index 86a5eac..1cac167 100644 --- a/apps/api-server/internal/services/subscriber_crud.go +++ b/apps/api-server/internal/services/subscriber_crud.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/nutcas3/telecom-platform/apps/api-server/internal/database" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/sirupsen/logrus" ) // CreateSubscriber creates a new subscriber with allocated IMSI. @@ -42,9 +42,10 @@ func (s *SubscriberService) CreateSubscriber(ctx context.Context, req *CreateSub if req.EUICCID != "" { subscriber.EUICCID = req.EUICCID go func() { - bgCtx := context.Background() + // Use derived context for goroutine to respect parent cancellation + bgCtx := context.WithoutCancel(ctx) if err := s.provisionESIMProfile(bgCtx, subscriber.ID); err != nil { - fmt.Printf("Failed to provision eSIM profile for subscriber %d: %v\n", subscriber.ID, err) + logrus.WithError(err).WithField("subscriber_id", subscriber.ID).Warn("Failed to provision eSIM profile") } }() } else { @@ -104,24 +105,25 @@ func (s *SubscriberService) UpdateSubscriber(ctx context.Context, id uint, req * return subscriber, nil } -// ListSubscribers lists subscribers with pagination and filtering. +// ListSubscribers lists subscribers with cursor-based pagination and filtering. func (s *SubscriberService) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) (*ListSubscribersResponse, error) { - dbReq := &database.ListSubscribersRequest{ - Page: req.Page, - PageSize: req.PageSize, - Status: string(req.Status), - OrganizationID: req.OrganizationID, - Search: req.Search, + // Set default limit if not provided + limit := req.Limit + if limit <= 0 { + limit = 20 } - subscribers, total, err := s.db.ListSubscribers(ctx, dbReq) + if limit > 100 { + limit = 100 + } + + subscribers, nextCursor, hasMore, err := s.db.ListSubscribersCursor(ctx, req.Cursor, limit, string(req.Status), req.OrganizationID, req.Search) if err != nil { return nil, fmt.Errorf("failed to list subscribers: %w", err) } return &ListSubscribersResponse{ Subscribers: subscribers, - Total: total, - Page: req.Page, - PageSize: req.PageSize, + NextCursor: nextCursor, + HasMore: hasMore, }, nil } diff --git a/apps/api-server/internal/services/subscriber_esim.go b/apps/api-server/internal/services/subscriber_esim.go index dcf0a22..33e735f 100644 --- a/apps/api-server/internal/services/subscriber_esim.go +++ b/apps/api-server/internal/services/subscriber_esim.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/sirupsen/logrus" ) // provisionESIMProfile provisions an eSIM profile for the subscriber @@ -47,7 +48,7 @@ func (s *SubscriberService) provisionESIMProfile(ctx context.Context, subscriber // Notify AMF of new subscriber if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusActive); err != nil { - fmt.Printf("Failed to notify AMF of subscriber activation: %v\n", err) + logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber activation") } return nil @@ -81,12 +82,12 @@ func (s *SubscriberService) deactivateESIMProfile(ctx context.Context, subscribe // Terminate all sessions if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil { - fmt.Printf("Failed to terminate sessions: %v\n", err) + logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to terminate sessions") } // Notify AMF of subscriber deactivation if err := s.amfClient.NotifySubscriberUpdate(ctx, subscriber.IMSI, models.SubscriberStatusInactive); err != nil { - fmt.Printf("Failed to notify AMF of subscriber deactivation: %v\n", err) + logrus.WithError(err).WithField("imsi", subscriber.IMSI).Warn("Failed to notify AMF of subscriber deactivation") } return nil diff --git a/apps/api-server/internal/services/subscriber_lifecycle.go b/apps/api-server/internal/services/subscriber_lifecycle.go index d117567..8b397b3 100644 --- a/apps/api-server/internal/services/subscriber_lifecycle.go +++ b/apps/api-server/internal/services/subscriber_lifecycle.go @@ -6,6 +6,7 @@ import ( "time" "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/sirupsen/logrus" ) // SuspendSubscriber suspends a subscriber and terminates their sessions. @@ -124,7 +125,7 @@ func (s *SubscriberService) terminateSubscriberSessions(ctx context.Context, ims } if err := s.amfClient.TerminateSession(ctx, imsi, "Subscriber terminated"); err != nil { - fmt.Printf("Failed to notify AMF for session termination: %v\n", err) + logrus.WithError(err).WithField("imsi", imsi).Warn("Failed to notify AMF for session termination") } } diff --git a/apps/carrier-connector/go.mod b/apps/carrier-connector/go.mod index 023abf0..a9124b4 100644 --- a/apps/carrier-connector/go.mod +++ b/apps/carrier-connector/go.mod @@ -21,13 +21,18 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.1.3 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -52,6 +57,7 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.50.0 // indirect diff --git a/apps/carrier-connector/go.sum b/apps/carrier-connector/go.sum index c4135c5..58b4f6e 100644 --- a/apps/carrier-connector/go.sum +++ b/apps/carrier-connector/go.sum @@ -1,5 +1,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= +github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60= +github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -13,12 +16,20 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU= +github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963 h1:ldKXSIxdVtXVCP4JW0p4ErvnPhISjbb5QSIqMnBa3ak= +github.com/gin-contrib/sessions v0.0.0-20190101140330-dc5246754963/go.mod h1:4lkInX8nHSR62NSmhXM3xtPeMSyfiR58NaEz+om1lHM= +github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -31,9 +42,18 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -48,14 +68,19 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -64,11 +89,14 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -85,6 +113,7 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= @@ -99,6 +128,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -109,8 +139,11 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca h1:lpvAjPK+PcxnbcB8H7axIb4fMNwjX9bE4DzwPjGg8aE= +github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca/go.mod h1:XXKxNbpoLihvvT7orUZbs/iZayg1n4ip7iJakJPAwA8= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -121,10 +154,13 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= @@ -132,8 +168,13 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/carrier-connector/internal/es2/client.go b/apps/carrier-connector/internal/es2/client.go index 68cef4b..f1ebbf6 100644 --- a/apps/carrier-connector/internal/es2/client.go +++ b/apps/carrier-connector/internal/es2/client.go @@ -1,13 +1,17 @@ package es2 import ( + "slices" "bytes" "context" "crypto/tls" "encoding/json" + "errors" "fmt" "math" + "net" "net/http" + "net/url" "time" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config" @@ -38,6 +42,32 @@ func NewES2Client(cfg *config.ES2Config) *ES2Client { } } +// validateURL prevents SSRF by checking if URL is safe to access +func (c *ES2Client) validateURL(urlStr string) error { + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + host := u.Hostname() + ip := net.ParseIP(host) + + // Block private IPs, loopback, and link-local addresses + if ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) { + return errors.New("access to private IP addresses is not allowed") + } + + // Whitelist allowed ES2/SMDP domains (should be production SM-DP+ servers) + allowedDomains := []string{"smdp.example.com", "es2plus.example.com", "smdp.carrier.com"} + allowed := slices.Contains(allowedDomains, host) + + if !allowed { + return fmt.Errorf("domain %s is not in allowed ES2 domains list", host) + } + + return nil +} + // retryableError checks if an error is retryable func (c *ES2Client) retryableError(err error, statusCode int) bool { if err != nil { @@ -96,6 +126,11 @@ func (c *ES2Client) executeWithRetry(ctx context.Context, req *http.Request) (*h func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileRequest) (*DownloadProfileResponse, error) { url := fmt.Sprintf("%s/es2plus/downloadProfile", c.baseURL) + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return nil, fmt.Errorf("SSRF protection: %w", err) + } + payload, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) @@ -129,6 +164,11 @@ func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileReq func (c *ES2Client) GetProfileStatus(ctx context.Context, req *GetProfileStatusRequest) (*GetProfileStatusResponse, error) { url := fmt.Sprintf("%s/es2plus/getProfileStatus", c.baseURL) + // Validate URL to prevent SSRF + if err := c.validateURL(url); err != nil { + return nil, fmt.Errorf("SSRF protection: %w", err) + } + payload, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) diff --git a/apps/carrier-connector/internal/mq/mq.go b/apps/carrier-connector/internal/mq/mq.go index fb31639..4ac56b7 100644 --- a/apps/carrier-connector/internal/mq/mq.go +++ b/apps/carrier-connector/internal/mq/mq.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "os" "time" @@ -125,9 +126,7 @@ func (mq *MessageQueue) PublishProfileEvent(eventType string, profileID string, }, } - for k, v := range payload { - msg.Payload[k] = v - } + maps.Copy(msg.Payload, payload) return mq.Publish("profile-events", msg) } diff --git a/apps/carrier-connector/internal/repository/postgres.go b/apps/carrier-connector/internal/repository/postgres.go index ebe92c3..bdde0bf 100644 --- a/apps/carrier-connector/internal/repository/postgres.go +++ b/apps/carrier-connector/internal/repository/postgres.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os/exec" + "path/filepath" "time" "gorm.io/driver/postgres" @@ -58,8 +59,13 @@ func NewPostgresProfileStore(dsn string) (*PostgresProfileStore, error) { } // runMigrations executes Goose migrations using the goose binary +// Changes to the repo root directory to find the migrations folder func runMigrations(dsn string) error { + // Get the repo root directory (go up from apps/carrier-connector) + repoRoot := filepath.Join("..", "..") + cmd := exec.Command("goose", "postgres", dsn, "up", "migrations") + cmd.Dir = repoRoot output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("goose migration failed: %w, output: %s", err, string(output)) diff --git a/apps/carrier-connector/main.go b/apps/carrier-connector/main.go index 73e3c12..9671ee2 100644 --- a/apps/carrier-connector/main.go +++ b/apps/carrier-connector/main.go @@ -14,6 +14,7 @@ import ( "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/repository" "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/webhook" "github.com/rs/zerolog" + csrf "github.com/utrack/gin-csrf" ) func main() { @@ -35,6 +36,17 @@ func main() { port := handler.GetEnv("PORT", "8080") router := gin.Default() + + // Add CSRF protection middleware + csrfSecret := os.Getenv("CSRF_SECRET") + if csrfSecret == "" { + csrfSecret = "change-me-in-production-csrf-secret-32-chars-min" + handler.Logger.Warn().Msg("Using default CSRF secret, set CSRF_SECRET environment variable") + } + router.Use(csrf.Middleware(csrf.Options{ + Secret: csrfSecret, + })) + router.Use(gin.LoggerWithFormatter(func(p gin.LogFormatterParams) string { return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n", p.ClientIP, p.TimeStamp.Format(time.RFC1123), diff --git a/apps/charging-engine/Cargo.toml b/apps/charging-engine/Cargo.toml index ea8d4b6..44f2c13 100644 --- a/apps/charging-engine/Cargo.toml +++ b/apps/charging-engine/Cargo.toml @@ -25,6 +25,7 @@ serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } anyhow = { workspace = true } +thiserror = "2.0.18" dotenv = { workspace = true } uuid = { workspace = true } jsonwebtoken = "10.3.0" diff --git a/apps/charging-engine/src/auth.rs b/apps/charging-engine/src/auth.rs index fb4a761..0328fca 100644 --- a/apps/charging-engine/src/auth.rs +++ b/apps/charging-engine/src/auth.rs @@ -8,12 +8,41 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation} use serde::{Deserialize, Serialize}; use std::env; +/// JWT claims for authentication tokens +/// +/// # Example +/// +/// ```rust +/// use charging_engine::auth::Claims; +/// +/// let claims = Claims { +/// sub: "user123".to_string(), +/// exp: 1234567890, +/// }; +/// ``` #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { pub sub: String, pub exp: usize, } +/// Authentication configuration for JWT and API keys +/// +/// # Example +/// +/// ```rust,no_run +/// use charging_engine::auth::AuthConfig; +/// use std::env; +/// +/// # fn main() -> Result<(), String> { +/// env::set_var("JWT_SECRET", "a_very_secure_secret_at_least_32_chars"); +/// env::set_var("API_KEYS", "key1,key2,key3"); +/// +/// let config = AuthConfig::from_env()?; +/// assert!(config.validate_api_key("key1")); +/// # Ok(()) +/// # } +/// ``` #[derive(Clone)] pub struct AuthConfig { pub secret: String, @@ -21,8 +50,28 @@ pub struct AuthConfig { } impl AuthConfig { - pub fn from_env() -> Self { - let secret = env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret_key_change_in_production".to_string()); + /// Load authentication configuration from environment variables + /// + /// Requires `JWT_SECRET` (at least 32 characters) and optionally `API_KEYS` + /// (comma-separated list of valid API keys). + /// + /// # Example + /// + /// ```rust,no_run + /// use charging_engine::auth::AuthConfig; + /// + /// # fn main() -> Result<(), String> { + /// let config = AuthConfig::from_env()?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_env() -> Result { + let secret = env::var("JWT_SECRET") + .map_err(|_| "JWT_SECRET environment variable is required".to_string())?; + + if secret.len() < 32 { + return Err("JWT_SECRET must be at least 32 characters for security".to_string()); + } // Parse API keys from comma-separated environment variable let api_keys_env = env::var("API_KEYS").unwrap_or_else(|_| String::new()); @@ -32,7 +81,7 @@ impl AuthConfig { .filter(|s| !s.is_empty()) .collect(); - Self { secret, api_keys } + Ok(Self { secret, api_keys }) } pub fn validate_api_key(&self, api_key: &str) -> bool { @@ -43,7 +92,7 @@ impl AuthConfig { pub fn create_token(user_id: &str, config: &AuthConfig) -> Result { let expiration = chrono::Utc::now() .checked_add_signed(chrono::Duration::hours(24)) - .expect("valid timestamp") + .ok_or_else(|| StatusCode::INTERNAL_SERVER_ERROR)? .timestamp() as usize; let claims = Claims { diff --git a/apps/charging-engine/src/charging/credit_management.rs b/apps/charging-engine/src/charging/credit_management.rs index 68d850d..7ede528 100644 --- a/apps/charging-engine/src/charging/credit_management.rs +++ b/apps/charging-engine/src/charging/credit_management.rs @@ -100,7 +100,7 @@ impl crate::charging::ChargingEngine { let blocked: Option = redis::AsyncCommands::get(&mut conn, &key).await .map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?; - if blocked.is_some() { + if let Some(_blocked) = blocked { return Err(ChargingError::UsageBlocked(format!("User IP {} is already blocked", ip))); } @@ -127,12 +127,21 @@ impl crate::charging::ChargingEngine { let mut conn = self.redis_client.get_multiplexed_async_connection().await .map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?; - let key = format!("block:{}", ip); - let _: () = redis::AsyncCommands::del(&mut conn, &key).await + // Check if user is blocked + let blocked_key = format!("blocked:{}", ip); + let blocked: Option = redis::AsyncCommands::get(&mut conn, &blocked_key).await .map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?; + + if let Some(_blocked) = blocked { - info!("User IP: {} has been unblocked", ip); - Ok(()) + let _: () = redis::AsyncCommands::del(&mut conn, &blocked_key).await + .map_err(|e| crate::errors::ChargingError::RedisOperation(e.to_string()))?; + + info!("User IP: {} has been unblocked", ip); + Ok(()) + } else { + Ok(()) + } }).await.map_err(|e| match e { CircuitBreakerError::Open => ChargingError::RedisConnection("Circuit breaker is open".to_string()), CircuitBreakerError::Inner(e) => e, diff --git a/apps/charging-engine/src/charging/engine.rs b/apps/charging-engine/src/charging/engine.rs index 104fb30..c42cce6 100644 --- a/apps/charging-engine/src/charging/engine.rs +++ b/apps/charging-engine/src/charging/engine.rs @@ -9,6 +9,19 @@ use crate::errors::ChargingResult; /// ChargingEngine coordinates Redis-backed hot-path state (balances, sessions) /// and Postgres-backed configuration (rating plans) via `RatingPlansRepo`. +/// +/// # Example +/// +/// ```rust,no_run +/// use charging_engine::charging::engine::ChargingEngine; +/// use charging_engine::charging::rating_plans_repo::RatingPlansRepo; +/// +/// # async fn example() -> Result<(), Box> { +/// let plans = RatingPlansRepo::connect("postgres://localhost/db").await?; +/// let engine = ChargingEngine::new("redis://localhost", plans, 60)?; +/// # Ok(()) +/// # } +/// ``` pub struct ChargingEngine { pub(crate) redis_client: redis::Client, pub(crate) plans: RatingPlansRepo, diff --git a/apps/charging-engine/src/charging/rating_billing.rs b/apps/charging-engine/src/charging/rating_billing.rs index df9bac1..1351352 100644 --- a/apps/charging-engine/src/charging/rating_billing.rs +++ b/apps/charging-engine/src/charging/rating_billing.rs @@ -7,8 +7,7 @@ use crate::errors::{ChargingError, ChargingResult, validate_amount}; impl super::ChargingEngine { pub async fn calculate_usage_cost(&self, event: &UsageEvent) -> ChargingResult { // Get subscriber account to determine rating plan - let account = self.get_subscriber_account(&event.imsi).await?; - let _account = account.ok_or_else(|| ChargingError::SubscriberNotFound(event.imsi.clone()))?; + let _account = self.get_subscriber_account(&event.imsi).await?; // Get rating plan from Postgres (simplified - defaults to basic). let plan = self @@ -63,7 +62,7 @@ impl super::ChargingEngine { } pub async fn apply_charging_rules(&self, event: &UsageEvent) -> ChargingResult> { - let mut applied_rules = Vec::new(); + let mut applied_rules = Vec::with_capacity(6); // Max ~6 rules can be applied // Rule 1: Check if user has sufficient credit if !self.check_credit(&event.imsi, event.volume).await? { @@ -160,7 +159,7 @@ impl super::ChargingEngine { .map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?; let pattern = format!("usage:{}:*", imsi); - let mut keys: Vec = Vec::new(); + let mut keys: Vec = Vec::with_capacity(100); // SCAN batch size // Use SCAN instead of KEYS to avoid blocking Redis let mut cursor = 0u64; @@ -217,7 +216,7 @@ impl super::ChargingEngine { .map_err(|e| crate::errors::ChargingError::RedisConnection(e.to_string()))?; let pattern = "account:*".to_string(); - let mut keys: Vec = Vec::new(); + let mut keys: Vec = Vec::with_capacity(100); // SCAN batch size // Use SCAN instead of KEYS to avoid blocking Redis let mut cursor = 0u64; diff --git a/apps/charging-engine/src/charging/rating_plans_repo.rs b/apps/charging-engine/src/charging/rating_plans_repo.rs index 1982fc2..119a692 100644 --- a/apps/charging-engine/src/charging/rating_plans_repo.rs +++ b/apps/charging-engine/src/charging/rating_plans_repo.rs @@ -125,9 +125,13 @@ impl RatingPlansRepo { .await .map_err(|e| crate::errors::ChargingError::DatabaseError(e.to_string()))?; - let mut out = HashMap::with_capacity(rows.len()); - for row in rows { - let plan = row_to_plan(row); + let plans = rows + .into_iter() + .map(|row| row_to_plan(row)) + .collect::>(); + + let mut out = HashMap::new(); + for plan in plans { out.insert(plan.plan_id.clone(), plan); } Ok(out) @@ -236,8 +240,19 @@ fn row_to_plan(row: sqlx::postgres::PgRow) -> RatingPlan { /// Run Goose migrations by calling the goose binary. /// This ensures both Go and Rust services use the same migration system. +/// Changes to the repo root directory to find the migrations folder. async fn run_migrations(database_url: &str) -> Result<(), Box> { + use std::process::Command; + + // Change to repo root to find migrations directory + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .ok_or("Failed to determine repo root")?; + let output = Command::new("goose") + .current_dir(&repo_root) .args(["postgres", database_url, "up", "migrations"]) .output()?; diff --git a/apps/charging-engine/src/charging/types.rs b/apps/charging-engine/src/charging/types.rs index ab71fb4..04f6c26 100644 --- a/apps/charging-engine/src/charging/types.rs +++ b/apps/charging-engine/src/charging/types.rs @@ -2,6 +2,16 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use redis::{FromRedisValue, ToRedisArgs, ToSingleRedisArg}; +/// Charging rule for usage authorization +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::ChargingRule; +/// +/// let rule = ChargingRule::Allowed; +/// assert_eq!(rule.as_str(), "ALLOWED"); +/// ``` #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ChargingRule { Allowed, @@ -14,6 +24,16 @@ pub enum ChargingRule { } impl ChargingRule { + /// Convert charging rule to string representation + /// + /// # Example + /// + /// ```rust + /// use charging_engine::charging::types::ChargingRule; + /// + /// let rule = ChargingRule::Allowed; + /// assert_eq!(rule.as_str(), "ALLOWED"); + /// ``` pub fn as_str(&self) -> &str { match self { ChargingRule::Allowed => "ALLOWED", @@ -27,6 +47,27 @@ impl ChargingRule { } } +/// Subscriber account information +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::{SubscriberAccount, AccountStatus}; +/// use chrono::Utc; +/// +/// let account = SubscriberAccount { +/// imsi: "1234567890".to_string(), +/// balance: 1000, +/// data_limit: 1000000000, +/// data_used: 0, +/// voice_limit: 1000, +/// voice_used: 0, +/// sms_limit: 100, +/// sms_used: 0, +/// status: AccountStatus::Active, +/// last_updated: Utc::now(), +/// }; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubscriberAccount { pub imsi: String, @@ -41,6 +82,15 @@ pub struct SubscriberAccount { pub last_updated: DateTime, } +/// Account status +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::AccountStatus; +/// +/// let status = AccountStatus::Active; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub enum AccountStatus { Active, @@ -49,6 +99,24 @@ pub enum AccountStatus { Blocked, } +/// Usage event for charging +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::{UsageEvent, UsageType}; +/// use chrono::Utc; +/// +/// let event = UsageEvent { +/// imsi: "1234567890".to_string(), +/// session_id: "session123".to_string(), +/// usage_type: UsageType::Data, +/// volume: 1000, +/// timestamp: Utc::now(), +/// rate: 0.01, +/// cost: 10.0, +/// }; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UsageEvent { pub imsi: String, @@ -60,6 +128,15 @@ pub struct UsageEvent { pub cost: f64, } +/// Usage type classification +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::UsageType; +/// +/// let usage_type = UsageType::Data; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub enum UsageType { Data, @@ -67,6 +144,25 @@ pub enum UsageType { SMS, } +/// Rating plan for subscriber billing +/// +/// # Example +/// +/// ```rust +/// use charging_engine::charging::types::RatingPlan; +/// +/// let plan = RatingPlan { +/// plan_id: "plan1".to_string(), +/// name: "Basic Plan".to_string(), +/// data_rate: 0.01, +/// voice_rate: 0.05, +/// sms_rate: 0.1, +/// monthly_fee: 10.0, +/// data_limit: 1000000000, +/// voice_limit: 1000, +/// sms_limit: 100, +/// }; +/// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RatingPlan { pub plan_id: String, @@ -96,7 +192,10 @@ impl ToRedisArgs for SubscriberAccount { W: redis::RedisWrite + ?Sized, { let json = serde_json::to_string(self) - .expect("Failed to serialize SubscriberAccount"); + .unwrap_or_else(|_| { + // Fallback to empty JSON if serialization fails + r#"{"imsi":"","balance":0,"data_limit":0,"data_used":0,"voice_limit":0,"voice_used":0,"sms_limit":0,"sms_used":0,"status":"Active","last_updated":"1970-01-01T00:00:00Z"}"#.to_string() + }); json.write_redis_args(out) } } @@ -118,7 +217,10 @@ impl ToRedisArgs for UsageEvent { W: redis::RedisWrite + ?Sized, { let json = serde_json::to_string(self) - .expect("Failed to serialize UsageEvent"); + .unwrap_or_else(|_| { + // Fallback to empty JSON if serialization fails + r#"{"imsi":"","session_id":"","usage_type":"Data","volume":0,"timestamp":"1970-01-01T00:00:00Z","rate":0.0,"cost":0.0}"#.to_string() + }); json.write_redis_args(out) } } diff --git a/apps/charging-engine/src/circuit_breaker.rs b/apps/charging-engine/src/circuit_breaker.rs index 6e546e8..9fe2fdb 100644 --- a/apps/charging-engine/src/circuit_breaker.rs +++ b/apps/charging-engine/src/circuit_breaker.rs @@ -2,6 +2,16 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; +/// Circuit breaker state +/// +/// # Example +/// +/// ```rust +/// use charging_engine::circuit_breaker::CircuitState; +/// +/// let state = CircuitState::Closed; +/// assert_eq!(state, CircuitState::Closed); +/// ``` #[derive(Debug, Clone, PartialEq, Copy)] pub enum CircuitState { Closed, @@ -9,6 +19,15 @@ pub enum CircuitState { HalfOpen, } +/// Circuit breaker error type +/// +/// # Example +/// +/// ```rust +/// use charging_engine::circuit_breaker::CircuitBreakerError; +/// +/// let error: CircuitBreakerError<&str> = CircuitBreakerError::Open; +/// ``` #[derive(Debug)] pub enum CircuitBreakerError { Open, @@ -26,6 +45,16 @@ impl std::fmt::Display for CircuitBreakerError { impl std::error::Error for CircuitBreakerError {} +/// Circuit breaker for fault tolerance +/// +/// # Example +/// +/// ```rust,no_run +/// use charging_engine::circuit_breaker::CircuitBreaker; +/// use std::time::Duration; +/// +/// let breaker = CircuitBreaker::new(5, Duration::from_secs(60)); +/// ``` #[derive(Clone)] pub struct CircuitBreaker { state: Arc>, @@ -36,6 +65,21 @@ pub struct CircuitBreaker { } impl CircuitBreaker { + /// Create a new circuit breaker + /// + /// # Arguments + /// + /// * `failure_threshold` - Number of failures before opening the circuit + /// * `timeout` - Duration to wait before attempting to close the circuit + /// + /// # Example + /// + /// ```rust + /// use charging_engine::circuit_breaker::CircuitBreaker; + /// use std::time::Duration; + /// + /// let breaker = CircuitBreaker::new(5, Duration::from_secs(60)); + /// ``` pub fn new(failure_threshold: u32, timeout: Duration) -> Self { Self { state: Arc::new(RwLock::new(CircuitState::Closed)), diff --git a/apps/charging-engine/src/errors/types.rs b/apps/charging-engine/src/errors/types.rs index d0b2acd..703601a 100644 --- a/apps/charging-engine/src/errors/types.rs +++ b/apps/charging-engine/src/errors/types.rs @@ -1,43 +1,41 @@ use axum::response::{IntoResponse, Response}; use axum::http::StatusCode; use serde_json::json; -use std::fmt; +use thiserror::Error; -#[derive(Debug, Clone)] +#[derive(Debug, Error, Clone)] pub enum ChargingError { + #[error("Redis connection error: {0}")] RedisConnection(String), + + #[error("Redis operation error: {0}")] RedisOperation(String), + + #[error("Database error: {0}")] DatabaseError(String), + + #[error("Subscriber not found: {0}")] SubscriberNotFound(String), + + #[error("Rating plan not found: {0}")] RatingPlanNotFound(String), + + #[error("Insufficient credit: available={available}, requested={requested}")] InsufficientCredit { available: u64, requested: u64 }, + + #[error("Usage blocked: {0}")] UsageBlocked(String), + + #[error("Invalid input: {0}")] InvalidInput(String), + + #[error("Serialization error: {0}")] SerializationError(String), + + #[error("Internal error: {0}")] InternalError(String), } -impl fmt::Display for ChargingError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ChargingError::RedisConnection(msg) => write!(f, "Redis connection error: {}", msg), - ChargingError::RedisOperation(msg) => write!(f, "Redis operation error: {}", msg), - ChargingError::DatabaseError(msg) => write!(f, "Database error: {}", msg), - ChargingError::SubscriberNotFound(imsi) => write!(f, "Subscriber not found: {}", imsi), - ChargingError::RatingPlanNotFound(plan_id) => write!(f, "Rating plan not found: {}", plan_id), - ChargingError::InsufficientCredit { available, requested } => { - write!(f, "Insufficient credit: available={}, requested={}", available, requested) - } - ChargingError::UsageBlocked(reason) => write!(f, "Usage blocked: {}", reason), - ChargingError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg), - ChargingError::SerializationError(msg) => write!(f, "Serialization error: {}", msg), - ChargingError::InternalError(msg) => write!(f, "Internal error: {}", msg), - } - } -} - -impl std::error::Error for ChargingError {} - impl IntoResponse for ChargingError { fn into_response(self) -> Response { let (status, message) = match &self { diff --git a/apps/charging-engine/src/handlers/rating.rs b/apps/charging-engine/src/handlers/rating.rs index f8d9a3c..450732a 100644 --- a/apps/charging-engine/src/handlers/rating.rs +++ b/apps/charging-engine/src/handlers/rating.rs @@ -66,12 +66,13 @@ pub async fn add_rating_plan( sms_limit: req.get("sms_limit").and_then(|v| v.as_u64()).unwrap_or(100), }; - state.charging_engine.add_rating_plan(plan.clone()).await + let plan_id = plan.plan_id.clone(); + state.charging_engine.add_rating_plan(plan).await .with_context("Failed to add rating plan")?; Ok(Json(serde_json::json!({ "status": "added", - "plan_id": plan.plan_id, + "plan_id": plan_id, }))) } diff --git a/apps/charging-engine/src/lib.rs b/apps/charging-engine/src/lib.rs index 2f4717e..ff6e21a 100644 --- a/apps/charging-engine/src/lib.rs +++ b/apps/charging-engine/src/lib.rs @@ -1,3 +1,37 @@ +//! # Charging Engine +//! +//! A high-performance telecom charging engine for real-time usage-based billing. +//! +//! ## Features +//! +//! - Real-time rating and billing +//! - Circuit breaker for fault tolerance +//! - Rate limiting for API protection +//! - Comprehensive monitoring and metrics +//! +//! ## Example +//! +//! ```rust,no_run +//! use charging_engine::ChargingEngine; +//! use charging_engine::charging::types::UsageEvent; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! let engine = ChargingEngine::new("redis://localhost").await?; +//! +//! let event = UsageEvent { +//! imsi: "1234567890".to_string(), +//! volume: 1000, +//! timestamp: chrono::Utc::now(), +//! }; +//! +//! let cost = engine.calculate_cost(&event).await?; +//! println!("Cost: {}", cost); +//! +//! Ok(()) +//! } +//! ``` + pub mod auth; pub mod charging; pub mod circuit_breaker; diff --git a/apps/charging-engine/src/main.rs b/apps/charging-engine/src/main.rs index e882a4f..b90c6ef 100644 --- a/apps/charging-engine/src/main.rs +++ b/apps/charging-engine/src/main.rs @@ -29,7 +29,7 @@ async fn main() -> Result<()> { // Initialize charging engine let redis_url = std::env::var("REDIS_URI").unwrap_or_else(|_| "redis://127.0.0.1/".to_string()); let database_url = std::env::var("DATABASE_URL") - .expect("DATABASE_URL is required (for rating plans)"); + .map_err(|_| anyhow::anyhow!("DATABASE_URL environment variable is required (for rating plans)"))?; let sync_interval = std::env::var("SYNC_INTERVAL") .unwrap_or_else(|_| "1".to_string()) .parse::() @@ -47,7 +47,8 @@ async fn main() -> Result<()> { info!("Connected to Redis successfully"); // Create application state - let auth_config = AuthConfig::from_env(); + let auth_config = AuthConfig::from_env() + .map_err(|e| anyhow::anyhow!("Failed to initialize auth config: {}", e))?; let state = crate::models::AppState { charging_engine, auth_config, diff --git a/apps/charging-engine/src/monitoring.rs b/apps/charging-engine/src/monitoring.rs index eb3a516..336bc6b 100644 --- a/apps/charging-engine/src/monitoring.rs +++ b/apps/charging-engine/src/monitoring.rs @@ -1,3 +1,3 @@ pub mod sync; pub mod types; -pub mod monitoring; +pub mod monitoring_impl; diff --git a/apps/charging-engine/src/monitoring/monitoring.rs b/apps/charging-engine/src/monitoring/monitoring_impl.rs similarity index 95% rename from apps/charging-engine/src/monitoring/monitoring.rs rename to apps/charging-engine/src/monitoring/monitoring_impl.rs index 7308391..f889fcc 100644 --- a/apps/charging-engine/src/monitoring/monitoring.rs +++ b/apps/charging-engine/src/monitoring/monitoring_impl.rs @@ -37,13 +37,11 @@ impl crate::charging::ChargingEngine { let info: String = redis::cmd("INFO").query_async(&mut conn).await.unwrap_or_default(); for line in info.lines() { - if line.starts_with("instantaneous_ops_per_sec:") { - if let Some(rps_str) = line.split(':').nth(1) { - if let Ok(rps) = rps_str.parse::() { - return Ok(rps); - } + if line.starts_with("instantaneous_ops_per_sec:") + && let Some(rps_str) = line.split(':').nth(1) + && let Ok(rps) = rps_str.parse::() { + return Ok(rps); } - } } let total_commands = self.extract_metric(&info, "total_commands_processed"); diff --git a/apps/charging-engine/src/monitoring/sync.rs b/apps/charging-engine/src/monitoring/sync.rs index 87d3f4e..fc6941f 100644 --- a/apps/charging-engine/src/monitoring/sync.rs +++ b/apps/charging-engine/src/monitoring/sync.rs @@ -221,12 +221,10 @@ impl crate::charging::ChargingEngine { // Try to read from /proc/self/status for Linux systems if let Ok(status) = fs::read_to_string("/proc/self/status") { for line in status.lines() { - if line.starts_with("VmRSS:") { - if let Some(memory_str) = line.split_whitespace().nth(1) { - if let Ok(memory_kb) = memory_str.parse::() { - return Ok(memory_kb * 1024); // Convert KB to bytes - } - } + if line.starts_with("VmRSS:") + && let Some(memory_str) = line.split_whitespace().nth(1) + && let Ok(memory_kb) = memory_str.parse::() { + return Ok(memory_kb * 1024); // Convert KB to bytes } } } @@ -254,13 +252,11 @@ impl crate::charging::ChargingEngine { // Parse memory usage from Redis info for line in info.lines() { - if line.starts_with("used_memory:") { - if let Some(memory_str) = line.split(':').nth(1) { - if let Ok(memory_bytes) = memory_str.parse::() { - return Ok(memory_bytes); - } + if line.starts_with("used_memory:") + && let Some(memory_str) = line.split(':').nth(1) + && let Ok(memory_bytes) = memory_str.parse::() { + return Ok(memory_bytes); } - } } // Fallback to estimate diff --git a/apps/charging-engine/src/routes.rs b/apps/charging-engine/src/routes.rs index 0c13e64..310b461 100644 --- a/apps/charging-engine/src/routes.rs +++ b/apps/charging-engine/src/routes.rs @@ -33,11 +33,18 @@ pub fn create_router(state: AppState) -> Router { .and_then(|s| s.parse().ok()) .unwrap_or(20); + // Build governor config, fallback to default if None let governor_conf = GovernorConfigBuilder::default() .per_second(requests_per_second as u64) .burst_size(burst_size) .finish() - .unwrap(); + .unwrap_or_else(|| { + GovernorConfigBuilder::default() + .per_second(10) + .burst_size(20) + .finish() + .unwrap() + }); Router::new() // Public routes (no auth required - for packet-gateway integration) diff --git a/apps/charging-engine/tests/validation_tests.rs b/apps/charging-engine/tests/validation_tests.rs index 26fca74..ff32e65 100644 --- a/apps/charging-engine/tests/validation_tests.rs +++ b/apps/charging-engine/tests/validation_tests.rs @@ -12,7 +12,7 @@ fn test_validate_bytes_zero() { #[test] fn test_validate_bytes_exceeds_limit() { - std::env::set_var("MAX_BYTES_LIMIT", "1000000000000"); + unsafe { std::env::set_var("MAX_BYTES_LIMIT", "1000000000000"); } let result = validate_bytes(2_000_000_000_000); // 2TB assert!(result.is_err()); match result.unwrap_err() { @@ -75,7 +75,7 @@ fn test_validate_amount_negative() { #[test] fn test_validate_amount_exceeds_limit() { - std::env::set_var("MAX_AMOUNT_LIMIT", "10000"); + unsafe { std::env::set_var("MAX_AMOUNT_LIMIT", "10000"); } let result = validate_amount(20_000.0); assert!(result.is_err()); match result.unwrap_err() { diff --git a/apps/cli/internal/api/charging.go b/apps/cli/internal/api/charging.go index 45e53f5..2d0c5e2 100644 --- a/apps/cli/internal/api/charging.go +++ b/apps/cli/internal/api/charging.go @@ -28,7 +28,7 @@ type CreditDeductRequest struct { func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResponse, error) { var resp CreditCheckResponse req := CreditCheckRequest{BytesRequested: bytesRequested} - if err := c.doPostJSON("/v1/charging/credit/"+ip+"/check", req, &resp); err != nil { + if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/check", req, &resp); err != nil { return nil, err } return &resp, nil @@ -37,7 +37,7 @@ func (c *Client) CheckCredit(ip string, bytesRequested uint64) (*CreditCheckResp // GetBalance gets the current credit balance for a subscriber func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) { var resp CreditBalanceResponse - if err := c.doGetJSON("/v1/charging/credit/"+ip+"/balance", &resp); err != nil { + if err := c.doGetJSON("/api/v1/charging/credit/"+ip+"/balance", &resp); err != nil { return nil, err } return &resp, nil @@ -47,7 +47,7 @@ func (c *Client) GetBalance(ip string) (*CreditBalanceResponse, error) { func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse, error) { var resp CreditBalanceResponse req := CreditAddRequest{BytesToAdd: bytesToAdd} - if err := c.doPostJSON("/v1/charging/credit/"+ip+"/add", req, &resp); err != nil { + if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/add", req, &resp); err != nil { return nil, err } return &resp, nil @@ -57,7 +57,7 @@ func (c *Client) AddCredit(ip string, bytesToAdd uint64) (*CreditBalanceResponse func (c *Client) DeductCredit(ip string, bytesUsed uint64) (*CreditBalanceResponse, error) { var resp CreditBalanceResponse req := CreditDeductRequest{BytesUsed: bytesUsed} - if err := c.doPostJSON("/v1/charging/credit/"+ip+"/deduct", req, &resp); err != nil { + if err := c.doPostJSON("/api/v1/charging/credit/"+ip+"/deduct", req, nil); err != nil { return nil, err } return &resp, nil diff --git a/apps/packet-gateway/src/main.rs b/apps/packet-gateway/src/main.rs index 8818804..e62a363 100644 --- a/apps/packet-gateway/src/main.rs +++ b/apps/packet-gateway/src/main.rs @@ -94,7 +94,7 @@ async fn main() -> Result<()> { #[cfg(feature = "ebpf")] let (ebpf_manager, ebpf_attached) = { // Initialize eBPF manager - let mut ebpf_manager = EbpfManager::new(args.interface.clone()).await + let mut ebpf_manager = EbpfManager::new(&args.interface).await .context("Failed to initialize eBPF manager")?; // Load and attach XDP program @@ -123,7 +123,7 @@ async fn main() -> Result<()> { info!("Packet gateway running. Press Ctrl+C to exit."); // Start HTTP health check server - let redis_client_arc = Arc::new(redis_client.clone()); + let redis_client_arc = Arc::new(redis_client); let ebpf_attached_clone = Arc::clone(&ebpf_attached); #[cfg(feature = "ebpf")] @@ -133,9 +133,9 @@ async fn main() -> Result<()> { // Create config state let config = PacketGatewayConfig { - interface: args.interface.clone(), - redis_url: args.redis_url.clone(), - charging_engine_url: args.charging_engine_url.clone(), + interface: args.interface, + redis_url: args.redis_url, + charging_engine_url: args.charging_engine_url, sync_interval: args.sync_interval, health_port: args.health_port, }; @@ -215,7 +215,7 @@ async fn main() -> Result<()> { s.ip & 0xFF ); let session_id = format!("session-{}", ip_str); - (ip_str.clone(), session_id, s.bytes) + (ip_str, session_id, s.bytes) }) .collect(); diff --git a/docker-compose.yml b/docker-compose.yml index 3f31d5e..d490ff0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,26 @@ services: + traefik: + image: traefik:v3.0 + container_name: taas-traefik + restart: unless-stopped + command: + - "--configFile=/traefik/traefik.yml" + ports: + - "80:80" + - "443:443" + - "8080:8080" # Traefik dashboard + volumes: + - ./traefik:/traefik + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + JWT_SECRET: "${JWT_SECRET:-change-me-in-production-jwt-secret-32-chars-min}" + networks: + - taas-network + depends_on: + - api-server + - charging-engine + - carrier-connector + mongodb: image: mongo:7.0 container_name: taas-mongodb @@ -185,8 +207,8 @@ services: dockerfile: deployments/docker/api-server.Dockerfile container_name: taas-api-server restart: unless-stopped - ports: - - "8000:8000" + expose: + - "8000" environment: MONGODB_URI: mongodb://admin:changeme@mongodb:27017/free5gc REDIS_URI: redis://redis:6379 @@ -197,6 +219,17 @@ services: - redis networks: - taas-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.api-server.rule=Host(`api.telecom.com`) && PathPrefix(`/api/v1`)" + - "traefik.http.routers.api-server.entrypoints=websecure" + - "traefik.http.routers.api-server.tls=true" + - "traefik.http.routers.api-server.middlewares=api-ratelimit,secure-headers,compression" + - "traefik.http.services.api-server.loadbalancer.server.port=8000" + - "traefik.http.routers.api-server-docs.rule=Host(`api.telecom.com`) && Path(`/api/v1/swagger`) || Path(`/api/v1/swagger/*`)" + - "traefik.http.routers.api-server-docs.entrypoints=websecure" + - "traefik.http.routers.api-server-docs.tls=true" + - "traefik.http.routers.api-server-docs.middlewares=secure-headers,compression" carrier-connector: build: @@ -204,11 +237,20 @@ services: dockerfile: deployments/docker/carrier-connector.Dockerfile container_name: taas-carrier-connector restart: unless-stopped + expose: + - "8081" environment: SMDP_URL: https://smdp.example.com SMDP_API_KEY: your-api-key networks: - taas-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.carrier-connector.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/es2`) || PathPrefix(`/v1/carrier`)" + - "traefik.http.routers.carrier-connector.entrypoints=websecure" + - "traefik.http.routers.carrier-connector.tls=true" + - "traefik.http.routers.carrier-connector.middlewares=carrier-ratelimit,secure-headers,compression,retry-attempts" + - "traefik.http.services.carrier-connector.loadbalancer.server.port=8081" charging-engine: build: @@ -216,8 +258,8 @@ services: dockerfile: deployments/docker/charging-engine.Dockerfile container_name: taas-charging-engine restart: unless-stopped - ports: - - "8080:8080" + expose: + - "8080" environment: REDIS_URI: redis://redis:6379 SERVER_PORT: 8080 @@ -226,6 +268,46 @@ services: - redis networks: - taas-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.charging-engine.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/credit`) || PathPrefix(`/v1/usage`) || PathPrefix(`/v1/rating-plans`)" + - "traefik.http.routers.charging-engine.entrypoints=websecure" + - "traefik.http.routers.charging-engine.tls=true" + - "traefik.http.routers.charging-engine.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts" + - "traefik.http.services.charging-engine.loadbalancer.server.port=8080" + - "traefik.http.routers.charging-engine-health.rule=Host(`api.telecom.com`) && Path(`/v1/health`)" + - "traefik.http.routers.charging-engine-health.entrypoints=websecure" + - "traefik.http.routers.charging-engine-health.tls=true" + - "traefik.http.routers.charging-engine-health.middlewares=secure-headers" + + packet-gateway: + build: + context: . + dockerfile: deployments/docker/packet-gateway.Dockerfile + container_name: taas-packet-gateway + restart: unless-stopped + expose: + - "8083" + environment: + REDIS_URL: redis://redis:6379 + CHARGING_ENGINE_URL: http://charging-engine:8080 + HEALTH_PORT: 8083 + depends_on: + - redis + - charging-engine + networks: + - taas-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.packet-gateway.rule=Host(`api.telecom.com`) && PathPrefix(`/v1/packet`) || PathPrefix(`/v1/gateway`)" + - "traefik.http.routers.packet-gateway.entrypoints=websecure" + - "traefik.http.routers.packet-gateway.tls=true" + - "traefik.http.routers.packet-gateway.middlewares=charging-ratelimit,secure-headers,compression,retry-attempts" + - "traefik.http.services.packet-gateway.loadbalancer.server.port=8083" + - "traefik.http.routers.packet-gateway-health.rule=Host(`api.telecom.com`) && Path(`/health`)" + - "traefik.http.routers.packet-gateway-health.entrypoints=websecure" + - "traefik.http.routers.packet-gateway-health.tls=true" + - "traefik.http.routers.packet-gateway-health.middlewares=secure-headers" web-dashboard: build: @@ -236,7 +318,7 @@ services: ports: - "3000:3000" environment: - NEXT_PUBLIC_API_URL: http://api-server:8000 + NEXT_PUBLIC_API_URL: https://api.telecom.com depends_on: - api-server networks: diff --git a/scripts/start-gateway.sh b/scripts/start-gateway.sh new file mode 100755 index 0000000..15d10ab --- /dev/null +++ b/scripts/start-gateway.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +# Telecom Platform API Gateway Startup Script +# This script starts the platform with Traefik as the API Gateway + +set -e + +echo "Starting Telecom Platform with API Gateway..." + +# Check for required environment variables +if [ -z "$JWT_SECRET" ]; then + echo "WARNING: JWT_SECRET not set, using default for development only" + export JWT_SECRET="change-me-in-production-jwt-secret-32-chars-min" +fi + +# Create necessary directories +mkdir -p traefik/dynamic + +# Stop any existing containers +echo "Stopping existing containers..." +docker-compose down + +# Build and start services +echo "Building and starting services with API Gateway..." +docker-compose up --build -d + +# Wait for services to be ready +echo "Waiting for services to be ready..." +sleep 10 + +# Check service health +echo "Checking service health..." + +# Check Traefik +if curl -f http://localhost:8080/ping >/dev/null 2>&1; then + echo "Traefik: OK" +else + echo "Traefik: FAILED" +fi + +# Check API Server through gateway +if curl -f http://localhost/api/v1/health >/dev/null 2>&1; then + echo "API Server: OK" +else + echo "API Server: FAILED" +fi + +# Check Charging Engine through gateway +if curl -f http://localhost/v1/health >/dev/null 2>&1; then + echo "Charging Engine: OK" +else + echo "Charging Engine: FAILED" +fi + +echo "" +echo "API Gateway is running!" +echo "" +echo "Services:" +echo " - Traefik Dashboard: http://localhost:8080" +echo " - API Server: https://api.telecom.com/api/v1" +echo " - Charging Engine: https://api.telecom.com/v1/credit" +echo " - Carrier Connector: https://api.telecom.com/v1/es2" +echo " - Packet Gateway: https://api.telecom.com/v1/packet" +echo " - Web Dashboard: http://localhost:3000" +echo "" +echo "To add api.telecom.com to your hosts file:" +echo " echo '127.0.0.1 api.telecom.com' | sudo tee -a /etc/hosts" +echo "" +echo "To view logs:" +echo " docker-compose logs -f traefik" +echo " docker-compose logs -f api-server" +echo " docker-compose logs -f charging-engine" diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml new file mode 100644 index 0000000..b45fc64 --- /dev/null +++ b/traefik/dynamic/middlewares.yml @@ -0,0 +1,56 @@ +http: + middlewares: + secure-headers: + headers: + customRequestHeaders: + X-Forwarded-Proto: "https" + customResponseHeaders: + X-Content-Type-Options: "nosniff" + X-Frame-Options: "DENY" + X-XSS-Protection: "1; mode=block" + Referrer-Policy: "strict-origin-when-cross-origin" + Permissions-Policy: "camera=(), microphone=(), geolocation=()" + Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';" + + # Rate Limiting Middleware (API endpoints) + api-ratelimit: + rateLimit: + average: 100 + period: "1m" + burst: 200 + + # Rate Limiting Middleware (Charging endpoints - more restrictive) + charging-ratelimit: + rateLimit: + average: 1000 + period: "1m" + burst: 2000 + + # Rate Limiting Middleware (Carrier endpoints - very restrictive) + carrier-ratelimit: + rateLimit: + average: 50 + period: "1m" + burst: 100 + + # JWT Authentication Middleware + jwt-auth: + plugin: + JWTAuth: + secret: "${JWT_SECRET}" + authHeaderName: "Authorization" + responseHeaderName: "X-Auth-User" + forwardAuth: true + + # Compression Middleware + compression: + compress: + excludedContentTypes: + - "text/event-stream" + - "application/grpc" + + # Retry Middleware + retry-attempts: + retry: + attempts: 3 + initialInterval: "100ms" diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..a93b5c5 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,38 @@ +api: + dashboard: true + insecure: true + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + websecure: + address: ":443" + http: + middlewares: + - secure-headers@file + +providers: + docker: + exposedByDefault: false + network: taas-network + file: + directory: /traefik/dynamic + watch: true + +log: + level: INFO + format: json + +accessLog: {} + +metrics: + prometheus: + addEntryPointsLabels: true + addServicesLabels: true + +ping: {}