mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 02:52:53 +00:00
+50
-3
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod types;
|
||||
pub mod helpers;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod types;
|
||||
pub mod helpers;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user