diff --git a/data/conf/samples/radius_ipam/acct.json b/data/conf/samples/radius_ipam/acct.json new file mode 100644 index 000000000..9560c06f9 --- /dev/null +++ b/data/conf/samples/radius_ipam/acct.json @@ -0,0 +1,457 @@ +{ + "radius_agent": { + "request_processors": [ + { + "id": "IPSAccountingStart", + "filters": [ + "*string:~*req.Acct-Status-Type:Start" + ], + "flags": [ + "*initiate", + "*chargers", + "*ips", + "*accounts" + ], + "request_fields": [ + { + "tag": "Category", + "path": "*cgreq.Category", + "type": "*constant", + "value": "ips" + }, + { + "tag": "RequestType", + "path": "*cgreq.RequestType", + "type": "*constant", + "value": "*rated", + "mandatory": true + }, + { + "tag": "OriginID", + "path": "*cgreq.OriginID", + "type": "*variable", + "value": "~*req.Acct-Session-Id", + "mandatory": true + }, + { + "tag": "OriginHost", + "path": "*cgreq.OriginHost", + "type": "*variable", + "value": "~*req.NAS-IP-Address", + "mandatory": true + }, + { + "tag": "Account", + "path": "*cgreq.Account", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Subject", + "path": "*cgreq.Subject", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Destination", + "path": "*cgreq.Destination", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "SetupTime", + "path": "*cgreq.SetupTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "AnswerTime", + "path": "*cgreq.AnswerTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "IMSI", + "path": "*cgreq.IMSI", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "APN", + "path": "*cgreq.APN", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "CallingStationId", + "path": "*cgreq.CallingStationId", + "type": "*variable", + "value": "~*req.Calling-Station-Id", + "mandatory": true + }, + { + "tag": "NAS-Identifier", + "path": "*cgreq.NAS-Identifier", + "type": "*variable", + "value": "~*req.NAS-Identifier", + "mandatory": true + }, + { + "tag": "ServiceType", + "path": "*cgreq.ServiceType", + "type": "*variable", + "value": "~*req.Service-Type", + "mandatory": true + }, + { + "tag": "FramedProtocol", + "path": "*cgreq.FramedProtocol", + "type": "*variable", + "value": "~*req.Framed-Protocol", + "mandatory": true + }, + { + "tag": "NASPortType", + "path": "*cgreq.NASPortType", + "type": "*variable", + "value": "~*req.NAS-Port-Type", + "mandatory": true + }, + { + "tag": "AcctAuthentic", + "path": "*cgreq.AcctAuthentic", + "type": "*variable", + "value": "~*req.Acct-Authentic", + "mandatory": true + }, + { + "tag": "AcctDelayTime", + "path": "*cgreq.AcctDelayTime", + "type": "*variable", + "value": "~*req.Acct-Delay-Time", + "mandatory": true + }, + { + "tag": "AllocatedIP", + "path": "*cgreq.AllocatedIP", + "type": "*variable", + "value": "~*req.Framed-IP-Address", + "mandatory": true + }, + { + "tag": "SessionID", + "path": "*vars.*sessionID", + "type": "*variable", + "value": "~*req.Acct-Session-Id" + }, + { + "tag": "RemoteAddr", + "path": "*cgreq.RemoteAddr", + "type": "*variable", + "value": "~*vars.RemoteHost:s/(.*):\\d+/${1}/" + } + ], + "reply_fields": [ + { + "tag": "ProxyState", + "path": "*rep.Proxy-State", + "type": "*variable", + "value": "~*req.Proxy-State", + "mandatory": true + } + ] + }, + { + "id": "IPSAccountingAlive", + "filters": [ + "*string:~*req.Acct-Status-Type:Alive" + ], + "flags": [ + "*update" + ], + "request_fields": [ + { + "tag": "Category", + "path": "*cgreq.Category", + "type": "*constant", + "value": "ips" + }, + { + "tag": "RequestType", + "path": "*cgreq.RequestType", + "type": "*constant", + "value": "*rated", + "mandatory": true + }, + { + "tag": "OriginID", + "path": "*cgreq.OriginID", + "type": "*variable", + "value": "~*req.Acct-Session-Id", + "mandatory": true + }, + { + "tag": "Account", + "path": "*cgreq.Account", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Subject", + "path": "*cgreq.Subject", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Destination", + "path": "*cgreq.Destination", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "SetupTime", + "path": "*cgreq.SetupTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "IMSI", + "path": "*cgreq.IMSI", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "APN", + "path": "*cgreq.APN", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "CallingStationId", + "path": "*cgreq.CallingStationId", + "type": "*variable", + "value": "~*req.Calling-Station-Id", + "mandatory": true + }, + { + "tag": "NAS-Identifier", + "path": "*cgreq.NAS-Identifier", + "type": "*variable", + "value": "~*req.NAS-Identifier", + "mandatory": true + }, + { + "tag": "ServiceType", + "path": "*cgreq.ServiceType", + "type": "*variable", + "value": "~*req.Service-Type", + "mandatory": true + }, + { + "tag": "FramedProtocol", + "path": "*cgreq.FramedProtocol", + "type": "*variable", + "value": "~*req.Framed-Protocol", + "mandatory": true + }, + { + "tag": "AcctInputOctets", + "path": "*cgreq.AcctInputOctets", + "type": "*variable", + "value": "~*req.Acct-Input-Octets", + "mandatory": true + }, + { + "tag": "AcctOutputOctets", + "path": "*cgreq.AcctOutputOctets", + "type": "*variable", + "value": "~*req.Acct-Output-Octets", + "mandatory": true + }, + { + "tag": "SessionID", + "path": "*vars.*sessionID", + "type": "*variable", + "value": "~*req.Acct-Session-Id" + } + ], + "reply_fields": [ + { + "tag": "ProxyState", + "path": "*rep.Proxy-State", + "type": "*variable", + "value": "~*req.Proxy-State", + "mandatory": true + } + ] + }, + { + "id": "IPSAccountingStop", + "filters": [ + "*string:~*req.Acct-Status-Type:Stop" + ], + "flags": [ + "*terminate", + "*cdrs", + "*ips", + "*accounts" + ], + "request_fields": [ + { + "tag": "Category", + "path": "*cgreq.Category", + "type": "*constant", + "value": "ips" + }, + { + "tag": "RequestType", + "path": "*cgreq.RequestType", + "type": "*constant", + "value": "*rated", + "mandatory": true + }, + { + "tag": "OriginID", + "path": "*cgreq.OriginID", + "type": "*variable", + "value": "~*req.Acct-Session-Id", + "mandatory": true + }, + { + "tag": "OriginHost", + "path": "*cgreq.OriginHost", + "type": "*variable", + "value": "~*req.NAS-IP-Address", + "mandatory": true + }, + { + "tag": "Account", + "path": "*cgreq.Account", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Subject", + "path": "*cgreq.Subject", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Destination", + "path": "*cgreq.Destination", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "SetupTime", + "path": "*cgreq.SetupTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "Usage", + "path": "*cgreq.Usage", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "IMSI", + "path": "*cgreq.IMSI", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "APN", + "path": "*cgreq.APN", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "CallingStationId", + "path": "*cgreq.CallingStationId", + "type": "*variable", + "value": "~*req.Calling-Station-Id", + "mandatory": true + }, + { + "tag": "NAS-Identifier", + "path": "*cgreq.NAS-Identifier", + "type": "*variable", + "value": "~*req.NAS-Identifier", + "mandatory": true + }, + { + "tag": "ServiceType", + "path": "*cgreq.ServiceType", + "type": "*variable", + "value": "~*req.Service-Type", + "mandatory": true + }, + { + "tag": "FramedProtocol", + "path": "*cgreq.FramedProtocol", + "type": "*variable", + "value": "~*req.Framed-Protocol", + "mandatory": true + }, + { + "tag": "AcctInputOctets", + "path": "*cgreq.AcctInputOctets", + "type": "*variable", + "value": "~*req.Acct-Input-Octets", + "mandatory": true + }, + { + "tag": "AcctOutputOctets", + "path": "*cgreq.AcctOutputOctets", + "type": "*variable", + "value": "~*req.Acct-Output-Octets", + "mandatory": true + }, + { + "tag": "AcctTerminateCause", + "path": "*cgreq.AcctTerminateCause", + "type": "*variable", + "value": "~*req.Acct-Terminate-Cause", + "mandatory": true + }, + { + "tag": "SessionID", + "path": "*vars.*sessionID", + "type": "*variable", + "value": "~*req.Acct-Session-Id" + } + ], + "reply_fields": [ + { + "tag": "ProxyState", + "path": "*rep.Proxy-State", + "type": "*variable", + "value": "~*req.Proxy-State", + "mandatory": true + } + ] + } + ] + } +} diff --git a/data/conf/samples/radius_ipam/auth.json b/data/conf/samples/radius_ipam/auth.json new file mode 100644 index 000000000..c409d6d26 --- /dev/null +++ b/data/conf/samples/radius_ipam/auth.json @@ -0,0 +1,190 @@ +{ + "radius_agent": { + "request_processors": [ + { + "id": "IPSAuthorization", + "filters": [ + "*string:~*vars.*radReqType:*radAuth" + ], + "flags": [ + "*authorize", + "*ips", + "*continue" + ], + "request_fields": [ + { + "tag": "Category", + "path": "*cgreq.Category", + "type": "*constant", + "value": "ips" + }, + { + "tag": "RequestType", + "path": "*cgreq.RequestType", + "type": "*constant", + "value": "*rated", + "mandatory": true + }, + { + "tag": "OriginID", + "path": "*cgreq.OriginID", + "type": "*variable", + "value": "~*req.Acct-Session-Id", + "mandatory": true + }, + { + "tag": "Account", + "path": "*cgreq.Account", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Subject", + "path": "*cgreq.Subject", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "Destination", + "path": "*cgreq.Destination", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "SetupTime", + "path": "*cgreq.SetupTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "AnswerTime", + "path": "*cgreq.AnswerTime", + "type": "*variable", + "value": "~*req.Event-Timestamp", + "mandatory": true + }, + { + "tag": "IMSI", + "path": "*cgreq.IMSI", + "type": "*variable", + "value": "~*req.User-Name", + "mandatory": true + }, + { + "tag": "APN", + "path": "*cgreq.APN", + "type": "*variable", + "value": "~*req.Called-Station-Id", + "mandatory": true + }, + { + "tag": "CallingStationId", + "path": "*cgreq.CallingStationId", + "type": "*variable", + "value": "~*req.Calling-Station-Id", + "mandatory": true + }, + { + "tag": "NAS-Identifier", + "path": "*cgreq.NAS-Identifier", + "type": "*variable", + "value": "~*req.NAS-Identifier", + "mandatory": true + }, + { + "tag": "FramedPool", + "path": "*cgreq.FramedPool", + "type": "*variable", + "value": "~*req.Framed-Pool", + "mandatory": true + }, + { + "tag": "ServiceType", + "path": "*cgreq.ServiceType", + "type": "*variable", + "value": "~*req.Service-Type", + "mandatory": true + }, + { + "tag": "FramedProtocol", + "path": "*cgreq.FramedProtocol", + "type": "*variable", + "value": "~*req.Framed-Protocol", + "mandatory": true + }, + { + "tag": "NAS-IP", + "path": "*cgreq.NAS-IP", + "type": "*variable", + "value": "~*req.NAS-IP-Address", + "mandatory": true + } + ], + "reply_fields": [ + { + "tag": "ProxyState", + "path": "*rep.Proxy-State", + "type": "*variable", + "value": "~*req.Proxy-State" + }, + { + "tag": "Code", + "path": "*rep.*radReplyCode", + "filters": [ + "*notempty:~*cgrep.Error:" + ], + "type": "*constant", + "value": "AccessReject" + }, + { + "tag": "ErrorReplyMessage", + "path": "*rep.Reply-Message", + "filters": [ + "*notempty:~*cgrep.Error:" + ], + "type": "*variable", + "value": "~*cgrep.Error", + "blocker": true + }, + { + "tag": "FramedIPAddress", + "path": "*rep.Framed-IP-Address", + "type": "*variable", + "value": "~*cgrep.AllocatedIP.Address", + "mandatory": true + }, + { + "tag": "FramedProtocol", + "path": "*rep.Framed-Protocol", + "type": "*constant", + "value": "PPP", + "mandatory": true + }, + { + "tag": "ServiceType", + "path": "*rep.Service-Type", + "type": "*constant", + "value": "Framed", + "mandatory": true + }, + { + "tag": "ReplyMessage", + "path": "*rep.Reply-Message", + "type": "*variable", + "value": "Pool:;~*cgrep.AllocatedIP.PoolID; - ;~*cgrep.AllocatedIP.Message" + }, + { + "tag": "Class", + "path": "*rep.Class", + "type": "*variable", + "value": "~*cgrep.AllocatedIP.ProfileID" + } + ] + } + ] + } +} diff --git a/data/conf/samples/radius_ipam/cgrates.json b/data/conf/samples/radius_ipam/cgrates.json new file mode 100644 index 000000000..0d54bffcf --- /dev/null +++ b/data/conf/samples/radius_ipam/cgrates.json @@ -0,0 +1,40 @@ +{ + "sessions": { + "enabled": true, + "ips_conns": [ + "*localhost" + ], + "cdrs_conns": [ + "*localhost" + ], + "chargers_conns": [ + "*localhost" + ], + }, + "ips": { + "enabled": true, + "store_interval": "-1" + }, + "cdrs": { + "enabled": true, + }, + "chargers": { + "enabled": true + }, + "radius_agent": { + "enabled": true, + "sessions_conns": [ + "*bijson_localhost" + ], + "listeners": [ + { + "network": "udp", + "auth_address": "127.0.0.1:1812", + "acct_address": "127.0.0.1:1813" + } + ] + }, + "apiers": { + "enabled": true + } +} diff --git a/general_tests/radius_ipam_it_test.go b/general_tests/radius_ipam_it_test.go new file mode 100644 index 000000000..130d6236d --- /dev/null +++ b/general_tests/radius_ipam_it_test.go @@ -0,0 +1,437 @@ +//go:build integration +// +build integration + +/* +Real-time Online/Offline Charging System (OCS) for Telecom & ISP environments +Copyright (C) ITsysCOM GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see +*/ + +package general_tests + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cgrates/birpc" + "github.com/cgrates/birpc/context" + "github.com/cgrates/cgrates/engine" + "github.com/cgrates/cgrates/sessions" + "github.com/cgrates/cgrates/utils" + "github.com/cgrates/radigo" +) + +func TestRadiusIPAM(t *testing.T) { + switch *utils.DBType { + case utils.MetaInternal: + case utils.MetaMySQL, utils.MetaMongo, utils.MetaPostgres: + t.SkipNow() + default: + t.Fatal("unsupported dbtype value") + } + + var testRadiusDict = ` +ATTRIBUTE Framed-Pool 88 string + +VALUE Service-Type Framed 2 +VALUE Framed-Protocol PPP 1 +VALUE Framed-Protocol GPRS-PDP-Context 7 +VALUE NAS-Port-Type Virtual 5 +VALUE Acct-Status-Type Start 1 +VALUE Acct-Status-Type Stop 2 +VALUE Acct-Status-Type Alive 3 +VALUE Acct-Authentic RADIUS 1 +VALUE Acct-Terminate-Cause User-Request 1 +` + + dictDir := t.TempDir() + dictPath := filepath.Join(dictDir, "dictionary.test") + if err := os.WriteFile(dictPath, []byte(testRadiusDict), 0644); err != nil { + t.Fatal(err) + } + + ng := engine.TestEngine{ + ConfigPath: filepath.Join(*utils.DataDir, "conf", "samples", "radius_ipam"), + ConfigJSON: fmt.Sprintf(`{ +"radius_agent": { + "client_dictionaries": { + "*default": [ + %q + ] + } +} +}`, dictDir+"/"), + DBCfg: engine.InternalDBCfg, + LogBuffer: &bytes.Buffer{}, + } + t.Cleanup(func() { fmt.Println(ng.LogBuffer) }) + client, cfg := ng.Run(t) + + var replySetCharger string + if err := client.Call(context.Background(), utils.APIerSv1SetChargerProfile, + &engine.ChargerProfileWithAPIOpts{ + ChargerProfile: &engine.ChargerProfile{ + Tenant: "cgrates.org", + ID: "DEFAULT", + RunID: utils.MetaDefault, + AttributeIDs: []string{utils.MetaNone}, + }, + }, &replySetCharger); err != nil { + t.Fatal(err) + } + + ippID := "IMSI_123456789012345" + var replySet string + if err := client.Call(context.Background(), utils.APIerSv1SetIPProfile, + &engine.IPProfileWithAPIOpts{ + IPProfile: &engine.IPProfile{ + Tenant: "cgrates.org", + ID: ippID, + FilterIDs: []string{"*string:~*req.IMSI:123456789012345"}, + TTL: -1, + Pools: []*engine.IPPool{ + { + ID: "DEFAULT", + Type: "*ipv4", + Range: "10.100.0.1/32", // Single IP to ensure rejection scenario + Strategy: "*ascending", + Message: "Default IP pool", + }, + }, + }, + }, &replySet); err != nil { + t.Error(err) + } + + imsi := "123456789012345" + msisdn := "987654321098765" + apn := "internet.test.apn" + + nasID := "test-nas-server-1" + nasIP := "192.168.1.10" + poolName := "test-pool-primary" + + passwd := "CGRateSPassword1" + currentTimestamp := fmt.Sprintf("%d", time.Now().Unix()) + + authSessionID := "auth-session-12345-67890" + acctSessionID := "acct-session-abcdef-123456" + + proxyAuth := "4829" + proxyAuthReject := "4830" + proxyAcctStart := "4831" + proxyAcctAlive := "4832" + proxyAcctStop := "4833" + + // Step 1: Access-Request (should not allocate) + dictRad := radigo.RFC2865Dictionary() + dictRad.ParseFromReader(strings.NewReader(testRadiusDict)) + secret := cfg.RadiusAgentCfg().ClientSecrets[utils.MetaDefault] + net := cfg.RadiusAgentCfg().Listeners[0].Network + authAddr := cfg.RadiusAgentCfg().Listeners[0].AuthAddr + clientAuth, err := radigo.NewClient(net, authAddr, secret, dictRad, 1, nil, utils.Logger) + if err != nil { + t.Fatal(err) + } + + reply := sendRadReq(t, clientAuth, radigo.AccessRequest, 1, + map[string]string{ + "User-Name": imsi, + "Service-Type": "Framed", + "Framed-Protocol": "GPRS-PDP-Context", + "Called-Station-Id": apn, + "Calling-Station-Id": msisdn, + "NAS-Identifier": nasID, + "Acct-Session-Id": authSessionID, + "Framed-Pool": poolName, + "User-Password": passwd, + "Event-Timestamp": currentTimestamp, + "NAS-IP-Address": nasIP, + "Proxy-State": proxyAuth, + }, radigo.AccessAccept, + ) + checkAllocs(t, client, ippID) + checkActiveSessions(t, client, 0) + + // retrieve allocatedIP (to be used in Accounting-Request Start) + var allocatedIP string + for _, avp := range reply.AVPs { + if avp.Number == 8 { // Framed-IP-Address + if len(avp.RawValue) == 4 { + allocatedIP = fmt.Sprintf("%d.%d.%d.%d", + avp.RawValue[0], avp.RawValue[1], + avp.RawValue[2], avp.RawValue[3]) + break + } + } + } + if allocatedIP != "10.100.0.1" { + t.Errorf("expected IP from DEFAULT pool (10.100.0.1), got %s", allocatedIP) + } + + // Step 2: Accounting-Request Start (should allocate) + acctAddr := cfg.RadiusAgentCfg().Listeners[0].AcctAddr + clientAcct, err := radigo.NewClient(net, acctAddr, secret, dictRad, 1, nil, utils.Logger) + if err != nil { + t.Fatal(err) + } + + sendRadReq(t, clientAcct, radigo.AccountingRequest, 2, + map[string]string{ + "User-Name": imsi, + "Acct-Status-Type": "Start", + "NAS-Identifier": nasID, + "Called-Station-Id": apn, + "Framed-Protocol": "GPRS-PDP-Context", + "Service-Type": "Framed", + "NAS-Port-Type": "Virtual", + "Calling-Station-Id": msisdn, + "Acct-Authentic": "RADIUS", + "Acct-Delay-Time": "0", + "Acct-Session-Id": acctSessionID, + "Framed-IP-Address": allocatedIP, + "NAS-IP-Address": nasIP, + "Event-Timestamp": currentTimestamp, + "Proxy-State": proxyAcctStart, + }, radigo.AccountingResponse, + ) + checkAllocs(t, client, ippID, acctSessionID) + checkActiveSessions(t, client, 1) + + // Step 2.5: Send another Access-Request after IP allocation + // This should receive Access-Reject since IP is already allocated. + rejectSessID := "reject-session-98765" + rejectTimestamp := fmt.Sprintf("%d", time.Now().Unix()) + + rejectReply := sendRadReq(t, clientAuth, radigo.AccessRequest, 5, + map[string]string{ + "User-Name": imsi, + "Service-Type": "Framed", + "Framed-Protocol": "GPRS-PDP-Context", + "Called-Station-Id": apn, + "Calling-Station-Id": msisdn, + "NAS-Identifier": nasID, + "Acct-Session-Id": rejectSessID, + "Framed-Pool": poolName, + "User-Password": passwd, + "Event-Timestamp": rejectTimestamp, + "NAS-IP-Address": nasIP, + "Proxy-State": proxyAuthReject, + }, radigo.AccessReject) + + // Verify Access-Reject response contains error message and no IP address. + var hasReplyMessage, hasFramedIP bool + var replyMessage string + for _, avp := range rejectReply.AVPs { + if avp.Number == 18 { // Reply-Message + hasReplyMessage = true + replyMessage = string(avp.RawValue) + } + if avp.Number == 8 { // Framed-IP-Address + hasFramedIP = true + } + } + if !hasReplyMessage || replyMessage == "" { + t.Errorf("Access-Reject should contain Reply-Message with error, got: %q", replyMessage) + } + if hasFramedIP { + t.Error("Access-Reject should not contain Framed-IP-Address") + } + if !strings.Contains(replyMessage, "IP_UNAUTHORIZED") { + t.Errorf("Reply-Message should contain IP_UNAUTHORIZED error, got: %q", replyMessage) + } + + // Step 3: Accounting-Request Alive (should maintain allocation) + time.Sleep(100 * time.Millisecond) + aliveTimestamp := fmt.Sprintf("%d", time.Now().Unix()) + sendRadReq(t, clientAcct, radigo.AccountingRequest, 3, + map[string]string{ + "User-Name": imsi, + "Acct-Status-Type": "Alive", + "Service-Type": "Framed", + "Acct-Session-Id": acctSessionID, + "Framed-Protocol": "GPRS-PDP-Context", + "Called-Station-Id": apn, + "Calling-Station-Id": msisdn, + "NAS-Identifier": nasID, + "Acct-Input-Octets": "1234567", + "Acct-Output-Octets": "7654321", + "NAS-IP-Address": nasIP, + "Event-Timestamp": aliveTimestamp, + "Proxy-State": proxyAcctAlive, + }, radigo.AccountingResponse, + ) + checkAllocs(t, client, ippID, acctSessionID) + + // Step 4: Accounting-Request Stop (should release) + time.Sleep(100 * time.Millisecond) + stopTimestamp := fmt.Sprintf("%d", time.Now().Unix()) + sendRadReq(t, clientAcct, radigo.AccountingRequest, 4, + map[string]string{ + "User-Name": imsi, + "Acct-Status-Type": "Stop", + "Service-Type": "Framed", + "Acct-Session-Id": acctSessionID, + "Framed-Protocol": "GPRS-PDP-Context", + "Called-Station-Id": apn, + "Calling-Station-Id": msisdn, + "NAS-Identifier": nasID, + "Acct-Input-Octets": "9876543", + "Acct-Output-Octets": "1234567", + "Acct-Terminate-Cause": "User-Request", + "NAS-IP-Address": nasIP, + "Event-Timestamp": stopTimestamp, + "Proxy-State": proxyAcctStop, + }, radigo.AccountingResponse, + ) + time.Sleep(time.Second) + checkAllocs(t, client, ippID) + checkActiveSessions(t, client, 0) + checkCDR(t, client, imsi) +} + +func sendRadReq(t *testing.T, client *radigo.Client, code radigo.PacketCode, id uint8, avps map[string]string, expectedCode radigo.PacketCode) *radigo.Packet { + t.Helper() + req := client.NewRequest(code, id) + + for attr, val := range avps { + if err := req.AddAVPWithName(attr, val, ""); err != nil { + t.Fatal(err) + } + if code == radigo.AccessRequest && attr == "User-Password" { + secret := []byte("CGRateS.org") + for i := len(req.AVPs) - 1; i >= 0; i-- { + if req.AVPs[i].Name == "User-Password" { + req.AVPs[i].RawValue = radigo.EncodeUserPassword([]byte(val), secret, req.Authenticator[:]) + break + } + } + } + } + + replyPacket, err := client.SendRequest(req) + if err != nil { + t.Fatal(err) + } + + if replyPacket.Code != expectedCode { + t.Errorf("expected reply code %s, got %s for request %s: %+v", expectedCode.String(), replyPacket.Code.String(), code.String(), utils.ToJSON(replyPacket)) + } + + return replyPacket +} + +func checkAllocs(tb testing.TB, client *birpc.Client, id string, wantAllocs ...string) { + tb.Helper() + var allocs engine.IPAllocations + if err := client.Call(context.Background(), utils.IPsV1GetIPAllocations, + &utils.TenantIDWithAPIOpts{ + TenantID: &utils.TenantID{ + Tenant: "cgrates.org", + ID: id, + }, + }, &allocs); err != nil { + tb.Fatalf("Failed to get IP allocations for %s: %v", id, err) + } + if len(allocs.Allocations) != len(wantAllocs) { + tb.Errorf("%s unexpected result: %s", utils.IPsV1GetIPAllocations, utils.ToJSON(allocs)) + } + + for _, allocID := range wantAllocs { + if _, exists := allocs.Allocations[allocID]; !exists { + tb.Errorf("%s unexpected result: %s", utils.IPsV1GetIPAllocations, utils.ToJSON(allocs)) + return + } + } +} + +func allocateIP(tb testing.TB, client *birpc.Client, eventID, id, allocID string) { + tb.Helper() + args := &utils.CGREvent{ + Tenant: "cgrates.org", + ID: eventID, + Event: map[string]any{ + utils.AccountField: id, + utils.AnswerTime: utils.TimePointer(time.Now()), + utils.Usage: 10, + utils.Tenant: "cgrates.org", + }, + APIOpts: map[string]any{ + utils.OptsIPsAllocationID: allocID, + }, + } + var reply engine.AllocatedIP + if err := client.Call(context.Background(), utils.IPsV1AllocateIP, args, &reply); err != nil { + tb.Fatalf("Failed to allocate IP for profile %s with allocation ID %s: %v", id, allocID, err) + } +} + +func releaseIP(tb testing.TB, client *birpc.Client, id, allocID string) { + tb.Helper() + args := &utils.CGREvent{ + Tenant: "cgrates.org", + ID: utils.GenUUID(), + Event: map[string]any{ + utils.AccountField: id, + utils.AnswerTime: utils.TimePointer(time.Now()), + utils.Usage: 10, + utils.Tenant: "cgrates.org", + }, + APIOpts: map[string]any{ + utils.OptsIPsAllocationID: allocID, + }, + } + if err := client.Call(context.Background(), utils.IPsV1ReleaseIP, args, nil); err != nil { + tb.Errorf("Error releasing IPProfile %s: %v", id, err) + } +} + +func checkCDR(tb testing.TB, client *birpc.Client, acnt string) { + tb.Helper() + var cdrs []*engine.ExternalCDR + if err := client.Call(context.Background(), utils.APIerSv1GetCDRs, + &utils.AttrGetCdrs{ + Accounts: []string{acnt}, + }, &cdrs); err != nil { + tb.Fatal(err) + } + if len(cdrs) != 1 { + tb.Fatalf("%s received %d cdrs, want exactly one", utils.APIerSv1GetCDRs, len(cdrs)) + } + tb.Logf("CDR contents: %s", utils.ToIJSON(cdrs[0])) +} + +func checkActiveSessions(tb testing.TB, client *birpc.Client, wantCount int) { + tb.Helper() + var sessions []*sessions.ExternalSession + if err := client.Call(context.Background(), utils.SessionSv1GetActiveSessions, + &utils.SessionFilter{}, &sessions); err != nil { + if wantCount == 0 && err.Error() == utils.ErrNotFound.Error() { + tb.Logf("no active sessions found (expected)") + return + } + tb.Fatalf("failed to get active sessions: %v", err) + } + if len(sessions) != wantCount { + tb.Fatalf("%s received %d sessions, want exactly %d", + utils.SessionSv1GetActiveSessions, len(sessions), wantCount) + } + tb.Logf("%s reply: %s", utils.SessionSv1GetActiveSessions, utils.ToIJSON(sessions)) +}