Difference between revisions of "M4 Execute External Script"

From Kolmisoft Wiki
Jump to navigationJump to search
 
(4 intermediate revisions by the same user not shown)
Line 37: Line 37:
* '''external_script_timeout_ms''' - timeout for external script execution (in milliseconds). Default value 900 ms.
* '''external_script_timeout_ms''' - timeout for external script execution (in milliseconds). Default value 900 ms.
* '''external_script_reject_on_timeout''' - if value is set to 1, then the call will be rejected (with HGC 386) on external script execution timeout. Default value 0 (do not reject).
* '''external_script_reject_on_timeout''' - if value is set to 1, then the call will be rejected (with HGC 386) on external script execution timeout. Default value 0 (do not reject).
* '''external_script_reject_on_failure''' - if value is set to 1, then the call will be rejected (with HGC 386) on external script execution failure (for example external script is not even running). Default value 0 (do not reject).


Note: if timeout of more than 900ms is needed, then radius authentication timeout (default 1 second) should be increased as well. Edit '''/usr/local/etc/radiusclient/radiusclient.conf''', modify setting '''radius_auth_timeout'''. Kamailio restart is required.
<br/>Note: if timeout of more than 900ms is needed, then radius authentication timeout (default 1 second) should be increased as well. Edit '''/usr/local/etc/radiusclient/radiusclient.conf''', modify setting '''radius_auth_timeout'''. Kamailio restart is required.


When settings are changed, core reload is required:
When settings are changed, core reload is required:
Line 192: Line 193:
log.SetOutput(logFile)
log.SetOutput(logFile)
// And set flags like Ldate | Ltime for more info
// And set flags like Ldate | Ltime for more info
log.SetFlags(log.Ldate | log.Ltime)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)


// Register the handler function
// Register the handler function
Line 404: Line 405:
If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.
If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.


When main.go source is modified, you need to rebuild it and restart systemd service:
When '''main.go''' source is modified, you need to rebuild it and restart systemd service:


  cd /usr/local/m4_external_script
  cd /usr/local/m4_external_script
Line 475: Line 476:
log.SetOutput(logFile)
log.SetOutput(logFile)
// And set flags like Ldate | Ltime for more info
// And set flags like Ldate | Ltime for more info
log.SetFlags(log.Ldate | log.Ltime)
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)


// Check if we got JSON string as an argument
// Check if we got JSON string as an argument
Line 598: Line 599:
If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.
If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.


When main.go source is modified, you need to rebuild it:
When '''main.go''' source is modified, you need to rebuild it:


  cd /usr/local/m4_external_script
  cd /usr/local/m4_external_script
  /usr/local/go/bin/go mod tidy
  /usr/local/go/bin/go mod tidy
  /usr/local/go/bin/go build
  /usr/local/go/bin/go build

Latest revision as of 15:13, 10 July 2025

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) in Execute external script option.

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).

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:

/usr/local/m4_external_script

Data from M4 is passed to this script as single-line JSON string to STDIN (more in Variables sent by M4 section).

M4 expects to receive only single-line JSON response to STDOUT (more in Variables received by M4 section).

Global configuration

Global external script settings can be defined in /etc/m2/system.conf in Radius (core) server:

  • external_script_timeout_ms - timeout for external script execution (in milliseconds). Default value 900 ms.
  • external_script_reject_on_timeout - if value is set to 1, then the call will be rejected (with HGC 386) on external script execution timeout. Default value 0 (do not reject).
  • external_script_reject_on_failure - if value is set to 1, then the call will be rejected (with HGC 386) on external script execution failure (for example external script is not even running). Default value 0 (do not reject).


Note: if timeout of more than 900ms is needed, then radius authentication timeout (default 1 second) should be increased as well. Edit /usr/local/etc/radiusclient/radiusclient.conf, modify setting radius_auth_timeout. Kamailio restart is required.

When settings are changed, core reload is required:

m2 reload

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":4,"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 386 . If executed on Termination Point, then the call will not be rejected, but Termination Point will be skipped. 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 tells to change source number.

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 
[NOTICE] External script: received output from HTTP script: {"message":"success","new_src":"37011111111"}  
[NOTICE] External script executed in 0.135276 seconds 
 [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 an external script in Go (Golang) programming language.

This script will execute API request to some external service provider 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.

Here are the steps how to create this project:

1. Connect to Radius (core) server via ssh as root.

2. Download Go:

wget https://go.dev/dl/go1.24.0.linux-amd64.tar.gz -O /usr/src/go1.24.0.linux-amd64.tar.gz

3. Extract Go archive:

tar -C /usr/local -xzf /usr/src/go1.24.0.linux-amd64.tar.gz

4. Create directory for external script project:

mkdir -p /usr/local/m4_external_script

5. Switch to this directory:

cd /usr/local/m4_external_script

6. Initialize Go project:

/usr/local/go/bin/go mod init 127.0.0.1/m4_external_script

7. Create main source file:

touch main.go

8. Copy the source code bellow to main.go:

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("/usr/local/m4_external_script/m4_external_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 | log.Lmicroseconds)

	// 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 = "number 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)
}

9. Sort out dependencies:

/usr/local/go/bin/go mod tidy

10. Build script:

/usr/local/go/bin/go build

11. Create systemd service for this script to run it in background and start on system boot:

touch /etc/systemd/system/m4_external_script.service

12. Copy the following configuration to /etc/systemd/system/m4_external_script.service:

[Unit]
Description=M4 external script
Wants=network.target
After=network.target

[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/usr/local/m4_external_script
ExecStart=/usr/local/m4_external_script/m4_external_script
ExecStop=/bin/killall m4_external_script
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

13. Reload systemctl:

systemctl daemon-reload

14. Enable external script to start on system boot:

systemctl enable m4_external_script

15. Start external script (runs in background and listens for HTTP request on 127.0.0.1:8833):

systemctl start m4_external_script

16. Check if script is running:

systemctl status m4_external_script

You should see:

Active: active (running)

17. Check script log:

head /usr/local/m4_external_script/m4_external_script.log

You should see something like:

2025/06/27 15:22:37 Starting server on 127.0.0.1:8833...

18. In GUI Connection Point Advanced settings, set URL to this script in Execute external script option:

http://127.0.0.1:8833

19. Save Connection Point settings and try to make Call Tracing. In the Call Tracing output, you should see similar lines:

...
2025-06-28 06:01:57	[NOTICE]	 Executing external script: http://127.0.0.1:8833
2025-06-28 06:01:57	[NOTICE]	 External script: received output from HTTP: {`message`:`success`,`new_src`:`+492586276644`}
2025-06-28 06:01:57	[NOTICE]	 External script executed in 0.161371 seconds
2025-06-28 06:01:57	[NOTICE]	 External script: changing src from [37000000000] to [+492586276644]
...

20. Check script log at /usr/local/m4_external_script/m4_external_script.log, you should see:

2025/06/28 06:01:57 -- Received request from M4 --
2025/06/28 06:01:57 User ID: 2
2025/06/28 06:01:57 Connection Point ID: 2
2025/06/28 06:01:57 Source number: 37000000000
2025/06/28 06:01:57 Destination number: 37011111111
2025/06/28 06:01:57 API response: {Phone:+49 258-627-6644}

If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.

When main.go source is modified, you need to rebuild it and restart systemd service:

cd /usr/local/m4_external_script
/usr/local/go/bin/go mod tidy
/usr/local/go/bin/go build
systemctl restart m4_external_script

Local script

Another way of executing external script is to call it directly by providing path to this script. This is less performant option and should be used only if there is no way to implement communication via HTTP. A lot of system resources may be used for creating a new process for each call. This can lead to system degradation.

The flow of execution will be:

M4 <-- system call --> External script <-- HTTP --> API Service provider

During the call (but before it is sent to the Termination Point), M4 core will execute external script directly by spawning child process. 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 (to STDOUT). This way, we can implement a middleware between M4 and API service provider to handle API construction and API response.

The steps to create this external script is similar to External HTTP script:

Follow steps 1-7 to prepare Go environment.

In step 8, copy the following code to main.go:

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("/usr/local/m4_external_script/m4_external_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 | log.Lmicroseconds)

	// 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

	// 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
	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 = "number 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)
}

Follow steps 9 and 10 to build module.

Skip steps 11-17 as we don't need systemd service in this case.

In step 18, use the following path in Execute external script option:

/usr/local/m4_external_script/m4_external_script

Follow steps 19 and 20.

If all steps succeeded, you can start to modify main.go source file with your business logic and test it using Call Tracing until it is ready for live calls.

When main.go source is modified, you need to rebuild it:

cd /usr/local/m4_external_script
/usr/local/go/bin/go mod tidy
/usr/local/go/bin/go build