Skip to content

Commit 0aac58e

Browse files
authored
Merge pull request #1278 from sah-anshu/whitelisted_ip
Whitelisted ip
2 parents 158e128 + 74a1134 commit 0aac58e

10 files changed

Lines changed: 309 additions & 21 deletions

File tree

jasmin/protocols/cli/statsm.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,20 @@ def user(self, arg, opts):
2828
user = self.pb['router'].getUser(opts.user)
2929
# SMPP Server stats
3030
for k, v in user.getCnxStatus().smpps.items():
31-
if isinstance(v, dict):
31+
if k == 'bound_peer_ips':
32+
# Render the live peer list as a human-readable multi-line
33+
# string: one entry per bound connection.
34+
if not v:
35+
v = '-'
36+
else:
37+
lines = []
38+
for e in v:
39+
lines.append('%s (%s @ %s)' % (
40+
e.get('peer', '?'),
41+
e.get('bind_type', '?'),
42+
formatDateTime(e.get('bound_at', 0))))
43+
v = '; '.join(lines)
44+
elif isinstance(v, dict):
3245
v = json.dumps(v)
3346

3447
row = []
@@ -60,7 +73,8 @@ def user(self, arg, opts):
6073
tabulate(table, headers, tablefmt="plain", numalign="left").encode('ascii'))
6174

6275
def users(self, arg, opts):
63-
headers = ["#User id", "SMPP Bound connections", "SMPP L.A.", "HTTP requests counter", "HTTP L.A."]
76+
headers = ["#User id", "SMPP Bound connections", "SMPP Peer IPs",
77+
"SMPP L.A.", "HTTP requests counter", "HTTP L.A."]
6478

6579
table = []
6680
users = pickle.loads(self.pb['router'].perspective_user_get_all(None))
@@ -70,6 +84,14 @@ def users(self, arg, opts):
7084
row.append(user.getCnxStatus().smpps['bound_connections_count']['bind_receiver'] +
7185
user.getCnxStatus().smpps['bound_connections_count']['bind_transmitter'] +
7286
user.getCnxStatus().smpps['bound_connections_count']['bind_transceiver'])
87+
# Distinct peer IPs currently holding a bind (comma-separated)
88+
peers = user.getCnxStatus().smpps.get('bound_peer_ips', []) or []
89+
seen = []
90+
for e in peers:
91+
p = e.get('peer')
92+
if p and p not in seen:
93+
seen.append(p)
94+
row.append(','.join(seen) if seen else '-')
7395
row.append(formatDateTime(user.getCnxStatus().smpps['last_activity_at']))
7496
row.append(user.getCnxStatus().httpapi['connects_count'])
7597
row.append(formatDateTime(user.getCnxStatus().httpapi['last_activity_at']))

jasmin/protocols/cli/usersm.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434

3535
SmppsCredentialKeyMap = {'class': 'SmppsCredential',
3636
'keyMapValue': 'smpps_credential',
37-
'Authorization': {'bind': 'bind'},
37+
'Authorization': {'bind': 'bind', 'ip': 'ip'},
3838
'Quota': {'max_bindings': 'max_bindings'}}
3939

4040
# A config map between console-configuration keys and User keys.
@@ -91,7 +91,12 @@ def castToBuiltCorrectCredType(cred, section, key, value, update=False):
9191
getattr(_o, 'set%s' % section)(key, value)
9292
elif cred == 'SmppsCredential':
9393
if section == 'Authorization':
94-
if value.lower() in TrueBoolCastMap:
94+
# The `ip` authorization is a CIDR/IP whitelist string — keep it
95+
# as-is. Only `bind` (and similar future boolean authorizations)
96+
# get coerced from the human "yes/no" form.
97+
if key == 'ip':
98+
value = value.strip()
99+
elif value.lower() in TrueBoolCastMap:
95100
value = True
96101
elif value.lower() in FalseBoolCastMap:
97102
value = False

jasmin/protocols/smpp/factory.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,12 +635,46 @@ def addBinding(self, connection):
635635
_SMPPBindManager.addBinding(self, connection)
636636

637637
# Update CnxStatus
638-
self.user.getCnxStatus().smpps['bind_count'] += 1
639-
self.user.getCnxStatus().smpps['bound_connections_count'][connection.bind_type.name] += 1
638+
cnx_smpps = self.user.getCnxStatus().smpps
639+
cnx_smpps['bind_count'] += 1
640+
cnx_smpps['bound_connections_count'][connection.bind_type.name] += 1
641+
642+
# Record live peer IP so `stats user <uid>` can show who is bound right now
643+
try:
644+
peer_host = connection.transport.getPeer().host
645+
except Exception:
646+
peer_host = 'unknown'
647+
cnx_smpps.setdefault('bound_peer_ips', []).append({
648+
'session_id': getattr(connection, 'session_id', None),
649+
'peer': peer_host,
650+
'bind_type': connection.bind_type.name,
651+
'bound_at': datetime.now(),
652+
})
640653

641654
def removeBinding(self, connection):
642655
_SMPPBindManager.removeBinding(self, connection)
643656

644657
# Update CnxStatus
645-
self.user.getCnxStatus().smpps['unbind_count'] += 1
646-
self.user.getCnxStatus().smpps['bound_connections_count'][connection.bind_type.name] -= 1
658+
cnx_smpps = self.user.getCnxStatus().smpps
659+
cnx_smpps['unbind_count'] += 1
660+
cnx_smpps['bound_connections_count'][connection.bind_type.name] -= 1
661+
662+
# Drop the matching peer-IP entry (match by session_id; fall back to
663+
# first entry with the same peer host if the session_id is absent)
664+
sid = getattr(connection, 'session_id', None)
665+
try:
666+
peer_host = connection.transport.getPeer().host
667+
except Exception:
668+
peer_host = None
669+
entries = cnx_smpps.get('bound_peer_ips', []) or []
670+
if sid is not None:
671+
cnx_smpps['bound_peer_ips'] = [e for e in entries if e.get('session_id') != sid]
672+
elif peer_host is not None:
673+
# No session_id — remove the first matching peer so we don't
674+
# leak entries on unbind.
675+
new_entries = list(entries)
676+
for i, e in enumerate(new_entries):
677+
if e.get('peer') == peer_host and e.get('bind_type') == connection.bind_type.name:
678+
new_entries.pop(i)
679+
break
680+
cnx_smpps['bound_peer_ips'] = new_entries

jasmin/protocols/smpp/protocol.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,25 @@ def doBindRequest(self, reqPDU, sessionState):
550550
self.sendErrorResponse(reqPDU, CommandStatus.ESME_RINVPASWD, username)
551551
return
552552

553+
# Per-user IP whitelist check. `smpps_credential.getAuthorization('ip')`
554+
# returns a comma-separated list of IPv4/IPv6 CIDRs; default "0.0.0.0/0"
555+
# allows anything (backwards compatible).
556+
try:
557+
from jasmin.tools.ipmatch import is_ip_allowed
558+
peer_host = self.transport.getPeer().host
559+
smpps_cred = getattr(auth_avatar, 'smpps_credential', None)
560+
whitelist = smpps_cred.getAuthorization('ip') if smpps_cred is not None else None
561+
if whitelist and not is_ip_allowed(peer_host, whitelist):
562+
self.log.warning(
563+
'SMPP Bind rejected for username "%s" from %s: IP not in whitelist (%s)',
564+
username, peer_host, whitelist)
565+
self.sendErrorResponse(reqPDU, CommandStatus.ESME_RBINDFAIL, username)
566+
return
567+
except Exception as e:
568+
# Never deny on an internal check error — log and continue. The
569+
# whitelist is a guard rail, not the primary auth.
570+
self.log.error('IP whitelist check failed for username "%s": %s', username, e)
571+
553572
# Check we're not already bound, and are open to being bound
554573
if self.sessionState != SMPPSessionStates.OPEN:
555574
self.log.warning('Duplicate SMPP bind request received from: %s', username)

jasmin/routing/jasminApi.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,54 @@ def setQuota(self, key, value):
164164
class SmppsCredential(CredentialGeneric):
165165
"""Credential set for SMPP Server connection"""
166166

167+
# Default IP whitelist matches everything (current behaviour — no regression
168+
# when the attribute is absent on pickled users from older versions).
169+
DEFAULT_IP_WHITELIST = '0.0.0.0/0'
170+
167171
def __init__(self, default_authorizations=True):
168172
if not isinstance(default_authorizations, bool):
169173
default_authorizations = False
170174

171-
self.authorizations = {'bind': default_authorizations, }
175+
# `bind`: boolean, same as before.
176+
# `ip` : comma-separated list of IPv4/IPv6 addresses or CIDRs. The
177+
# remote peer IP of each bind request must fall inside at
178+
# least one of these networks, or the bind is rejected.
179+
# Default is "any IPv4" so upgrading doesn't lock users out.
180+
self.authorizations = {
181+
'bind': default_authorizations,
182+
'ip': self.DEFAULT_IP_WHITELIST,
183+
}
172184

173185
self.quotas = {'max_bindings': None}
174186

187+
def setAuthorization(self, key, value):
188+
"""Per-key validation. `bind` is a bool; `ip` is a CIDR/IP whitelist."""
189+
if key == 'ip':
190+
# Back-compat: SmppsCredential instances pickled before the `ip`
191+
# authorization existed won't have it in their `authorizations`
192+
# dict. Accept it here regardless so `user -u ... ip <cidr>` works
193+
# on legacy users (the getAuthorization fallback mirrors this).
194+
from jasmin.tools.ipmatch import validate_whitelist
195+
ok, err = validate_whitelist(value)
196+
if not ok:
197+
raise jasminApiCredentialError(
198+
'Authorization ip is not a valid value (%r): %s' % (value, err))
199+
# Bypass the base class because its validator insists on bool.
200+
self.authorizations[key] = value
201+
return
202+
203+
if key not in self.authorizations:
204+
raise jasminApiCredentialError('%s is not a valid Authorization' % key)
205+
206+
# Anything else (e.g. 'bind') must be a bool — delegate to base.
207+
CredentialGeneric.setAuthorization(self, key, value)
208+
209+
def getAuthorization(self, key):
210+
"""Back-compat: older pickled users may lack the `ip` key."""
211+
if key == 'ip' and 'ip' not in self.authorizations:
212+
return self.DEFAULT_IP_WHITELIST
213+
return CredentialGeneric.getAuthorization(self, key)
214+
175215
def setQuota(self, key, value):
176216
"""Additional validation steps"""
177217
if key == 'max_bindings' and value is not None and (value < 0 or not isinstance(value, int)):
@@ -217,6 +257,12 @@ def __init__(self):
217257
'bind_transceiver': 0,
218258
'bind_transmitter': 0,
219259
},
260+
# Live roster of bound peers. Each entry is a dict:
261+
# {'session_id': str, 'peer': '1.2.3.4', 'bind_type': 'bind_transceiver',
262+
# 'bound_at': datetime}
263+
# Populated by SMPPBindManager.addBinding and pruned by removeBinding.
264+
# In-memory only (CnxStatus is not persisted).
265+
'bound_peer_ips': [],
220266
'submit_sm_request_count': 0,
221267
'last_activity_at': 0,
222268
'qos_last_submit_sm_at': 0,

jasmin/tools/ipmatch.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""IP whitelist matching using the stdlib `ipaddress` module.
2+
3+
A *whitelist* is a string — a comma-separated list of IPv4/IPv6 addresses
4+
and/or CIDR networks. Whitespace around entries is tolerated. Examples::
5+
6+
'0.0.0.0/0' -> any IPv4 (default "allow all")
7+
'::/0' -> any IPv6
8+
'10.0.0.0/8' -> one /8 network
9+
'10.0.0.0/8, 192.168.1.5' -> network + single host
10+
'10.0.0.0/8, 2001:db8::/32' -> mixed v4+v6
11+
12+
An empty or `None` whitelist is treated as "nothing allowed".
13+
14+
The parser is tolerant and safe: any malformed entry is ignored (not
15+
raised). This is deliberate — a bad config row should not silently crash
16+
an authentication path. The caller can use :func:`validate_whitelist` at
17+
config-set time to surface errors to the operator.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import ipaddress
23+
from typing import Iterable
24+
25+
26+
def _split(whitelist: str | None) -> list[str]:
27+
if not whitelist:
28+
return []
29+
return [p.strip() for p in str(whitelist).split(',') if p.strip()]
30+
31+
32+
def _parse_networks(whitelist: str | None) -> list:
33+
"""Return a list of `IPv4Network` / `IPv6Network` parsed from the
34+
whitelist string. Malformed entries are skipped silently.
35+
"""
36+
out = []
37+
for entry in _split(whitelist):
38+
try:
39+
out.append(ipaddress.ip_network(entry, strict=False))
40+
except (ValueError, TypeError):
41+
continue
42+
return out
43+
44+
45+
def validate_whitelist(whitelist: str | None) -> tuple[bool, str | None]:
46+
"""Validate a whitelist string. Returns ``(ok, error_message_or_None)``.
47+
48+
An empty string / None is treated as an error (set `0.0.0.0/0` explicitly
49+
to allow everything). This helps the operator avoid accidentally locking
50+
themselves out by clearing the field.
51+
"""
52+
parts = _split(whitelist)
53+
if not parts:
54+
return False, 'whitelist is empty (use "0.0.0.0/0" to allow any IPv4)'
55+
for entry in parts:
56+
try:
57+
ipaddress.ip_network(entry, strict=False)
58+
except (ValueError, TypeError) as e:
59+
return False, 'invalid entry %r: %s' % (entry, e)
60+
return True, None
61+
62+
63+
def is_ip_allowed(ip: str | None, whitelist: str | None) -> bool:
64+
"""Return True if *ip* falls inside any network in *whitelist*.
65+
66+
*ip* may be an IPv4 or IPv6 address as a string (e.g. what
67+
``transport.getPeer().host`` gives you). An empty *ip* or empty
68+
*whitelist* returns False.
69+
"""
70+
if not ip or not whitelist:
71+
return False
72+
try:
73+
addr = ipaddress.ip_address(ip)
74+
except (ValueError, TypeError):
75+
return False
76+
for net in _parse_networks(whitelist):
77+
try:
78+
if addr.version != net.version:
79+
continue
80+
if addr in net:
81+
return True
82+
except (ValueError, TypeError):
83+
continue
84+
return False

0 commit comments

Comments
 (0)