Skip to content
3 changes: 3 additions & 0 deletions .cursorindexingignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@

# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
.specstory/**
4 changes: 4 additions & 0 deletions .specstory/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# SpecStory project identity file
/.project.json
# SpecStory explanation file
/.what-is-this.md
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `postgres` module folder
- `redis` module folder
- `rtpengine` module folder
- basic structure of folders and files (`modules`, `.env.template`, `.gitignore`, `CHANGELOG.md`, ...)
- basic structure of folders and files (`modules`, `.env.template`, `.gitignore`, `CHANGELOG.md`, ...)
2 changes: 1 addition & 1 deletion build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ buildah config --entrypoint=/ \
--label="org.nethserver.min-core=3.12.4-0" \
--label="org.nethserver.max-per-node=1" \
--label="org.nethserver.rootfull=0" \
--label="org.nethserver.authorizations=node:fwadm traefik@any:certadm" \
--label="org.nethserver.authorizations=node:fwadm,portsadm traefik@any:certadm" \
--label="org.nethserver.tcp-ports-demand=2" \
--label="org.nethserver.images=${repobase}/nethvoice-proxy-postgres:${IMAGETAG:-latest} \
${repobase}/nethvoice-proxy-kamailio:${IMAGETAG:-latest} \
Expand Down
68 changes: 58 additions & 10 deletions imageroot/actions/configure-module/20configure
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,84 @@ data = json.load(sys.stdin)

local_networks = set()

# Function to create rich rules for port forwarding
def create_port_forward_rules(local_networks_list, private_ip):
"""Create rich rules for each local network"""
rules = []
for network in local_networks_list:
if network: # Skip empty networks
# Port forward 5060 UDP to 6060
rules.append(f'rule family=ipv4 source address={network} forward-port port=5060 protocol=udp to-port=6060')
# Port forward 5060 TCP to 6060
rules.append(f'rule family=ipv4 source address={network} forward-port port=5060 protocol=tcp to-port=6060')
# Port forward 5061 TCP to 6061
rules.append(f'rule family=ipv4 source address={network} forward-port port=5061 protocol=tcp to-port=6061')
return rules

if "addresses" in data:

address = data["addresses"]

# Set public and private IP
if "public_address" not in address:
# if there's no public address, the proxy is not behind NAT

# Clean up any existing port forwarding rich rules based on previous configuration
prev_localnetworks = os.environ.get("LOCALNETWORKS")
prev_private_ip = os.environ.get("PRIVATE_IP")
if prev_localnetworks and prev_private_ip:
prev_networks = [n for n in prev_localnetworks.split(",") if n]
previous_rules = create_port_forward_rules(prev_networks, prev_private_ip)
if previous_rules:
result = agent.remove_rich_rules(previous_rules)
if not result:
print("Warning: Failed to remove some rich rules", file=sys.stderr)

agent.set_env("PUBLIC_IP", address["address"])
agent.set_env("PRIVATE_IP", "")
agent.set_env("BEHIND_NAT", "false")
agent.unset_env("LOCALNETWORKS")
else:
# if there's a public address, the proxy is behind NAT
# If there's a public address, the proxy is behind NAT

# Get local network from routing table (excluding default route)
detected_network = os.popen("ip route | grep -v default | grep 'src " + address["address"] + "' | awk '{print $1}'").read().strip()
if detected_network:
local_networks.add(detected_network)

# Check if local_networks field is present in data
if "local_networks" in data and data["local_networks"]:
# Add extra local networks
local_networks.update(set(data["local_networks"]))

# Remove existing port forwarding rules based on previous configuration before adding new ones
prev_localnetworks = os.environ.get("LOCALNETWORKS")
prev_private_ip = os.environ.get("PRIVATE_IP")
if prev_localnetworks and prev_private_ip:
prev_networks = [n for n in prev_localnetworks.split(",") if n]
previous_rules = create_port_forward_rules(prev_networks, prev_private_ip)
if previous_rules:
agent.remove_rich_rules(previous_rules)

agent.set_env("PUBLIC_IP", address["public_address"])
agent.set_env("PRIVATE_IP", address["address"])
agent.set_env("BEHIND_NAT", "true")

# Get local network from routing table (excluding default route)
local_networks.add(os.popen("ip route | grep -v default | grep 'src " + address["address"] + "' | awk '{print $1}'").read().strip())
agent.set_env("LOCALNETWORKS", list(local_networks)[0])
# Store local networks
if local_networks:
agent.set_env("LOCALNETWORKS", ",".join(local_networks))

# Create and apply new port forwarding rules for each local network
if local_networks:
new_rules = create_port_forward_rules(list(local_networks), address["address"])
if new_rules:
result = agent.add_rich_rules(new_rules)
if not result:
print("Warning: Failed to add some rich rules", file=sys.stderr)

agent.set_env("DEFAULT_CONTACT", address["address"] + ":5060" if "public_address" not in address else address["public_address"] + ":5060")

if "service_net" in data:
service = data["service_net"]
agent.set_env("SERVICE_IP", service["address"])
agent.set_env("SERVICE_NET", service["netmask"])

#check if local_networks is not empty and local_networks filed is present in data
if local_networks and "local_networks" in data:
# Add extra local networks
local_networks.update(set(data["local_networks"]))
agent.set_env("LOCALNETWORKS", ",".join(local_networks))
5 changes: 4 additions & 1 deletion imageroot/actions/create-module/20firewall
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
import os
import agent

# Open SIP (5060), SIPS (5061) port and RTP range (10000-20000)
# Open SIP (5060), SIPS (5061), custom port (6060,6061) and RTP range (10000-20000)
agent.assert_exp(agent.add_public_service(os.environ['MODULE_ID'], [
"5060-5061/tcp",
"5060-5061/udp",
"6060/tcp",
"6060/udp",
"6061/tcp",
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The firewall configuration opens port 6061/tcp but not 6061/udp, while port 5061 is opened for both TCP and UDP (lines 13-14). The listener configuration in bootstrap.sh uses TLS on port 6061 (line 39), which typically only needs TCP. However, for consistency with how port 5061 is configured, consider whether 6061/udp should also be opened, or document why the asymmetry is intentional.

Copilot uses AI. Check for mistakes.
"10000-20000/udp"]))
29 changes: 29 additions & 0 deletions imageroot/actions/destroy-module/20firewall
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,33 @@
import os
import agent

# Function to create rich rules for port forwarding (same as configure-module)
def create_port_forward_rules(local_networks_list, private_ip):
"""Create rich rules for each local network"""
rules = []
for network in local_networks_list:
if network: # Skip empty networks
# Port forward 5060 UDP to 6060
rules.append(f'rule family=ipv4 source address={network} forward-port port=5060 protocol=udp to-port=6060')
# Port forward 5060 TCP to 6060
rules.append(f'rule family=ipv4 source address={network} forward-port port=5060 protocol=tcp to-port=6060')
# Port forward 5061 TCP to 6061
rules.append(f'rule family=ipv4 source address={network} forward-port port=5061 protocol=tcp to-port=6061')
return rules

# Clean up any existing port forwarding rich rules (only if behind NAT)
behind_nat = os.environ.get("BEHIND_NAT") == "true"
public_ip = os.environ.get("PUBLIC_IP")
prev_localnetworks = os.environ.get("LOCALNETWORKS")
prev_private_ip = os.environ.get("PRIVATE_IP")

# Only remove rich-rules if there was a public address (behind NAT scenario)
if behind_nat and public_ip and prev_localnetworks and prev_private_ip:
prev_networks = [n for n in prev_localnetworks.split(",") if n]
previous_rules = create_port_forward_rules(prev_networks, prev_private_ip)
if previous_rules:
result = agent.remove_rich_rules(previous_rules)
if not result:
print("Warning: Failed to remove some rich rules", file=sys.stderr)

agent.remove_public_service(os.environ['MODULE_ID'])
4 changes: 4 additions & 0 deletions modules/kamailio/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ if [ "${BEHIND_NAT}" == "true" ]; then
echo "listen=tcp:${PRIVATE_IP}:5060 advertise ${PUBLIC_IP}:5060" >> /tmp/kamailio-local-additional.cfg
# doing the same for TLS
echo "listen=tls:${PRIVATE_IP}:5061 advertise ${PUBLIC_IP}:5061" >> /tmp/kamailio-local-additional.cfg
# Add listeners for port 6060
echo "listen=udp:${PRIVATE_IP}:6060" >> /tmp/kamailio-local-additional.cfg
echo "listen=tcp:${PRIVATE_IP}:6060" >> /tmp/kamailio-local-additional.cfg
echo "listen=tls:${PRIVATE_IP}:6061" >> /tmp/kamailio-local-additional.cfg
else
# now I have to add the listen with the public IP in the kamailio-local-additional.cfg
echo "listen=udp:${PUBLIC_IP}:5060" > /tmp/kamailio-local-additional.cfg
Expand Down
51 changes: 47 additions & 4 deletions modules/kamailio/config/kamailio.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ route[WITHINDLG] {
route(DLGURI);
if ( is_method("ACK") ) {
# ACK is forwarded statelessly
# Ensure correct socket is used (especially for INTERNAL_NETWORK)
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding route(SET_SOCKET) before route(NATMANAGE) for ACK messages is a good enhancement to ensure the correct socket is used. However, the comment "especially for INTERNAL_NETWORK" suggests this is primarily for internal network handling, but SET_SOCKET also modifies socket settings for LOCALNETWORKS. Ensure this change doesn't cause issues for ACK messages in other scenarios by verifying the socket selection logic works correctly for all network types.

Suggested change
# Ensure correct socket is used (especially for INTERNAL_NETWORK)
# Ensure correct socket is used for ACKs across all relevant network types

Copilot uses AI. Check for mistakes.
route(SET_SOCKET);
route(NATMANAGE);
} else if ( is_method("NOTIFY") ) {
# Add Record-Route for in-dialog NOTIFY as per RFC 6665.
Expand Down Expand Up @@ -1053,17 +1055,29 @@ route[HANDLE_ALIAS] {
} # end of route[HANDLE_ALIAS]

# -----------------------------------------------------------------------------
# route[SET_FROM_SOCKET]
# this route set the from socket accoding also to LOCALNETWORKS
# route[SET_SOCKET]
# this route sets the from socket according to LOCALNETWORKS and INTERNAL_NETWORK
# - LOCALNETWORKS: uses PRIVATE_IP for advertised address
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "uses PRIVATE_IP for advertised address" but the actual implementation now sets the socket to PRIVATE_IP:6060 instead of using set_advertised_address(). The comment should be updated to accurately reflect that the socket is being set to a specific IP:port combination, not just using an advertised address.

Suggested change
# - LOCALNETWORKS: uses PRIVATE_IP for advertised address
# - LOCALNETWORKS: sets the socket to PRIVATE_IP:6060 (specific IP:port)

Copilot uses AI. Check for mistakes.
# - INTERNAL_NETWORK: forces socket to SERVICE_IP for traffic to Asterisk
# -----------------------------------------------------------------------------
route[SET_SOCKET] {
if( $shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - in SET_SOCKET route \n");
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: ENTER \n");
if(is_request()) {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: is_request=TRUE \n");
# print $rd is an ip and is in LOCALNETWORKS then print it
$var(destination_ip)= $null; # reinitialize the variable
$var(destination_ip) = $(ru{uri.host}); # fetch the destination ip from ru
# if $du is present, the destination ip is the one in $du
if ($du != $null && $du != 0 && $du != "") {
$var(destination_ip) = $(du{uri.host});
} else {
$var(destination_ip) = $(ru{uri.host}); # fetch the destination ip from ru
}
Comment on lines +1071 to +1075
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new logic to extract destination_ip from $du when present (lines 1071-1075) is a good improvement. However, the comparison $du != 0 is checking if $du is the numeric value 0, which may not be meaningful for a URI string. Consider simplifying to just check $du != $null && $du != "" for clarity.

Copilot uses AI. Check for mistakes.
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: destination_ip=$var(destination_ip) ru=$ru \n");
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - destination ip: $var(destination_ip) \n");
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: before validity check \n");
if ($var(destination_ip) != $null && $var(destination_ip) != 0 && $var(destination_ip) != "") {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: destination_ip is VALID \n");
if ($avp(direction) == 'in' && $dlg_var(direction) == 'in') {
if (($du == $null) && ($ru =~ "^sips:") && !($ru =~ ";transport=")) {
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - ru: $ru - du: $du - Rewriting ru and du for EA-51 \n");
Expand All @@ -1075,7 +1089,8 @@ route[SET_SOCKET] {
if (is_in_subnet($var(destination_ip), LOCALNETWORKS)) {
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - destination ip is in LOCALNETWORKS \n");
$var(from_socket) = PRIVATE_IP;
set_advertised_address(PRIVATE_IP);
$fs = PRIVATE_IP + ":6060";
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded port ":6060" is used here for LOCALNETWORKS traffic, but this differs from the standard port :5060 used elsewhere. While this appears intentional based on the PR description, there's an inconsistency: the listener setup in bootstrap.sh adds port 6060 only for NAT scenarios, but this code will try to use it regardless of NAT configuration. This could cause issues when not behind NAT.

Suggested change
$fs = PRIVATE_IP + ":6060";
$fs = PRIVATE_IP;

Copilot uses AI. Check for mistakes.
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET(fs): $fs \n");
# logging I've set the PRIVATE IP
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - from_socket: $var(from_socket) \n");
# handling rtpengine manage
Expand All @@ -1085,9 +1100,29 @@ route[SET_SOCKET] {
# logging rtpengine_conf
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - rtpengine_conf: $var(rtpengine_conf) \n");
}
# Handle traffic to INTERNAL_NETWORK (service network where Asterisk lives)
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: about to check INTERNAL_NETWORK for $var(destination_ip) \n");
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - checking if $var(destination_ip) is in INTERNAL_NETWORK \n");
if (is_in_subnet($var(destination_ip), INTERNAL_NETWORK)) {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: MATCH! $var(destination_ip) IS in INTERNAL_NETWORK \n");
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - destination ip is in INTERNAL_NETWORK, forcing socket to SERVICE_IP \n");
# Force using the service network interface
$fs = SERVICE_IP + ":5060";
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: forced $fs \n");
# replace external in rtpengine_conf with internal
$var(rtpengine_conf) = $(var(rtpengine_conf){s.replace,external,internal});
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - forced socket: $fs, rtpengine_conf: $var(rtpengine_conf) \n");
} else {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: NO MATCH! $var(destination_ip) NOT in INTERNAL_NETWORK \n");
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - destination ip $var(destination_ip) is NOT in INTERNAL_NETWORK \n");
}
Comment on lines +1106 to +1118
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The INTERNAL_NETWORK check at line 1106 can override the LOCALNETWORKS socket setting from line 1092. If a destination IP is in both LOCALNETWORKS and INTERNAL_NETWORK, the socket will first be set to PRIVATE_IP:6060 (line 1092), but then immediately overridden to SERVICE_IP:5060 (line 1110). Consider restructuring this logic with else-if to ensure only one socket configuration is applied, or document why both checks should run sequentially.

Copilot uses AI. Check for mistakes.
} else {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: destination_ip INVALID (null/0/empty) \n");
}
} else {
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: is_request=FALSE, checking is_reply \n");
if(is_reply()){
if ($shv(debug) == 1) xlog('L_WARN', "[TT157] - $ci $rm-$cs - SET_SOCKET: is_reply=TRUE \n");
$var(destination_ip)= $null; # reinitialize the variable
# logging $dlg_var(source_ip)
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - Original source ip: $dlg_var(source_ip) \n");
Expand All @@ -1102,7 +1137,15 @@ route[SET_SOCKET] {
# logging rtpengine_conf
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - rtpengine_conf: $var(rtpengine_conf) \n");
}
# Handle reply to INTERNAL_NETWORK (service network)
if (is_in_subnet($var(destination_ip), INTERNAL_NETWORK)) {
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - reply destination ip is in INTERNAL_NETWORK \n");
# replace external in rtpengine_conf with internal
$var(rtpengine_conf) = $(var(rtpengine_conf){s.replace,external,internal});
if ($shv(debug) == 1) xlog('L_WARN', "[DEV] - $ci $rm-$cs - rtpengine_conf: $var(rtpengine_conf) \n");
}
}
}
}
} # end of route[SET_SOCKET]
# TT157 TEST MARKER
1 change: 1 addition & 0 deletions modules/kamailio/config/template.kamailio-local.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
#!define WITH_TLS
#!define LOCALNETWORKS "${LOCALNETWORKS}"
#!define PRIVATE_IP "${PRIVATE_IP}"
#!define SERVICE_IP "${SERVICE_IP}"

server_header="Server: ${KML_SERVER_HEADER}"
user_agent_header="User-Agent: ${KML_UA_HEADER}"
Expand Down
Loading
Loading