Merge pull request #1 from nutcas3/phase-2

Phase 2
This commit is contained in:
Nutcase
2026-04-14 18:31:03 +03:00
committed by GitHub
43 changed files with 3921 additions and 211 deletions
+50 -3
View File
@@ -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
)
+155
View File
@@ -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=
+83
View File
@@ -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",
},
}
}
@@ -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"`
}
@@ -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"`
}
@@ -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"
}
@@ -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
}
@@ -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
}
@@ -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())
}
@@ -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)
}
@@ -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
}
@@ -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"`
}
@@ -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"`
}
@@ -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
}
@@ -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
}
@@ -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
}
+1 -2
View File
@@ -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
)
+10 -4
View File
@@ -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
)
+17
View File
@@ -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=
@@ -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",
}
}
@@ -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"`
}
-1
View File
@@ -3,7 +3,6 @@ package main
import (
"crypto/tls"
"fmt"
"log"
"os"
"time"
+127
View File
@@ -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<crate::charging::ChargingEngine>,
}
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<String>,
State(state): State<AppState>,
Json(req): Json<CreditCheckRequest>,
) -> ChargingResult<Json<CreditCheckResponse>> {
// 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<String>,
State(state): State<AppState>,
Json(req): Json<DeductRequest>,
) -> 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<String>,
State(state): State<AppState>,
Json(req): Json<AddCreditRequest>,
) -> 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<String>,
State(state): State<AppState>,
) -> ChargingResult<Json<BalanceResponse>> {
// 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<Json<HealthResponse>> {
Ok(Json(HealthResponse::default()))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod types;
pub mod engine;
pub mod credit_management;
pub mod rating_billing;
pub use engine::ChargingEngine;
@@ -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<u64> {
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<u64> {
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<u64> {
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<bool> {
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<bool> {
let mut conn = self.redis_client.get_async_connection().await
.context("Failed to get Redis connection")?;
let key = format!("block:{}", ip);
let blocked: Option<String> = 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<Option<SubscriberAccount>> {
let mut conn = self.redis_client.get_async_connection().await
.context("Failed to get Redis connection")?;
let key = format!("account:{}", imsi);
let account: Option<SubscriberAccount> = 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(())
}
}
@@ -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<String, RatingPlan>,
sync_interval: Duration,
}
impl ChargingEngine {
pub fn new(redis_url: &str, sync_interval_secs: u64) -> ChargingResult<Self> {
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<RatingPlan> {
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(())
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod types;
pub mod engine;
pub mod credit_management;
pub mod rating_billing;
pub use engine::ChargingEngine;
@@ -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<f64> {
// 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<UsageEvent> {
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<Vec<String>> {
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<f64> {
// 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<String> = 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<UsageEvent>>(&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<u32> {
let mut conn = self.redis_client.get_async_connection().await
.context("Failed to get Redis connection")?;
let pattern = "account:*".to_string();
let keys: Vec<String> = 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<crate::charging_types::SubscriberAccount>>(&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)
}
}
@@ -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<Utc>,
}
#[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<Utc>,
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<Utc>,
pub end_time: Option<DateTime<Utc>>,
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<Condition>,
pub actions: Vec<Action>,
}
#[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<String, String>,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod types;
pub mod helpers;
+162
View File
@@ -0,0 +1,162 @@
use anyhow::Context;
pub type ChargingResult<T> = Result<T, ChargingError>;
pub trait ErrorContext<T> {
fn with_context(self, msg: &str) -> ChargingResult<T>;
}
impl<T, E> ErrorContext<T> for Result<T, E>
where
E: Into<ChargingError>,
{
fn with_context(self, msg: &str) -> ChargingResult<T> {
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::<u8>() {
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<redis::RedisError> for ChargingError {
fn from(err: redis::RedisError) -> Self {
ChargingError::RedisConnection(err.to_string())
}
}
impl From<serde_json::Error> for ChargingError {
fn from(err: serde_json::Error) -> Self {
ChargingError::SerializationError(err.to_string())
}
}
impl From<std::num::ParseIntError> for ChargingError {
fn from(err: std::num::ParseIntError) -> Self {
ChargingError::InvalidInput(format!("Parse error: {}", err))
}
}
impl From<std::num::ParseFloatError> 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);
}
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod types;
pub mod helpers;
+64
View File
@@ -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()
}
}
+27 -199
View File
@@ -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::<u64>()
.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<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<CreditCheckRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(CreditCheckResponse {
allowed: false,
remaining_bytes: 0,
}),
);
}
};
let key = format!("credit:{}", ip);
// Get current credit balance
let credit: i64 = conn.get(&key).await.unwrap_or(0);
let allowed = credit >= req.bytes_requested as i64;
info!(
"Credit check for {}: {} bytes requested, {} bytes available, allowed: {}",
ip, req.bytes_requested, credit, allowed
);
(
StatusCode::OK,
Json(CreditCheckResponse {
allowed,
remaining_bytes: credit,
}),
)
}
#[derive(Deserialize)]
struct DeductRequest {
bytes_used: u64,
}
/// POST /v1/credit/:ip/deduct
/// Deduct bytes from user's credit balance
async fn deduct_credit(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<DeductRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
};
let key = format!("credit:{}", ip);
// Atomically subtract bytes
let new_balance: i64 = conn.decr(&key, req.bytes_used).await.unwrap_or(0);
info!(
"User {} deducted {} bytes, remaining: {}",
ip, req.bytes_used, new_balance
);
StatusCode::OK
}
#[derive(Deserialize)]
struct AddCreditRequest {
bytes_to_add: u64,
}
/// POST /v1/credit/:ip/add
/// Add bytes to user's credit balance
async fn add_credit(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
Json(req): Json<AddCreditRequest>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(e) => {
warn!("Redis connection failed: {}", e);
return StatusCode::INTERNAL_SERVER_ERROR;
}
};
let key = format!("credit:{}", ip);
// Atomically add bytes
let new_balance: i64 = conn.incr(&key, req.bytes_to_add).await.unwrap_or(0);
info!(
"User {} added {} bytes, new balance: {}",
ip, req.bytes_to_add, new_balance
);
StatusCode::OK
}
#[derive(Serialize)]
struct BalanceResponse {
ip: String,
balance_bytes: i64,
}
/// GET /v1/credit/:ip/balance
/// Get current credit balance
async fn get_balance(
Path(ip): Path<String>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let mut conn = match state.redis.get_multiplexed_async_connection().await {
Ok(c) => c,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BalanceResponse {
ip: ip.clone(),
balance_bytes: 0,
}),
);
}
};
let key = format!("credit:{}", ip);
let balance: i64 = conn.get(&key).await.unwrap_or(0);
(
StatusCode::OK,
Json(BalanceResponse {
ip,
balance_bytes: balance,
}),
)
}
async fn health_check() -> &'static str {
"OK"
}
+48
View File
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AppState {
pub charging_engine: std::sync::Arc<crate::charging::ChargingEngine>,
}
#[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<chrono::Utc>,
}
impl Default for HealthResponse {
fn default() -> Self {
Self {
status: "OK".to_string(),
timestamp: chrono::Utc::now(),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod sync;
pub mod types;
pub mod monitoring;
pub mod operations;
@@ -0,0 +1,4 @@
pub mod sync;
pub mod types;
pub mod monitoring;
pub mod operations;
@@ -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<SystemStats> {
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<u64> {
// 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<HealthStatus> {
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<u64> {
// 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<PerformanceMetrics> {
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::<u64>().ok())
.unwrap_or(0)
}
async fn calculate_rps(&self) -> ChargingResult<f64> {
// This would calculate actual requests per second
// For now, return a placeholder
Ok(100.0)
}
async fn calculate_avg_response_time(&self) -> ChargingResult<f64> {
// This would calculate actual average response time
// For now, return a placeholder
Ok(5.0) // 5ms
}
pub async fn get_error_statistics(&self) -> ChargingResult<ErrorStats> {
let mut conn = self.redis_client.get_async_connection().await
.context("Failed to get Redis connection")?;
let pattern = "error:*".to_string();
let keys: Vec<String> = 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<Option<String>> {
let mut conn = self.redis_client.get_async_connection().await
.context("Failed to get Redis connection")?;
let last_error: Option<String> = 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<chrono::Utc>,
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<String, u64>,
pub last_error: Option<String>,
}
@@ -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<String> = 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<String> = 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<String> = 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<String> = 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(())
}
}
+195
View File
@@ -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<String> = 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<String> = 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<String> = 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<String> = 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<SystemStats> {
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<u64> {
// 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<HealthStatus> {
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<u64> {
// This would get actual memory usage in a real implementation
// For now, return a placeholder
Ok(50_000_000) // 50MB placeholder
}
}
@@ -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<chrono::Utc>,
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<String, u64>,
pub last_error: Option<String>,
}
@@ -0,0 +1,152 @@
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <linux/tcp.h>
#include <linux/icmp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
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";
+2 -2
View File
@@ -1,6 +1,6 @@
go 1.26
use (
./apps/api-server
./apps/carrier-connector
./apps/api-server
./apps/carrier-connector
)