IP blocking at pfsense instead of in hass

I have my firewall, pfsense, using haproxy to do ssl termination and then connect to my home-assistant on my lan. I wanted to make things so that if an ip is banned it’s blocked at my firewall rather than being able to hit my server.

I have 2 scripts I load onto my pfsense that will modify an Alias that I can use to block the connections.

/etc/phpshellsessions/banip

//<?
global $argv, $command_split;

if (is_array($command_split)) {
    $args = array_slice($command_split, 2);
} else {
    $args = array_slice($argv, 3);
}

$ip = $args[0];
$jail = $args[1];

date_default_timezone_set('America/New_York');
$date = date('l jS \of F Y h:i:s A');

parse_config(true);

$alias_name = "Fail2Ban";
create_modify_alias($alias_name, $ip, $jail, $date);

/* save our changes */
save_config("Fail2Ban banned [${ip}] from [${jail}]");

/* Kill the state for that ip */
/* Use of the ! allows for shell execution per: https://openschoolsolutions.org/automate-pfsense-with-pfssh-php/ */
! pfctl -k $ip

/* ========== FUNCTIONS ============= */
function create_modify_alias($name, $ip, $jail, $date) {
  $index = find_alias($name);

  /* we couldn't find the alias so create an empty one */
  if ($index == -1) {
    $index = create_empty_alias($name, $date);
  }

  if ($index >= 0) {
    modify_alias($index, $ip, "${jail} - ${date}");
  }
}

function find_alias($name) {
  global $config;
  $index = -1;

  for ($i = 0; $i <= count($config['aliases']['alias']); $i++) {
    if ($config['aliases']['alias'][$i]['name'] == $name) {
      $index = $i;
      break;
    }
  }

  return $index;
}

function create_empty_alias($name, $date) {
  $index = add_alias_ip(
    $name,
    'host',
    "Auto Created: ${date}",
    '127.0.0.1',
    "DO NOT DELETE, REQUIRED SO THE ALIAS IS NEVER EMPTY"
  );
  return $index;
}

function modify_alias($index, $address, $detail) {
  global $config;

  /* Get current */
  $aliases = explode(' ',$config['aliases']['alias'][$index]['address']);
  $details = explode('||',$config['aliases']['alias'][$index]['detail']);

  if (!in_array($address, $aliases)) {
    /* add the new stuff */
    array_push($aliases, $address);
    array_push($details, $detail);

    /* write it back */
    $config['aliases']['alias'][$index]['address'] = implode(' ',$aliases);
    $config['aliases']['alias'][$index]['detail'] = implode('||',$details);
  }
}

function add_alias_ip($name, $type, $descr, $address, $detail) {
  global $config;
  $new_alias = array(
    'name' => $name,
    'type' => $type,
    'descr' => $descr,
    'address' => $address,
    'detail' => $detail
  );
  $index = array_push($config['aliases']['alias'], $new_alias) - 1;
  return $index;
}

function save_config($desc) {
  write_config($desc);
  log_error("write_config: ${desc}");
}

/etc/phpshellsession/unbanip

//<?
global $argv, $command_split;

if (is_array($command_split)) {
    $args = array_slice($command_split, 2);
} else {
    $args = array_slice($argv, 3);
}

$ip = $args[0];
$jail = $args[1];

parse_config(true);

$alias_name = "Fail2Ban";

$index = find_alias($alias_name);
if ($index >= 0) {
  $aliases_left = remove_alias($index, $ip);
}

save_config("Fail2Ban unbanned [${ip}] from [${jail}]");

/* ================ FUNCTIONS =============== */
function find_alias($name) {
  global $config;
  $index = -1;

  for ($i = 0; $i <= count($config['aliases']['alias']); $i++) {
    if ($config['aliases']['alias'][$i]['name'] == $name) {
      $index = $i;
      break;
    }
  }

  return $index;
}

function remove_alias($index, $address) {
  global $config;

  /* Get current */
  $aliases = explode(' ',$config['aliases']['alias'][$index]['address']);
  $details = explode('||',$config['aliases']['alias'][$index]['detail']);

  /* add the new stuff */
  while ($key = array_search($address, $aliases)) {
    unset($aliases[$key]);
    unset($details[$key]);
  }

  /* write it back */
  $config['aliases']['alias'][$index]['address'] = implode(' ',$aliases);
  $config['aliases']['alias'][$index]['detail'] = implode('||',$details);

  return count($aliases);
}

function save_config($desc) {
  write_config($desc);
  log_error("write_config: ${desc}");
}

/etc/fail2ban/actions.d/pfsense.conf

# Fail2Ban configuration file
#
# Author: Mike
#

[Definition]
# Option:  actionban
# Notes.:  command executed when banning an IP. Take care that the
#          command is executed with Fail2Ban user rights.
# Tags:    See jail.conf(5) man page
# Values:  CMD
#
actionban = sudo -u pfsense ssh root@firewall_ip "/usr/local/sbin/pfSsh.php playback banip <ip> <name>"

# Option:  actionunban
# Notes.:  command executed when unbanning an IP. Take care that the
#          command is executed with Fail2Ban user rights.
# Tags:    See jail.conf(5) man page
# Values:  CMD
#
actionunban = sudo -u pfsense ssh root@firewall_ip "/usr/local/sbin/pfSsh.php playback unbanip <ip> <name>"

I created a user named pfsense on my home-assistant system and generated ssh keys. Then on my pfsense box I allow the pfsense ssh key to login as root.

Then following the fail2ban docs: https://www.home-assistant.io/components/fail2ban/
Just change the action in jail.local
Before:
action = iptables-allports[name=HASS]
After:
action = pfsense[name=HASS]

You can test that the alias is updated with:
sudo fail2ban-client set hass banip 555.555.555.555
then remove it
sudo fail2ban-client set hass unbanip 555.555.555.555

I also disabled ip_ban_enabled in home-assistant so that fail2ban will do all of it. Now when fail2ban detects 5 failed logins to home-assistant it will ssh to my firewall and run the banip script to add an entry to the Fail2Ban alias.
Dummy ban of invalid ip 555.555.555.555

Firewall rule to block only access to HTTPS port

Edit:
@tkohhh had some good changes to immediately kill the state for the ip being banned: https://openschoolsolutions.org/automate-pfsense-with-pfssh-php/

6 Likes

This deserves a lot more love than it currently has. Thank you - this has been immensely helpful for me.

Any chance you could point me in the right direction for the SSH Keys that you mention? How are those generated, and how are they used?

Good work on this… I’ll be working on implementing this in my environment!

You generate them yourself which can be done at the command line with ssh-kegen

Place the contents of the .pub file into the authorized keys setting of the user account on the pfsense system. Then make sure those are the keys used by fail2ban

I’m getting quite close to having this all set up, but I’ve encountered a problem that I’m hoping you might be able to help with.

First of all, I couldn’t use the pfsense admin account because when you SSH with that account, it takes you to a menu page rather than the shell prompt. So, I created a new pfsense user (fail2ban) with shell access. That takes you directly to the shell prompt.

However, when I SSH using that account and then try to run the banip script, I get the following notification in pfsense: Unable to open /cf/conf/config.xml for writing in write_config()

I confirmed that the banip script does in fact work when I SSH using the admin account, so that indicates that it is a permissions issue on the fail2ban account. I put the user into the admins group, but I’m still getting the same error notification.

Any ideas on how I could get past this?

Thanks for any help you might be able to provide!

First, ssh as root not admin that will avoid the menu.

Second, that other account doesn’t have the permission needed so see above and use root.

Tada!

Unfortunately using root also displays the menu. :frowning:

If you ssh yeah, but in automated process it’ll work. I did nothing special but you could always enable sudo.

You’ll need to install the sudo module per the docs: https://docs.netgate.com/pfsense/en/latest/usermanager/granting-users-access-to-ssh.html

I see… you are correct about that! Thank you!

So, now I’ve been able to add the IP to the fail2ban alias in pfSense. The fail2ban alias is set up as a block rule on the WAN side. However, in testing I’m finding that I’m still able to reach the homeassistant site after the IP has been added to the alias. I suspect this is because the state of the connection already exists. Do you have this problem? If not, how do you get pfSense to drop that state?

Thanks again for your help!

I’d probably go with this

so your line would change from

actionban = sudo -u pfsense ssh root@firewall_ip "/usr/local/sbin/pfSsh.php playback banip <ip> <name>"

to

actionban = sudo -u pfsense ssh root@firewall_ip "/usr/local/sbin/pfSsh.php playback banip <ip> <name> && pfctl -k <ip>"

Just guessing there. I figure since it will block a new connection there isn’t really anything with an established state to worry about killing off

Thank you kindly for pointing me in the right direction! I noticed two things when I was testing this setup. First of all was the active state that I mentioned in my previous post. Secondly, I also found that after modifying the alias (both on ban and unban) I was not seeing the correct action when attempting to access home assistant. In other words, when the IP was present in the alias, I was still able to access home assistant. After I did get it blocking, and then the IP was removed from the alias, it would continue to be blocked.

So, I added a couple of lines to your banip and unbanip scripts. I hope it’s OK that I post those changes here for the benefit of anyone else experiencing these issues.

/etc/phpshellsessions/banip

//<?
global $argv, $command_split;

if (is_array($command_split)) {
    $args = array_slice($command_split, 2);
} else {
    $args = array_slice($argv, 3);
}

$ip = $args[0];
$jail = $args[1];

date_default_timezone_set('America/Los_Angeles');
$date = date('l jS \of F Y h:i:s A');

parse_config(true);

$alias_name = "Fail2Ban";
create_modify_alias($alias_name, $ip, $jail, $date);

/* save our changes */
save_config("Fail2Ban banned [${ip}] from [${jail}]");

/* reload config TK191210 */
! /etc/rc.filter_configure

/* clear active states TK191210 */
! pfctl -k $ip

/* ========== FUNCTIONS ============= */
function create_modify_alias($name, $ip, $jail, $date) {
  $index = find_alias($name);

  /* we couldn't find the alias so create an empty one */
  if ($index == -1) {
    $index = create_empty_alias($name, $date);
  }

  if ($index >= 0) {
    modify_alias($index, $ip, "${jail} - ${date}");
  }
}

function find_alias($name) {
  global $config;
  $index = -1;

  for ($i = 0; $i <= count($config['aliases']['alias']); $i++) {
    if ($config['aliases']['alias'][$i]['name'] == $name) {
      $index = $i;
      break;
    }
  }

  return $index;
}

function create_empty_alias($name, $date) {
  $index = add_alias_ip(
    $name,
    'host',
    "Auto Created: ${date}",
    '127.0.0.1',
    "DO NOT DELETE, REQUIRED SO THE ALIAS IS NEVER EMPTY"
  );
  return $index;
}

function modify_alias($index, $address, $detail) {
  global $config;

  /* Get current */
  $aliases = explode(' ',$config['aliases']['alias'][$index]['address']);
  $details = explode('||',$config['aliases']['alias'][$index]['detail']);

  if (!in_array($address, $aliases)) {
    /* add the new stuff */
    array_push($aliases, $address);
    array_push($details, $detail);

    /* write it back */
    $config['aliases']['alias'][$index]['address'] = implode(' ',$aliases);
    $config['aliases']['alias'][$index]['detail'] = implode('||',$details);
  }
}

function add_alias_ip($name, $type, $descr, $address, $detail) {
  global $config;
  $new_alias = array(
    'name' => $name,
    'type' => $type,
    'descr' => $descr,
    'address' => $address,
    'detail' => $detail
  );
  $index = array_push($config['aliases']['alias'], $new_alias) - 1;
  return $index;
}

function save_config($desc) {
  write_config($desc);
  log_error("write_config: ${desc}");
}

/etc/phpshellsessions/unbanip

//<?
global $argv, $command_split;

if (is_array($command_split)) {
    $args = array_slice($command_split, 2);
} else {
    $args = array_slice($argv, 3);
}

$ip = $args[0];
$jail = $args[1];

parse_config(true);

$alias_name = "Fail2Ban";

$index = find_alias($alias_name);
if ($index >= 0) {
  $aliases_left = remove_alias($index, $ip);
}

save_config("Fail2Ban unbanned [${ip}] from [${jail}]");

/* reload config TK191210 */
! /etc/rc.filter_configure

/* ================ FUNCTIONS =============== */
function find_alias($name) {
  global $config;
  $index = -1;

  for ($i = 0; $i <= count($config['aliases']['alias']); $i++) {
    if ($config['aliases']['alias'][$i]['name'] == $name) {
      $index = $i;
      break;
    }
  }

  return $index;
}

function remove_alias($index, $address) {
  global $config;

  /* Get current */
  $aliases = explode(' ',$config['aliases']['alias'][$index]['address']);
  $details = explode('||',$config['aliases']['alias'][$index]['detail']);

  /* add the new stuff */
  while ($key = array_search($address, $aliases)) {
    unset($aliases[$key]);
    unset($details[$key]);
  }

  /* write it back */
  $config['aliases']['alias'][$index]['address'] = implode(' ',$aliases);
  $config['aliases']['alias'][$index]['detail'] = implode('||',$details);

  return count($aliases);
}

function save_config($desc) {
  write_config($desc);
  log_error("write_config: ${desc}");
}

I added the line “! /etc/rc.filter_configure” to both scripts to force pfSense to reload the filter after changing the alias. I added the line “! pfctl -k $ip” to kill any active states for the offending IP address.

I’ve tested this and it seems to be working well. I hope somebody will find this helpful!

Yikes I left out the filter_configure?!?!

Guess it’s good I haven’t ever seen any attacks on my server.

What does the ! do on those lines? Negating the return value?

The ! tells the php shell to execute a normal shell command. I found that information here: https://openschoolsolutions.org/automate-pfsense-with-pfssh-php/

Honestly this is a super cool project and I much prefer blocking at the firewall rather than the server. Better yet, this can work for all Fail2Ban jails so I’ll be changing my Nextcloud jail action to ban at pfSense as well! Thanks for all the work you did on this… you certainly did most of the heavy lifting!

That’s pretty awesome, now I’ll have to go back and rewrite some stuff I did at work that uses multiple script files and php. With that I can combine them all.

Will update the first post later today.

1 Like

Actually the write_config() call is reloading the rules so that filter_configure shouldn’t be needed. Pretty sure I tried this with my phone in the beginning. Will check when I get home.

Were you able to test this? My testing had it behaving incorrectly without the filter_configure. Once I added it, it worked as expected.