M4 Execute External Script

From Kolmisoft Wiki
Jump to navigationJump to search

About

Execute external script feature allows to execute an external code before M4 sends the call to the Termination Point.

This is advanced feature, please do not use this it if you are not sure what you are doing! Incorrect usage may cause system instability.

Configuration

To execute external script, in Connection Point Advanced settings enter path to the external script (either HTTP URL or direct path to local script).

HTTP URL

M4 sends HTTP request to this URL. This is the preferred option. Even if the external script is located locally, it is recommended to connect to it via HTTP as performance is much better compared to executing local script directly.

HTTP URL must start with http:// or https://, for example:

http://127.0.0.1:8833

HTTP POST request to this URL is sent with JSON body containing variables from M4 (more in Variables sent by M4 section).

M4 expects to receive response with JSON body (more in Variables received by M4 section).

Default timeout for HTTP request is 900 ms.

Default timeout can be changed in /etc/m2/system.conf by adding external_script_timeout_ms variable.

Local script path

In the case of very light usage, an external script can be executed directly by providing path to local script, for example:

/root/scripts/external_script

Data from M4 is passed to this script as single-line JSON string to STDIN.

M4 expects to receive only single-line JSON response to STDOUT.

There is no timeout, when executing local script directly.

Variables sent by M4

The following variables related to current call are sent to external script in JSON format.

  • cp_id - ID of Connection Point. Value is integer.
  • user_id - ID of User. Value is integer.
  • src - localized source number. Value is string.
  • dst - localized destination number. Value is string.

For example:

{"cp_id":2,"user_id":2,"src":"37000000000","dst":"37012345678"}

For HTTP external scripts, JSON is sent in the body of POST method. For local scripts, JSON is sent to STDIN.

Variables received by M4

M4 expects the following variables in JSON format.

  • status_code - status code to indicate if script execution was successful. This variable is only for information purposes. Value is integer.
  • message - short message to explain returned code. This variable is only for information purposes. Value is string.
  • reject_call - if the value is set to 1, then M4 will reject the call with HGC 384. Any other value is ignored. Value is integer.
  • new_src - sets a new source number. Value is string.
  • src_prefix - adds prefix to source number. Value is string.
  • src_suffix - adds suffix to source number. Value is string.
  • new_dst - sets a new destination number. Value is string.
  • dst_prefix - adds prefix to destination number. Value is string.
  • dst_suffix - adds suffix to destination number. Value is string.

For example:

{"status_code":0,"message":"success","new_src":"37011111111"}

This response indicates that external script execution was successful and sets a new source number when dialing to Termination Point.

For HTTP external scripts, JSON must be sent in the response body. For local scripts, single-line JSON string must be sent to STDOUT (only JSON and nothing else should be sent to STDOUT).

All variables are optional.

Logs

Log /var/log/radius/radius.log can provide some information about external script execution:

...
[NOTICE] Executing external script: http://127.0.0.1:8833 
 [DEBUG] External script: received output from HTTP script: {"message":"success","new_src":"37011111111"}  
[NOTICE] External script executed in 0.135276 seconds 
 [DEBUG] Status code: 0 
 [DEBUG] Message: success 
 [DEBUG] New src: 37011111111 
[NOTICE] External script: changing src from [37000000000] to [37011111111]
...

Example script

As an example, let's an create external script in Go (golang) programming language.

This script will execute API request to some external service to get source number.

External HTTP script

The preferred way is to connect to external script via HTTP protocol. This example will demonstrate how to create Go script that listens for HTTP request on local network (127.0.0.1:8833) and implements business logic - retrieve new source number from API service provider.

The flow of execution will be:

M4 <-- HTTP --> External script (running locally) <-- HTTP --> API Service provider

During the call (but before it is sent to the Termination Point), M4 core will execute HTTP request to external script, which is running locally and listening for HTTP request on specific port. External script then executes its own HTTP request to some API service provider and gets data from it. Once the response is received, external script handles the response and constructs a response to M4 core. This way, we can implement a middleware between M4 and API service provider to handle API construction and API response.

TODO: how to download golang and create project

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// Request structure that M4 sends to this script
type Request struct {
	ConnectionPointID int    `json:"cp_id"`   // Connection Point ID
	UserID            int    `json:"user_id"` // User ID
	Src               string `json:"src"`     // Source number
	Dst               string `json:"dst"`     // Destination number
}

// Response structure that M4 expects to receive from this script
type Response struct {
	StatusCode int    `json:"status_code,omitempty"`
	Message    string `json:"message,omitempty"`
	RejectCall int    `json:"reject_call,omitempty"`
	NewSrc     string `json:"new_src,omitempty"`
	SrcPrefix  string `json:"src_prefix,omitempty"`
	SrcSuffix  string `json:"src_suffix,omitempty"`
	NewDst     string `json:"new_dst,omitempty"`
	DstPrefix  string `json:"dst_prefix,omitempty"`
	DstSuffix  string `json:"dst_suffix,omitempty"`
}

func main() {
	// Set up logging to a file
	logFile, err := os.OpenFile("/tmp/external_m4_script.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Fatalf("Failed to open log file: %v", err)
	}
	// Ensure the log file is closed when main exits
	defer logFile.Close()

	// Set the output of the standard logger to the file
	log.SetOutput(logFile)
	// And set flags like Ldate | Ltime for more info
	log.SetFlags(log.Ldate | log.Ltime)

	// Register the handler function
	http.HandleFunc("/", requestHandler)

	// Define the address and port to listen on
	addr := "127.0.0.1:8833"
	log.Printf("Starting server on %s...", addr)

	// Start the HTTP server
	err = http.ListenAndServe(addr, nil)
	if err != nil {
		log.Fatalf("Server failed to start: %v", err)
	}
}

// Handle incoming requests from M4
func requestHandler(w http.ResponseWriter, r *http.Request) {
	// Create a request variable to hold received data from M4
	var requestData Request
	// Create a response variable to send data back to M4
	var responseData Response

	// Get request data from M4 (in JSON format)
	if err := json.NewDecoder(r.Body).Decode(&requestData); err != nil {
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	// Print request data
	log.Printf("-- Received request from M4 --\n")
	log.Printf("User ID: %d\n", requestData.UserID)
	log.Printf("Connection Point ID: %d\n", requestData.ConnectionPointID)
	log.Printf("Source number: %s\n", requestData.Src)
	log.Printf("Destination number: %s\n", requestData.Dst)

	// Defer will execute a function on return (in this case, HTTP response)
	defer func() {
		json.NewEncoder(w).Encode(responseData)
	}()

	// Set a response header JSON (this is the format M4 expects)
	w.Header().Set("Content-Type", "application/json")

	// Set default values for response
	// In case of an error, we will return these values
	// On success, we will overwrite these values with proper ones
	responseData.StatusCode = 1
	responseData.RejectCall = 1

	// Implement your business logic here
	// ...
	// ...
	// ...

	// As an example, let's send an API request to a dummy service to get JSON response with new source number
	apiURL := fmt.Sprintf("https://dummyjson.com/users/%d", requestData.UserID)

	// Create HTTP client with a timeout of 1 second (we don't want to force SIP to wait too long)
	client := &http.Client{
		Timeout: 1 * time.Second,
	}

	// Execute API query
	respAPI, err := client.Get(apiURL)
	if err != nil {
		// Handle timeout
		if urlErr, ok := err.(*url.Error); ok {
			if urlErr.Timeout() {
				responseData.Message = "timeout"
				return
			}
		}

		responseData.Message = "http request error"
		return
	}
	defer respAPI.Body.Close()

	// Check response status code
	if respAPI.StatusCode != http.StatusOK {
		responseData.Message = "status code failed"
		return
	}

	// Read the response body
	body, err := io.ReadAll(respAPI.Body)
	if err != nil {
		responseData.Message = "read error"
		return
	}

	// Define a struct to hold the API response
	// Dummy JSON response contains many fields, but we only need the "phone" field
	type DataAPI struct {
		Phone string `json:"phone"`
	}

	// Variable to store JSON response
	var dataAPI DataAPI

	// Unmarshal the JSON response into variable
	if err := json.Unmarshal(body, &dataAPI); err != nil {
		responseData.Message = "json error"
		return
	}

	// Log the API response
	log.Printf("API response: %+v\n", dataAPI)

	// Check if we got a phone number from the API
	if dataAPI.Phone == "" {
		responseData.Message = "not found"
		return
	}

	// If we reached this point, it means we successfully got a phone number from the API
	// Overwrite status code and message with success values
	responseData.StatusCode = 0
	responseData.Message = "success"
	responseData.RejectCall = 0

	// Set new source number (and filter out spaces and dashes from the number)
	responseData.NewSrc = strings.NewReplacer(" ", "", "-", "").Replace(dataAPI.Phone)
}

Local script

WIP

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

// Request structure that M4 sends to this script
type Request struct {
	ConnectionPointID int    `json:"cp_id"`   // Connection Point ID
	UserID            int    `json:"user_id"` // User ID
	Src               string `json:"src"`     // Source number
	Dst               string `json:"dst"`     // Destination number
}

// Response structure that M4 expects to receive from this script
type Response struct {
	StatusCode int    `json:"status_code,omitempty"`
	Message    string `json:"message,omitempty"`
	RejectCall int    `json:"reject_call,omitempty"`
	NewSrc     string `json:"new_src,omitempty"`
	SrcPrefix  string `json:"src_prefix,omitempty"`
	SrcSuffix  string `json:"src_suffix,omitempty"`
	NewDst     string `json:"new_dst,omitempty"`
	DstPrefix  string `json:"dst_prefix,omitempty"`
	DstSuffix  string `json:"dst_suffix,omitempty"`
}

func main() {
	// Set up logging to a file
	logFile, err := os.OpenFile("/tmp/external_m4_script.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Fatalf("Failed to open log file: %v", err)
	}
	// Ensure the log file is closed when main exits
	defer logFile.Close()

	// Set the output of the standard logger to the file
	log.SetOutput(logFile)
	// And set flags like Ldate | Ltime | Lshortfile for more info
	log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)

	// Check if we got JSON string as an argument
	if len(os.Args) != 2 {
		log.Printf("Script expects JSON string as an argument\n")
		return
	}

	// Unmarshal the JSON string into a Request struct
	var requestData Request
	if err := json.Unmarshal([]byte(os.Args[1]), &requestData); err != nil {
		log.Printf("Failed to parse JSON: %v\n", err)
		return
	}

	// Print the extracted arguments
	log.Printf("-- Received request from M4 --\n")
	log.Printf("Connection Point ID: %d\n", requestData.ConnectionPointID)
	log.Printf("User ID: %d\n", requestData.UserID)
	log.Printf("Source: %s\n", requestData.Src)
	log.Printf("Destination: %s\n", requestData.Dst)

	// Create a response variable to send data back to M4
	var responseData Response

	// Implement your business logic here
	// ...
	// ...
	// ...

	// As an example, let's send an API request to a dummy service to get JSON response
	apiURL := fmt.Sprintf("https://dummyjson.com/users/%d", requestData.UserID)

	// Create HTTP client with a timeout of 1 second (we don't want to force SIP to wait too long)
	client := &http.Client{
		Timeout: 1 * time.Second,
	}

	// Execute API query
	respAPI, err := client.Get(apiURL)
	if err != nil {
		// Handle timeout
		if urlErr, ok := err.(*url.Error); ok {
			if urlErr.Timeout() {
				responseData.Message = "timeout"
				return
			}
		}

		responseData.Message = "http request error"
		return
	}
	defer respAPI.Body.Close()

	// Check response status code
	if respAPI.StatusCode != http.StatusOK {
		responseData.Message = "status code failed"
		return
	}

	// Read the response body
	body, err := io.ReadAll(respAPI.Body)
	if err != nil {
		responseData.Message = "read error"
		return
	}

	// Define a struct to hold the API response
	// Dummy JSON response contains many fields, but we only need the "phone" field
	type DataAPI struct {
		Phone string `json:"phone"`
	}

	// Variable to store JSON response
	var dataAPI DataAPI

	// Unmarshal the JSON response into variable
	if err := json.Unmarshal(body, &dataAPI); err != nil {
		responseData.Message = "json error"
		return
	}

	// Log the API response
	log.Printf("API response: %+v\n", dataAPI)

	// Check if we got a phone number from the API
	if dataAPI.Phone == "" {
		responseData.Message = "not found"
		return
	}

	// If we reached this point, it means we successfully got a phone number from the API
	// Overwrite status code and message with success values
	responseData.StatusCode = 0
	responseData.Message = "success"
	responseData.RejectCall = 0

	// Set new source number (and filter out spaces and dashes from the number)
	responseData.NewSrc = strings.NewReplacer(" ", "", "-", "").Replace(dataAPI.Phone)

	// Print JSON response using JSON encoder
	json.NewEncoder(os.Stdout).Encode(responseData)
}