Have you ever needed to use the SCCP (Skinny) protocol in your Python project? Probably not.
Looking for automation tools for legacy Cisco CallManager? Doubtful.
Didn’t IP Telephony die like 10 years ago? Allegedly.
But on the off chance you’re here for exactly that… you’ve come to the right place.
pyskinny includes both a SCCP Client and a grab-bag of practical scripts for poking at old-school CUCM/CallManager (4.x): AXL v1 SOAP, SQL via AXL, real-time snapshots via the RTMT ASTIsapi endpoints, and direct 79xx phone control (screenshots + button presses).
- SCCP softphone client (79xx softkeys + CM2 button-template phones)
- Console UI (
run_console), Cisco-style CLI, macro/IVR mode - Hold/resume, blind & consulted transfer, conference (lab-tested on cm2 + cm31/33/41/43)
- Default RTP TX is silence with local RX monitor;
--rtp-mic/--rtp-tonefor troubleshooting
- Console UI (
- CallManager 4.1 AXL v1 SOAP helpers
listPhoneByName/listPhoneByDescriptionexecuteSQLQuerywith a smart rewrite to safely expandDevice.*(avoids the XML LOB column)
- AST / “RIS-ish” device snapshot
- Uses
ASTIsapi.dll?OpenDeviceSearch(works on 4.x; TLSv1 & NTLM friendly)
- Uses
- Phone control (79xx era)
/CGI/Screenshot(decodes CiscoIPPhoneImage → PNG)/CGI/Execute(dial digits, softkeys, navigation, hardkeys)
- JSON everywhere
--json,--jsonl,--pretty
Unit tests (no CallManager required):
pip install -e ".[dev]"
pytest -m "not integration" -v --no-audioLab docs
| Topic | Doc |
|---|---|
| Simulator scenarios (IVR, 3 phones, admin reset) | docs/lab-cookbook.md |
| CM 3.x labs (cm31, cm33 — 7960/7970) | docs/lab-cm3x.md |
| Softkey hold/resume (cm31/33/41/43) | docs/lab-softkey-hold.md |
| CM2 button phones (Stimulus hold/transfer) | docs/lab-cm2-buttons.md |
Unit tests (.github/workflows/test.yml): runs on every push/PR to main on Ubuntu with Python 3.11 and 3.12. Command: pytest -m "not integration" -v --no-audio.
Integration tests (.github/workflows/integration.yml): manual workflow_dispatch on a self-hosted runner in the lab network. Defaults to all five labs (cm2, cm31, cm33, cm41, cm43) via tests/test_integration_live.py; workflow inputs override server IPs and shared MACs.
# All labs (built-in server IPs — override with PYSKINNY_CM41_SERVER etc.)
$env:PYSKINNY_INTEGRATION_LABS = "cm2,cm31,cm33,cm41,cm43"
$env:PYSKINNY_SKIP_TFTP = "1"
pytest tests/test_integration_live.py -m integration -v --no-audio
# Single lab (pytest marker — no env var needed)
pytest tests/test_integration_live.py -m "integration and cm2" -v --no-audio
# Or limit labs via env (PowerShell: set on the same line, or export before pytest)
$env:PYSKINNY_INTEGRATION_LABS = "cm2"; pytest tests/test_integration_live.py -m integration -v --no-audio| Lab | Default IP | Model | Identity |
|---|---|---|---|
| cm2 | 10.0.0.11 | Virtual30SPplus | pyskinny01–03 |
| cm31 | 10.0.0.181 | 7960 | SEP + MAC |
| cm33 | 10.0.0.182 | 7970 | SEP + MAC |
| cm41 | 10.0.0.180 | 7970 | SEP + MAC |
| cm43 | 100.69.0.100 | 7970 | SEP + MAC |
Shared MACs: 222233334444–446. Disable a lab: PYSKINNY_CM33_DISABLE=1.
Integration scenarios (each lab, three endpoints where noted): register/unregister, outbound connect/hangup, hold/resume, blind transfer, consulted transfer.
# Examples
pytest tests/test_integration_live.py -m "integration and cm2" -v --no-audio
pytest tests/test_integration_live.py -k "hold or blind or consulted" -m integration -v --no-audioDiagnostics (against live CM): python -m utils.dump_softkeys --config, python -m utils.dump_buttons --config (CM2).
Only one SCCP client can hold a device/MAC at a time — stop consoles before running integration tests.
Simulator tests (no CUCM):
pytest tests/test_simulator.py -v
pytest tests/test_cucm_capture_regression.py -v # wire format vs cm_cap / cm_call pcapsRegenerate committed capture fixtures after updating pcaps: python -m utils.extract_cucm_capture_fixtures
- Python 3.11+ (tested on 3.12)
- macOS/Linux (Windows likely fine)
- System curl (for ancient TLS fallbacks)
- Install:
pip install .orpip install -e ".[dev]"from a clone (see Quick start) - Note: Tested only with CallManager 4.1(3). Will it work on other versions? Unlikely.
Install from a clone (editable, best for development):
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"Or install runtime only:
pip install .After install, console entry points are on your PATH:
| Command | Same as |
|---|---|
pyskinny-console |
python -m examples.run_console |
pyskinny-cli |
python -m examples.run_cli |
pyskinny-macro |
python -m examples.run_macro |
pyskinny-sim |
python -m examples.run_simulator |
pyskinny-tftp-relay |
python -m simulator.tftp_relay |
pyskinny-phone |
python -m tools.phone |
pyskinny-callmanager |
python -m tools.callmanager |
python -m examples.* and python -m tools.* still work after install.
# Using a macro file
python -m examples.run_macro --server <server_ip_or_hostname> --mac <device_mac_address> --model <device_model_number> --macro-file examples/ivr.macro
2025-09-05 11:35:13,643 [MESSAGE] ui.macro_cli : Executing: ON_DISCONNECT ['GOTO', 'TOP']
2025-09-05 11:35:13,643 [MESSAGE] ui.macro_cli : Executing: WAIT_CALL ['0', 'RING']
2025-09-05 11:35:13,643 [MESSAGE] ui.macro_cli : Press 'q' to quit
2025-09-05 11:35:25,031 [MESSAGE] ui.macro_cli : Executing: SOFTKEY ['Answer']
2025-09-05 11:35:25,534 [MESSAGE] ui.macro_cli : Executing: GOTO ['MENU']
2025-09-05 11:35:25,534 [MESSAGE] ui.macro_cli : Executing: PLAY ['media/main_menu.wav']
2025-09-05 11:35:25,542 [MESSAGE] ui.macro_cli : Executing: WAIT_DIGIT ['0']
2025-09-05 11:35:34,450 [MESSAGE] ui.macro_cli : Executing: SWITCH ['last_digit', '1:SERVICE;2:SUPPORT;3:EXT_DIAL;9:MENU;DEFAULT:MENU']
2025-09-05 11:35:34,450 [MESSAGE] ui.macro_cli : Executing: PLAY ['media/customer_service.wav']
2025-09-05 11:35:34,454 [MESSAGE] ui.macro_cli : Executing: WAIT_DIGIT ['0']
2025-09-05 11:35:45,351 [MESSAGE] ui.macro_cli : Executing: WAIT_CALL ['0', 'RING']
2025-09-05 11:35:45,351 [MESSAGE] ui.macro_cli : Press 'q' to quit
# Using a macro from the CLI
python -m examples.run_macro -vvvv --server <server_ip_or_hostname> --mac <device_mac_address> --model <device_model_number> --macro "WAIT 2,CALL 1001,HOLD,WAIT 2,RESUME,WAIT 5,TRANSFER 1002,END"
2025-09-05 11:37:38,817 [MESSAGE] ui.macro_cli : Executing: WAIT ['2']
2025-09-05 11:37:40,822 [MESSAGE] ui.macro_cli : Executing: CALL ['1001']
2025-09-05 11:37:43,344 [MESSAGE] ui.macro_cli : Executing: WAIT ['10']
2025-09-05 11:37:53,349 [MESSAGE] ui.macro_cli : Executing: SOFTKEY ['EndCall']python -m examples.run_cli
Press '?' for help. Type 'exit' to quit.
phone# set server 10.0.0.180
server = 10.0.0.180
phone# set mac 222233334444
mac = 222233334444
phone# set model 7970
model = 7970
phone# set auto_connect true
auto_connect = True
phone# save
phone# show config
server: 10.0.0.180
mac: 222233334444
model: 7970
auto_connect: True
phone# connect
phone# phone call 1006
Calling 1006 ...
phone# phone hold
Hold
phone# phone resume
Resume
phone# phone transfer 1007
Blind transfer to 1007 ...
phone# show calls
Line CallId CallType CallState Time FromNum FromName ToNum ToName
1 16777221 OutBoundCall Connected 2 seconds 1003 Python 1006 Python7971
phone# phone send softkey EndCall
Softkey EndCall (line 1, call 0)
phone# exit# 79xx (softkeys)
pyskinny-console --server 10.0.0.180 --mac 222233334444 --model 7970
# CM2 button phone (device name, not MAC)
pyskinny-console --server 10.0.0.11 --device pyskinny01 --model Virtual30SPplus
# Optional: mini web UI + RTP flags (see utils/cli_media.py)
pyskinny-console --config --web-port 8766Keyboard (console)
| Key | Action |
|---|---|
0-9 * # |
DTMF / dial |
| Space | Hook on/off |
h |
Hold / resume toggle |
t |
Transfer (dial target, then t again to complete) |
e or F-key EndCall |
Hang up |
| F1–F8 | Softkeys (79xx) or button-template actions (CM2) |
q |
Quit (sends Unregister) |
Audio: hear the remote party by default (RX monitor). TX is silence unless --rtp-mic, --rtp-tone, or --rtp-loopback. CM2 may report compression_type=160366308; that is mapped to G.711 and does not affect listen-only calls.
python tools/callmanager.py --server <server_ip_or_hostname> --user administrator --pass <windows_admin_password> --mode name --pattern 'SEP%' --json --pretty
[
{
"pkid": "b4fa8ba2-ccc1-419b-9c3b-a447d0ab3604",
"name": "SEP333344445555",
"product": "Cisco 7971",
"model": "Cisco 7971"
}
]python tools/callmanager.py --server <server_ip_or_hostname> --user administrator --pass <windows_admin_password> --sql "SELECT d.*, n.DNOrPattern
FROM Device d
JOIN DeviceNumPlanMap m ON m.fkDevice = d.pkid
JOIN NumPlan n ON n.pkid = m.fkNumPlan
ORDER BY d.Name" --pretty
[
{
"pkid": "{B4FA8BA2-CCC1-419B-9C3B-A447D0AB3604}",
"Name": "SEP333344445555",
"Description": "SEP333344445555",
"tkModel": "119",
"tkDeviceProtocol": "0",
"tkProtocolSide": "1",
"SpecialLoadInformation": "",
"fkDevicePool": "{F71F52BC-B6C9-4968-8A86-DCDA3EEC3DD7}",
"fkPhoneTemplate": "{9C9F4E1E-7879-4AD0-842F-A0F253864260}",
"AssocPC": "",
"fkCallingSearchSpace": "{96B5C16D-68B7-41FF-960F-36990DB223EC}",
"CtiID": "1942436578",
"tkClass": "1",
"AddOnModules": "0",
"fkProcessNode": "",
"DefaultDTMFCapability": "0",
"fkLocation": "{C8185284-D1D9-4FD5-9D7B-6ED6BB0F646C}",
"tkProduct": "119",
"DialPlanWizardGenID": "",
"DeviceLevelTraceFlag": "0",
"LoginUserid": "",
"LoginTime": "",
"AllowHotelingFlag": "0",
"tkDeviceProfile": "0",
"ikDevice_DefaultProfile": "",
"fkMediaResourceList": "{B2E1E3C2-5198-47FC-9F44-B898CD542D2C}",
"UserHoldMOHAudioSourceID": "0",
"NetworkHoldMOHAudioSourceID": "0",
"LoginDuration": "",
"Unit": "0",
"SubUnit": "0",
"VersionStamp": "{1EF073B9-5898-4109-B1C7-96F4969B5A4C}",
"tkCountry": "",
"ikDevice_CurrentLoginProfile": "",
"tkUserLocale": "",
"tkProduct_Base": "",
"fkCallingSearchSpace_AAR": "",
"fkAARNeighborhood": "",
"fkSoftkeyTemplate": "",
"retryVideoCallAsAudio": "1",
"RouteListEnabled": "",
"fkCallManagerGroup": "",
"tkStatus_MLPPIndicationStatus": "2",
"tkPreemption": "2",
"MLPPDomainID": "-1",
"tkStatus_CallInfoPrivate": "2",
"tkStatus_BuiltInBridge": "2",
"tkQSIG": "4",
"tkDeviceSecurityMode": "0",
"V150ModemRelayCapable": "0",
"tkNetworkLocation": "0",
"ignorePI": "0",
"tkPacketCaptureMode": "0",
"PacketCaptureDuration": "60",
"AuthenticationString": "",
"tkAuthenticationMode": "1",
"tkCertificateStatus": "1",
"tkKeySize": "2",
"tkCertificateOperation": "1",
"UpgradeFinishTime": "",
"tkCertificate": "0",
"msrepl_tran_version": "{4895266D-F371-49FA-8818-12CAC83FE7C7}",
"DNOrPattern": "1006"
}
]Tip:
--no-rewritedisables the safety net if you really want raw SQL.
python tools/callmanager.py --server 10.0.0.180 --user administrator --pass <windows_admin_password> --ris --pretty
{
"node": "CUCM4",
"totalDevices": 1,
"devices": [
{
"name": "SEP333344445555",
"ip": "192.168.100.195",
"dirNumber": "1006",
"status_enum": 2,
"status": "Unregistered",
"model_enum": 119,
"model": "Cisco 7971",
"product_enum": 119,
"product": "Cisco 7971",
"perfmonObject": 2,
"timestamp_raw": "1757065587",
"timestamp_iso": "2025-09-05T09:46:27Z"
}
]
}- General:
--server <ip_or_hostname> --user administrator --pass <windows_admin_password>
- AXL listing:
--mode {name|description},--pattern 'SEP%'
- SQL:
--sql "SELECT …",--no-rewrite(optional)
- AST/RIS-ish:
--ris
- Output:
--json(array),--jsonl(one object per line),--pretty
# Screenshot (auto-detects CiscoIPPhoneImage, writes PNG)
python tools/phone.py --phoneip 10.0.0.71 --user <username> --pass <password> --output screenshot.png
# Dial a number
python tools/phone.py --phoneip 10.0.0.71 --user <username> --pass <password> --dial 1001
# Press keys/softkeys/navigation
python tools/phone.py --phoneip 10.0.0.71 --user <username> --pass <password> --keys "123#"
python tools/phone.py --phoneip 10.0.0.71 --user <username> --pass <password> --softkey 1
python tools/phone.py --phoneip 10.0.0.71 --user <username> --pass <password> --nav downEnable Web Access = Enabled on the phone in CUCM.
In order for auth to work, you'll need to associate a user to the device in CallManager, then you can authenticate with those user credentials.
# Extract voice related configuration parameters from CME and store in JSON file. Specify DN range to build database of used DNs.
python tools/cme.py --host <cme_ip_or_hostname> --username <router_username> --password <router_password> --transport telnet collect -o <filename.json> --dn-start 9000 --dn-end 9999
# Add a new phone to CME. Dry run will show you the configuration that will be added.
python tools/cme.py --host <cme_ip_or_hostname> --username <router_username> --password <router_password> --transport telnet add-phone --json <filename.json> --mac 4444.5555.6666 --model 7970 --dry-run
# Add a new phone to CME. Actually add the configuration and save it.
python tools/cme.py --host <cme_ip_or_hostname> --username <router_username> --password <router_password> --transport telnet add-phone --json <filename.json> --mac 4444.5555.6666 --model 7970 --commit- Targeted at CUCM/CallManager 4.x (Windows/IIS).
- AXL WSDL:
/CCMAPI/AXL/V1/AXLAPI.wsdl - ASTIsapi:
/ast/ASTIsapi.dll(OpenDeviceSearch,GetAlertSummaryList, etc.) - Phone models tested: 7940/7960 (others likely work if they expose
/CGI/*).
- “XML is a large text column…”: don’t
SELECT * FROM Device; the tool auto-expands safe columns or pick columns explicitly. - TLS/SSL errors (EOF / handshake): these boxes speak TLS1.0 + antique ciphers. The scripts fall back to:
- forcing TLSv1 in
requests, and if needed, - shelling out to
curl --tlsv1(and--ntlm).
- forcing TLSv1 in
- Unauthorized HTML instead of XML: include the domain (
CUCM4\administrator) for NTLM, and use the double-? URL.
A minimal SCCP server that accepts any device registration and auto-assigns DNs starting at 1000 (configurable). Enough of the Skinny handshake is implemented for pyskinny clients to register and show a line.
# Terminal 1 — simulator (listens on TCP 2000)
python -m examples.run_simulator -v
# Terminal 2 — softphone aimed at the simulator
python -m examples.run_cli
phone# set server 127.0.0.1
phone# set mac AABBCCDDEEFF
phone# set model 7970
phone# set auto_connect true
phone# connectOptions: --port, --dn-start, --host, --name, --no-tftp, --tftp-port, --tftp-root, --advertise-host, --provision MAC, --auto-answer MAC, --auto-answer-all, --ivr-dn, --admin-port (default 8090, web UI for Reset/Restart/bulk actions), --rtp-sim-peer.
Full lab walkthrough: docs/lab-cookbook.md (three consoles, IVR macro, admin reconnect, second call while on hold).
The simulator runs an embedded TFTP server (via tftpy) and serves:
| File | Purpose |
|---|---|
XMLDefault.cnf.xml |
Fallback — points phones at the simulator's Skinny port |
SEP<MAC>.cnf.xml |
Per-device file with auto-assigned DN (created on first TFTP or Skinny register) |
Ringlist.xml |
Minimal empty ring list (seeded; optional PCM rings from CUCM) |
Optional TFTP files from CUCM — Hardware phones (especially 7912/7905) may also request locale files under United_States/ (gkdefault.cfg, gk<MAC>, etc.) and ring PCM files referenced by Ringlist.xml. These are not required for register and call if SEP<MAC>.cnf.xml was served successfully. Copy them from your CallManager TFTP directory when you want full ring menus or legacy GK profiles:
# Example: mirror CUCM TFTP into a folder, then point the simulator at it
python -m examples.run_simulator -v --advertise-host 10.102.172.11 --tftp-root D:\cucm-tftp-mirrorOn CUCM, files usually live under the publisher’s TFTP path (OS Admin → Software Upgrades → TFTP file management, or the tftp folder on the server). Copy Ringlist.xml, United_States\, and any .raw/.pcm ring files the phone requests.
Port 69 is the Cisco default but usually requires Administrator (Windows) or root (Linux). Run the simulator on 6969 (no admin) and use the TFTP relay when a hardware phone must talk to port 69:
# Terminal A — elevated (Administrator)
python -m simulator.tftp_relay -v
# Terminal B — normal user
python -m examples.run_simulator -vv --advertise-host 10.102.172.11 --tftp-port 6969 --tftp-root simulator/tftp_assetsThe relay listens on 0.0.0.0:69 and forwards each TFTP transfer to 127.0.0.1:6969. Phones keep using CallManager / TFTP address 10.102.172.11 (port 69).
If you start the simulator without the relay and port 69 is unavailable, it automatically falls back to 6969 and logs a warning. pyskinny clients can use tftp_port=6969 on PhoneState in that case.
Force a specific port (no fallback): --tftp-port 6969.
Pre-provision a MAC before a physical phone boots (TFTP happens before Skinny TCP):
python -m examples.run_simulator --provision 222233334444 --provision 222233334445Use --advertise-host when binding 0.0.0.0 so phone XML contains the lab IP phones can reach (not 0.0.0.0).
Two pyskinny clients registered to the simulator can call each other by DN (auto-assigned from 1000). The simulator routes on dialed digits, rings the callee, and connects on Answer (or off-hook while ringing).
# Terminal 1
python -m examples.run_simulator -v
# Terminal 2 — phone A (gets DN 1000)
python -m examples.run_cli
phone# set server 127.0.0.1
phone# set mac AABBCCDDEE01
phone# set model 7970
phone# connect
phone# show config
# Terminal 3 — phone B (gets DN 1001)
python -m examples.run_cli
phone# set server 127.0.0.1
phone# set mac AABBCCDDEE02
phone# set model 7970
phone# connect
phone# show config
# On phone A — dial phone B's DN from show config
phone# call 1001
# On phone B — answer when it rings
phone# send softkey Answer
phone# show callsAuto-answer — on the CLI softphone:
phone# set auto_answer true
phone# connectIncoming calls are answered automatically via the Answer softkey. Persisted in examples/cli.config like other set options.
On the simulator, auto-answer can be enabled server-side (useful for headless tests):
python -m examples.run_simulator -v --auto-answer AABBCCDDEE02
python -m examples.run_simulator -v --auto-answer-allRTP media is negotiated after connect (OpenReceiveChannel / StartMediaTransmission); audio path is basic but call state should reach Connected.
This is not a full CUCM replacement — no call routing or AXL — but it is useful for lab automation and client development without a Windows CallManager VM.
Use two channels in parallel:
| Tool | Role |
|---|---|
examples/run_simulator |
SCCP/TFTP (register, dial tone, call state) |
python -m utils.phone_remote |
HTTP CGI — press softkeys / digits on the physical LCD |
python -m utils.phone_web_probe |
Diagnose phone HTTP (GET / vs /CGI/*) |
set PHONE_IP=10.102.10.209
set PHONE_USER=Administrator
set PHONE_PASS=your_phone_web_password
python -m utils.phone_web_probe --ip %PHONE_IP% --user %PHONE_USER% --password %PHONE_PASS%
python -m utils.phone_remote screenshot -o screen.png
python -m utils.phone_remote newcall # Soft2 by default (PHONE_NEW_CALL_SOFTKEY)
python -m utils.phone_remote keys 1001
python -m utils.phone_remote interactiveWeb UI — browser-based remote control with live LCD refresh when /CGI/Screenshot works:
python -m examples.run_phone_web
# open http://127.0.0.1:8765/
# or: pyskinny-phone-web --host 0.0.0.0 # LAN access (lab only)Enter the phone IP and web credentials, click Probe — softkeys/keypad enable when /CGI/Execute works even if screenshots are unavailable.
Console web UI — control the running curses softphone in your browser (synthetic LCD, same SCCP session):
pyskinny-console --config --web-port 8766
pyskinny-cli --config --web-port 8766
pyskinny-macro --config --macro-file examples/ivr.macro --web-port 8766
# open http://127.0.0.1:8766/For pyskinny-cli, the web UI starts after you connect (or on startup if auto_connect is enabled). Softkeys and keypad call SCCPClient directly (not HTTP to a hardware phone). Use --web-host 0.0.0.0 only on trusted lab networks.
7912 + phone_remote: The embedded web server is not a normal home page on port 80. curl http://<phone>/ often yields empty reply even when CGI push works. Use phone_web_probe to test /CGI/Screenshot and /CGI/Execute. On CUCM, <webAccess>0</webAccess> in SEP*.cnf.xml is normal when the admin “Web Access” checkbox is checked (0 = enabled, 1 = disabled). Clear <proxyServerURL></proxyServerURL> in the sim TFTP config — a proxy URL breaks local HTTP/CGI on 7912-class phones (the simulator patches this automatically). XML push auth goes to the simulator’s authenticate.asp, which must return plain AUTHORIZED (not XML).
On register, the simulator log should show legacy for a 7912 (type=0x7537). New Call should log outbound dial ... tone=33 (0x21) legacy=True matching CUCM (cm_cap.pcapng: SetRinger → SetSpeakerMode → SetLamp → CallState OffHook → SelectSoftKeys → DisplayPromptStatus → ActivateCallPlane → StartTone tone 33 dir 0).
Wireshark on the sim host:
"c:\Program Files\Wireshark\tshark.exe" -i 2 -f "host <phone-ip> and tcp port 2000"
Live CM labs (cm2, cm31, cm33, cm41, cm43): integration tests cover registration, connect/hangup, hold/resume, blind transfer, consulted transfer. See docs/lab-softkey-hold.md and docs/lab-cm2-buttons.md.
Done
- CallManager simulator, admin UI, TFTP relay, capture regression tests
- Console / CLI / macro softphones; web UI; auto-reconnect after CM Reset
- Hold/resume, blind & consulted transfer, conference (sim + client + live labs)
- CM2 button phones: Stimulus hold (3), transfer (4), SetLamp call tracking, synthetic
cm2-Nrefs - GitHub Actions CI;
pip install -e ".[dev]"
Later
- SIP phone support
- Additional Skinny codecs (G.729 TX, wideband) beyond G.711 monitor
These endpoints are old and permissive. Keep usage to trusted lab/LANs and authorized systems. Don’t enable phone web access on exposed networks.
MIT