feat: Initialize telecom platform with core services and infrastructure

- Add Go-based API server and carrier connector services
- Add Rust-based charging engine and packet gateway services
- Add Docker Compose configuration for local development
- Add comprehensive documentation (API, Architecture, Deployment)
- Add development scripts for Redis, MongoDB, and environment setup
- Add workspace configuration for Go modules and pnpm
- Remove unnecessary documentation files from web-dashboard
- Add project build configuration (Makefile, Cargo.toml)

This commit establishes the foundational microservices architecture for the sovereign Telecom-as-a-Service platform.
This commit is contained in:
nutcas3
2026-04-03 19:55:38 +03:00
parent a69c6a125a
commit b69b7ec087
26 changed files with 2573 additions and 6 deletions
+6
View File
@@ -0,0 +1,6 @@
MONGODB_URI=mongodb://localhost:27017/free5gc
REDIS_URI=redis://localhost:6379
API_PORT=8000
GIN_MODE=debug
JWT_SECRET=change-me-in-production
LOG_LEVEL=info
+13
View File
@@ -0,0 +1,13 @@
module github.com/taas-platform/telecom-platform/apps/api-server
go 1.26
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.35.0
go.mongodb.org/mongo-driver v1.17.9
go.opentelemetry.io/otel v1.43.0
)
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/rs/zerolog"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var (
logger zerolog.Logger
dbClient *mongo.Client
)
func main() {
// Initialize logger
logger = zerolog.New(os.Stdout).With().
Timestamp().
Str("service", "api-server").
Logger()
// Load environment variables
if err := godotenv.Load(); err != nil {
logger.Info().Msg("No .env file found, using system environment")
}
// Connect to MongoDB
mongoURI := getEnv("MONGODB_URI", "mongodb://localhost:27017/free5gc")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
dbClient, err = mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
if err != nil {
logger.Fatal().Err(err).Msg("Failed to connect to MongoDB")
}
defer dbClient.Disconnect(context.Background())
// Verify connection
if err := dbClient.Ping(ctx, nil); err != nil {
logger.Fatal().Err(err).Msg("Failed to ping MongoDB")
}
logger.Info().Msg("Connected to MongoDB successfully")
// Initialize Gin router
router := setupRouter()
// Start server
port := getEnv("API_PORT", "8000")
logger.Info().Str("port", port).Msg("Starting API server")
if err := router.Run(":" + port); err != nil {
logger.Fatal().Err(err).Msg("Failed to start server")
}
}
func setupRouter() *gin.Engine {
// Set Gin mode
if getEnv("GIN_MODE", "debug") == "release" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()
// Health check
router.GET("/health", healthCheck)
// API v1 routes
v1 := router.Group("/v1")
{
// eSIM management
v1.POST("/esims", createESIM)
v1.GET("/esims/:id", getESIM)
v1.GET("/esims/:id/usage", getESIMUsage)
v1.DELETE("/esims/:id", deleteESIM)
// Subscriber management
v1.GET("/subscribers", listSubscribers)
v1.GET("/subscribers/:imsi", getSubscriber)
}
return router
}
// Health check endpoint
func healthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "api-server",
"timestamp": time.Now().Unix(),
})
}
// POST /v1/esims - Create new eSIM
func createESIM(c *gin.Context) {
var req struct {
DataPlan string `json:"data_plan" binding:"required"`
CountryCode string `json:"country_code" binding:"required"`
Carrier string `json:"carrier,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate IMSI (simplified - use proper allocation in production)
imsi := fmt.Sprintf("208930%09d", time.Now().UnixNano()%1000000000)
// Using Go 1.26's new(expr) for optional pointer fields
esim := gin.H{
"esim_id": uuid.New().String(),
"imsi": imsi,
"iccid": generateICCID(imsi),
"activation_code": fmt.Sprintf("LPA:1$smdp.example.com$%s", uuid.New().String()),
"status": "provisioned",
"data_plan": req.DataPlan,
"country_code": req.CountryCode,
"created_at": time.Now(),
}
logger.Info().
Str("esim_id", esim["esim_id"].(string)).
Str("imsi", imsi).
Msg("eSIM created")
c.JSON(http.StatusCreated, esim)
}
// GET /v1/esims/:id - Get eSIM details
func getESIM(c *gin.Context) {
esimID := c.Param("id")
// TODO: Implement actual database lookup
esim := gin.H{
"esim_id": esimID,
"imsi": "208930000000001",
"iccid": "8933123456789012345",
"status": "active",
"data_plan": "1GB",
"created_at": time.Now().Add(-24 * time.Hour),
}
c.JSON(http.StatusOK, esim)
}
// GET /v1/esims/:id/usage - Get eSIM usage statistics
func getESIMUsage(c *gin.Context) {
esimID := c.Param("id")
usage := gin.H{
"esim_id": esimID,
"data_used": 250 * 1024 * 1024, // 250 MB in bytes
"data_limit": 1024 * 1024 * 1024, // 1 GB in bytes
"sessions": []gin.H{
{
"started_at": time.Now().Add(-2 * time.Hour),
"ended_at": time.Now().Add(-1 * time.Hour),
"bytes_up": 10 * 1024 * 1024,
"bytes_down": 50 * 1024 * 1024,
},
},
"updated_at": time.Now(),
}
c.JSON(http.StatusOK, usage)
}
// DELETE /v1/esims/:id - Terminate eSIM
func deleteESIM(c *gin.Context) {
esimID := c.Param("id")
logger.Info().Str("esim_id", esimID).Msg("eSIM terminated")
c.JSON(http.StatusOK, gin.H{
"message": "eSIM terminated successfully",
"esim_id": esimID,
})
}
// GET /v1/subscribers - List all subscribers
func listSubscribers(c *gin.Context) {
// TODO: Implement MongoDB query
subscribers := []gin.H{
{
"imsi": "208930000000001",
"status": "active",
"created_at": time.Now().Add(-7 * 24 * time.Hour),
},
}
c.JSON(http.StatusOK, gin.H{
"subscribers": subscribers,
"count": len(subscribers),
})
}
// GET /v1/subscribers/:imsi - Get subscriber details
func getSubscriber(c *gin.Context) {
imsi := c.Param("imsi")
subscriber := gin.H{
"imsi": imsi,
"plmn_id": "20893",
"status": "active",
"created_at": time.Now().Add(-7 * 24 * time.Hour),
}
c.JSON(http.StatusOK, subscriber)
}
// Utility functions
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func generateICCID(imsi string) string {
// ICCID format: 89 (Telecom) + CC (Country) + ISSUER + ACCOUNT + CHECK
// Simplified implementation
return fmt.Sprintf("893312%s%d", imsi[5:14], time.Now().Unix()%10)
}
+10
View File
@@ -0,0 +1,10 @@
module github.com/taas-platform/telecom-platform/apps/carrier-connector
go 1.26
require (
github.com/go-resty/resty/v2 v2.15.3
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.33.0
)
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"crypto/tls"
"fmt"
"log"
"os"
"time"
"github.com/go-resty/resty/v2"
"github.com/joho/godotenv"
"github.com/rs/zerolog"
)
var logger zerolog.Logger
type ES2Client struct {
baseURL string
apiKey string
httpClient *resty.Client
}
type ProfileOrder struct {
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
K string `json:"k"`
OPc string `json:"opc"`
MCC string `json:"mcc"`
MNC string `json:"mnc"`
ProfileType string `json:"profileType"`
}
type ProfileResponse struct {
ActivationCode string `json:"activationCode"`
ProfileID string `json:"profileId"`
Status string `json:"status"`
}
func main() {
// Initialize logger
logger = zerolog.New(os.Stdout).With().
Timestamp().
Str("service", "carrier-connector").
Logger()
// Load environment variables
if err := godotenv.Load(); err != nil {
logger.Info().Msg("No .env file found, using system environment")
}
logger.Info().Msg("Carrier Connector started")
// Initialize ES2+ client
smdpURL := getEnv("SMDP_URL", "https://smdp.example.com")
apiKey := getEnv("SMDP_API_KEY", "test-api-key")
client := NewES2Client(smdpURL, apiKey)
// Example: Order a profile
order := &ProfileOrder{
ICCID: "8933123456789012345",
IMSI: "208930000000001",
K: "465B5CE8B199B49FAA5F0A2EE238A6BC",
OPc: "E8ED289DEBA952E4283B54E88E6183CA",
MCC: "208",
MNC: "93",
ProfileType: "operational",
}
logger.Info().
Str("imsi", order.IMSI).
Msg("Ordering eSIM profile from SM-DP+")
// In production, this would be triggered by API requests
response, err := client.OrderProfile(order)
if err != nil {
logger.Error().Err(err).Msg("Failed to order profile")
} else {
logger.Info().
Str("activation_code", response.ActivationCode).
Str("profile_id", response.ProfileID).
Msg("Profile ordered successfully")
}
// Keep service running
logger.Info().Msg("Carrier Connector running. Press Ctrl+C to exit.")
select {}
}
func NewES2Client(baseURL, apiKey string) *ES2Client {
client := resty.New().
SetBaseURL(baseURL).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", apiKey)).
SetHeader("Content-Type", "application/json").
SetTimeout(10 * time.Second).
SetTLSClientConfig(&tls.Config{
MinVersion: tls.VersionTLS12,
})
return &ES2Client{
baseURL: baseURL,
apiKey: apiKey,
httpClient: client,
}
}
func (c *ES2Client) OrderProfile(order *ProfileOrder) (*ProfileResponse, error) {
var response ProfileResponse
// NOTE: This is a simplified example
// In production, implement full GSMA ES2+ protocol
resp, err := c.httpClient.R().
SetBody(order).
SetResult(&response).
Post("/gsma/rsp2/es2plus/createProfile")
if err != nil {
return nil, fmt.Errorf("ES2+ API call failed: %w", err)
}
if resp.StatusCode() != 200 {
return nil, fmt.Errorf("SM-DP+ returned error: %s", resp.String())
}
return &response, nil
}
func getEnv(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
+3
View File
@@ -0,0 +1,3 @@
REDIS_URI=redis://127.0.0.1/
SERVER_PORT=8080
RUST_LOG=info
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "charging-engine"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
redis = { workspace = true }
axum = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
anyhow = { workspace = true }
dotenv = { workspace = true }
uuid = { workspace = true }
[[bin]]
name = "charging-engine"
path = "src/main.rs"
+228
View File
@@ -0,0 +1,228 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing::{info, warn};
#[derive(Clone)]
struct AppState {
redis: redis::Client,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_target(false)
.compact()
.init();
// Load environment variables
dotenv::dotenv().ok();
// Connect to Redis
let redis_url = std::env::var("REDIS_URI").unwrap_or_else(|_| "redis://127.0.0.1/".to_string());
let redis_client = redis::Client::open(redis_url)?;
// Test connection
let mut conn = redis_client.get_multiplexed_async_connection().await?;
let _: () = redis::cmd("PING").query_async(&mut conn).await?;
info!("Connected to Redis successfully");
let state = Arc::new(AppState {
redis: redis_client,
});
// Build HTTP API with CORS
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/v1/credit/:ip/check", post(check_credit))
.route("/v1/credit/:ip/deduct", post(deduct_credit))
.route("/v1/credit/:ip/add", post(add_credit))
.route("/v1/credit/:ip/balance", get(get_balance))
.route("/health", get(health_check))
.layer(cors)
.with_state(state);
// Start server
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string());
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Charging engine listening on {}", addr);
axum::serve(listener, app).await?;
Ok(())
}
#[derive(Deserialize)]
struct CreditCheckRequest {
bytes_requested: u64,
}
#[derive(Serialize)]
struct CreditCheckResponse {
allowed: bool,
remaining_bytes: i64,
}
/// POST /v1/credit/:ip/check
/// Check if user has enough credit for data usage
async fn check_credit(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<CreditCheckRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(CreditCheckResponse {
allowed: false,
remaining_bytes: 0,
}),
);
}
};
let key = format!("credit:{}", ip);
// Get current credit balance
let credit: i64 = conn.get(&key).await.unwrap_or(0);
let allowed = credit >= req.bytes_requested as i64;
info!(
"Credit check for {}: {} bytes requested, {} bytes available, allowed: {}",
ip, req.bytes_requested, credit, allowed
);
(
StatusCode::OK,
Json(CreditCheckResponse {
allowed,
remaining_bytes: credit,
}),
)
}
#[derive(Deserialize)]
struct DeductRequest {
bytes_used: u64,
}
/// POST /v1/credit/:ip/deduct
/// Deduct bytes from user's credit balance
async fn deduct_credit(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<DeductRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
};
let key = format!("credit:{}", ip);
// Atomically subtract bytes
let new_balance: i64 = conn.decr(&key, req.bytes_used).await.unwrap_or(0);
info!(
"User {} deducted {} bytes, remaining: {}",
ip, req.bytes_used, new_balance
);
StatusCode::OK
}
#[derive(Deserialize)]
struct AddCreditRequest {
bytes_to_add: u64,
}
/// POST /v1/credit/:ip/add
/// Add bytes to user's credit balance
async fn add_credit(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<AddCreditRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
};
let key = format!("credit:{}", ip);
// Atomically add bytes
let new_balance: i64 = conn.incr(&key, req.bytes_to_add).await.unwrap_or(0);
info!(
"User {} added {} bytes, new balance: {}",
ip, req.bytes_to_add, new_balance
);
StatusCode::OK
}
#[derive(Serialize)]
struct BalanceResponse {
ip: String,
balance_bytes: i64,
}
/// GET /v1/credit/:ip/balance
/// Get current credit balance
async fn get_balance(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BalanceResponse {
ip: ip.clone(),
balance_bytes: 0,
}),
);
}
};
let key = format!("credit:{}", ip);
let balance: i64 = conn.get(&key).await.unwrap_or(0);
(
StatusCode::OK,
Json(BalanceResponse {
ip,
balance_bytes: balance,
}),
)
}
async fn health_check() -> &'static str {
"OK"
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "packet-gateway"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
aya = { workspace = true }
aya-log = { workspace = true }
tokio = { workspace = true }
redis = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenv = { workspace = true }
clap = { version = "4.5", features = ["derive"] }
[[bin]]
name = "packet-gateway"
path = "src/main.rs"
+79
View File
@@ -0,0 +1,79 @@
use anyhow::{Context, Result};
use clap::Parser;
use redis::AsyncCommands;
use tokio::time::{interval, Duration};
use tracing::{info, warn, error};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Network interface to attach XDP program
#[arg(short, long, default_value = "eth0")]
interface: String,
/// Redis connection URL
#[arg(short, long, default_value = "redis://127.0.0.1/")]
redis_url: String,
/// Stats sync interval in seconds
#[arg(short, long, default_value = "1")]
sync_interval: u64,
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize tracing
tracing_subscriber::fmt()
.with_target(false)
.compact()
.init();
dotenv::dotenv().ok();
let args = Args::parse();
info!("Starting Packet Gateway");
info!("Interface: {}", args.interface);
info!("Redis URL: {}", args.redis_url);
// NOTE: This is a simplified version
// For full eBPF implementation, you need:
// 1. Compile eBPF program with aya-bpf
// 2. Load and attach XDP program
// 3. Access eBPF maps for packet stats and credit control
// Connect to Redis
let redis_client = redis::Client::open(args.redis_url.as_str())?;
let mut redis_conn = redis_client.get_multiplexed_async_connection().await?;
// Test connection
let _: () = redis::cmd("PING").query_async(&mut redis_conn).await?;
info!("Connected to Redis successfully");
// Simulate packet processing loop
// In production, this would read from eBPF maps
let mut ticker = interval(Duration::from_secs(args.sync_interval));
info!("Packet gateway running. Press Ctrl+C to exit.");
loop {
ticker.tick().await;
// Simulate updating packet statistics to Redis
// In real implementation, read from eBPF map
let test_ip = "10.0.0.1";
let test_bytes: u64 = 1024 * 1024; // 1 MB
let key = format!("usage:{}", test_ip);
let _: () = redis_conn.set_ex(&key, test_bytes, 3600).await?;
info!("Updated usage for {}: {} bytes", test_ip, test_bytes);
// Check credit status
let credit_key = format!("credit:{}", test_ip);
let credit: i64 = redis_conn.get(&credit_key).await.unwrap_or(0);
if credit <= 0 {
warn!("User {} has no credit - would block traffic in eBPF", test_ip);
}
}
}
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md