mirror of
https://github.com/cgrates/cgrates.git
synced 2026-02-14 12:49:54 +05:00
Radius radReqAsSMGEvent, handler for *usage_difference, tests
This commit is contained in:
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -56,7 +57,7 @@ func radPassesFieldFilter(pkt *radigo.Packet, processorVars map[string]string, f
|
||||
return
|
||||
}
|
||||
for _, avp := range avps { // they all need to match the filter
|
||||
if !fieldFilter.FilterPasses(avp.StringValue()) {
|
||||
if !fieldFilter.FilterPasses(avp.GetStringValue()) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func radComposedFieldValue(pkt *radigo.Packet,
|
||||
}
|
||||
for _, avp := range pkt.AttributesWithName(
|
||||
attrVendorFromPath(rsrTpl.Id)) {
|
||||
outVal += rsrTpl.ParseValue(avp.StringValue())
|
||||
outVal += rsrTpl.ParseValue(avp.GetStringValue())
|
||||
}
|
||||
}
|
||||
return outVal
|
||||
@@ -85,13 +86,31 @@ func radComposedFieldValue(pkt *radigo.Packet,
|
||||
|
||||
// radMetaHandler handles *handler type in configuration fields
|
||||
func radMetaHandler(pkt *radigo.Packet, processorVars map[string]string,
|
||||
handlerTag, arg string) (outVal string, err error) {
|
||||
cfgFld *config.CfgCdrField) (outVal string, err error) {
|
||||
handlerArgs := strings.Split(
|
||||
radComposedFieldValue(pkt, processorVars, cfgFld.Value), utils.HandlerArgSep)
|
||||
switch cfgFld.HandlerId {
|
||||
case MetaUsageDifference: // expects tEnd|tStart in the composed val
|
||||
if len(handlerArgs) != 2 {
|
||||
return "", errors.New("unexpected number of arguments")
|
||||
}
|
||||
tEnd, err := utils.ParseTimeDetectLayout(handlerArgs[0], cfgFld.Timezone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tStart, err := utils.ParseTimeDetectLayout(handlerArgs[1], cfgFld.Timezone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Printf("tEnd: %v, tStart: %v\n", tEnd, tStart)
|
||||
return tEnd.Sub(tStart).String(), nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// radFieldOutVal formats the field value retrieved from RADIUS packet
|
||||
func radFieldOutVal(pkt *radigo.Packet, processorVars map[string]string,
|
||||
cfgFld *config.CfgCdrField, extraParam interface{}) (outVal string, err error) {
|
||||
cfgFld *config.CfgCdrField) (outVal string, err error) {
|
||||
// make sure filters are passing
|
||||
passedAllFilters := true
|
||||
for _, fldFilter := range cfgFld.FieldFilter {
|
||||
@@ -113,7 +132,7 @@ func radFieldOutVal(pkt *radigo.Packet, processorVars map[string]string,
|
||||
case utils.META_COMPOSED:
|
||||
outVal = radComposedFieldValue(pkt, processorVars, cfgFld.Value)
|
||||
case utils.META_HANDLER:
|
||||
if outVal, err = radMetaHandler(pkt, processorVars, cfgFld.HandlerId, cfgFld.Layout); err != nil {
|
||||
if outVal, err = radMetaHandler(pkt, processorVars, cfgFld); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
@@ -126,13 +145,12 @@ func radFieldOutVal(pkt *radigo.Packet, processorVars map[string]string,
|
||||
}
|
||||
|
||||
// radPktAsSMGEvent converts a RADIUS packet into SMGEvent
|
||||
func radReqAsSMGEvent(radPkt *radigo.Packet, procVars map[string]string,
|
||||
cfgFlds []*config.CfgCdrField,
|
||||
procFlags utils.StringMap) (smgEv sessionmanager.SMGenericEvent, err error) {
|
||||
func radReqAsSMGEvent(radPkt *radigo.Packet, procVars map[string]string, procFlags utils.StringMap,
|
||||
cfgFlds []*config.CfgCdrField) (smgEv sessionmanager.SMGenericEvent, err error) {
|
||||
outMap := make(map[string]string) // work with it so we can append values to keys
|
||||
outMap[utils.EVENT_NAME] = EvRadiusReq
|
||||
for _, cfgFld := range cfgFlds {
|
||||
fmtOut, err := radFieldOutVal(radPkt, procVars, cfgFld, nil)
|
||||
fmtOut, err := radFieldOutVal(radPkt, procVars, cfgFld)
|
||||
if err != nil {
|
||||
if err == ErrFilterNotPassing {
|
||||
continue // Do nothing in case of Filter not passing
|
||||
@@ -145,6 +163,9 @@ func radReqAsSMGEvent(radPkt *radigo.Packet, procVars map[string]string,
|
||||
outMap[cfgFld.FieldId] = fmtOut
|
||||
}
|
||||
}
|
||||
if len(procFlags) != 0 {
|
||||
outMap[utils.CGRFlags] = procFlags.String()
|
||||
}
|
||||
return sessionmanager.SMGenericEvent(utils.ConvertMapValStrIf(outMap)), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,12 @@ package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cgrates/cgrates/config"
|
||||
"github.com/cgrates/cgrates/sessionmanager"
|
||||
"github.com/cgrates/cgrates/utils"
|
||||
"github.com/cgrates/radigo"
|
||||
)
|
||||
@@ -52,6 +55,13 @@ BEGIN-VENDOR Cisco
|
||||
ATTRIBUTE Cisco-AVPair 1 string
|
||||
ATTRIBUTE Cisco-NAS-Port 2 string
|
||||
END-VENDOR Cisco
|
||||
|
||||
ATTRIBUTE Sip-Method 101 integer
|
||||
ATTRIBUTE Sip-Response-Code 102 integer
|
||||
ATTRIBUTE Sip-From-Tag 105 string
|
||||
ATTRIBUTE Sip-To-Tag 104 string
|
||||
ATTRIBUTE Ascend-User-Acct-Time 143 integer
|
||||
|
||||
`
|
||||
|
||||
func init() {
|
||||
@@ -60,6 +70,17 @@ func init() {
|
||||
coder = radigo.NewCoder()
|
||||
}
|
||||
|
||||
func TestAttrVendorFromPath(t *testing.T) {
|
||||
if attrName, vendorName := attrVendorFromPath("User-Name"); attrName != "User-Name" ||
|
||||
vendorName != "" {
|
||||
t.Error("failed")
|
||||
}
|
||||
if attrName, vendorName := attrVendorFromPath("Cisco/Cisco-NAS-Port"); attrName != "Cisco-NAS-Port" ||
|
||||
vendorName != "Cisco" {
|
||||
t.Error("failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadPassesFieldFilter(t *testing.T) {
|
||||
pkt := radigo.NewPacket(radigo.AccountingRequest, 1, dictRad, coder, "CGRateS.org")
|
||||
if err := pkt.AddAVPWithName("User-Name", "flopsy", ""); err != nil {
|
||||
@@ -100,3 +121,132 @@ func TestRadPassesFieldFilter(t *testing.T) {
|
||||
t.Error("passing invalid filter value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadComposedFieldValue(t *testing.T) {
|
||||
pkt := radigo.NewPacket(radigo.AccountingRequest, 1, dictRad, coder, "CGRateS.org")
|
||||
if err := pkt.AddAVPWithName("User-Name", "flopsy", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Cisco-NAS-Port", "CGR1", "Cisco"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
eOut := fmt.Sprintf("%s|flopsy|CGR1", MetaRadAcctStart)
|
||||
if out := radComposedFieldValue(pkt, map[string]string{MetaRadReqType: MetaRadAcctStart},
|
||||
utils.ParseRSRFieldsMustCompile(fmt.Sprintf("%s;^|;User-Name;^|;Cisco/Cisco-NAS-Port", MetaRadReqType), utils.INFIELD_SEP)); out != eOut {
|
||||
t.Errorf("Expecting: <%s>, received: <%s>", eOut, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadFieldOutVal(t *testing.T) {
|
||||
pkt := radigo.NewPacket(radigo.AccountingRequest, 1, dictRad, coder, "CGRateS.org")
|
||||
if err := pkt.AddAVPWithName("User-Name", "flopsy", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Cisco-NAS-Port", "CGR1", "Cisco"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
eOut := fmt.Sprintf("%s|flopsy|CGR1", MetaRadAcctStart)
|
||||
cfgFld := &config.CfgCdrField{Tag: "ComposedTest", Type: utils.META_COMPOSED, FieldId: utils.DESTINATION,
|
||||
Value: utils.ParseRSRFieldsMustCompile(fmt.Sprintf("%s;^|;User-Name;^|;Cisco/Cisco-NAS-Port", MetaRadReqType), utils.INFIELD_SEP), Mandatory: true}
|
||||
if outVal, err := radFieldOutVal(pkt, map[string]string{MetaRadReqType: MetaRadAcctStart}, cfgFld); err != nil {
|
||||
t.Error(err)
|
||||
} else if outVal != eOut {
|
||||
t.Errorf("Expecting: <%s>, received: <%s>", eOut, outVal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadReqAsSMGEvent(t *testing.T) {
|
||||
pkt := radigo.NewPacket(radigo.AccountingRequest, 1, dictRad, coder, "CGRateS.org")
|
||||
// Sample minimal packet sent by Kamailio
|
||||
if err := pkt.AddAVPWithName("Acct-Status-Type", "2", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Service-Type", "15", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Sip-Response-Code", "200", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Sip-Method", "8", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Event-Timestamp", "1497106119", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Sip-From-Tag", "75c2f57b", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Sip-To-Tag", "51585361", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Acct-Session-Id", "e4921177ab0e3586c37f6a185864b71a@0:0:0:0:0:0:0:0", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("User-Name", "1001", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Called-Station-Id", "1002", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Ascend-User-Acct-Time", "1497106115", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("NAS-Port-Id", "5060", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("Acct-Delay-Time", "0", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := pkt.AddAVPWithName("NAS-IP-Address", "127.0.0.1", ""); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
cfgFlds := []*config.CfgCdrField{
|
||||
&config.CfgCdrField{Tag: "TOR", FieldId: utils.TOR, Type: utils.META_CONSTANT,
|
||||
Value: utils.ParseRSRFieldsMustCompile(utils.VOICE, utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "OriginID", FieldId: utils.ACCID, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("Acct-Session-Id;^-;Sip-From-Tag;^-;Sip-To-Tag", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "OriginHost", FieldId: utils.CDRHOST, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("NAS-IP-Address", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "RequestType", FieldId: utils.REQTYPE, Type: utils.META_CONSTANT,
|
||||
Value: utils.ParseRSRFieldsMustCompile(utils.META_PREPAID, utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Direction", FieldId: utils.DIRECTION, Type: utils.META_CONSTANT,
|
||||
Value: utils.ParseRSRFieldsMustCompile(utils.OUT, utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Tenant", FieldId: utils.TENANT, Type: utils.META_CONSTANT,
|
||||
Value: utils.ParseRSRFieldsMustCompile("cgrates.org", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Category", FieldId: utils.CATEGORY, Type: utils.META_CONSTANT,
|
||||
Value: utils.ParseRSRFieldsMustCompile("call", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Account", FieldId: utils.ACCOUNT, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("User-Name", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Destination", FieldId: utils.DESTINATION, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("Called-Station-Id", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "SetupTime", FieldId: utils.SETUP_TIME, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("Ascend-User-Acct-Time", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "AnswerTime", FieldId: utils.ANSWER_TIME, Type: utils.META_COMPOSED,
|
||||
Value: utils.ParseRSRFieldsMustCompile("Ascend-User-Acct-Time", utils.INFIELD_SEP)},
|
||||
&config.CfgCdrField{Tag: "Usage", FieldId: utils.USAGE, Type: utils.META_HANDLER, HandlerId: MetaUsageDifference,
|
||||
Value: utils.ParseRSRFieldsMustCompile("Event-Timestamp;^|;Ascend-User-Acct-Time", utils.INFIELD_SEP)},
|
||||
}
|
||||
|
||||
eSMGEv := sessionmanager.SMGenericEvent{
|
||||
utils.EVENT_NAME: EvRadiusReq,
|
||||
utils.TOR: utils.VOICE,
|
||||
utils.ACCID: "e4921177ab0e3586c37f6a185864b71a@0:0:0:0:0:0:0:0-75c2f57b-51585361",
|
||||
utils.REQTYPE: utils.META_PREPAID,
|
||||
utils.DIRECTION: utils.OUT,
|
||||
utils.TENANT: "cgrates.org",
|
||||
utils.CATEGORY: "call",
|
||||
utils.ACCOUNT: "1001",
|
||||
utils.DESTINATION: "1002",
|
||||
utils.SETUP_TIME: "1497106115",
|
||||
utils.ANSWER_TIME: "1497106115",
|
||||
utils.USAGE: "4s",
|
||||
utils.CDRHOST: "127.0.0.1",
|
||||
}
|
||||
|
||||
if smgEv, err := radReqAsSMGEvent(pkt, map[string]string{MetaRadReqType: MetaRadAcctStop}, nil, cfgFlds); err != nil {
|
||||
t.Error(err)
|
||||
} else if !reflect.DeepEqual(eSMGEv, smgEv) {
|
||||
t.Errorf("Expecting: %+v\n, received: %+v", eSMGEv, smgEv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,17 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MetaRadReqCode = "*radReqCode"
|
||||
MetaRadReplyCode = "*radReplyCode"
|
||||
MetaRadAuth = "*radAuth"
|
||||
MetaRadAcctStart = "*radAcctStart"
|
||||
MetaRadAcctUpdate = "*radAcctUpdate"
|
||||
MetaRadAcctStop = "*radAcctStop"
|
||||
MetaRadAcctEvent = "*radAcctEvent"
|
||||
MetaCGRMaxUsage = "*cgrMaxUsage"
|
||||
EvRadiusReq = "RADIUS_REQ"
|
||||
MetaRadReqCode = "*radReqCode"
|
||||
MetaRadReplyCode = "*radReplyCode"
|
||||
MetaRadAuth = "*radAuth"
|
||||
MetaRadAcctStart = "*radAcctStart"
|
||||
MetaRadAcctUpdate = "*radAcctUpdate"
|
||||
MetaRadAcctStop = "*radAcctStop"
|
||||
MetaRadAcctEvent = "*radAcctEvent"
|
||||
MetaCGRMaxUsage = "*cgrMaxUsage"
|
||||
MetaRadReqType = "*radReqType"
|
||||
EvRadiusReq = "RADIUS_REQUEST"
|
||||
MetaUsageDifference = "*usage_difference"
|
||||
)
|
||||
|
||||
func NewRadiusAgent(cgrCfg *config.CGRConfig, smg rpcclient.RpcClientConnection) (ra *RadiusAgent, err error) {
|
||||
@@ -103,7 +105,7 @@ func (ra *RadiusAgent) handleAcct(req *radigo.Packet) (rpl *radigo.Packet, err e
|
||||
utils.Logger.Debug(fmt.Sprintf("Received request: %s", utils.ToJSON(req)))
|
||||
procVars := make(map[string]string)
|
||||
if avps := req.AttributesWithName("Acct-Status-Type", ""); len(avps) != 0 { // populate accounting type
|
||||
switch avps[0].StringValue() { // first AVP found will give out the type of accounting
|
||||
switch avps[0].GetStringValue() { // first AVP found will give out the type of accounting
|
||||
case "Start":
|
||||
procVars[MetaRadAcctStart] = "true"
|
||||
case "Interim-Update":
|
||||
@@ -149,13 +151,14 @@ func (ra *RadiusAgent) processRequest(reqProcessor *config.RARequestProcessor,
|
||||
for k, v := range reqProcessor.Flags { // update processorVars with flags from processor
|
||||
processorVars[k] = strconv.FormatBool(v)
|
||||
}
|
||||
smgEv, err := radReqAsSMGEvent(req, processorVars, reqProcessor.RequestFields, reqProcessor.Flags)
|
||||
smgEv, err := radReqAsSMGEvent(req, processorVars, reqProcessor.Flags, reqProcessor.RequestFields)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var maxUsage time.Duration
|
||||
if processorVars[MetaRadReqCode] == "3" { // auth attempt, make sure that MaxUsage is enough
|
||||
switch processorVars[MetaRadReqType] {
|
||||
case MetaRadAuth: // auth attempt, make sure that MaxUsage is enough
|
||||
if err = ra.smg.Call("SMGenericV2.GetMaxUsage", smgEv, &maxUsage); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -166,13 +169,15 @@ func (ra *RadiusAgent) processRequest(reqProcessor *config.RARequestProcessor,
|
||||
} else if reqUsage.(time.Duration) < maxUsage {
|
||||
reply.Code = radigo.AccessReject
|
||||
}
|
||||
} else if _, has := processorVars[MetaRadAcctStart]; has {
|
||||
err = ra.smg.Call("SMGenericV1.InitiateSession", smgEv, &maxUsage)
|
||||
} else if _, has := processorVars[MetaRadAcctUpdate]; has {
|
||||
err = ra.smg.Call("SMGenericV1.UpdateSession", smgEv, &maxUsage)
|
||||
} else if _, has := processorVars[MetaRadAcctStop]; has {
|
||||
case MetaRadAcctStart:
|
||||
err = ra.smg.Call("SMGenericV2.InitiateSession", smgEv, &maxUsage)
|
||||
case MetaRadAcctUpdate:
|
||||
err = ra.smg.Call("SMGenericV2.UpdateSession", smgEv, &maxUsage)
|
||||
case MetaRadAcctStop:
|
||||
var rpl string
|
||||
err = ra.smg.Call("SMGenericV1.TerminateSession", smgEv, &rpl)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported radius request type: <%s>", processorVars[MetaRadReqType])
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -50,6 +50,7 @@ ATTRIBUTE Digest-Body-Digest 1069 string
|
||||
ATTRIBUTE Digest-CNonce 1070 string
|
||||
ATTRIBUTE Digest-Nonce-Count 1071 string
|
||||
ATTRIBUTE Digest-User-Name 1072 string
|
||||
ATTRIBUTE Ascend-User-Acct-Time 143 integer
|
||||
|
||||
ATTRIBUTE Sip-Uri-User 208 string # Proprietary, auth_radius
|
||||
ATTRIBUTE Sip-Group 211 string # Proprietary, group_radius
|
||||
|
||||
@@ -218,7 +218,7 @@ func (ec *EventCost) GetCost() float64 {
|
||||
if ec.Cost == nil {
|
||||
var cost float64
|
||||
for _, ci := range ec.Charges {
|
||||
cost += ci.Cost() * float64(ci.CompressFactor)
|
||||
cost += ci.TotalCost()
|
||||
}
|
||||
cost = utils.Round(cost, globalRoundingDecimals, utils.ROUNDING_MIDDLE)
|
||||
ec.Cost = &cost
|
||||
|
||||
@@ -147,6 +147,10 @@ func (cIt *ChargingIncrement) TotalUsage() time.Duration {
|
||||
return time.Duration(cIt.Usage.Nanoseconds() * int64(cIt.CompressFactor))
|
||||
}
|
||||
|
||||
func (cIt *ChargingIncrement) TotalCost() float64 {
|
||||
return cIt.Cost * float64(cIt.CompressFactor)
|
||||
}
|
||||
|
||||
// BalanceCharge represents one unit charged to a balance
|
||||
type BalanceCharge struct {
|
||||
AccountID string // keep reference for shared balances
|
||||
|
||||
2
glide.lock
generated
2
glide.lock
generated
@@ -18,7 +18,7 @@ imports:
|
||||
- name: github.com/cgrates/osipsdagram
|
||||
version: 3d6beed663452471dec3ca194137a30d379d9e8f
|
||||
- name: github.com/cgrates/radigo
|
||||
version: b6d2c57d9667095035ad4ffd0395bbbebb63ee94
|
||||
version: 44900e0407cbc7cb713f6c2b06b843f1d22b2370
|
||||
- name: github.com/cgrates/rpcclient
|
||||
version: dddae42e9344e877627cd4b7aba075d63b452c0b
|
||||
- name: github.com/ChrisTrenkamp/goxpath
|
||||
|
||||
Reference in New Issue
Block a user