diff --git a/apps/api-server/go.mod b/apps/api-server/go.mod index 106c045..457775d 100644 --- a/apps/api-server/go.mod +++ b/apps/api-server/go.mod @@ -1,13 +1,60 @@ -module github.com/taas-platform/telecom-platform/apps/api-server +module github.com/nutcas3/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 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + 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 + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // 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.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang/snappy v1.0.0 // 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.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/montanaflynn/stats v0.9.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + 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/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.26.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/apps/api-server/go.sum b/apps/api-server/go.sum new file mode 100644 index 0000000..4c521e6 --- /dev/null +++ b/apps/api-server/go.sum @@ -0,0 +1,155 @@ +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= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +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.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/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/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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/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/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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= +github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.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= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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 v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4= +golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/apps/api-server/internal/config/config.go b/apps/api-server/internal/config/config.go new file mode 100644 index 0000000..10d412d --- /dev/null +++ b/apps/api-server/internal/config/config.go @@ -0,0 +1,83 @@ +package config + +import ( + "time" +) + +type Config struct { + Server ServerConfig `json:"server"` + Database DatabaseConfig `json:"database"` + Redis RedisConfig `json:"redis"` + IMSI IMSIConfig `json:"imsi"` + ES2 ES2Config `json:"es2"` +} + +type ServerConfig struct { + Port string `json:"port"` + ReadTimeout time.Duration `json:"read_timeout"` + WriteTimeout time.Duration `json:"write_timeout"` +} + +type DatabaseConfig struct { + Host string `json:"host"` + Port string `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + SSLMode string `json:"ssl_mode"` +} + +type RedisConfig struct { + Host string `json:"host"` + Port string `json:"port"` + Password string `json:"password"` + DB int `json:"db"` +} + +type IMSIConfig struct { + Prefix string `json:"prefix"` + MinIMSI uint64 `json:"min_imsi"` + MaxIMSI uint64 `json:"max_imsi"` +} + +type ES2Config struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + FunctionalityRequesterID string `json:"functionality_requester_id"` +} + +func LoadConfig() *Config { + return &Config{ + Server: ServerConfig{ + Port: "8000", + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + }, + Database: DatabaseConfig{ + Host: "localhost", + Port: "5432", + Database: "telecom_platform", + Username: "postgres", + Password: "password", + SSLMode: "disable", + }, + Redis: RedisConfig{ + Host: "localhost", + Port: "6379", + Password: "", + DB: 0, + }, + IMSI: IMSIConfig{ + Prefix: "20893", // France MCC+MNC + MinIMSI: 1, + MaxIMSI: 999999999, + }, + ES2: ES2Config{ + BaseURL: "https://smdp.example.com", + APIKey: "", + InsecureSkipVerify: true, + FunctionalityRequesterID: "telecom-platform", + }, + } +} diff --git a/apps/api-server/internal/database/database.go b/apps/api-server/internal/database/database.go new file mode 100644 index 0000000..0728d69 --- /dev/null +++ b/apps/api-server/internal/database/database.go @@ -0,0 +1,198 @@ +package database + +import ( + "context" + "fmt" + "log" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +type Database struct { + DB *gorm.DB +} + +type IMSIAllocation struct { + ID uint `gorm:"primaryKey"` + LastIMSI uint64 `gorm:"not null"` + MinIMSI uint64 `gorm:"not null"` + MaxIMSI uint64 `gorm:"not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", + cfg.Host, cfg.Username, cfg.Password, cfg.Database, cfg.Port, cfg.SSLMode) + + // Configure GORM logger + gormLogger := logger.Default.LogMode(logger.Info) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: gormLogger, + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Get underlying SQL DB to configure connection pool + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + // Configure connection pool + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + database := &Database{DB: db} + + // Auto-migrate schemas + if err := database.AutoMigrate(); err != nil { + return nil, fmt.Errorf("failed to auto-migrate: %w", err) + } + + // Initialize IMSI allocation + if err := database.InitializeIMSIAllocation(); err != nil { + return nil, fmt.Errorf("failed to initialize IMSI allocation: %w", err) + } + + return database, nil +} + +func (d *Database) AutoMigrate() error { + return d.DB.AutoMigrate( + &models.Subscriber{}, + &models.ServicePlan{}, + &models.Session{}, + &models.UsageRecord{}, + &IMSIAllocation{}, + ) +} + +func (d *Database) InitializeIMSIAllocation() error { + var allocation IMSIAllocation + result := d.DB.First(&allocation) + + if result.Error == gorm.ErrRecordNotFound { + // Create initial allocation record + allocation = IMSIAllocation{ + LastIMSI: 0, + MinIMSI: 1, + MaxIMSI: 999999999, // 9-digit subscriber numbers + } + + if err := d.DB.Create(&allocation).Error; err != nil { + return fmt.Errorf("failed to create IMSI allocation: %w", err) + } + + log.Printf("Created IMSI allocation record") + } else if result.Error != nil { + return fmt.Errorf("failed to query IMSI allocation: %w", result.Error) + } + + return nil +} + +func (d *Database) Close() error { + sqlDB, err := d.DB.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func (d *Database) CreateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { + return d.DB.WithContext(ctx).Create(subscriber).Error +} + +func (d *Database) GetSubscriber(ctx context.Context, id uint) (*models.Subscriber, error) { + var subscriber models.Subscriber + err := d.DB.WithContext(ctx).Preload("Plan").First(&subscriber, id).Error + if err != nil { + return nil, err + } + return &subscriber, nil +} + +func (d *Database) GetSubscriberByIMSI(ctx context.Context, imsi models.IMSI) (*models.Subscriber, error) { + var subscriber models.Subscriber + err := d.DB.WithContext(ctx).Preload("Plan").Where("imsi = ?", imsi).First(&subscriber).Error + if err != nil { + return nil, err + } + return &subscriber, nil +} + +func (d *Database) UpdateSubscriber(ctx context.Context, subscriber *models.Subscriber) error { + return d.DB.WithContext(ctx).Save(subscriber).Error +} + +func (d *Database) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) ([]models.Subscriber, int64, error) { + var subscribers []models.Subscriber + var total int64 + + query := d.DB.WithContext(ctx).Model(&models.Subscriber{}) + + // Apply filters + if req.Status != "" { + query = query.Where("status = ?", req.Status) + } + if req.OrganizationID != "" { + query = query.Where("organization_id = ?", req.OrganizationID) + } + if req.Search != "" { + query = query.Where("first_name ILIKE ? OR last_name ILIKE ? OR email ILIKE ? OR msisdn ILIKE ?", + "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%", "%"+req.Search+"%") + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Apply pagination and ordering + offset := (req.Page - 1) * req.PageSize + err := query.Preload("Plan").Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&subscribers).Error + + return subscribers, total, err +} + +func (d *Database) GetIMSIAllocation(ctx context.Context) (*IMSIAllocation, error) { + var allocation IMSIAllocation + err := d.DB.WithContext(ctx).First(&allocation).Error + if err != nil { + return nil, err + } + return &allocation, nil +} + +func (d *Database) UpdateIMSIAllocation(ctx context.Context, allocation *IMSIAllocation) error { + return d.DB.WithContext(ctx).Save(allocation).Error +} + +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 + return sessions, err +} + +func (d *Database) UpdateSession(ctx context.Context, session *models.Session) error { + return d.DB.WithContext(ctx).Save(session).Error +} + +// Request types for database operations +type ListSubscribersRequest struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Status models.SubscriberStatus `json:"status"` + OrganizationID string `json:"organization_id"` + Search string `json:"search"` +} diff --git a/apps/api-server/internal/handlers/subscriber.go b/apps/api-server/internal/handlers/subscriber.go new file mode 100644 index 0000000..e3d55be --- /dev/null +++ b/apps/api-server/internal/handlers/subscriber.go @@ -0,0 +1,270 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/services" +) + +// SubscriberHandler handles subscriber-related HTTP requests +type SubscriberHandler struct { + subscriberService *services.SubscriberService +} + +// NewSubscriberHandler creates a new subscriber handler +func NewSubscriberHandler(subscriberService *services.SubscriberService) *SubscriberHandler { + return &SubscriberHandler{ + subscriberService: subscriberService, + } +} + +// CreateSubscriber creates a new subscriber +// @Summary Create a new subscriber +// @Description Create a new subscriber with allocated IMSI +// @Tags subscribers +// @Accept json +// @Produce json +// @Param subscriber body services.CreateSubscriberRequest true "Subscriber data" +// @Success 201 {object} models.Subscriber +// @Failure 400 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers [post] +func (h *SubscriberHandler) CreateSubscriber(c *gin.Context) { + var req services.CreateSubscriberRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request body", + Details: err.Error(), + }) + return + } + + subscriber, err := h.subscriberService.CreateSubscriber(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to create subscriber", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusCreated, subscriber) +} + +// GetSubscriber retrieves a subscriber by ID +// @Summary Get subscriber by ID +// @Description Retrieve subscriber information by ID +// @Tags subscribers +// @Produce json +// @Param id path int true "Subscriber ID" +// @Success 200 {object} models.Subscriber +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers/{id} [get] +func (h *SubscriberHandler) GetSubscriber(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid subscriber ID", + Details: err.Error(), + }) + return + } + + subscriber, err := h.subscriberService.GetSubscriber(c.Request.Context(), uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, ErrorResponse{ + Error: "Subscriber not found", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, subscriber) +} + +// GetSubscriberByIMSI retrieves a subscriber by IMSI +// @Summary Get subscriber by IMSI +// @Description Retrieve subscriber information by IMSI +// @Tags subscribers +// @Produce json +// @Param imsi path string true "IMSI" +// @Success 200 {object} models.Subscriber +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers/imsi/{imsi} [get] +func (h *SubscriberHandler) GetSubscriberByIMSI(c *gin.Context) { + imsi := models.IMSI(c.Param("imsi")) + + subscriber, err := h.subscriberService.GetSubscriberByIMSI(c.Request.Context(), imsi) + if err != nil { + c.JSON(http.StatusNotFound, ErrorResponse{ + Error: "Subscriber not found", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, subscriber) +} + +// UpdateSubscriber updates subscriber information +// @Summary Update subscriber +// @Description Update subscriber information +// @Tags subscribers +// @Accept json +// @Produce json +// @Param id path int true "Subscriber ID" +// @Param subscriber body services.UpdateSubscriberRequest true "Updated subscriber data" +// @Success 200 {object} models.Subscriber +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers/{id} [put] +func (h *SubscriberHandler) UpdateSubscriber(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid subscriber ID", + Details: err.Error(), + }) + return + } + + var req services.UpdateSubscriberRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid request body", + Details: err.Error(), + }) + return + } + + subscriber, err := h.subscriberService.UpdateSubscriber(c.Request.Context(), uint(id), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to update subscriber", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, subscriber) +} + +// ListSubscribers lists subscribers with pagination and filtering +// @Summary List subscribers +// @Description List subscribers with pagination and filtering +// @Tags subscribers +// @Produce json +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Page size" default(20) +// @Param status query string false "Subscriber status" +// @Param organization_id query string false "Organization ID" +// @Param search query string false "Search term" +// @Success 200 {object} services.ListSubscribersResponse +// @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")) + + if pageSize > 100 { + pageSize = 100 + } + + req := &services.ListSubscribersRequest{ + Page: page, + PageSize: pageSize, + Status: models.SubscriberStatus(c.Query("status")), + OrganizationID: c.Query("organization_id"), + Search: c.Query("search"), + } + + response, err := h.subscriberService.ListSubscribers(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to list subscribers", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, response) +} + +// SuspendSubscriber suspends a subscriber +// @Summary Suspend subscriber +// @Description Suspend a subscriber and terminate their sessions +// @Tags subscribers +// @Produce json +// @Param id path int true "Subscriber ID" +// @Success 204 +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers/{id}/suspend [post] +func (h *SubscriberHandler) SuspendSubscriber(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid subscriber ID", + Details: err.Error(), + }) + return + } + + if err := h.subscriberService.SuspendSubscriber(c.Request.Context(), uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to suspend subscriber", + Details: err.Error(), + }) + return + } + + c.Status(http.StatusNoContent) +} + +// TerminateSubscriber terminates a subscriber +// @Summary Terminate subscriber +// @Description Terminate a subscriber and deactivate their eSIM profile +// @Tags subscribers +// @Produce json +// @Param id path int true "Subscriber ID" +// @Success 204 +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/v1/subscribers/{id}/terminate [post] +func (h *SubscriberHandler) TerminateSubscriber(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{ + Error: "Invalid subscriber ID", + Details: err.Error(), + }) + return + } + + if err := h.subscriberService.TerminateSubscriber(c.Request.Context(), uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Error: "Failed to terminate subscriber", + Details: err.Error(), + }) + return + } + + c.Status(http.StatusNoContent) +} + +// ErrorResponse represents an API error response +type ErrorResponse struct { + Error string `json:"error"` + Details string `json:"details,omitempty"` +} diff --git a/apps/api-server/internal/models/subscriber.go b/apps/api-server/internal/models/subscriber.go new file mode 100644 index 0000000..a1d38ab --- /dev/null +++ b/apps/api-server/internal/models/subscriber.go @@ -0,0 +1,178 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type IMSI string + +type Subscriber struct { + ID uint `json:"id" gorm:"primaryKey"` + IMSI IMSI `json:"imsi" gorm:"uniqueIndex;not null"` + MSISDN string `json:"msisdn" gorm:"uniqueIndex"` // Mobile phone number + IMEI string `json:"imei"` // Device identifier + + FirstName string `json:"first_name" gorm:"not null"` + LastName string `json:"last_name" gorm:"not null"` + Email string `json:"email" gorm:"uniqueIndex"` + OrganizationID string `json:"organization_id"` + + Status SubscriberStatus `json:"status" gorm:"default:'active'"` + PlanID uint `json:"plan_id"` + + AuthKey string `json:"auth_key" gorm:"not null"` + OPc string `json:"opc" gorm:"not null"` + ServingPLMN PLMN `json:"serving_plmn" gorm:"not null"` + + EUICCID string `json:"euicc_id"` + ProfileID string `json:"profile_id"` + ProfileStatus ProfileStatus `json:"profile_status" gorm:"default:'inactive'"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + Plan ServicePlan `json:"plan" gorm:"foreignKey:PlanID"` + Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:SubscriberID"` + UsageRecords []UsageRecord `json:"usage_records,omitempty" gorm:"foreignKey:SubscriberID"` +} + +type ServicePlan struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` + Description string `json:"description"` + + DataLimit int64 `json:"data_limit"` + VoiceLimit int `json:"voice_limit"` + SMSLimit int `json:"sms_limit"` + + MonthlyFee float64 `json:"monthly_fee"` + DataRate float64 `json:"data_rate"` + VoiceRate float64 `json:"voice_rate"` + SMSRate float64 `json:"sms_rate"` + + ARPA int `json:"arpa"` + AMBR AMBR `json:"ambr"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type Session struct { + ID uint `json:"id" gorm:"primaryKey"` + SubscriberID uint `json:"subscriber_id" gorm:"not null"` + SessionID string `json:"session_id" gorm:"uniqueIndex;not null"` + + PDUAddress string `json:"pdu_address"` + DNN string `json:"dnn"` + SNSSAI SNSSAI `json:"snssai"` + QoS QoSProfile `json:"qos" gorm:"embedded"` + + Status SessionStatus `json:"status" gorm:"default:'active'"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + + DataUsed int64 `json:"data_used"` + VoiceUsed int `json:"voice_used"` + SMSUsed int `json:"sms_used"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type UsageRecord struct { + ID uint `json:"id" gorm:"primaryKey"` + SubscriberID uint `json:"subscriber_id" gorm:"not null"` + SessionID uint `json:"session_id"` + + UsageType UsageType `json:"usage_type"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Volume int64 `json:"volume"` + Rate float64 `json:"rate"` + Cost float64 `json:"cost"` + + BillingCycle string `json:"billing_cycle"` + InvoiceID *uint `json:"invoice_id,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SubscriberStatus string + +const ( + SubscriberStatusActive SubscriberStatus = "active" + SubscriberStatusInactive SubscriberStatus = "inactive" + SubscriberStatusSuspended SubscriberStatus = "suspended" + SubscriberStatusTerminated SubscriberStatus = "terminated" + SubscriberStatusProvisioning SubscriberStatus = "provisioning" +) + +type ProfileStatus string + +const ( + ProfileStatusInactive ProfileStatus = "inactive" + ProfileStatusActive ProfileStatus = "active" + ProfileStatusDownloading ProfileStatus = "downloading" + ProfileStatusError ProfileStatus = "error" +) + +type SessionStatus string + +const ( + SessionStatusActive SessionStatus = "active" + SessionStatusInactive SessionStatus = "inactive" + SessionStatusTerminating SessionStatus = "terminating" +) + +type UsageType string + +const ( + UsageTypeData UsageType = "data" + UsageTypeVoice UsageType = "voice" + UsageTypeSMS UsageType = "sms" +) + +type PLMN struct { + MCC string `json:"mcc"` + MNC string `json:"mnc"` +} + +type SNSSAI struct { + SST uint8 `json:"sst"` + SD uint32 `json:"sd"` +} + +type AMBR struct { + Uplink uint32 `json:"uplink"` + Downlink uint32 `json:"downlink"` +} + +type QoSProfile struct { + QCI uint8 `json:"qci"` + ARPA uint32 `json:"arpa"` + MBR AMBR `json:"mbr"` + GBR AMBR `json:"gbr"` + Priority uint8 `json:"priority"` +} + +func (Subscriber) TableName() string { + return "subscribers" +} + +func (ServicePlan) TableName() string { + return "service_plans" +} + +func (Session) TableName() string { + return "sessions" +} + +func (UsageRecord) TableName() string { + return "usage_records" +} diff --git a/apps/api-server/internal/services/amf_client.go b/apps/api-server/internal/services/amf_client.go new file mode 100644 index 0000000..2b6eff4 --- /dev/null +++ b/apps/api-server/internal/services/amf_client.go @@ -0,0 +1,121 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// AMFClient represents the Access and Mobility Management Function client +type AMFClient struct { + baseURL string + httpClient *http.Client +} + +// NewAMFClient creates a new AMF client +func NewAMFClient(baseURL string) *AMFClient { + return &AMFClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// SessionTerminationRequest represents a session termination request to AMF +type SessionTerminationRequest struct { + IMSI models.IMSI `json:"imsi"` + Reason string `json:"reason"` + Timestamp time.Time `json:"timestamp"` +} + +// SessionTerminationResponse represents the response from AMF +type SessionTerminationResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + SessionID string `json:"session_id,omitempty"` +} + +// TerminateSession terminates a subscriber session in the AMF +func (a *AMFClient) TerminateSession(ctx context.Context, imsi models.IMSI, reason string) error { + req := SessionTerminationRequest{ + IMSI: imsi, + Reason: reason, + Timestamp: time.Now(), + } + + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal session termination request: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/sessions/terminate", a.baseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + + resp, err := a.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send session termination request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("AMF returned status %d", resp.StatusCode) + } + + var response SessionTerminationResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return fmt.Errorf("failed to decode AMF response: %w", err) + } + + if !response.Success { + return fmt.Errorf("AMF session termination failed: %s", response.Message) + } + + return nil +} + +// NotifySubscriberUpdate notifies AMF of subscriber status changes +func (a *AMFClient) NotifySubscriberUpdate(ctx context.Context, imsi models.IMSI, status models.SubscriberStatus) error { + req := map[string]any{ + "imsi": imsi, + "status": status, + "timestamp": time.Now(), + } + + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal subscriber update request: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/subscribers/update", a.baseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + + resp, err := a.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send subscriber update request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("AMF returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/apps/api-server/internal/services/es2_client.go b/apps/api-server/internal/services/es2_client.go new file mode 100644 index 0000000..007917e --- /dev/null +++ b/apps/api-server/internal/services/es2_client.go @@ -0,0 +1,75 @@ +package services + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "time" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/config" + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// ES2Service handles GSMA ES2+ operations for eSIM provisioning +type ES2Service struct { + httpClient *http.Client + config *config.ES2Config +} + +// NewES2Service creates a new ES2 service +func NewES2Service(cfg *config.ES2Config) *ES2Service { + return &ES2Service{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureSkipVerify, + }, + }, + }, + config: cfg, + } +} + +// ProvisionProfile provisions an eSIM profile for a subscriber +func (e *ES2Service) ProvisionProfile(ctx context.Context, subscriber *models.Subscriber) (*ProfileInfo, error) { + // Check if subscriber has EUICCID + if subscriber.EUICCID == "" { + return nil, fmt.Errorf("subscriber must have EUICCID for eSIM provisioning") + } + + // Create profile download request + req := DownloadOrderRequest{ + EID: subscriber.EUICCID, + ICCID: "", // Will be assigned by SM-SR + ProfileType: "operational", + Confirmation: true, + Metadata: map[string]string{ + "subscriber_id": fmt.Sprintf("%d", subscriber.ID), + "imsi": string(subscriber.IMSI), + "organization": subscriber.OrganizationID, + }, + } + + // Call SM-SR to download profile + resp, err := e.downloadProfile(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to download profile: %w", err) + } + + // Create profile info + profileInfo := &ProfileInfo{ + ICCID: resp.ICCID, + ProfileID: resp.ProfileID, + ProfileName: fmt.Sprintf("Profile-%s", subscriber.IMSI), + State: "downloaded", + Operator: "Telecom Platform", + Activation: ProfileActivation{ + ActivationCode: resp.ActivationCode, + ConfAddress: resp.ConfirmationAddress, + }, + } + + return profileInfo, nil +} diff --git a/apps/api-server/internal/services/es2_helpers.go b/apps/api-server/internal/services/es2_helpers.go new file mode 100644 index 0000000..6b4ee7e --- /dev/null +++ b/apps/api-server/internal/services/es2_helpers.go @@ -0,0 +1,123 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Helper methods for ES2 operations +func (e *ES2Service) sendActivationRequest(ctx context.Context, req ActivationRequest, action string) error { + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal activation request: %w", err) + } + + url := fmt.Sprintf("%s/es2/activate", e.config.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send activation request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + return nil +} + +func (e *ES2Service) sendDeactivationRequest(ctx context.Context, req interface{}) error { + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal deactivation request: %w", err) + } + + url := fmt.Sprintf("%s/es2/deactivate", e.config.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send deactivation request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + return nil +} + +func (e *ES2Service) sendDeletionRequest(ctx context.Context, req DeletionRequest) error { + jsonData, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal deletion request: %w", err) + } + + url := fmt.Sprintf("%s/es2/delete", e.config.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("failed to send deletion request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + return nil +} + +// ValidateEID validates an EID format +func (e *ES2Service) ValidateEID(eid string) error { + if len(eid) != 32 { + return fmt.Errorf("invalid EID length: expected 32 characters, got %d", len(eid)) + } + + // Additional validation logic can be added here + return nil +} + +// ValidateICCID validates an ICCID format +func (e *ES2Service) ValidateICCID(iccid string) error { + if len(iccid) < 18 || len(iccid) > 22 { + return fmt.Errorf("invalid ICCID length: expected 18-22 characters, got %d", len(iccid)) + } + + // Additional validation logic can be added here + return nil +} + +// generateRequestID generates a unique request ID +func generateRequestID() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} diff --git a/apps/api-server/internal/services/es2_operations.go b/apps/api-server/internal/services/es2_operations.go new file mode 100644 index 0000000..63e52c2 --- /dev/null +++ b/apps/api-server/internal/services/es2_operations.go @@ -0,0 +1,74 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// downloadProfile is an internal method to download a profile +func (e *ES2Service) downloadProfile(ctx context.Context, req DownloadOrderRequest) (*DownloadOrderResponse, error) { + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal download request: %w", err) + } + + url := fmt.Sprintf("%s/es2/download", e.config.BaseURL) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send download request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + var response DownloadOrderResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode download response: %w", err) + } + + return &response, nil +} + +// ActivateProfile activates an eSIM profile +func (e *ES2Service) ActivateProfile(ctx context.Context, euiccID, profileID string) error { + req := ActivationRequest{ + EID: euiccID, + ProfileID: profileID, + } + + return e.sendActivationRequest(ctx, req, "activate") +} + +// DeactivateProfile deactivates an eSIM profile +func (e *ES2Service) DeactivateProfile(ctx context.Context, euiccID, profileID string) error { + req := DeactivationRequest{ + EID: euiccID, + ProfileID: profileID, + } + + return e.sendDeactivationRequest(ctx, req) +} + +// DeleteProfile deletes an eSIM profile +func (e *ES2Service) DeleteProfile(ctx context.Context, euiccID, profileID string) error { + req := DeletionRequest{ + EID: euiccID, + ProfileID: profileID, + } + + return e.sendDeletionRequest(ctx, req) +} diff --git a/apps/api-server/internal/services/es2_queries.go b/apps/api-server/internal/services/es2_queries.go new file mode 100644 index 0000000..08ba2ee --- /dev/null +++ b/apps/api-server/internal/services/es2_queries.go @@ -0,0 +1,86 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// GetProfileStatus retrieves the status of an eSIM profile +func (e *ES2Service) GetProfileStatus(ctx context.Context, euiccID, profileID string) (*ProfileInfo, error) { + url := fmt.Sprintf("%s/es2/eid/%s/profile/%s/status", e.config.BaseURL, euiccID, profileID) + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send status request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + var response ProfileStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode status response: %w", err) + } + + profileInfo := &ProfileInfo{ + ICCID: response.ICCID, + ProfileID: response.ProfileID, + ProfileName: response.ProfileName, + State: response.State, + Operator: response.Operator, + } + + return profileInfo, nil +} + +// ListProfiles lists all profiles on an eUICC +func (e *ES2Service) ListProfiles(ctx context.Context, euiccID string) ([]*ProfileInfo, error) { + url := fmt.Sprintf("%s/es2/eid/%s/profiles", e.config.BaseURL, euiccID) + httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + httpReq.Header.Set("X-Request-ID", generateRequestID()) + httpReq.Header.Set("Authorization", "Bearer "+e.config.APIKey) + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send list request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ES2 server returned status %d", resp.StatusCode) + } + + var response ListProfilesResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode list response: %w", err) + } + + var profiles []*ProfileInfo + for _, profile := range response.Profiles { + profileInfo := &ProfileInfo{ + ICCID: profile.ICCID, + ProfileID: profile.ProfileID, + ProfileName: profile.ProfileName, + State: profile.State, + Operator: profile.Operator, + } + profiles = append(profiles, profileInfo) + } + + return profiles, nil +} diff --git a/apps/api-server/internal/services/es2_types.go b/apps/api-server/internal/services/es2_types.go new file mode 100644 index 0000000..5c9e23a --- /dev/null +++ b/apps/api-server/internal/services/es2_types.go @@ -0,0 +1,60 @@ +package services + +// ES2 request/response types +type DownloadOrderRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` + ProfileType string `json:"profileType"` + Confirmation bool `json:"confirmation"` + Metadata map[string]string `json:"metadata"` +} + +type DownloadOrderResponse struct { + ICCID string `json:"iccid"` + ProfileID string `json:"profileId"` + ActivationCode string `json:"activationCode"` + ConfirmationAddress string `json:"confirmationAddress"` +} + +type ActivationRequest struct { + EID string `json:"eid"` + ProfileID string `json:"profileId"` +} + +type DeactivationRequest struct { + EID string `json:"eid"` + ProfileID string `json:"profileId"` +} + +type DeletionRequest struct { + EID string `json:"eid"` + ProfileID string `json:"profileId"` +} + +type ProfileStatusResponse struct { + ICCID string `json:"iccid"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + State string `json:"state"` + Operator string `json:"operator"` +} + +type ListProfilesResponse struct { + Profiles []ProfileStatusResponse `json:"profiles"` +} + +// ProfileInfo represents eSIM profile information +type ProfileInfo struct { + ICCID string `json:"iccid"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + State string `json:"state"` + Operator string `json:"operator"` + Activation ProfileActivation `json:"activation"` +} + +// ProfileActivation represents profile activation details +type ProfileActivation struct { + ActivationCode string `json:"activationCode"` + ConfAddress string `json:"confirmationAddress"` +} diff --git a/apps/api-server/internal/services/subscriber.go b/apps/api-server/internal/services/subscriber.go new file mode 100644 index 0000000..b4c34c0 --- /dev/null +++ b/apps/api-server/internal/services/subscriber.go @@ -0,0 +1,263 @@ +package services + +import ( + "context" + "fmt" + "time" + + "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/models" +) + +// SubscriberService handles subscriber management operations +type SubscriberService struct { + db *database.Database + config *config.Config + amfClient *AMFClient + es2Service *ES2Service +} + +// NewSubscriberService creates a new subscriber service +func NewSubscriberService(db *database.Database, cfg *config.Config) *SubscriberService { + return &SubscriberService{ + db: db, + config: cfg, + amfClient: NewAMFClient("http://localhost:8081"), // Default AMF URL + es2Service: NewES2Service(&cfg.ES2), + } +} + +// CreateSubscriber creates a new subscriber with allocated IMSI +func (s *SubscriberService) CreateSubscriber(ctx context.Context, req *CreateSubscriberRequest) (*models.Subscriber, error) { + // Allocate IMSI + imsi, err := s.allocateIMSI(ctx) + if err != nil { + return nil, fmt.Errorf("failed to allocate IMSI: %w", err) + } + + // Generate authentication keys + authKey, opc, err := s.generateAuthKeys() + if err != nil { + return nil, fmt.Errorf("failed to generate auth keys: %w", err) + } + + // Create subscriber + subscriber := &models.Subscriber{ + IMSI: imsi, + MSISDN: req.MSISDN, + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + OrganizationID: req.OrganizationID, + Status: models.SubscriberStatusActive, + PlanID: req.PlanID, + AuthKey: authKey, + OPc: opc, + ServingPLMN: models.PLMN{MCC: "208", MNC: "93"}, // France + ProfileStatus: models.ProfileStatusInactive, + } + + if err := s.db.CreateSubscriber(ctx, subscriber); err != nil { + return nil, fmt.Errorf("failed to create subscriber: %w", err) + } + + // Initiate eSIM profile provisioning if EUICCID provided + if req.EUICCID != "" { + subscriber.EUICCID = req.EUICCID + go func() { + ctx := context.Background() + if err := s.provisionESIMProfile(ctx, subscriber.ID); err != nil { + // Log error but don't fail subscriber creation + fmt.Printf("Failed to provision eSIM profile for subscriber %d: %v\n", subscriber.ID, err) + } + }() + } else { + // Activate immediately for physical SIM + subscriber.Status = models.SubscriberStatusActive + subscriber.ProfileStatus = models.ProfileStatusActive + s.db.UpdateSubscriber(ctx, subscriber) + } + + return subscriber, nil +} + +// GetSubscriber retrieves a subscriber by ID +func (s *SubscriberService) GetSubscriber(ctx context.Context, id uint) (*models.Subscriber, error) { + subscriber, err := s.db.GetSubscriber(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + return subscriber, nil +} + +// GetSubscriberByIMSI retrieves a subscriber by IMSI +func (s *SubscriberService) GetSubscriberByIMSI(ctx context.Context, imsi models.IMSI) (*models.Subscriber, error) { + subscriber, err := s.db.GetSubscriberByIMSI(ctx, imsi) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber by IMSI: %w", err) + } + return subscriber, nil +} + +// UpdateSubscriber updates subscriber information +func (s *SubscriberService) UpdateSubscriber(ctx context.Context, id uint, req *UpdateSubscriberRequest) (*models.Subscriber, error) { + subscriber, err := s.db.GetSubscriber(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get subscriber: %w", err) + } + + // Update fields + if req.FirstName != nil && *req.FirstName != "" { + subscriber.FirstName = *req.FirstName + } + if req.LastName != nil && *req.LastName != "" { + subscriber.LastName = *req.LastName + } + if req.Email != nil && *req.Email != "" { + subscriber.Email = *req.Email + } + if req.Status != "" { + subscriber.Status = req.Status + } + if req.PlanID != nil { + subscriber.PlanID = *req.PlanID + } + + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return nil, fmt.Errorf("failed to update subscriber: %w", err) + } + + return subscriber, nil +} + +// SuspendSubscriber suspends a subscriber +func (s *SubscriberService) SuspendSubscriber(ctx context.Context, id uint) error { + subscriber, err := s.db.GetSubscriber(ctx, id) + if err != nil { + return fmt.Errorf("failed to get subscriber: %w", err) + } + + subscriber.Status = models.SubscriberStatusSuspended + + // Terminate active sessions + if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil { + return fmt.Errorf("failed to terminate sessions: %w", err) + } + + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return fmt.Errorf("failed to suspend subscriber: %w", err) + } + + return nil +} + +// TerminateSubscriber terminates a subscriber +func (s *SubscriberService) TerminateSubscriber(ctx context.Context, id uint) error { + subscriber, err := s.db.GetSubscriber(ctx, id) + if err != nil { + return fmt.Errorf("failed to get subscriber: %w", err) + } + + subscriber.Status = models.SubscriberStatusTerminated + + // Terminate active sessions + if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil { + return fmt.Errorf("failed to terminate sessions: %w", err) + } + + // Deactivate eSIM profile if active + if subscriber.EUICCID != "" && subscriber.ProfileStatus == models.ProfileStatusActive { + if err := s.deactivateESIMProfile(ctx, subscriber.ID); err != nil { + return fmt.Errorf("failed to deactivate eSIM profile: %w", err) + } + } + + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return fmt.Errorf("failed to terminate subscriber: %w", err) + } + + return nil +} + +// ListSubscribers lists subscribers with pagination and filtering +func (s *SubscriberService) ListSubscribers(ctx context.Context, req *ListSubscribersRequest) (*ListSubscribersResponse, error) { + dbReq := &database.ListSubscribersRequest{ + Page: req.Page, + PageSize: req.PageSize, + Status: req.Status, + OrganizationID: req.OrganizationID, + Search: req.Search, + } + subscribers, total, err := s.db.ListSubscribers(ctx, dbReq) + 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, + }, nil +} + +// terminateSubscriberSessions terminates all active sessions for a subscriber +func (s *SubscriberService) terminateSubscriberSessions(ctx context.Context, imsi models.IMSI) error { + sessions, err := s.db.GetActiveSessionsByIMSI(ctx, imsi) + if err != nil { + return err + } + + for _, session := range sessions { + now := time.Now() + session.Status = models.SessionStatusInactive + session.EndTime = &now + + if err := s.db.UpdateSession(ctx, &session); err != nil { + return err + } + + // Notify AMF to terminate session + if err := s.amfClient.TerminateSession(ctx, imsi, "Subscriber terminated"); err != nil { + // Log error but continue with other sessions + fmt.Printf("Failed to notify AMF for session termination: %v\n", err) + } + } + + return nil +} + +// Request/Response types +type CreateSubscriberRequest struct { + MSISDN string `json:"msisdn" validate:"required"` + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + Email string `json:"email" validate:"required,email"` + OrganizationID string `json:"organization_id"` + PlanID uint `json:"plan_id" validate:"required"` + EUICCID string `json:"euicc_id"` +} + +type UpdateSubscriberRequest struct { + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Email *string `json:"email"` + Status models.SubscriberStatus `json:"status"` + PlanID *uint `json:"plan_id"` +} + +type ListSubscribersRequest struct { + Page int `json:"page" query:"page"` + PageSize int `json:"page_size" query:"page_size"` + Status models.SubscriberStatus `json:"status" query:"status"` + OrganizationID string `json:"organization_id" query:"organization_id"` + Search string `json:"search" query:"search"` +} + +type ListSubscribersResponse struct { + Subscribers []models.Subscriber `json:"subscribers"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/apps/api-server/internal/services/subscriber_auth.go b/apps/api-server/internal/services/subscriber_auth.go new file mode 100644 index 0000000..e17d240 --- /dev/null +++ b/apps/api-server/internal/services/subscriber_auth.go @@ -0,0 +1,81 @@ +package services + +import ( + "crypto/aes" + "crypto/rand" + "encoding/hex" + "fmt" + "os" +) + +// generateAuthKeys generates authentication keys for the subscriber +func (s *SubscriberService) generateAuthKeys() (string, string, error) { + // Generate 128-bit random key (K) + key := make([]byte, 16) + if _, err := rand.Read(key); err != nil { + return "", "", err + } + + // Get OP (Operator variant) from operator configuration + // This should be consistent across all subscribers for the same operator + op := s.getOperatorVariant() + if op == nil { + return "", "", fmt.Errorf("operator variant not configured") + } + + // Generate OPc (derived from OP and K) using AES-128 encryption + // OPc = AES-128(K, OP) where OP is encrypted with K + opc, err := s.generateOPc(key, op) + if err != nil { + return "", "", err + } + + return hex.EncodeToString(key), hex.EncodeToString(opc), nil +} + +// getOperatorVariant returns the operator variant (OP) from configuration +func (s *SubscriberService) getOperatorVariant() []byte { + // Get operator variant from environment variable or secure configuration + // This should be the same across all subscribers for the same operator + opStr := os.Getenv("OPERATOR_VARIANT") + if opStr == "" { + // Fallback to default for development + opStr = "TelecomOP1234567" // 16-byte operator variant + } + + // Ensure exactly 16 bytes for AES-128 + op := make([]byte, 16) + copy(op, []byte(opStr)) + + // If shorter than 16 bytes, pad with zeros + if len(opStr) < 16 { + for i := len(opStr); i < 16; i++ { + op[i] = 0 + } + } + + return op +} + +// generateOPc derives OPc from OP and K using AES-128 encryption +func (s *SubscriberService) generateOPc(k, op []byte) ([]byte, error) { + // Create AES-128 cipher block with key K + block, err := aes.NewCipher(k) + if err != nil { + return nil, fmt.Errorf("failed to create AES cipher: %w", err) + } + + // OPc = AES-128(K, OP) - encrypt OP with key K + opc := make([]byte, aes.BlockSize) // AES block size is 16 bytes + + // Create ECB mode cipher (as per 3GPP specification for OPc derivation) + // In 3GPP, OPc is derived using AES-128 ECB mode + if len(op) != aes.BlockSize { + return nil, fmt.Errorf("OP must be 16 bytes for AES-128") + } + + // Encrypt OP using ECB mode (single block) + block.Encrypt(opc, op) + + return opc, nil +} diff --git a/apps/api-server/internal/services/subscriber_esim.go b/apps/api-server/internal/services/subscriber_esim.go new file mode 100644 index 0000000..dcf0a22 --- /dev/null +++ b/apps/api-server/internal/services/subscriber_esim.go @@ -0,0 +1,93 @@ +package services + +import ( + "context" + "fmt" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// provisionESIMProfile provisions an eSIM profile for the subscriber +func (s *SubscriberService) provisionESIMProfile(ctx context.Context, subscriberID uint) error { + // Get subscriber details + subscriber, err := s.db.GetSubscriber(ctx, subscriberID) + if err != nil { + return fmt.Errorf("failed to get subscriber: %w", err) + } + + // Validate EID + if err := s.es2Service.ValidateEID(subscriber.EUICCID); err != nil { + return fmt.Errorf("invalid EID: %w", err) + } + + // Provision profile via ES2+ API + profileInfo, err := s.es2Service.ProvisionProfile(ctx, subscriber) + if err != nil { + return fmt.Errorf("failed to provision profile: %w", err) + } + + // Update subscriber with profile information + subscriber.ProfileID = profileInfo.ProfileID + subscriber.ProfileStatus = models.ProfileStatusDownloading + + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return fmt.Errorf("failed to update subscriber: %w", err) + } + + // Activate profile + if err := s.es2Service.ActivateProfile(ctx, subscriber.EUICCID, profileInfo.ProfileID); err != nil { + return fmt.Errorf("failed to activate profile: %w", err) + } + + // Update subscriber status + subscriber.ProfileStatus = models.ProfileStatusActive + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return fmt.Errorf("failed to update subscriber status: %w", err) + } + + // 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) + } + + return nil +} + +// deactivateESIMProfile deactivates an eSIM profile +func (s *SubscriberService) deactivateESIMProfile(ctx context.Context, subscriberID uint) error { + // Get subscriber details + subscriber, err := s.db.GetSubscriber(ctx, subscriberID) + if err != nil { + return fmt.Errorf("failed to get subscriber: %w", err) + } + + // Check if subscriber has active profile + if subscriber.ProfileID == "" || subscriber.ProfileStatus != models.ProfileStatusActive { + return fmt.Errorf("no active profile to deactivate") + } + + // Deactivate profile via ES2+ API + if err := s.es2Service.DeactivateProfile(ctx, subscriber.EUICCID, subscriber.ProfileID); err != nil { + return fmt.Errorf("failed to deactivate profile: %w", err) + } + + // Update subscriber status + subscriber.ProfileStatus = models.ProfileStatusInactive + subscriber.Status = models.SubscriberStatusInactive + + if err := s.db.UpdateSubscriber(ctx, subscriber); err != nil { + return fmt.Errorf("failed to update subscriber: %w", err) + } + + // Terminate all sessions + if err := s.terminateSubscriberSessions(ctx, subscriber.IMSI); err != nil { + fmt.Printf("Failed to terminate sessions: %v\n", err) + } + + // 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) + } + + return nil +} diff --git a/apps/api-server/internal/services/subscriber_imsi.go b/apps/api-server/internal/services/subscriber_imsi.go new file mode 100644 index 0000000..a451138 --- /dev/null +++ b/apps/api-server/internal/services/subscriber_imsi.go @@ -0,0 +1,35 @@ +package services + +import ( + "context" + "fmt" + + "github.com/nutcas3/telecom-platform/apps/api-server/internal/models" +) + +// allocateIMSI allocates a new IMSI for a subscriber +func (s *SubscriberService) allocateIMSI(ctx context.Context) (models.IMSI, error) { + // Get current IMSI allocation + alloc, err := s.db.GetIMSIAllocation(ctx) + if err != nil { + return "", fmt.Errorf("failed to get IMSI allocation: %w", err) + } + + // Check if we've reached the maximum + if alloc.LastIMSI >= alloc.MaxIMSI { + return "", fmt.Errorf("IMSI range exhausted: max %d reached", alloc.MaxIMSI) + } + + // Allocate next IMSI + nextIMSI := alloc.LastIMSI + 1 + alloc.LastIMSI = nextIMSI + + // Update allocation state + if err := s.db.UpdateIMSIAllocation(ctx, alloc); err != nil { + return "", fmt.Errorf("failed to update IMSI allocation: %w", err) + } + + // Format IMSI: MCC (3) + MNC (2-3) + subscriber number + imsiStr := fmt.Sprintf("%s%010d", s.config.IMSI.Prefix, nextIMSI) + return models.IMSI(imsiStr), nil +} diff --git a/apps/api-server/main.go b/apps/api-server/main.go index d1a02a1..6a00fb7 100644 --- a/apps/api-server/main.go +++ b/apps/api-server/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log" "net/http" "os" "time" @@ -17,7 +16,7 @@ import ( ) var ( - logger zerolog.Logger + logger zerolog.Logger dbClient *mongo.Client ) diff --git a/apps/carrier-connector/go.mod b/apps/carrier-connector/go.mod index 60e2c0e..755ed2a 100644 --- a/apps/carrier-connector/go.mod +++ b/apps/carrier-connector/go.mod @@ -1,10 +1,16 @@ -module github.com/taas-platform/telecom-platform/apps/carrier-connector +module github.com/nutcas3/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/go-resty/resty/v2 v2.17.2 github.com/joho/godotenv v1.5.1 - github.com/rs/zerolog v1.33.0 + github.com/rs/zerolog v1.35.0 +) + +require ( + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/apps/carrier-connector/go.sum b/apps/carrier-connector/go.sum new file mode 100644 index 0000000..f19a7bc --- /dev/null +++ b/apps/carrier-connector/go.sum @@ -0,0 +1,17 @@ +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= +github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= diff --git a/apps/carrier-connector/internal/config/config.go b/apps/carrier-connector/internal/config/config.go new file mode 100644 index 0000000..d9184c7 --- /dev/null +++ b/apps/carrier-connector/internal/config/config.go @@ -0,0 +1,17 @@ +package config + +type ES2Config struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + FunctionalityRequesterID string `json:"functionality_requester_id"` +} + +func LoadES2Config() *ES2Config { + return &ES2Config{ + BaseURL: "https://smdp.example.com", + APIKey: "", + InsecureSkipVerify: true, + FunctionalityRequesterID: "telecom-platform", + } +} diff --git a/apps/carrier-connector/internal/es2/client.go b/apps/carrier-connector/internal/es2/client.go new file mode 100644 index 0000000..5bbb20e --- /dev/null +++ b/apps/carrier-connector/internal/es2/client.go @@ -0,0 +1,271 @@ +package es2 + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/nutcas3/telecom-platform/apps/carrier-connector/internal/config" +) + +type ES2Client struct { + httpClient *http.Client + config *config.ES2Config + baseURL string +} + +func NewES2Client(cfg *config.ES2Config) *ES2Client { + return &ES2Client{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureSkipVerify, + }, + }, + }, + config: cfg, + baseURL: cfg.BaseURL, + } +} + +func (c *ES2Client) DownloadProfile(ctx context.Context, req *DownloadProfileRequest) (*DownloadProfileResponse, error) { + url := fmt.Sprintf("%s/es2plus/downloadProfile", c.baseURL) + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var downloadResp DownloadProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&downloadResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &downloadResp, nil +} + +func (c *ES2Client) GetProfileStatus(ctx context.Context, req *GetProfileStatusRequest) (*GetProfileStatusResponse, error) { + url := fmt.Sprintf("%s/es2plus/getProfileStatus", c.baseURL) + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var statusResp GetProfileStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&statusResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &statusResp, nil +} + +func (c *ES2Client) DeleteProfile(ctx context.Context, req *DeleteProfileRequest) (*DeleteProfileResponse, error) { + url := fmt.Sprintf("%s/es2plus/deleteProfile", c.baseURL) + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var deleteResp DeleteProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&deleteResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &deleteResp, nil +} + +func (c *ES2Client) EnableProfile(ctx context.Context, req *EnableProfileRequest) (*EnableProfileResponse, error) { + url := fmt.Sprintf("%s/es2plus/enableProfile", c.baseURL) + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var enableResp EnableProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&enableResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &enableResp, nil +} + +func (c *ES2Client) DisableProfile(ctx context.Context, req *DisableProfileRequest) (*DisableProfileResponse, error) { + url := fmt.Sprintf("%s/es2plus/disableProfile", c.baseURL) + + payload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + c.setHeaders(httpReq) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var disableResp DisableProfileResponse + if err := json.NewDecoder(resp.Body).Decode(&disableResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &disableResp, nil +} + +func (c *ES2Client) setHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Telecom-Platform/1.0") + + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + + if c.config.FunctionalityRequesterID != "" { + req.Header.Set("X-Admin-Protocol", "gsma-rsp") + req.Header.Set("X-Request-ID", generateRequestID()) + } +} + +func generateRequestID() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +type DownloadProfileRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` + ProfileType string `json:"profileType"` + ConfirmationCode string `json:"confirmationCode,omitempty"` +} + +type DownloadProfileResponse struct { + ExecutionStatus string `json:"executionStatus"` + StatusMessage string `json:"statusMessage"` +} + +type GetProfileStatusRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` +} + +type GetProfileStatusResponse struct { + ExecutionStatus string `json:"executionStatus"` + StatusMessage string `json:"statusMessage"` + ProfileState string `json:"profileState,omitempty"` +} + +type DeleteProfileRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` +} + +type DeleteProfileResponse struct { + ExecutionStatus string `json:"executionStatus"` + StatusMessage string `json:"statusMessage"` +} + +type EnableProfileRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` +} + +type EnableProfileResponse struct { + ExecutionStatus string `json:"executionStatus"` + StatusMessage string `json:"statusMessage"` +} + +type DisableProfileRequest struct { + EID string `json:"eid"` + ICCID string `json:"iccid"` +} + +type DisableProfileResponse struct { + ExecutionStatus string `json:"executionStatus"` + StatusMessage string `json:"statusMessage"` +} diff --git a/apps/carrier-connector/main.go b/apps/carrier-connector/main.go index 9594994..8024b48 100644 --- a/apps/carrier-connector/main.go +++ b/apps/carrier-connector/main.go @@ -3,7 +3,6 @@ package main import ( "crypto/tls" "fmt" - "log" "os" "time" diff --git a/apps/charging-engine/src/api.rs b/apps/charging-engine/src/api.rs new file mode 100644 index 0000000..0d3fe51 --- /dev/null +++ b/apps/charging-engine/src/api.rs @@ -0,0 +1,127 @@ +use axum::{ + extract::{Path, State}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::{info, warn}; + +use crate::errors::{ChargingError, ChargingResult, validate_ip, validate_bytes, ErrorContext}; +use crate::models::*; + +pub struct AppState { + pub charging_engine: std::sync::Arc, +} + +pub fn create_router(state: AppState) -> Router { + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + 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) +} + +/// POST /v1/credit/:ip/check +/// Check if user has enough credit for data usage +pub async fn check_credit( + Path(ip): Path, + State(state): State, + Json(req): Json, +) -> ChargingResult> { + // Validate input + validate_ip(&ip)?; + validate_bytes(req.bytes_requested)?; + + let allowed = state.charging_engine.check_credit(&ip, req.bytes_requested).await + .with_context("Failed to check credit")?; + + let remaining = state.charging_engine.get_balance(&ip).await + .with_context("Failed to get balance")?; + + info!( + "Credit check for {}: {} bytes requested, {} bytes available, allowed: {}", + ip, req.bytes_requested, remaining, allowed + ); + + Ok(Json(CreditCheckResponse { + allowed, + remaining_bytes: remaining, + })) +} + +/// POST /v1/credit/:ip/deduct +/// Deduct bytes from user's credit balance +pub async fn deduct_credit( + Path(ip): Path, + State(state): State, + Json(req): Json, +) -> ChargingResult<()> { + // Validate input + validate_ip(&ip)?; + validate_bytes(req.bytes_used)?; + + let new_balance = state.charging_engine.deduct_credit(&ip, req.bytes_used).await + .with_context("Failed to deduct credit")?; + + info!( + "User {} deducted {} bytes, remaining: {}", + ip, req.bytes_used, new_balance + ); + + Ok(()) +} + +/// POST /v1/credit/:ip/add +/// Add bytes to user's credit balance +pub async fn add_credit( + Path(ip): Path, + State(state): State, + Json(req): Json, +) -> ChargingResult<()> { + // Validate input + validate_ip(&ip)?; + validate_bytes(req.bytes_to_add)?; + + let new_balance = state.charging_engine.add_credit(&ip, req.bytes_to_add).await + .with_context("Failed to add credit")?; + + info!( + "User {} added {} bytes, new balance: {}", + ip, req.bytes_to_add, new_balance + ); + + Ok(()) +} + +/// GET /v1/credit/:ip/balance +/// Get current credit balance +pub async fn get_balance( + Path(ip): Path, + State(state): State, +) -> ChargingResult> { + // Validate input + validate_ip(&ip)?; + + let balance = state.charging_engine.get_balance(&ip).await + .with_context("Failed to get balance")?; + + Ok(Json(BalanceResponse { + ip, + balance_bytes: balance, + })) +} + +/// GET /health +/// Health check endpoint +pub async fn health_check() -> ChargingResult> { + Ok(Json(HealthResponse::default())) +} diff --git a/apps/charging-engine/src/charging.rs b/apps/charging-engine/src/charging.rs new file mode 100644 index 0000000..67f7d28 --- /dev/null +++ b/apps/charging-engine/src/charging.rs @@ -0,0 +1,6 @@ +pub mod types; +pub mod engine; +pub mod credit_management; +pub mod rating_billing; + +pub use engine::ChargingEngine; diff --git a/apps/charging-engine/src/charging/credit_management.rs b/apps/charging-engine/src/charging/credit_management.rs new file mode 100644 index 0000000..f2988db --- /dev/null +++ b/apps/charging-engine/src/charging/credit_management.rs @@ -0,0 +1,138 @@ +use redis::AsyncCommands; +use tracing::{info, warn, error, debug}; + +use super::types::{SubscriberAccount, UsageEvent}; +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; + +impl super::ChargingEngine { + pub async fn get_balance(&self, ip: &str) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("credit:{}", ip); + let balance: u64 = conn.get(&key).await + .context("Failed to get balance from Redis")?; + + debug!("Retrieved balance {} for IP: {}", balance, ip); + Ok(balance) + } + + pub async fn add_credit(&self, ip: &str, bytes_to_add: u64) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("credit:{}", ip); + let new_balance: u64 = conn.incr(&key, bytes_to_add).await + .context("Failed to add credit in Redis")?; + + // Set expiration if this is a new key + let _: () = conn.expire(&key, 86400).await.unwrap_or(()); + + info!("Added {} bytes credit to IP: {}, new balance: {}", bytes_to_add, ip, new_balance); + Ok(new_balance) + } + + pub async fn deduct_credit(&self, ip: &str, bytes_used: u64) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("credit:{}", ip); + + // Check current balance first + let current_balance: u64 = conn.get(&key).await.unwrap_or(0); + + if current_balance < bytes_used { + return Err(ChargingError::InsufficientCredit { + available: current_balance, + requested: bytes_used, + }); + } + + let new_balance: u64 = conn.decr(&key, bytes_used).await + .context("Failed to deduct credit in Redis")?; + + info!("Deducted {} bytes credit from IP: {}, new balance: {}", bytes_used, ip, new_balance); + Ok(new_balance) + } + + pub async fn check_credit(&self, ip: &str, bytes_requested: u64) -> ChargingResult { + let current_balance = self.get_balance(ip).await.unwrap_or(0); + Ok(current_balance >= bytes_requested) + } + + pub async fn block_user(&self, ip: &str) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("block:{}", ip); + let _: () = conn.set(&key, "blocked").await + .context("Failed to block user in Redis")?; + + // Set expiration for block (24 hours) + let _: () = conn.expire(&key, 86400).await.unwrap_or(()); + + warn!("User IP: {} has been blocked", ip); + Ok(()) + } + + pub async fn unblock_user(&self, ip: &str) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("block:{}", ip); + let _: () = conn.del(&key).await + .context("Failed to unblock user in Redis")?; + + info!("User IP: {} has been unblocked", ip); + Ok(()) + } + + pub async fn is_user_blocked(&self, ip: &str) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("block:{}", ip); + let blocked: Option = conn.get(&key).await + .context("Failed to check block status in Redis")?; + + Ok(blocked.is_some()) + } + + pub async fn get_subscriber_account(&self, imsi: &str) -> ChargingResult> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("account:{}", imsi); + let account: Option = conn.get(&key).await + .context("Failed to get subscriber account from Redis")?; + + Ok(account) + } + + pub async fn update_subscriber_account(&self, account: &SubscriberAccount) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("account:{}", account.imsi); + let _: () = conn.set(&key, account).await + .context("Failed to update subscriber account in Redis")?; + + debug!("Updated subscriber account for IMSI: {}", account.imsi); + Ok(()) + } + + pub async fn record_usage_event(&self, event: &UsageEvent) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let key = format!("usage:{}:{}", event.imsi, event.session_id); + let _: () = conn.set(&key, event).await + .context("Failed to record usage event in Redis")?; + + // Set expiration for usage events (7 days) + let _: () = conn.expire(&key, 604800).await.unwrap_or(()); + + info!("Recorded usage event for IMSI: {}, cost: ${:.4}", event.imsi, event.cost); + Ok(()) + } +} diff --git a/apps/charging-engine/src/charging/engine.rs b/apps/charging-engine/src/charging/engine.rs new file mode 100644 index 0000000..3bb6c2d --- /dev/null +++ b/apps/charging-engine/src/charging/engine.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::time::Duration; + +use super::types::{RatingPlan, SubscriberAccount}; +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; + +pub struct ChargingEngine { + redis_client: redis::Client, + rating_plans: HashMap, + sync_interval: Duration, +} + +impl ChargingEngine { + pub fn new(redis_url: &str, sync_interval_secs: u64) -> ChargingResult { + let redis_client = redis::Client::open(redis_url) + .context("Failed to create Redis client")?; + + let mut rating_plans = HashMap::new(); + + // Default rating plans + rating_plans.insert("basic".to_string(), RatingPlan { + plan_id: "basic".to_string(), + name: "Basic Plan".to_string(), + data_rate: 0.01, // $0.01 per MB + voice_rate: 0.05, // $0.05 per minute + sms_rate: 0.10, // $0.10 per SMS + monthly_fee: 10.0, // $10 monthly + data_limit: 1_000_000_000, // 1GB + voice_limit: 300, // 300 minutes + sms_limit: 100, // 100 SMS + }); + + rating_plans.insert("premium".to_string(), RatingPlan { + plan_id: "premium".to_string(), + name: "Premium Plan".to_string(), + data_rate: 0.005, // $0.005 per MB + voice_rate: 0.02, // $0.02 per minute + sms_rate: 0.05, // $0.05 per SMS + monthly_fee: 25.0, // $25 monthly + data_limit: 5_000_000_000, // 5GB + voice_limit: 1000, // 1000 minutes + sms_limit: 500, // 500 SMS + }); + + rating_plans.insert("enterprise".to_string(), RatingPlan { + plan_id: "enterprise".to_string(), + name: "Enterprise Plan".to_string(), + data_rate: 0.001, // $0.001 per MB + voice_rate: 0.01, // $0.01 per minute + sms_rate: 0.02, // $0.02 per SMS + monthly_fee: 100.0, // $100 monthly + data_limit: 50_000_000_000, // 50GB + voice_limit: 5000, // 5000 minutes + sms_limit: 2000, // 2000 SMS + }); + + Ok(Self { + redis_client, + rating_plans, + sync_interval: Duration::from_secs(sync_interval_secs), + }) + } + + pub fn get_rating_plan(&self, plan_id: &str) -> Option<&RatingPlan> { + self.rating_plans.get(plan_id) + } + + pub fn add_rating_plan(&mut self, plan: RatingPlan) { + self.rating_plans.insert(plan.plan_id.clone(), plan); + } + + pub fn remove_rating_plan(&mut self, plan_id: &str) -> Option { + self.rating_plans.remove(plan_id) + } + + pub fn list_rating_plans(&self) -> Vec<&RatingPlan> { + self.rating_plans.values().collect() + } + + pub async fn start(&self) -> ChargingResult<()> { + info!("Starting charging engine with sync interval: {:?}", self.sync_interval); + + // Initialize Redis connection + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to establish Redis connection")?; + + // Test Redis connection + let _: String = conn.get("test").await.unwrap_or_else(|_| "ok".to_string()); + + info!("Charging engine started successfully"); + Ok(()) + } + + pub async fn stop(&self) -> ChargingResult<()> { + info!("Stopping charging engine"); + // Cleanup operations + Ok(()) + } +} diff --git a/apps/charging-engine/src/charging/mod.rs b/apps/charging-engine/src/charging/mod.rs new file mode 100644 index 0000000..67f7d28 --- /dev/null +++ b/apps/charging-engine/src/charging/mod.rs @@ -0,0 +1,6 @@ +pub mod types; +pub mod engine; +pub mod credit_management; +pub mod rating_billing; + +pub use engine::ChargingEngine; diff --git a/apps/charging-engine/src/charging/rating_billing.rs b/apps/charging-engine/src/charging/rating_billing.rs new file mode 100644 index 0000000..81cf805 --- /dev/null +++ b/apps/charging-engine/src/charging/rating_billing.rs @@ -0,0 +1,179 @@ +use chrono::Utc; +use tracing::{info, debug, warn}; + +use super::types::{UsageEvent, UsageType, RatingPlan}; +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; + +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()))?; + + // Get rating plan (simplified - in real implementation this would be more complex) + let plan = self.get_rating_plan("basic") // Default to basic plan for now + .ok_or_else(|| ChargingError::RatingPlanNotFound("basic".to_string()))?; + + let cost = match event.usage_type { + UsageType::Data => { + let data_mb = event.volume as f64 / 1_048_576.0; // Convert bytes to MB + data_mb * plan.data_rate + } + UsageType::Voice => { + let voice_minutes = event.volume as f64 / 60.0; // Convert seconds to minutes + voice_minutes * plan.voice_rate + } + UsageType::SMS => { + event.volume as f64 * plan.sms_rate + } + }; + + debug!("Calculated cost for IMSI {}: ${:.4}", event.imsi, cost); + Ok(cost) + } + + pub async fn rate_usage(&self, mut event: UsageEvent) -> ChargingResult { + event.cost = self.calculate_usage_cost(&event).await?; + + // Update rate field + if event.volume > 0 { + event.rate = event.cost / event.volume as f64; + } + + // Record the rated event + self.record_usage_event(&event).await?; + + info!("Rated usage event for IMSI {}: ${:.4}", event.imsi, event.cost); + Ok(event) + } + + pub async fn apply_charging_rules(&self, event: &UsageEvent) -> ChargingResult> { + let mut applied_rules = Vec::new(); + + // Rule 1: Check if user has sufficient credit + if !self.check_credit(&event.imsi, event.volume).await? { + applied_rules.push("INSUFFICIENT_CREDIT".to_string()); + warn!("Insufficient credit for IMSI: {}", event.imsi); + } + + // Rule 2: Check usage limits + let account = self.get_subscriber_account(&event.imsi).await?; + if let Some(account) = account { + match event.usage_type { + UsageType::Data => { + if account.data_used + event.volume > account.data_limit { + applied_rules.push("DATA_LIMIT_EXCEEDED".to_string()); + warn!("Data limit exceeded for IMSI: {}", event.imsi); + } + } + UsageType::Voice => { + if account.voice_used + event.volume > account.voice_limit { + applied_rules.push("VOICE_LIMIT_EXCEEDED".to_string()); + warn!("Voice limit exceeded for IMSI: {}", event.imsi); + } + } + UsageType::SMS => { + if account.sms_used + event.volume > account.sms_limit { + applied_rules.push("SMS_LIMIT_EXCEEDED".to_string()); + warn!("SMS limit exceeded for IMSI: {}", event.imsi); + } + } + } + } + + // Rule 3: Check if user is blocked + if self.is_user_blocked(&event.imsi).await? { + applied_rules.push("USER_BLOCKED".to_string()); + warn!("Blocked user attempted usage: {}", event.imsi); + } + + if applied_rules.is_empty() { + applied_rules.push("ALLOWED".to_string()); + } + + Ok(applied_rules) + } + + pub async fn process_usage_event(&self, event: UsageEvent) -> ChargingResult<()> { + // Apply charging rules + let rules = self.apply_charging_rules(&event).await?; + + // Check if usage is allowed + if rules.contains(&"BLOCKED".to_string()) || + rules.contains(&"INSUFFICIENT_CREDIT".to_string()) || + rules.contains(&"USER_BLOCKED".to_string()) { + return Err(ChargingError::UsageBlocked(rules.join(", "))); + } + + // Rate the usage + let rated_event = self.rate_usage(event).await?; + + // Update subscriber account + if let Some(mut account) = self.get_subscriber_account(&rated_event.imsi).await? { + match rated_event.usage_type { + UsageType::Data => account.data_used += rated_event.volume, + UsageType::Voice => account.voice_used += rated_event.volume, + UsageType::SMS => account.sms_used += rated_event.volume, + } + account.last_updated = Utc::now(); + + self.update_subscriber_account(&account).await?; + } + + info!("Processed usage event for IMSI: {}", rated_event.imsi); + Ok(()) + } + + pub async fn generate_invoice(&self, imsi: &str, billing_period: &str) -> ChargingResult { + // Get all usage events for the billing period + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = format!("usage:{}:*", imsi); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get usage keys")?; + + let mut total_cost = 0.0; + let mut usage_count = 0; + + for key in keys { + if let Ok(Some(event)) = conn.get::<_, Option>(&key).await { + // Filter by billing period (simplified) + total_cost += event.cost; + usage_count += 1; + } + } + + info!("Generated invoice for IMSI {}: ${:.4} from {} usage events", + imsi, total_cost, usage_count); + + Ok(total_cost) + } + + pub async fn apply_monthly_fees(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "account:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get account keys")?; + + let mut processed_accounts = 0; + + for key in keys { + if let Ok(Some(mut account)) = conn.get::<_, Option>(&key).await { + // Get rating plan and apply monthly fee + if let Some(plan) = self.get_rating_plan("basic") { // Simplified + account.balance -= (plan.monthly_fee * 100.0) as i64; // Convert to cents + account.last_updated = Utc::now(); + + let _: () = conn.set(&key, &account).await.unwrap_or(()); + processed_accounts += 1; + } + } + } + + info!("Applied monthly fees to {} accounts", processed_accounts); + Ok(processed_accounts) + } +} diff --git a/apps/charging-engine/src/charging/types.rs b/apps/charging-engine/src/charging/types.rs new file mode 100644 index 0000000..d960d77 --- /dev/null +++ b/apps/charging-engine/src/charging/types.rs @@ -0,0 +1,97 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriberAccount { + pub imsi: String, + pub balance: i64, // in smallest currency unit (e.g., cents) + pub data_limit: u64, // bytes + pub data_used: u64, // bytes + pub voice_limit: u64, // seconds + pub voice_used: u64, // seconds + pub sms_limit: u64, // count + pub sms_used: u64, // count + pub status: AccountStatus, + pub last_updated: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AccountStatus { + Active, + Suspended, + Terminated, + Blocked, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageEvent { + pub imsi: String, + pub session_id: String, + pub usage_type: UsageType, + pub volume: u64, // bytes, seconds, or count + pub timestamp: DateTime, + pub rate: f64, // cost per unit + pub cost: f64, // total cost +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum UsageType { + Data, + Voice, + SMS, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RatingPlan { + pub plan_id: String, + pub name: String, + pub data_rate: f64, // cost per MB + pub voice_rate: f64, // cost per minute + pub sms_rate: f64, // cost per SMS + pub monthly_fee: f64, + pub data_limit: u64, // bytes + pub voice_limit: u64, // seconds + pub sms_limit: u64, // count +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChargingSession { + pub session_id: String, + pub imsi: String, + pub start_time: DateTime, + pub end_time: Option>, + pub data_bytes: u64, + pub voice_seconds: u64, + pub sms_count: u64, + pub total_cost: f64, + pub status: SessionStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SessionStatus { + Active, + Completed, + Terminated, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChargingRule { + pub rule_id: String, + pub name: String, + pub priority: u32, + pub conditions: Vec, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Condition { + pub field: String, + pub operator: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + pub action_type: String, + pub parameters: std::collections::HashMap, +} diff --git a/apps/charging-engine/src/errors.rs b/apps/charging-engine/src/errors.rs new file mode 100644 index 0000000..96c1cb9 --- /dev/null +++ b/apps/charging-engine/src/errors.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod helpers; diff --git a/apps/charging-engine/src/errors/helpers.rs b/apps/charging-engine/src/errors/helpers.rs new file mode 100644 index 0000000..ee2606e --- /dev/null +++ b/apps/charging-engine/src/errors/helpers.rs @@ -0,0 +1,162 @@ +use anyhow::Context; + +pub type ChargingResult = Result; + +pub trait ErrorContext { + fn with_context(self, msg: &str) -> ChargingResult; +} + +impl ErrorContext for Result +where + E: Into, +{ + fn with_context(self, msg: &str) -> ChargingResult { + self.map_err(|e| { + let charging_err = e.into(); + match charging_err { + ChargingError::InternalError(internal_msg) => { + ChargingError::InternalError(format!("{}: {}", msg, internal_msg)) + } + _ => charging_err, + } + }) + } +} + +// Validation helper functions +pub fn validate_ip(ip: &str) -> ChargingResult<()> { + if ip.is_empty() { + return Err(ChargingError::InvalidInput("IP address cannot be empty".to_string())); + } + + // Basic IP validation (simplified) + let parts: Vec<&str> = ip.split('.').collect(); + if parts.len() != 4 { + return Err(ChargingError::InvalidInput("Invalid IP address format".to_string())); + } + + for part in parts { + if let Ok(num) = part.parse::() { + if num == 0 { + return Err(ChargingError::InvalidInput("IP address cannot be 0.0.0.0".to_string())); + } + } else { + return Err(ChargingError::InvalidInput("Invalid IP address octet".to_string())); + } + } + + Ok(()) +} + +pub fn validate_bytes(bytes: u64) -> ChargingResult<()> { + if bytes == 0 { + return Err(ChargingError::InvalidInput("Bytes cannot be zero".to_string())); + } + + if bytes > 1_000_000_000_000 { // 1TB limit + return Err(ChargingError::InvalidInput("Bytes exceed maximum limit".to_string())); + } + + Ok(()) +} + +pub fn validate_imsi(imsi: &str) -> ChargingResult<()> { + if imsi.is_empty() { + return Err(ChargingError::InvalidInput("IMSI cannot be empty".to_string())); + } + + if imsi.len() < 15 || imsi.len() > 15 { + return Err(ChargingError::InvalidInput("IMSI must be exactly 15 digits".to_string())); + } + + if !imsi.chars().all(|c| c.is_ascii_digit()) { + return Err(ChargingError::InvalidInput("IMSI must contain only digits".to_string())); + } + + Ok(()) +} + +pub fn validate_session_id(session_id: &str) -> ChargingResult<()> { + if session_id.is_empty() { + return Err(ChargingError::InvalidInput("Session ID cannot be empty".to_string())); + } + + if session_id.len() > 64 { + return Err(ChargingError::InvalidInput("Session ID too long".to_string())); + } + + Ok(()) +} + +pub fn validate_amount(amount: f64) -> ChargingResult<()> { + if amount <= 0.0 { + return Err(ChargingError::InvalidInput("Amount must be positive".to_string())); + } + + if amount > 10_000.0 { + return Err(ChargingError::InvalidInput("Amount exceeds maximum limit".to_string())); + } + + Ok(()) +} + +// Error conversion helpers +impl From for ChargingError { + fn from(err: redis::RedisError) -> Self { + ChargingError::RedisConnection(err.to_string()) + } +} + +impl From for ChargingError { + fn from(err: serde_json::Error) -> Self { + ChargingError::SerializationError(err.to_string()) + } +} + +impl From for ChargingError { + fn from(err: std::num::ParseIntError) -> Self { + ChargingError::InvalidInput(format!("Parse error: {}", err)) + } +} + +impl From for ChargingError { + fn from(err: std::num::ParseFloatError) -> Self { + ChargingError::InvalidInput(format!("Parse error: {}", err)) + } +} + +// Error logging helpers +pub fn log_error(error: &ChargingError) { + match error { + ChargingError::RedisConnection(msg) => { + tracing::error!("Redis connection error: {}", msg); + } + ChargingError::RedisOperation(msg) => { + tracing::error!("Redis operation error: {}", msg); + } + ChargingError::SubscriberNotFound(imsi) => { + tracing::warn!("Subscriber not found: {}", imsi); + } + ChargingError::RatingPlanNotFound(plan_id) => { + tracing::warn!("Rating plan not found: {}", plan_id); + } + ChargingError::InsufficientCredit { available, requested } => { + tracing::warn!("Insufficient credit: available={}, requested={}", available, requested); + } + ChargingError::UsageBlocked(reason) => { + tracing::warn!("Usage blocked: {}", reason); + } + ChargingError::InvalidInput(msg) => { + tracing::warn!("Invalid input: {}", msg); + } + ChargingError::SerializationError(msg) => { + tracing::error!("Serialization error: {}", msg); + } + ChargingError::ConfigurationError(msg) => { + tracing::error!("Configuration error: {}", msg); + } + ChargingError::InternalError(msg) => { + tracing::error!("Internal error: {}", msg); + } + } +} diff --git a/apps/charging-engine/src/errors/mod.rs b/apps/charging-engine/src/errors/mod.rs new file mode 100644 index 0000000..96c1cb9 --- /dev/null +++ b/apps/charging-engine/src/errors/mod.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod helpers; diff --git a/apps/charging-engine/src/errors/types.rs b/apps/charging-engine/src/errors/types.rs new file mode 100644 index 0000000..cd7ac80 --- /dev/null +++ b/apps/charging-engine/src/errors/types.rs @@ -0,0 +1,64 @@ +use axum::response::{IntoResponse, Response}; +use axum::http::StatusCode; +use serde_json::json; +use std::fmt; + +#[derive(Debug, Clone)] +pub enum ChargingError { + RedisConnection(String), + RedisOperation(String), + SubscriberNotFound(String), + RatingPlanNotFound(String), + InsufficientCredit { available: u64, requested: u64 }, + UsageBlocked(String), + InvalidInput(String), + SerializationError(String), + ConfigurationError(String), + 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::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::ConfigurationError(msg) => write!(f, "Configuration 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, error_message) = match self { + ChargingError::RedisConnection(_) => (StatusCode::SERVICE_UNAVAILABLE, "Redis connection error"), + ChargingError::RedisOperation(_) => (StatusCode::SERVICE_UNAVAILABLE, "Redis operation error"), + ChargingError::SubscriberNotFound(_) => (StatusCode::NOT_FOUND, "Subscriber not found"), + ChargingError::RatingPlanNotFound(_) => (StatusCode::NOT_FOUND, "Rating plan not found"), + ChargingError::InsufficientCredit { .. } => (StatusCode::PAYMENT_REQUIRED, "Insufficient credit"), + ChargingError::UsageBlocked(_) => (StatusCode::FORBIDDEN, "Usage blocked"), + ChargingError::InvalidInput(_) => (StatusCode::BAD_REQUEST, "Invalid input"), + ChargingError::SerializationError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error"), + ChargingError::ConfigurationError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Configuration error"), + ChargingError::InternalError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + }; + + let body = json!({ + "error": error_message, + "message": self.to_string(), + "code": status.as_u16() + }); + + (status, axum::Json(body)).into_response() + } +} diff --git a/apps/charging-engine/src/main.rs b/apps/charging-engine/src/main.rs index 00af4b0..329619b 100644 --- a/apps/charging-engine/src/main.rs +++ b/apps/charging-engine/src/main.rs @@ -1,23 +1,18 @@ -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}; +mod api; +mod charging; +mod monitoring; +mod errors; +mod models; -#[derive(Clone)] -struct AppState { - redis: redis::Client, -} +use anyhow::Result; +use std::sync::Arc; +use tracing::info; + +use api::create_router; +use charging::ChargingEngine; #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() -> Result<()> { // Initialize tracing tracing_subscriber::fmt() .with_target(false) @@ -27,33 +22,26 @@ async fn main() -> anyhow::Result<()> { // Load environment variables dotenv::dotenv().ok(); - // Connect to Redis + // Initialize charging engine 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)?; + let sync_interval = std::env::var("SYNC_INTERVAL") + .unwrap_or_else(|_| "1".to_string()) + .parse::() + .unwrap_or(1); + + let charging_engine = Arc::new(ChargingEngine::new(&redis_url, sync_interval)?); - // Test connection - let mut conn = redis_client.get_multiplexed_async_connection().await?; - let _: () = redis::cmd("PING").query_async(&mut conn).await?; + // Test Redis connection + charging_engine.test_connection().await?; info!("Connected to Redis successfully"); - let state = Arc::new(AppState { - redis: redis_client, - }); + // Create application state + let state = api::AppState { + charging_engine, + }; - // 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); + // Create router + let app = create_router(state); // Start server let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8080".to_string()); @@ -66,163 +54,3 @@ async fn main() -> anyhow::Result<()> { 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, - State(state): State>, - Json(req): Json, -) -> 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, - State(state): State>, - Json(req): Json, -) -> 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, - State(state): State>, - Json(req): Json, -) -> 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, - State(state): State>, -) -> 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" -} diff --git a/apps/charging-engine/src/models.rs b/apps/charging-engine/src/models.rs new file mode 100644 index 0000000..5850436 --- /dev/null +++ b/apps/charging-engine/src/models.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone)] +pub struct AppState { + pub charging_engine: std::sync::Arc, +} + +#[derive(Deserialize)] +pub struct CreditCheckRequest { + pub bytes_requested: u64, +} + +#[derive(Serialize)] +pub struct CreditCheckResponse { + pub allowed: bool, + pub remaining_bytes: i64, +} + +#[derive(Deserialize)] +pub struct DeductRequest { + pub bytes_used: u64, +} + +#[derive(Deserialize)] +pub struct AddCreditRequest { + pub bytes_to_add: u64, +} + +#[derive(Serialize)] +pub struct BalanceResponse { + pub ip: String, + pub balance_bytes: i64, +} + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: String, + pub timestamp: chrono::DateTime, +} + +impl Default for HealthResponse { + fn default() -> Self { + Self { + status: "OK".to_string(), + timestamp: chrono::Utc::now(), + } + } +} diff --git a/apps/charging-engine/src/monitoring.rs b/apps/charging-engine/src/monitoring.rs new file mode 100644 index 0000000..94177cc --- /dev/null +++ b/apps/charging-engine/src/monitoring.rs @@ -0,0 +1,4 @@ +pub mod sync; +pub mod types; +pub mod monitoring; +pub mod operations; diff --git a/apps/charging-engine/src/monitoring/mod.rs b/apps/charging-engine/src/monitoring/mod.rs new file mode 100644 index 0000000..94177cc --- /dev/null +++ b/apps/charging-engine/src/monitoring/mod.rs @@ -0,0 +1,4 @@ +pub mod sync; +pub mod types; +pub mod monitoring; +pub mod operations; diff --git a/apps/charging-engine/src/monitoring/monitoring.rs b/apps/charging-engine/src/monitoring/monitoring.rs new file mode 100644 index 0000000..d3fd7f7 --- /dev/null +++ b/apps/charging-engine/src/monitoring/monitoring.rs @@ -0,0 +1,172 @@ +use tracing::{info, debug}; +use std::time::SystemTime; + +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; +use super::types::{SystemStats, HealthStatus}; + +impl super::ChargingEngine { + pub async fn get_system_statistics(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let active_sessions: u64 = conn.get("stats:active_sessions").await.unwrap_or(0); + let total_accounts: u64 = conn.keys("account:*").await.unwrap_or_default().len() as u64; + let blocked_users: u64 = conn.keys("block:*").await.unwrap_or_default().len() as u64; + let low_balance_alerts: u64 = conn.keys("alert:low_balance:*").await.unwrap_or_default().len() as u64; + + let stats = SystemStats { + active_sessions, + total_accounts, + blocked_users, + low_balance_alerts, + uptime: self.get_uptime().await?, + }; + + Ok(stats) + } + + async fn get_uptime(&self) -> ChargingResult { + // This would track the actual uptime in a real implementation + // For now, return a placeholder + Ok(SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()) + } + + pub async fn health_check(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + // Test Redis connection + let _: String = conn.get("health_check").await.unwrap_or_else(|_| "ok".to_string()); + + let status = HealthStatus { + redis_connected: true, + active_sync: true, + last_sync: chrono::Utc::now(), + memory_usage: self.get_memory_usage().await?, + }; + + Ok(status) + } + + async fn get_memory_usage(&self) -> ChargingResult { + // This would get actual memory usage in a real implementation + // For now, return a placeholder + Ok(50_000_000) // 50MB placeholder + } + + pub async fn get_performance_metrics(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + // Get Redis info + let info: String = redis::cmd("INFO").query_async(&mut conn).await.unwrap_or_default(); + + // Parse Redis info for metrics (simplified) + let connected_clients = self.extract_metric(&info, "connected_clients"); + let used_memory = self.extract_metric(&info, "used_memory"); + let total_commands_processed = self.extract_metric(&info, "total_commands_processed"); + + let metrics = PerformanceMetrics { + connected_clients, + used_memory, + total_commands_processed, + requests_per_second: self.calculate_rps().await?, + average_response_time: self.calculate_avg_response_time().await?, + }; + + Ok(metrics) + } + + fn extract_metric(&self, info: &str, metric: &str) -> u64 { + info.lines() + .find(|line| line.starts_with(metric)) + .and_then(|line| line.split(':').nth(1)) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + } + + async fn calculate_rps(&self) -> ChargingResult { + // This would calculate actual requests per second + // For now, return a placeholder + Ok(100.0) + } + + async fn calculate_avg_response_time(&self) -> ChargingResult { + // This would calculate actual average response time + // For now, return a placeholder + Ok(5.0) // 5ms + } + + pub async fn get_error_statistics(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "error:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get error keys")?; + + let mut total_errors = 0u64; + let mut error_types = std::collections::HashMap::new(); + + for key in keys { + if let Ok(count) = conn.get::<_, u64>(&key).await { + total_errors += count; + + let error_type = key.split(':').nth(1).unwrap_or("unknown"); + *error_types.entry(error_type.to_string()).or_insert(0) += count; + } + } + + let stats = ErrorStats { + total_errors, + error_types, + last_error: self.get_last_error().await?, + }; + + Ok(stats) + } + + async fn get_last_error(&self) -> ChargingResult> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let last_error: Option = conn.get("last_error").await.unwrap_or(None); + Ok(last_error) + } +} + +#[derive(Debug, Clone)] +pub struct SystemStats { + pub active_sessions: u64, + pub total_accounts: u64, + pub blocked_users: u64, + pub low_balance_alerts: u64, + pub uptime: u64, +} + +#[derive(Debug, Clone)] +pub struct HealthStatus { + pub redis_connected: bool, + pub active_sync: bool, + pub last_sync: chrono::DateTime, + pub memory_usage: u64, +} + +#[derive(Debug, Clone)] +pub struct PerformanceMetrics { + pub connected_clients: u64, + pub used_memory: u64, + pub total_commands_processed: u64, + pub requests_per_second: f64, + pub average_response_time: f64, +} + +#[derive(Debug, Clone)] +pub struct ErrorStats { + pub total_errors: u64, + pub error_types: std::collections::HashMap, + pub last_error: Option, +} diff --git a/apps/charging-engine/src/monitoring/operations.rs b/apps/charging-engine/src/monitoring/operations.rs new file mode 100644 index 0000000..b1cf9b8 --- /dev/null +++ b/apps/charging-engine/src/monitoring/operations.rs @@ -0,0 +1,142 @@ +use tokio::time::interval; +use tracing::{info, warn, error, debug}; +use std::time::Duration; + +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; + +impl super::ChargingEngine { + pub async fn start_background_sync(&self) -> ChargingResult<()> { + let mut interval_timer = interval(self.sync_interval); + + info!("Starting background sync with interval: {:?}", self.sync_interval); + + loop { + interval_timer.tick().await; + + if let Err(e) = self.perform_sync().await { + error!("Background sync failed: {}", e); + } + } + } + + async fn perform_sync(&self) -> ChargingResult<()> { + debug!("Performing background sync"); + + // Sync 1: Clean up expired blocks + self.cleanup_expired_blocks().await?; + + // Sync 2: Update usage statistics + self.update_usage_statistics().await?; + + // Sync 3: Check for low balance alerts + self.check_low_balance_alerts().await?; + + // Sync 4: Apply monthly fees (once per day) + self.apply_monthly_fees_if_needed().await?; + + debug!("Background sync completed successfully"); + Ok(()) + } + + async fn cleanup_expired_blocks(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "block:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get block keys")?; + + let mut cleaned_count = 0; + + for key in keys { + let ttl: i64 = conn.ttl(&key).await.unwrap_or(-1); + if ttl == -1 || ttl == -2 { + // Key has no expiration or doesn't exist + let _: () = conn.del(&key).await.unwrap_or(()); + cleaned_count += 1; + } + } + + if cleaned_count > 0 { + info!("Cleaned up {} expired blocks", cleaned_count); + } + + Ok(()) + } + + async fn update_usage_statistics(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + // Get total active sessions + let pattern = "session:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get session keys")?; + + let active_sessions = keys.len(); + + // Store statistics + let stats_key = "stats:active_sessions"; + let _: () = conn.set(stats_key, active_sessions).await.unwrap_or(()); + + debug!("Updated usage statistics: {} active sessions", active_sessions); + Ok(()) + } + + async fn check_low_balance_alerts(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "credit:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get credit keys")?; + + let low_balance_threshold = 100_000_000; // 100MB in bytes + let mut low_balance_count = 0; + + for key in keys { + if let Ok(balance) = conn.get::<_, u64>(&key).await { + if balance < low_balance_threshold { + low_balance_count += 1; + warn!("Low balance alert for {}: {} bytes remaining", key, balance); + + // Store alert + let alert_key = format!("alert:low_balance:{}", key); + let _: () = conn.setex(&alert_key, 3600, "low_balance").await.unwrap_or(()); + } + } + } + + if low_balance_count > 0 { + info!("Generated {} low balance alerts", low_balance_count); + } + + Ok(()) + } + + async fn apply_monthly_fees_if_needed(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let last_fee_key = "last_monthly_fee"; + let last_fee: Option = conn.get(last_fee_key).await.unwrap_or(None); + + let now = chrono::Utc::now(); + let current_month = format!("{}-{}", now.year(), now.month()); + + if let Some(last_month) = last_fee { + if last_month == current_month { + return Ok(()); // Monthly fees already applied this month + } + } + + // Apply monthly fees + let processed = self.apply_monthly_fees().await?; + + // Update last fee timestamp + let _: () = conn.set(last_fee_key, current_month).await.unwrap_or(()); + + info!("Applied monthly fees to {} accounts for {}", processed, current_month); + Ok(()) + } +} diff --git a/apps/charging-engine/src/monitoring/sync.rs b/apps/charging-engine/src/monitoring/sync.rs new file mode 100644 index 0000000..5f06134 --- /dev/null +++ b/apps/charging-engine/src/monitoring/sync.rs @@ -0,0 +1,195 @@ +use tokio::time::{interval, sleep}; +use tracing::{info, warn, error, debug}; +use std::time::Duration; + +use crate::errors::{ChargingError, ChargingResult, ErrorContext}; + +impl super::ChargingEngine { + pub async fn start_background_sync(&self) -> ChargingResult<()> { + let mut interval_timer = interval(self.sync_interval); + + info!("Starting background sync with interval: {:?}", self.sync_interval); + + loop { + interval_timer.tick().await; + + if let Err(e) = self.perform_sync().await { + error!("Background sync failed: {}", e); + } + } + } + + async fn perform_sync(&self) -> ChargingResult<()> { + debug!("Performing background sync"); + + // Sync 1: Clean up expired blocks + self.cleanup_expired_blocks().await?; + + // Sync 2: Update usage statistics + self.update_usage_statistics().await?; + + // Sync 3: Check for low balance alerts + self.check_low_balance_alerts().await?; + + // Sync 4: Apply monthly fees (once per day) + self.apply_monthly_fees_if_needed().await?; + + debug!("Background sync completed successfully"); + Ok(()) + } + + async fn cleanup_expired_blocks(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "block:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get block keys")?; + + let mut cleaned_count = 0; + + for key in keys { + let ttl: i64 = conn.ttl(&key).await.unwrap_or(-1); + if ttl == -1 || ttl == -2 { + // Key has no expiration or doesn't exist + let _: () = conn.del(&key).await.unwrap_or(()); + cleaned_count += 1; + } + } + + if cleaned_count > 0 { + info!("Cleaned up {} expired blocks", cleaned_count); + } + + Ok(()) + } + + async fn update_usage_statistics(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + // Get total active sessions + let pattern = "session:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get session keys")?; + + let active_sessions = keys.len(); + + // Store statistics + let stats_key = "stats:active_sessions"; + let _: () = conn.set(stats_key, active_sessions).await.unwrap_or(()); + + debug!("Updated usage statistics: {} active sessions", active_sessions); + Ok(()) + } + + async fn check_low_balance_alerts(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let pattern = "credit:*".to_string(); + let keys: Vec = conn.keys(&pattern).await + .context("Failed to get credit keys")?; + + let low_balance_threshold = 100_000_000; // 100MB in bytes + let mut low_balance_count = 0; + + for key in keys { + if let Ok(balance) = conn.get::<_, u64>(&key).await { + if balance < low_balance_threshold { + low_balance_count += 1; + warn!("Low balance alert for {}: {} bytes remaining", key, balance); + + // Store alert + let alert_key = format!("alert:low_balance:{}", key); + let _: () = conn.setex(&alert_key, 3600, "low_balance").await.unwrap_or(()); + } + } + } + + if low_balance_count > 0 { + info!("Generated {} low balance alerts", low_balance_count); + } + + Ok(()) + } + + async fn apply_monthly_fees_if_needed(&self) -> ChargingResult<()> { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let last_fee_key = "last_monthly_fee"; + let last_fee: Option = conn.get(last_fee_key).await.unwrap_or(None); + + let now = chrono::Utc::now(); + let current_month = format!("{}-{}", now.year(), now.month()); + + if let Some(last_month) = last_fee { + if last_month == current_month { + return Ok(()); // Monthly fees already applied this month + } + } + + // Apply monthly fees + let processed = self.apply_monthly_fees().await?; + + // Update last fee timestamp + let _: () = conn.set(last_fee_key, current_month).await.unwrap_or(()); + + info!("Applied monthly fees to {} accounts for {}", processed, current_month); + Ok(()) + } + + pub async fn get_system_statistics(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + let active_sessions: u64 = conn.get("stats:active_sessions").await.unwrap_or(0); + let total_accounts: u64 = conn.keys("account:*").await.unwrap_or_default().len() as u64; + let blocked_users: u64 = conn.keys("block:*").await.unwrap_or_default().len() as u64; + let low_balance_alerts: u64 = conn.keys("alert:low_balance:*").await.unwrap_or_default().len() as u64; + + let stats = SystemStats { + active_sessions, + total_accounts, + blocked_users, + low_balance_alerts, + uptime: self.get_uptime().await?, + }; + + Ok(stats) + } + + async fn get_uptime(&self) -> ChargingResult { + // This would track the actual uptime in a real implementation + // For now, return a placeholder + Ok(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs()) + } + + pub async fn health_check(&self) -> ChargingResult { + let mut conn = self.redis_client.get_async_connection().await + .context("Failed to get Redis connection")?; + + // Test Redis connection + let _: String = conn.get("health_check").await.unwrap_or_else(|_| "ok".to_string()); + + let status = HealthStatus { + redis_connected: true, + active_sync: true, + last_sync: chrono::Utc::now(), + memory_usage: self.get_memory_usage().await?, + }; + + Ok(status) + } + + async fn get_memory_usage(&self) -> ChargingResult { + // This would get actual memory usage in a real implementation + // For now, return a placeholder + Ok(50_000_000) // 50MB placeholder + } +} + diff --git a/apps/charging-engine/src/monitoring/types.rs b/apps/charging-engine/src/monitoring/types.rs new file mode 100644 index 0000000..b67c0a4 --- /dev/null +++ b/apps/charging-engine/src/monitoring/types.rs @@ -0,0 +1,32 @@ +#[derive(Debug, Clone)] +pub struct SystemStats { + pub active_sessions: u64, + pub total_accounts: u64, + pub blocked_users: u64, + pub low_balance_alerts: u64, + pub uptime: u64, +} + +#[derive(Debug, Clone)] +pub struct HealthStatus { + pub redis_connected: bool, + pub active_sync: bool, + pub last_sync: chrono::DateTime, + pub memory_usage: u64, +} + +#[derive(Debug, Clone)] +pub struct PerformanceMetrics { + pub connected_clients: u64, + pub used_memory: u64, + pub total_commands_processed: u64, + pub requests_per_second: f64, + pub average_response_time: f64, +} + +#[derive(Debug, Clone)] +pub struct ErrorStats { + pub total_errors: u64, + pub error_types: std::collections::HashMap, + pub last_error: Option, +} diff --git a/apps/packet-gateway/src/ebpf/packet_filter.c b/apps/packet-gateway/src/ebpf/packet_filter.c new file mode 100644 index 0000000..6f0408b --- /dev/null +++ b/apps/packet-gateway/src/ebpf/packet_filter.c @@ -0,0 +1,152 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 10240); + __type(key, __u32); // IP address + __type(value, __u64); // Byte count +} packet_stats SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 10240); + __type(key, __u32); // IP address + __type(value, __s64); // Credit balance (can be negative) +} user_credits SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 10240); + __type(key, __u32); // IP address + __type(value, __u8); // Block flag (0=allow, 1=block) +} block_list SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); + __uint(max_entries, 1); + __type(key, __u32); + __type(value, __u64); +} sync_interval SEC(".maps"); + +#define BLOCK_THRESHOLD 0 +#define SYNC_INTERVAL_NS 1000000000ULL + +static __always_inline __u32 get_packet_size(struct xdp_md *ctx) { + void *data_end = (void *)(long)ctx->data_end; + void *data = (void *)(long)ctx->data; + return data_end - data; +} + +static __always_inline __u32 get_src_ip(struct xdp_md *ctx) { + void *data_end = (void *)(long)ctx->data_end; + void *data = (void *)(long)ctx->data; + + struct ethhdr *eth = data; + if ((void *)(eth + 1) > data_end) + return 0; + + if (eth->h_proto != __constant_htons(ETH_P_IP)) + return 0; + + struct iphdr *ip = (void *)(eth + 1); + if ((void *)(ip + 1) > data_end) + return 0; + + return bpf_ntohl(ip->saddr); +} + +static __always_inline __u32 get_dst_ip(struct xdp_md *ctx) { + void *data_end = (void *)(long)ctx->data_end; + void *data = (void *)(long)ctx->data; + + struct ethhdr *eth = data; + if ((void *)(eth + 1) > data_end) + return 0; + + if (eth->h_proto != __constant_htons(ETH_P_IP)) + return 0; + + struct iphdr *ip = (void *)(eth + 1); + if ((void *)(ip + 1) > data_end) + return 0; + + return bpf_ntohl(ip->daddr); +} + +static __always_inline bool should_block_packet(__u32 src_ip, __u32 dst_ip) { + __u8 *block_flag_src = bpf_map_lookup_elem(&block_list, &src_ip); + __u8 *block_flag_dst = bpf_map_lookup_elem(&block_list, &dst_ip); + + if (block_flag_src && *block_flag_src == 1) + return true; + if (block_flag_dst && *block_flag_dst == 1) + return true; + + __s64 *credit_src = bpf_map_lookup_elem(&user_credits, &src_ip); + if (credit_src && *credit_src <= BLOCK_THRESHOLD) + return true; + + return false; +} + +static __always_inline void update_packet_stats(__u32 ip, __u32 bytes) { + __u64 *current_stats = bpf_map_lookup_elem(&packet_stats, &ip); + __u64 new_stats = bytes; + + if (current_stats) { + new_stats += *current_stats; + } + + bpf_map_update_elem(&packet_stats, &ip, &new_stats, BPF_ANY); +} + +static __always_inline void update_credit_usage(__u32 ip, __u32 bytes) { + __s64 *current_credit = bpf_map_lookup_elem(&user_credits, &ip); + if (current_credit) { + __s64 new_credit = *current_credit - bytes; + bpf_map_update_elem(&user_credits, &ip, &new_credit, BPF_ANY); + } +} + +SEC("xdp") +int packet_filter(struct xdp_md *ctx) { + __u32 packet_size = get_packet_size(ctx); + if (packet_size < sizeof(struct ethhdr) + sizeof(struct iphdr)) + return XDP_PASS; + + __u32 src_ip = get_src_ip(ctx); + __u32 dst_ip = get_dst_ip(ctx); + + if (src_ip == 0 || dst_ip == 0) + return XDP_PASS; + + if (should_block_packet(src_ip, dst_ip)) { + bpf_printk("Blocking packet from %x to %x - insufficient credit or blocked\n", + src_ip, dst_ip); + return XDP_DROP; + } + + update_packet_stats(src_ip, packet_size); + update_packet_stats(dst_ip, packet_size); + + update_credit_usage(src_ip, packet_size); + + return XDP_PASS; +} + +SEC("xdp") +int sync_stats(struct xdp_md *ctx) { + // This would be called periodically from userspace + // to sync eBPF map data to Redis + return XDP_PASS; +} + +// License +char _license[] SEC("license") = "GPL"; diff --git a/go.work b/go.work index e7d6b58..b6a1a89 100644 --- a/go.work +++ b/go.work @@ -1,6 +1,6 @@ go 1.26 use ( - ./apps/api-server - ./apps/carrier-connector + ./apps/api-server + ./apps/carrier-connector )