{"id":135,"date":"2026-04-24T19:54:50","date_gmt":"2026-04-24T19:54:50","guid":{"rendered":"https:\/\/phonesstillexist.com\/?p=135"},"modified":"2026-05-12T16:13:21","modified_gmt":"2026-05-12T16:13:21","slug":"hardening-freeswitch-a-production-baseline-for-day-one","status":"publish","type":"post","link":"https:\/\/phonesstillexist.com\/index.php\/2026\/04\/24\/hardening-freeswitch-a-production-baseline-for-day-one\/","title":{"rendered":"Hardening FreeSWITCH: A Production Baseline for Day One"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"538\" src=\"https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/04\/freeswitch-hardening-header-1024x538.png\" alt=\"Hardening FreeSWITCH\" class=\"wp-image-136\" srcset=\"https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/04\/freeswitch-hardening-header-1024x538.png 1024w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/04\/freeswitch-hardening-header-300x158.png 300w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/04\/freeswitch-hardening-header-768x403.png 768w, https:\/\/phonesstillexist.com\/wp-content\/uploads\/2026\/04\/freeswitch-hardening-header.png 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><em>Updated April 2026: substantial rewrite. The hardening script and this post now reflect what we actually see hitting public-IP FreeSWITCH boxes \u2014 two distinct attack patterns (credential brute-force and unauthenticated INVITE floods), and three failure modes that make a default fail2ban setup look like it&#8217;s working when it isn&#8217;t. New Section 1 explains the attack landscape; Section 7 describes the consolidated single-jail design that catches both patterns; new Section 9 covers ban enforcement (which on Debian 12 minimal images requires explicitly installing iptables \u2014 a step the script now handles automatically).<\/em> <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you don&#8217;t feel like reading, the script can be found here: <a href=\"https:\/\/github.com\/thevoiceguy\/freeswitch_tools\/blob\/main\/harden-freeswitch.sh\">https:\/\/github.com\/thevoiceguy\/freeswitch_tools\/blob\/main\/harden-freeswitch.sh<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A default FreeSWITCH installation is powerful, flexible, and easy to get running \u2014 which is exactly why it shouldn&#8217;t stay in its default state for long. Fresh installs ship with well-known default passwords, an admin socket that trusts the entire local network, and authentication-failure logging turned off. Automated SIP scanners sweep the public internet around the clock, looking for precisely this configuration, and when they find it, the result is almost always toll fraud: attackers register to your server, route international calls through it, and leave you with the bill. A single compromised FreeSWITCH instance can generate tens of thousands of dollars in fraudulent charges over a single weekend.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To make the cleanup pass less tedious, I put together a script that automates the whole process. You can grab it from my GitHub: <a href=\"https:\/\/github.com\/thevoiceguy\/freeswitch_tools\/blob\/main\/harden-freeswitch.sh\">harden-freeswitch.sh<\/a>. It&#8217;s a practical post-install hardening pass for FreeSWITCH on Debian or Ubuntu \u2014 it doesn&#8217;t try to solve every security concern, but it closes the biggest, most commonly exploited holes immediately after installation while keeping FreeSWITCH fully usable.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why FreeSWITCH Hardening Matters<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">FreeSWITCH often sits at the edge of real-time communications infrastructure. It may accept SIP registrations, process inbound calls, route outbound calls, expose management access through Event Socket, and handle RTP media streams. That combination \u2014 network-exposed, powerful, and directly connected to paid services \u2014 makes it a high-value target.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Leaving defaults in place creates avoidable risk. Default passwords, exposed management services, missing firewall rules, and incomplete logging make it trivial for attackers to probe the system without being blocked or even noticed. The goal of this script is simple: reduce the obvious attack surface immediately after installation while keeping FreeSWITCH usable.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a baseline, not a finish line. Production deployments should go further with per-extension passwords, SIP-TLS, dialplan restrictions, and proper monitoring. But none of that matters if the basics aren&#8217;t covered first.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How SIP Boxes Actually Get Attacked<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Before walking through what the script does, it&#8217;s worth understanding <em>what<\/em> it&#8217;s defending against. There are two distinct attack patterns that will hit your box within minutes of it touching the public internet, and each one looks different in the logs. A defense that only catches one of them will leave you mostly exposed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern 1: Credential brute-force.<\/strong> A scanner tries to authenticate against your server with guessed or stolen credentials. Each attempt produces a <code>SIP auth failure<\/code> line in the FreeSWITCH log, with the source IP, when <code>log-auth-failures<\/code> is enabled. This is the classic attack pattern that most fail2ban-FreeSWITCH guides describe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Pattern 2: Unauthenticated INVITE floods.<\/strong> A scanner sends INVITE after INVITE \u2014 typically targeting expensive international destinations \u2014 without ever attempting to authenticate. FreeSWITCH responds to each INVITE with a 401 challenge asking for credentials. The scanner ignores the challenge, abandons the call, and immediately fires the next INVITE from a different source port. <strong>This pattern produces no <code>SIP auth failure<\/code> lines at all.<\/strong> It generates only <code>SIP auth challenge<\/code> events, which a typical fail2ban filter doesn&#8217;t match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This second pattern is now the dominant one in the wild. On a real public-IP FreeSWITCH box, it accounts for the overwhelming majority of attack traffic \u2014 often hundreds of probes per minute from a single attacker rotating through source ports. The scanner is testing whether your dialplan will route calls without authentication. Most boxes won&#8217;t, which is why these probes look like waste \u2014 but the cost to the attacker is so low (a single UDP packet per probe) that they keep doing it forever, hoping to find the one misconfigured server that does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A defense that only watches for <code>SIP auth failure<\/code> would have caught a tiny fraction of this traffic. The script&#8217;s filter watches for <em>both<\/em> <code>SIP auth failure<\/code> and <code>SIP auth challenge<\/code> events, with thresholds set so that legitimate users (who generate a few challenges per registration cycle) never trigger it, but a scanner generating 20 events in 5 minutes does. More on that in Section 7.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is also a third pattern worth knowing about, even though we don&#8217;t directly defend against it at this layer: <strong>credential stuffing using harvested credentials<\/strong>. If an attacker already has valid SIP credentials (from a leaked database, a compromised softphone, or a misconfigured PBX they took over previously), they will skip both of the above patterns entirely and just place calls. The defense against this is per-extension passwords, dialplan restrictions, and outbound call monitoring \u2014 covered briefly in &#8220;What This Script Does Not Cover.&#8221;<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. Back Up the FreeSWITCH Configuration<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Before changing anything, the script creates a timestamped tarball of the entire FreeSWITCH configuration directory and saves it under <code>\/root<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This matters because hardening touches critical files, including <code>vars.xml<\/code>, <code>event_socket.conf.xml<\/code>, and both SIP profile configurations. If something breaks, you need a clean way to roll back. The script also leaves a <code>.bak<\/code> copy of each file it modifies, giving you two levels of recovery: per-file rollback for small mistakes, and full restore from the tarball for anything bigger.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Replace the Default SIP Password<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">FreeSWITCH&#8217;s default dialplan references a shared variable called <code>default_password<\/code> in <code>vars.xml<\/code>, set out of the box to the string <code>1234<\/code>. Every example extension in the default directory inherits this value. If it is left alone, anyone who knows FreeSWITCH (which is to say, every attacker on the internet) has a working password for every default user.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script generates a strong 28-character random password using <code>openssl rand<\/code> and replaces the default in <code>vars.xml<\/code>. The original file is preserved as <code>vars.xml.bak<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One caveat worth knowing: this only rotates the shared default. If you have already provisioned extensions with their own passwords in <code>directory\/default\/*.xml<\/code>, those are not touched, and in production, you should be using per-extension passwords anyway.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Replace the Event Socket Password<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The Event Socket (ESL) is FreeSWITCH&#8217;s admin control interface. It can run any FreeSWITCH command, originate calls, inspect live state, and script complex call flows. By default, it accepts the password <code>ClueCon<\/code> \u2014 a value that is not a secret to anyone who has ever touched FreeSWITCH.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script generates a second random password and rotates the ESL credential. Unauthorized ESL access is not a minor issue \u2014 it is full control of the FreeSWITCH instance, including the ability to place calls through any configured gateway.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. Bind Event Socket to Loopback<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In addition to rotating the Event Socket password, the script changes the <code>listen-ip<\/code> parameter to <code>127.0.0.1<\/code>. The management interface is now reachable only from the server itself, not from the wider network.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is one of the highest-value changes in the entire script. Even with a strong password, management interfaces should not be exposed to networks that do not need access to them. If you need remote ESL access, tunnel it over SSH \u2014 do not expose it directly.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. Enable SIP Authentication Failure Logging<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the single most important and most overlooked hardening step, and it deserves its own explanation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">By default, FreeSWITCH does not write the WARNING line that Fail2Ban needs to match. The auth failures still happen \u2014 the call is rejected \u2014 but the log line that contains the source IP never appears. Fail2Ban can&#8217;t ban an IP it never sees. This is why so many administrators install Fail2Ban with the FreeSWITCH jail enabled, watch it sit there with zero hits for weeks, and conclude that &#8220;Fail2Ban doesn&#8217;t work with FreeSWITCH.&#8221; Fail2Ban is fine. There is simply nothing in the log for it to match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script flips <code>log-auth-failures<\/code> to <code>true<\/code> on both SIP profiles:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sip_profiles\/internal.xml\nsip_profiles\/external.xml<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the parameter is already present and set to <code>false<\/code>, the script updates it. If it is missing entirely, the script injects it before the closing <code>&lt;\/settings&gt;<\/code> tag. The result either way is that failed SIP authentication events now produce log lines that Fail2Ban can actually act on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">6. Store the Generated Credentials Securely<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">New passwords are only useful if you can find them again. The script writes both the new SIP password and the new Event Socket password to:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/root\/freeswitch-credentials.txt<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The file is created with <code>umask 077<\/code> and <code>chmod 600<\/code> so only root can read it. You&#8217;ll need these values later to reconfigure SIP clients and any scripts that talk to the Event Socket, and this is a safer place for them than shell history or a scratchpad. Ultimately, you should move these credentials into a proper password vault (1Password, Bitwarden, HashiCorp Vault, etc.) and delete the file from the server entirely. A plaintext credentials file on disk is a convenience, not a long-term storage plan.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">7. Install and Configure Fail2Ban<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The script installs Fail2Ban and configures a single FreeSWITCH jail tuned to catch both attack patterns described in the opening section. The jail thresholds are:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>maxretry  = 20\nfindtime  = 300\nbantime   = 86400\nbanaction = iptables-allports<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Twenty SIP authentication events from the same IP within five minutes earns a 24-hour ban across all ports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why these numbers? Two reasons.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The threshold of 20 in 5 minutes is permissive on purpose.<\/strong> The filter matches both <code>SIP auth failure<\/code> (Pattern 1) and <code>SIP auth challenge<\/code> (Pattern 2). Including challenge events is essential for catching the unauthenticated-INVITE-flood pattern, but it would false-positive on legitimate users at low thresholds \u2014 every real INVITE generates a challenge as the normal first step of authentication. A real softphone produces only a handful of challenges per registration cycle, with cycles an hour or more apart. A scanner produces 20 challenges in seconds. The threshold sits in that gap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The bantime of 24 hours, with <code>iptables-allports<\/code>, makes a single ban materially expensive for the attacker.<\/strong> A scanner that gets banned has to burn a fresh IP per probe \u2014 they can&#8217;t just wait out a one-hour ban and resume. Combined with the recidive jail in Section 8, a sustained attacker quickly runs out of usable IPs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is one detail that catches many fail2ban-on-FreeSWITCH setups by surprise. The Fail2Ban package ships a <code>\/etc\/fail2ban\/filter.d\/freeswitch.conf<\/code> by default, but its regex does not match FreeSWITCH 1.10.x log output, which now includes a CPU-usage percentage between the timestamp and the <code>[WARNING]<\/code> tag. The script overwrites this file with a working regex that matches the current format. It specifically avoids using a <code>.local<\/code> override with <code>before = freeswitch.conf<\/code>, which creates a recursive include loop and crashes Fail2Ban with &#8220;maximum recursion depth exceeded.&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A note on the banaction. Fail2Ban&#8217;s default is <code>iptables-multiport<\/code>, which only blocks TCP. SIP scanner traffic on 5060\/UDP would walk right through TCP-only ban rules. The script sets <code>banaction = iptables-allports<\/code> explicitly so bans cover all ports and protocols. This sounds like a small detail; on a SIP server it is the difference between actually blocking attackers and just logging them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">8. Catch Repeat Offenders With a Recidive Jail<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A 24-hour ban is much better than a one-hour ban, but persistent scanners simply wait it out. Fail2Ban&#8217;s <code>recidive<\/code> jail solves this by watching Fail2Ban&#8217;s own log and escalating any IP that gets banned multiple times.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The script enables recidive with these parameters:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>bantime   = 604800   (7 days)\nfindtime  = 86400    (24 hours)\nmaxretry  = 3\nbanaction = banaction_allports<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Translation: any IP that gets banned three times across any jail within 24 hours gets banned for a week, across all ports. A persistent scanner that probes both SIP and SSH gets locked out of both at once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There is no false-positive risk. Recidive only escalates IPs that Fail2Ban has <em>already<\/em> banned multiple times. A legitimate user would have to get banned by the freeswitch jail three separate times within 24 hours to trigger recidive \u2014 extraordinarily unlikely for anyone who is not an attacker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In testing on a real public-IP box, recidive immediately picked up persistent scanner IPs the moment it started watching the historical log \u2014 IPs the freeswitch jail had banned and unbanned multiple times over previous days. Recidive identified them as repeat offenders and escalated them to week-long all-port bans within seconds. That is what &#8220;the same handful of attackers keep coming back&#8221; looks like in concrete numbers.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">9. Make Sure Bans Are Actually Enforced<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Fail2Ban can run, log bans, and <em>appear<\/em> fully functional while enforcing absolutely nothing. The daemon will say <code>Currently banned: 9<\/code> in its status output, the filter will match millions of log lines, and not a single attacker IP will be dropped at the kernel level. There are at least three ways this happens:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The kernel firewall framework is missing the userspace tool.<\/strong> Debian 12 minimal cloud images use nftables as the kernel firewall framework, but they do not install the <code>iptables<\/code> userspace command by default. Fail2Ban&#8217;s default banactions all shell out to <code>iptables<\/code>. Without it, the banaction commands silently fail; the ban gets recorded in Fail2Ban&#8217;s database, but no kernel rule is ever created. The script always installs <code>iptables<\/code> (which on Debian 12 is the iptables-nft compatibility shim), regardless of whether the script is also configuring UFW. Without iptables present, the rest of Fail2Ban is decorative.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The default banaction doesn&#8217;t match the protocol of the attack.<\/strong> As noted in Section 7, Fail2Ban&#8217;s default <code>iptables-multiport<\/code> banaction creates TCP-only rules. SIP scanner traffic on UDP ports walks right past it. The script sets <code>banaction = iptables-allports<\/code> on the freeswitch jail to drop all traffic from banned IPs, regardless of port or protocol.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fail2Ban&#8217;s reload doesn&#8217;t reliably swap banactions.<\/strong> Editing a jail config and running <code>fail2ban-client reload<\/code> doesn&#8217;t always replace the previously-active banaction with the new one. Sometimes both end up active simultaneously and conflict. The script handles this on every run by stopping fail2ban, flushing existing iptables state, and starting fail2ban fresh \u2014 guaranteeing a clean state regardless of what was there before.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To catch all of these failure modes, the script ends with a runtime verification step. It places a test ban on <code>192.0.2.99<\/code> (a documentation-reserved IP that no legitimate traffic ever uses), checks that an iptables rule appeared in the kernel, then unbans it. The output is one line:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&gt;&gt;&gt; Confirmed: bans are enforced at the iptables level.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you don&#8217;t see that line \u2014 if instead you see a warning that &#8220;bans are being LOGGED but NOT ENFORCED&#8221; \u2014 something is wrong and you need to fix it before walking away. This single check would have surfaced every one of the failure modes above within seconds.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">10. Make Fail2Ban Work on Minimal Debian Systems<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Fail2Ban&#8217;s default SSH jail reads <code>\/var\/log\/auth.log<\/code>, which minimal Debian cloud images often do not create because logging is routed through journald. The script installs <code>rsyslog<\/code> to produce the expected log file, installs <code>python3-systemd<\/code> so Fail2Ban can also read directly from the journal, and pins the SSH jail to the systemd backend as a belt-and-suspenders measure:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&#91;sshd]\nbackend = systemd<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is not strictly a FreeSWITCH-specific step, but it supports the overall goal: make sure host-level protection starts correctly and is actually watching the right log sources. A Fail2Ban installation that silently fails to start is worse than no Fail2Ban at all, because it creates the illusion of protection.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">11. Configure UFW Firewall Rules<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The script resets UFW to a clean state and applies a default-deny inbound policy. It then opens only what FreeSWITCH actually needs:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>OpenSSH (so you do not lock yourself out)<\/li>\n\n\n\n<li>UDP 16384\u201332768 for RTP media<\/li>\n\n\n\n<li>UDP\/TCP 5060 for SIP internal profile<\/li>\n\n\n\n<li>UDP\/TCP 5080 for SIP external profile<\/li>\n\n\n\n<li>TCP 5061 for SIP-TLS<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The RTP range is non-negotiable \u2014 without it, calls will connect but produce silence, because the audio has nowhere to go. The SIP ports can optionally be restricted (see Section 12).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The default-deny posture is the important part. Rather than leaving the server broadly reachable and trying to block bad traffic, UFW only permits the specific management, signaling, and media paths FreeSWITCH needs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">UFW and Fail2Ban operate at different layers. UFW is the standing policy: which ports are reachable at all. Fail2Ban is the dynamic layer: which IPs are temporarily banned regardless of port policy. Both are needed. UFW alone wouldn&#8217;t stop a SIP scanner that comes in over an allowed port. Fail2Ban alone wouldn&#8217;t prevent attackers from probing services you don&#8217;t even need exposed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">12. Optionally Restrict SIP by Source IP<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The script accepts a <code>SIP_ALLOW_FROM<\/code> environment variable to limit SIP access to specific IPs or CIDR ranges:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">bash<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo SIP_ALLOW_FROM=\"203.0.113.0\/24,198.51.100.7\" .\/harden-freeswitch.sh<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">When this is set, only the listed sources can reach ports 5060, 5061, and 5080. This is especially valuable when FreeSWITCH only needs to talk to known carriers, session border controllers, proxies, branch offices, or trusted client networks. <strong>Source-IP restriction is the single most effective SIP defense because it takes your server off the public SIP scanning radar entirely.<\/strong> Both attack patterns from the opening section depend on the attacker being able to reach your SIP ports in the first place. Restrict the source list and the entire problem disappears.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>SIP_ALLOW_FROM<\/code> is not set, the script still opens SIP globally but prints a warning. In that case, the combination of strong passwords (Sections 2 and 3), Fail2Ban (Section 7), recidive (Section 8), and verified ban enforcement (Section 9) has to carry the load.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">13. Reload FreeSWITCH in the Correct Order<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the subtlest step in the entire script, and it is worth understanding.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">When the script finishes editing the config files, FreeSWITCH is still running in memory with the old Event Socket password. The config on disk has changed, but nothing has been reloaded yet. If the script tried to authenticate the reload with the new password, it would fail because the running process does not know about the new password until after it reloads.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is ordering. Before changing the ESL password in the config, the script captures the currently-running password. It then uses that old password to authenticate a sequence of reload commands:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>reloadxml\nsofia profile internal rescan\nsofia profile external rescan\nreload mod_event_socket<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>reloadxml<\/code> picks up the SIP and vars changes. The two <code>sofia profile ... rescan<\/code> commands activate the <code>log-auth-failures<\/code> change from Section 5 without requiring a full service restart. The final <code>reload mod_event_socket<\/code> re-reads the Event Socket config, drops the current connection mid-command (expected behavior), and brings the listener back up bound to 127.0.0.1 with the new password.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">14. Update fs_cli Configuration<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">After the Event Socket password rotation, <code>fs_cli<\/code> would fall back to its defaults (<code>127.0.0.1:8021<\/code> \/ <code>ClueCon<\/code>) and fail to connect. The script writes a system-wide <code>fs_cli<\/code> config at:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/etc\/fs_cli.conf<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">and, when the script was invoked via <code>sudo<\/code> by a non-root user, also drops a per-user copy at:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>~\/.fs_cli_conf<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Both files are <code>chmod 600<\/code>, and the per-user copy is owned by the invoking user so they do not have to <code>sudo fs_cli<\/code> to administer the system. This preserves day-to-day administrative access and avoids the frustrating situation where FreeSWITCH is successfully hardened, but the administrator can no longer connect.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">15. Final Verification<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The script ends with three sanity checks, in addition to the ban-enforcement verification described in Section 9.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first is <code>fs_cli<\/code> connectivity:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>fs_cli -x status<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If that returns a status block, the new credentials and <code>fs_cli<\/code> config are working. If it fails, the script prints a clear warning pointing at <code>event_socket.conf.xml<\/code> and the FreeSWITCH service logs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The second is <code>fail2ban-regex<\/code> against the live FreeSWITCH log:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Lines: NNNNNN lines, 0 ignored, NNNNNN matched, NNNNNN missed<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If the matched count is in the thousands or millions on a box that has been online for any meaningful time, the filter is doing its job. Zero matches on a box with a non-empty log means something is wrong \u2014 usually a log path mismatch, or the FreeSWITCH log format has drifted from what the regex expects.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These verifications are short and they run in under five seconds. They exist because the most expensive failure mode in security work is the kind that hides \u2014 a system that looks fine but isn&#8217;t. Catching a misconfiguration while you&#8217;re still at the terminal is far better than discovering it the next day when something breaks.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What This Script Does Not Cover<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hardening is a spectrum, and this baseline intentionally leaves several decisions to the operator:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Disabling unused modules.<\/strong> Only you know which modules your deployment actually uses. Review <code>modules.conf.xml<\/code> and comment out anything you do not need.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SIP-TLS and SRTP.<\/strong> Encrypted signaling and media require a real domain name and a TLS certificate. This is the highest-value follow-up once you have a domain set up.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Dialplan restrictions and outbound call limits.<\/strong> The default dialplan includes example routes that are not safe for production. Audit the <code>dialplan\/<\/code> directory and remove anything you do not need, especially any outbound PSTN examples. This is the layer that defends against credential stuffing \u2014 an attacker with valid credentials skips Patterns 1 and 2 entirely and just makes calls. Outbound rate limits, allowed-destination lists, and per-extension call caps are the defenses that matter at that layer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Per-extension passwords.<\/strong> This script rotates only the shared <code>default_password<\/code>. In production, every extension should have its own strong password.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Monitoring and alerting.<\/strong> Fail2Ban will ban attackers, but you should also know when it is doing so. Ship its log to your monitoring stack.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Removing default users.<\/strong> The default users (1000 through 1019) are convenient for testing a fresh install. They are not something you want sitting on a production box.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Final Thoughts<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hardening FreeSWITCH does not have to be complicated, but it does need to be intentional. This script focuses on a strong first pass: rotate default credentials, lock down Event Socket access, make authentication failures visible, enable automated banning with thresholds tight enough for actual SIP scanner volume, escalate repeat offenders, install the firewall tooling that makes those bans real, apply firewall rules, and preserve administrative access through <code>fs_cli<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These are practical steps that reduce risk immediately after installation without trying to redesign the entire FreeSWITCH deployment. If you take one thing from this guide, make it this: <strong>do not put a FreeSWITCH box on the public internet without rotating the defaults, enabling log-auth-failures, putting a verified Fail2Ban in front of it, and restricting SIP source IPs whenever you can.<\/strong> That combination absorbs the overwhelming majority of opportunistic attacks. The rest of this script&#8217;s value is in catching the ways even that defense can silently fail.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For any FreeSWITCH system that will live beyond a quick lab test, this kind of post-install hardening should be part of the normal build process.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is the complete script for a quick copy and paste.  Its also available on my GitHub: <a href=\"https:\/\/github.com\/thevoiceguy\/freeswitch_tools\/blob\/main\/harden-freeswitch.sh\">https:\/\/github.com\/thevoiceguy\/freeswitch_tools\/blob\/main\/harden-freeswitch.sh<\/a><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n#\n# harden-freeswitch.sh\n# Post-install hardening for a default FreeSWITCH on Debian\/Ubuntu.\n#\n# What it does:\n#   1. Backs up the FreeSWITCH config dir\n#   2. Replaces the default SIP password (vars.xml: default_password=1234)\n#   3. Replaces the Event Socket password (ClueCon) and binds it to loopback\n#   4. Enables log-auth-failures on both SIP profiles so failed registrations\n#      actually reach the log (default is OFF \u2014 this is why fail2ban setups\n#      for FreeSWITCH silently fail to catch anything out of the box)\n#   5. Writes generated credentials to \/root\/freeswitch-credentials.txt\n#   6. Installs and configures fail2ban with a TWO-LAYER defense:\n#        - 'freeswitch' jail (20 SIP auth events \/ 5 min -> 24h ban,\n#          all-ports drop) catches the dominant modern attack pattern:\n#          unauthenticated INVITE floods that never even attempt to\n#          authenticate. The filter matches both 'auth failure' AND\n#          'auth challenge', which by itself would false-positive on\n#          legitimate users \u2014 but real softphones generate only 1-3\n#          challenges per registration cycle, while scanners generate\n#          20+ in seconds.\n#        - 'recidive' jail (3 cross-jail bans \/ 24h -> 7d all-port ban)\n#          escalates repeat offenders that wait out the 24h bantime.\n#        - rsyslog + python3-systemd so the sshd jail works on minimal\n#          Debian.\n#   7. Configures ufw: default deny, allow SSH + RTP, allow SIP\n#      (optionally restricted to specific source IPs)\n#   8. Reloads FreeSWITCH using the PREVIOUS ESL password so the reload\n#      itself authenticates; rescans sofia profiles to apply #4\n#   9. Writes \/etc\/fs_cli.conf and ~SUDO_USER\/.fs_cli_conf with the new\n#      ESL password so `fs_cli` keeps working after the rotation\n#  10. Runs fail2ban-regex against the live FreeSWITCH log to print a\n#      one-line summary of whether the filter is actually matching.\n#  11. Verifies bans are actually being enforced \u2014 places a test ban on\n#      an RFC5737 documentation IP, checks that an iptables rule\n#      appeared, then unbans. This catches the \"fail2ban running, bans\n#      logged, but no iptables rule actually present\" failure mode.\n#\n# Lessons baked into v4:\n#   - Debian 12 minimal images don't install iptables (they use nftables\n#     for the kernel firewall, but no userspace iptables tool). Without\n#     iptables, fail2ban's banactions silently fail and bans are theater.\n#     v4 always installs iptables, even with SKIP_FIREWALL=1.\n#   - fail2ban's default banaction is iptables-multiport (TCP only). For\n#     SIP-on-UDP, this means the actionstart wires up TCP-only rules and\n#     UDP scanner traffic walks right through. v4 sets banaction =\n#     iptables-allports explicitly on the freeswitch jail.\n#   - fail2ban's `reload` command does not reliably swap banactions when\n#     the jail config changes. v4 always does stop -> flush iptables ->\n#     start to guarantee a clean state, even on re-runs.\n#   - Two overlapping freeswitch jails (strict + aggressive) cause chain\n#     allocation conflicts in fail2ban's actionstart. v4 ships one jail\n#     whose filter matches both attack patterns.\n#\n# What it does NOT do (leaves to you):\n#   - Disable individual modules (too risky to guess what you use)\n#   - Set up SIP-TLS \/ SRTP (needs a domain + cert)\n#   - Restrict the dialplan \/ configure outbound gateways\n#   - Set per-extension passwords (only changes the shared default)\n#\n# Usage:\n#   sudo .\/harden-freeswitch.sh\n#   sudo SIP_ALLOW_FROM=\"203.0.113.0\/24,198.51.100.7\" .\/harden-freeswitch.sh\n#   sudo SKIP_FIREWALL=1 .\/harden-freeswitch.sh    # if you manage firewall elsewhere\n#\nset -euo pipefail\n\nSIP_ALLOW_FROM=\"${SIP_ALLOW_FROM:-}\"   # comma-separated CIDRs\/IPs; empty = allow from anywhere\nSKIP_FIREWALL=\"${SKIP_FIREWALL:-0}\"\nSKIP_FAIL2BAN=\"${SKIP_FAIL2BAN:-0}\"\nCREDS_FILE=\"\/root\/freeswitch-credentials.txt\"\n\n# --- preflight 1: privileges and FreeSWITCH layout ---------------------------\nif &#91;&#91; $EUID -ne 0 ]]; then\n  echo \"Run as root (sudo).\" >&amp;2\n  exit 1\nfi\n\nif &#91;&#91; -d \/etc\/freeswitch ]]; then\n  FS_CONF=\"\/etc\/freeswitch\"\nelif &#91;&#91; -d \/usr\/local\/freeswitch\/etc\/freeswitch ]]; then\n  FS_CONF=\"\/usr\/local\/freeswitch\/etc\/freeswitch\"\nelse\n  echo \"Could not find FreeSWITCH config directory.\" >&amp;2\n  echo \"Looked in \/etc\/freeswitch and \/usr\/local\/freeswitch\/etc\/freeswitch\" >&amp;2\n  exit 1\nfi\necho \">>> FreeSWITCH config dir: ${FS_CONF}\"\n\nif command -v fs_cli >\/dev\/null; then\n  FS_CLI=\"$(command -v fs_cli)\"\nelif &#91;&#91; -x \/usr\/local\/freeswitch\/bin\/fs_cli ]]; then\n  FS_CLI=\"\/usr\/local\/freeswitch\/bin\/fs_cli\"\nelse\n  FS_CLI=\"\"\n  echo \"!!! fs_cli not found in PATH; will skip live reload.\" >&amp;2\nfi\n\nVARS_XML=\"${FS_CONF}\/vars.xml\"\nESL_XML=\"${FS_CONF}\/autoload_configs\/event_socket.conf.xml\"\n\nfor f in \"$VARS_XML\" \"$ESL_XML\"; do\n  &#91;&#91; -f \"$f\" ]] || { echo \"Missing required file: $f\" >&amp;2; exit 1; }\ndone\n\n# --- preflight 2: runtime tools ----------------------------------------------\n# Check for tools the script needs at various points, but especially the\n# tools fail2ban needs to enforce bans. On Debian 12 minimal cloud images,\n# iptables is NOT installed by default \u2014 the system uses nftables. fail2ban\n# defaults to iptables-based banactions, so a missing iptables means bans\n# will be logged but never enforced. We saw this in practice: 4.4M SIP\n# auth events, fail2ban running, \"Total banned: 9\" in the status \u2014 and\n# zero IPs actually dropped at the kernel level.\n#\n# We always install iptables here, even when SKIP_FIREWALL=1, because\n# SKIP_FIREWALL means \"don't manage firewall policy\" not \"don't enforce\n# fail2ban bans.\" Those are different concerns.\n\necho \">>> Checking runtime dependencies\"\nNEEDS_INSTALL=()\n\n# iptables: required by fail2ban banactions. Debian 12 doesn't install it\n# by default. Check both PATH and \/usr\/sbin (which sudo includes but a\n# user's PATH may not).\nif ! command -v iptables >\/dev\/null 2>&amp;1 &amp;&amp; &#91;&#91; ! -x \/usr\/sbin\/iptables ]]; then\n  echo \">>> iptables not found \u2014 fail2ban needs it to enforce bans\"\n  NEEDS_INSTALL+=(iptables)\nfi\n\n# openssl: used to generate passwords. Almost always present, but cheap\n# to verify on a truly minimal install.\nif ! command -v openssl >\/dev\/null 2>&amp;1; then\n  NEEDS_INSTALL+=(openssl)\nfi\n\n# tar: used for the config backup. Same reasoning.\nif ! command -v tar >\/dev\/null 2>&amp;1; then\n  NEEDS_INSTALL+=(tar)\nfi\n\nif &#91;&#91; ${#NEEDS_INSTALL&#91;@]} -gt 0 ]]; then\n  echo \">>> Installing missing dependencies: ${NEEDS_INSTALL&#91;*]}\"\n  export DEBIAN_FRONTEND=noninteractive\n  apt-get update -qq\n  apt-get install -y --no-install-recommends \"${NEEDS_INSTALL&#91;@]}\"\nfi\n\n# --- 1. backup ---------------------------------------------------------------\nBACKUP=\"\/root\/freeswitch-config-backup-$(date +%Y%m%d-%H%M%S).tgz\"\necho \">>> Backing up ${FS_CONF} to ${BACKUP}\"\ntar czf \"$BACKUP\" -C \"$(dirname \"$FS_CONF\")\" \"$(basename \"$FS_CONF\")\"\n\n# --- 2. generate new passwords ----------------------------------------------\ngen_pw() { openssl rand -base64 24 | tr -d '\/+=' | cut -c1-28; }\nNEW_SIP_PW=\"$(gen_pw)\"\nNEW_ESL_PW=\"$(gen_pw)\"\n\n# --- 3. patch vars.xml (default_password) ------------------------------------\necho \">>> Updating default SIP password in vars.xml\"\nsed -i.bak -E \\\n  \"s|(default_password=)&#91;^\\\"]*|\\1${NEW_SIP_PW}|g\" \\\n  \"$VARS_XML\"\n\nif ! grep -q \"default_password=${NEW_SIP_PW}\" \"$VARS_XML\"; then\n  echo \"!!! Failed to update default_password in vars.xml\" >&amp;2\n  exit 1\nfi\n\n# --- 4. patch event_socket.conf.xml ------------------------------------------\nCURRENT_ESL_PW=\"$(grep -oP '&lt;param\\s+name=\"password\"\\s+value=\"\\K&#91;^\"]+' \"$ESL_XML\" | head -n1 || true)\"\nCURRENT_ESL_PW=\"${CURRENT_ESL_PW:-ClueCon}\"\n\necho \">>> Updating Event Socket password and binding to loopback\"\nsed -i.bak -E \\\n  \"s|(&lt;param name=\\\"password\\\" value=\\\")&#91;^\\\"]*(\\\"\/>)|\\1${NEW_ESL_PW}\\2|\" \\\n  \"$ESL_XML\"\nsed -i -E \\\n  \"s|(&lt;param name=\\\"listen-ip\\\" value=\\\")&#91;^\\\"]*(\\\"\/>)|\\1127.0.0.1\\2|\" \\\n  \"$ESL_XML\"\n\nif ! grep -q \"value=\\\"${NEW_ESL_PW}\\\"\" \"$ESL_XML\"; then\n  echo \"!!! Failed to update event socket password\" >&amp;2\n  exit 1\nfi\n\n# --- 4b. enable log-auth-failures on SIP profiles ----------------------------\necho \">>> Enabling log-auth-failures on SIP profiles\"\nfor profile in \"${FS_CONF}\/sip_profiles\/internal.xml\" \\\n               \"${FS_CONF}\/sip_profiles\/external.xml\"; do\n  &#91;&#91; -f \"$profile\" ]] || continue\n  if grep -q 'name=\"log-auth-failures\"' \"$profile\"; then\n    sed -i 's|&lt;param name=\"log-auth-failures\" value=\"false\"\/>|&lt;param name=\"log-auth-failures\" value=\"true\"\/>|' \"$profile\"\n  else\n    sed -i '\/&lt;\\\/settings>\/i\\    &lt;param name=\"log-auth-failures\" value=\"true\"\/>' \"$profile\"\n  fi\ndone\n\n# --- 5. save credentials -----------------------------------------------------\numask 077\ncat > \"$CREDS_FILE\" &lt;&lt;EOF\n# FreeSWITCH credentials - generated $(date -Iseconds)\n# Keep this file. Mode 600, root only.\n\nSIP default_password (vars.xml):  ${NEW_SIP_PW}\nEvent Socket password (ClueCon):  ${NEW_ESL_PW}\n\nConfig backup: ${BACKUP}\nConfig dir:    ${FS_CONF}\nEOF\nchmod 600 \"$CREDS_FILE\"\necho \">>> Credentials written to ${CREDS_FILE}\"\n\n# --- 6. fail2ban -------------------------------------------------------------\nif &#91;&#91; \"$SKIP_FAIL2BAN\" != \"1\" ]]; then\n  echo \">>> Installing and configuring fail2ban\"\n  export DEBIAN_FRONTEND=noninteractive\n  apt-get update -qq\n  apt-get install -y --no-install-recommends fail2ban rsyslog python3-systemd\n\n  # Stop fail2ban and flush iptables BEFORE writing new config. This handles\n  # two cases: fresh install (no-op, fail2ban isn't running yet) and re-runs\n  # (clears any stale chains and accumulated banactions from previous\n  # configurations). fail2ban's `reload` does not reliably swap banactions\n  # when jail configs change \u2014 full stop + flush + start is the only safe\n  # path.\n  systemctl stop fail2ban 2>\/dev\/null || true\n  iptables -F 2>\/dev\/null || true\n  iptables -X 2>\/dev\/null || true\n\n  # Determine the FreeSWITCH log path based on install layout.\n  if &#91;&#91; \"$FS_CONF\" == \"\/etc\/freeswitch\" ]]; then\n    FS_LOG=\"\/var\/log\/freeswitch\/freeswitch.log\"\n  else\n    FS_PREFIX=\"$(dirname \"$(dirname \"$FS_CONF\")\")\"\n    FS_LOG=\"${FS_PREFIX}\/var\/log\/freeswitch\/freeswitch.log\"\n  fi\n\n  if &#91;&#91; ! -f \"$FS_LOG\" ]]; then\n    echo \">>> $FS_LOG doesn't exist yet; creating it so fail2ban can start\"\n    mkdir -p \"$(dirname \"$FS_LOG\")\"\n    touch \"$FS_LOG\"\n    if id -u freeswitch >\/dev\/null 2>&amp;1; then\n      chown freeswitch:freeswitch \"$FS_LOG\" \"$(dirname \"$FS_LOG\")\" 2>\/dev\/null || true\n    fi\n  fi\n\n  # ---- FREESWITCH FILTER --------------------------------------------------\n  # The fail2ban package ships \/etc\/fail2ban\/filter.d\/freeswitch.conf but\n  # its regex doesn't match FreeSWITCH 1.10.x output (which includes a\n  # CPU-usage percentage between the timestamp and &#91;WARNING]). Overwrite\n  # it directly rather than using a .local with `before =`, which causes\n  # a recursive include loop that crashes fail2ban with \"maximum recursion\n  # depth exceeded\".\n  #\n  # This filter matches BOTH 'SIP auth failure' AND 'SIP auth challenge'.\n  # The strict version of this filter (failures only) misses the dominant\n  # modern attack pattern: scanners blasting unauthenticated INVITEs, never\n  # responding to the challenge, and rotating to the next source port.\n  # Those probes only generate 'challenge' lines \u2014 never 'failure' \u2014 so\n  # the strict filter sees nothing and bans no one despite millions of\n  # log entries.\n  #\n  # Matching 'challenge' would false-positive on legitimate users at low\n  # thresholds (every real INVITE generates a challenge), but the jail\n  # threshold below (20 in 5 min) is well above what any softphone\n  # produces during normal use.\n  cat >\/etc\/fail2ban\/filter.d\/freeswitch.conf &lt;&lt;'EOF'\n&#91;Definition]\nfailregex = ^.*\\&#91;WARNING\\]\\s+sofia_reg\\.c:\\d+\\s+SIP auth (?:failure|challenge) \\((?:REGISTER|INVITE)\\) on sofia profile \\S+ for \\&#91;&#91;^\\]]*\\] from ip &lt;HOST>\\s*$\n            ^.*\\&#91;WARNING\\]\\s+sofia_reg\\.c:\\d+\\s+Can't find user \\&#91;&#91;^\\]]*\\] from &lt;HOST>\\s*$\nignoreregex =\nEOF\n  rm -f \/etc\/fail2ban\/filter.d\/freeswitch.local\n\n  # ---- FREESWITCH JAIL ----------------------------------------------------\n  # banaction = iptables-allports is critical. fail2ban's default banaction\n  # is iptables-multiport, which only blocks TCP. SIP scanner traffic on\n  # 5060\/UDP would walk right through a TCP-only ban rule. We need the\n  # all-ports, all-protocols ban to actually drop the packets.\n  #\n  # 20 events in 5 minutes is well below any real scanner's volume (often\n  # 5+ INVITEs per second from a single IP) and well above what a\n  # legitimate softphone produces (1-3 challenges per registration cycle,\n  # cycles of an hour or more apart).\n  cat >\/etc\/fail2ban\/jail.d\/freeswitch.local &lt;&lt;EOF\n&#91;freeswitch]\nenabled   = true\nfilter    = freeswitch\nport      = 5060,5061,5080,5081\nprotocol  = all\nlogpath   = ${FS_LOG}\nbanaction = iptables-allports\nmaxretry  = 20\nfindtime  = 300\nbantime   = 86400\nEOF\n\n  # ---- RECIDIVE JAIL ------------------------------------------------------\n  # Watches fail2ban's own log. If an IP gets banned 3 times within 24\n  # hours (across ANY jail \u2014 sshd, freeswitch, etc.) it gets banned for a\n  # week across all ports via banaction_allports.\n  #\n  # This catches persistent scanners that wait out the 24h bantime and\n  # come back. Without recidive, the same handful of IPs cycle ban ->\n  # wait -> ban -> wait indefinitely. With it, a few cycles is all they\n  # get before they're locked out for a week across every port.\n  #\n  # No false-positive risk: recidive only escalates IPs that fail2ban has\n  # ALREADY banned multiple times.\n  cat >\/etc\/fail2ban\/jail.d\/recidive.local &lt;&lt;EOF\n&#91;recidive]\nenabled   = true\nlogpath   = \/var\/log\/fail2ban.log\nbanaction = %(banaction_allports)s\nbantime   = 604800\nfindtime  = 86400\nmaxretry  = 3\nEOF\n\n  # ---- SSHD ---------------------------------------------------------------\n  # Default sshd jail on minimal Debian reads \/var\/log\/auth.log which\n  # doesn't exist without rsyslog. Pin it to the systemd backend, which\n  # reads auth events directly from journald and works regardless of\n  # rsyslog state.\n  cat >\/etc\/fail2ban\/jail.d\/sshd.local &lt;&lt;EOF\n&#91;sshd]\nbackend = systemd\nEOF\n\n  systemctl enable --now fail2ban\n  # Brief sleep to let actionstart wire up the chains before subsequent steps\n  # try to use fail2ban-client (notably the verification step at the end).\n  sleep 3\nfi\n\n# --- 7. firewall (ufw) -------------------------------------------------------\nif &#91;&#91; \"$SKIP_FIREWALL\" != \"1\" ]]; then\n  echo \">>> Configuring ufw\"\n  apt-get install -y --no-install-recommends ufw\n\n  ufw --force reset >\/dev\/null\n  ufw default deny incoming\n  ufw default allow outgoing\n  ufw allow OpenSSH\n\n  ufw allow 16384:32768\/udp comment 'FreeSWITCH RTP'\n\n  if &#91;&#91; -n \"$SIP_ALLOW_FROM\" ]]; then\n    IFS=',' read -ra ALLOWS &lt;&lt;&lt; \"$SIP_ALLOW_FROM\"\n    for src in \"${ALLOWS&#91;@]}\"; do\n      src=\"$(echo \"$src\" | xargs)\"\n      &#91;&#91; -z \"$src\" ]] &amp;&amp; continue\n      ufw allow from \"$src\" to any port 5060 proto udp comment 'SIP internal'\n      ufw allow from \"$src\" to any port 5060 proto tcp comment 'SIP internal'\n      ufw allow from \"$src\" to any port 5080 proto udp comment 'SIP external'\n      ufw allow from \"$src\" to any port 5080 proto tcp comment 'SIP external'\n      ufw allow from \"$src\" to any port 5061 proto tcp comment 'SIP-TLS'\n    done\n    echo \">>> SIP restricted to: ${SIP_ALLOW_FROM}\"\n  else\n    ufw allow 5060\/udp comment 'SIP internal'\n    ufw allow 5060\/tcp comment 'SIP internal'\n    ufw allow 5080\/udp comment 'SIP external'\n    ufw allow 5080\/tcp comment 'SIP external'\n    ufw allow 5061\/tcp comment 'SIP-TLS'\n    echo \"!!! SIP is open to the world. Set SIP_ALLOW_FROM to restrict by source IP.\"\n  fi\n\n  ufw --force enable\n  ufw status verbose\nfi\n\n# --- 8. reload freeswitch ----------------------------------------------------\nif &#91;&#91; -n \"$FS_CLI\" ]] &amp;&amp; systemctl is-active --quiet freeswitch; then\n  echo \">>> Reloading FreeSWITCH with previous ESL password\"\n  \"$FS_CLI\" -p \"$CURRENT_ESL_PW\" -x 'reloadxml' || true\n  \"$FS_CLI\" -p \"$CURRENT_ESL_PW\" -x 'sofia profile internal rescan' || true\n  \"$FS_CLI\" -p \"$CURRENT_ESL_PW\" -x 'sofia profile external rescan' || true\n  \"$FS_CLI\" -p \"$CURRENT_ESL_PW\" -x 'reload mod_event_socket' || true\n  sleep 1\nfi\n\n# --- 9. fs_cli.conf ----------------------------------------------------------\necho \">>> Writing \/etc\/fs_cli.conf\"\ncat >\/etc\/fs_cli.conf &lt;&lt;EOF\n&#91;default]\n;; Auto-generated by harden-freeswitch.sh on $(date -Iseconds)\nhost     => 127.0.0.1\nport     => 8021\npassword => ${NEW_ESL_PW}\ndebug    => 6\nlog-uuid => true\nEOF\nchmod 600 \/etc\/fs_cli.conf\nchown root:root \/etc\/fs_cli.conf\n\nif &#91;&#91; -n \"${SUDO_USER:-}\" &amp;&amp; \"$SUDO_USER\" != \"root\" ]]; then\n  USER_HOME=\"$(getent passwd \"$SUDO_USER\" | cut -d: -f6)\"\n  if &#91;&#91; -n \"$USER_HOME\" &amp;&amp; -d \"$USER_HOME\" ]]; then\n    install -o \"$SUDO_USER\" -g \"$SUDO_USER\" -m 600 \\\n      \/etc\/fs_cli.conf \"${USER_HOME}\/.fs_cli_conf\"\n    echo \">>> Wrote ${USER_HOME}\/.fs_cli_conf for ${SUDO_USER}\"\n  fi\nfi\n\nif &#91;&#91; -n \"$FS_CLI\" ]] &amp;&amp; systemctl is-active --quiet freeswitch; then\n  if \"$FS_CLI\" -x 'status' >\/dev\/null 2>&amp;1; then\n    echo \">>> fs_cli connects OK with the new password.\"\n  else\n    echo \"!!! fs_cli could NOT connect after the password change.\" >&amp;2\n    echo \"!!! Check: ${ESL_XML} and journalctl -u freeswitch -n 50\" >&amp;2\n  fi\nfi\n\n# --- 10. verify the fail2ban filter is matching ------------------------------\n# Print a one-line summary so the operator can see whether the regex is\n# catching anything in their existing log. If matched=0 here on a box with\n# any meaningful log history, something is wrong \u2014 usually a log path\n# mismatch or the FreeSWITCH log format has drifted from what the regex\n# expects.\nif &#91;&#91; \"$SKIP_FAIL2BAN\" != \"1\" ]] &amp;&amp; &#91;&#91; -f \"$FS_LOG\" ]] &amp;&amp; &#91;&#91; -s \"$FS_LOG\" ]]; then\n  echo\n  echo \">>> fail2ban-regex result against ${FS_LOG}:\"\n  fail2ban-regex --print-no-missed \"$FS_LOG\" \/etc\/fail2ban\/filter.d\/freeswitch.conf 2>\/dev\/null \\\n    | grep -E \"^Lines:|^Failregex: \" \\\n    | head -5 || true\n  echo\nfi\n\n# --- 11. verify fail2ban bans are actually being enforced --------------------\n# This is the most important diagnostic in the script. It catches the\n# \"fail2ban looks healthy but isn't enforcing anything\" failure mode\n# that's invisible at the daemon level.\n#\n# We saw this in practice: 4.4M auth events in the FreeSWITCH log,\n# fail2ban running, \"Total banned: 9\" in fail2ban-client status \u2014 and\n# zero of those bans translated into iptables rules. Causes can include:\n#   - iptables not installed (Debian 12 minimal default)\n#   - banaction set to a TCP-only action while attacks come over UDP\n#   - stale chains from a prior run preventing actionstart\n#   - any future failure mode we haven't seen yet\n#\n# The check: ban an RFC5737 documentation IP, look for it in iptables,\n# unban. If we see the rule, enforcement works. If not, something is\n# wrong and the operator needs to know NOW, not when their box gets\n# pwned.\nif &#91;&#91; \"$SKIP_FAIL2BAN\" != \"1\" ]]; then\n  echo \">>> Verifying fail2ban ban enforcement...\"\n  TEST_IP=\"192.0.2.99\"   # RFC5737, reserved for documentation, never legitimate\n\n  fail2ban-client set freeswitch banip \"$TEST_IP\" >\/dev\/null 2>&amp;1 || true\n  sleep 2\n\n  if iptables -L -n 2>\/dev\/null | grep -q \"$TEST_IP\"; then\n    echo \">>> Confirmed: bans are enforced at the iptables level.\"\n    fail2ban-client set freeswitch unbanip \"$TEST_IP\" >\/dev\/null 2>&amp;1 || true\n  else\n    echo \"!!! WARNING: fail2ban placed a ban for $TEST_IP but no iptables rule appeared.\"\n    echo \"!!! Bans are being LOGGED but NOT ENFORCED.\"\n    echo \"!!! Check:\"\n    echo \"!!!   sudo iptables -L -n | grep f2b\"\n    echo \"!!!   sudo tail -50 \/var\/log\/fail2ban.log\"\n    echo \"!!!   sudo fail2ban-client status freeswitch\"\n  fi\n  echo\nfi\n\n# --- summary -----------------------------------------------------------------\ncat &lt;&lt;EOF\n\n================================================================\n FreeSWITCH hardening complete.\n================================================================\n New credentials:    ${CREDS_FILE}    (mode 600, root only)\n fs_cli config:      \/etc\/fs_cli.conf (mode 600)\n$( &#91;&#91; -n \"${SUDO_USER:-}\" &amp;&amp; \"$SUDO_USER\" != \"root\" ]] &amp;&amp; echo \" User fs_cli:        $(getent passwd \"$SUDO_USER\" | cut -d: -f6)\/.fs_cli_conf\" )\n Config backup:      ${BACKUP}\n Original XML files: &lt;file>.bak alongside each modified file\n================================================================\n\nActive fail2ban jails (two-layer SIP defense + sshd):\n  - sshd        SSH brute-force, default thresholds, journald backend\n  - freeswitch  20 SIP auth events \/ 5 min -> 24h all-ports ban\n                (catches unauthenticated INVITE floods AND credential\n                brute-force in a single jail)\n  - recidive    3 cross-jail bans \/ 24h -> 7d ALL-PORT ban\n                (escalates persistent repeat offenders)\n\nUseful checks:\n  fail2ban-client status\n  fail2ban-client status freeswitch\n  fail2ban-client status recidive\n  iptables -L INPUT -n | grep f2b      # confirm jails wired into INPUT\n  iptables -L -n | grep \"Chain f2b\"     # all should show (1 references)\n  cat ${CREDS_FILE}\n\nStill recommended (manual):\n  - Set per-extension passwords in ${FS_CONF}\/directory\/default\/*.xml\n    (don't rely on the shared default_password)\n  - Restrict the dialplan; remove any outbound routes you don't need\n  - Set up SIP-TLS (port 5061) with a real cert and enable SRTP\n  - Subscribe to https:\/\/github.com\/signalwire\/freeswitch\/security\/advisories\n  - Update any SIP clients you have with the new password:\n      cat ${CREDS_FILE}\n\nEOF<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>Updated April 2026: substantial rewrite. The hardening script and this post now reflect what we actually see hitting public-IP FreeSWITCH boxes \u2014 two distinct attack&#8230;<\/p>\n<div class=\"more-link-wrapper\"><a class=\"more-link\" href=\"https:\/\/phonesstillexist.com\/index.php\/2026\/04\/24\/hardening-freeswitch-a-production-baseline-for-day-one\/\">Continue reading<span class=\"screen-reader-text\">Hardening FreeSWITCH: A Production Baseline for Day One<\/span><\/a><\/div>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"iawp_total_views":98,"footnotes":""},"categories":[12],"tags":[14],"class_list":["post-135","post","type-post","status-publish","format-standard","hentry","category-freeswitch","tag-freeswitch","entry"],"_links":{"self":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/135","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/comments?post=135"}],"version-history":[{"count":7,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/135\/revisions"}],"predecessor-version":[{"id":146,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/posts\/135\/revisions\/146"}],"wp:attachment":[{"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/media?parent=135"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/categories?post=135"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/phonesstillexist.com\/index.php\/wp-json\/wp\/v2\/tags?post=135"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}