diff --git a/.gitignore b/.gitignore
index 3687d0f27..855a6c393 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
*.pyc
.idea
+venv
/*/*/work
diff --git a/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/Api/MaintenanceController.php b/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/Api/MaintenanceController.php
new file mode 100644
index 000000000..0a2e180da
--- /dev/null
+++ b/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/Api/MaintenanceController.php
@@ -0,0 +1,170 @@
+getData(
+ ["server_status_list"],
+ ["rowCount", "current", "searchPhrase", "sort"]
+ );
+ }
+
+ /**
+ * set server weight
+ * @return array|mixed
+ */
+ public function serverWeightAction()
+ {
+ return $this->saveData(
+ ["server_weight"],
+ ["backend", "server", "weight"]
+ );
+ }
+
+ /**
+ * set server administrative state
+ * @return array|mixed
+ */
+ public function serverStateAction()
+ {
+ return $this->saveData(
+ ["server_state"],
+ ["backend", "server", "state"]
+ );
+ }
+
+ /**
+ * set server administrative state for multiple servers
+ * @return array|mixed
+ */
+ public function serverStateBulkAction()
+ {
+ return $this->saveData(
+ ["server_state_bulk"],
+ ["server_ids", "state"]
+ );
+ }
+
+ /**
+ * set server weight for multiple servers
+ * @return array|mixed
+ */
+ public function serverWeightBulkAction()
+ {
+ return $this->saveData(
+ ["server_weight_bulk"],
+ ["server_ids", "weight"]
+ );
+ }
+
+ /**
+ * Execute a backend command securely
+ * @param array $command
+ * @param array $arguments
+ * @return string
+ */
+ protected function safeBackendCmd(array $command, array $arguments = [])
+ {
+ $backend = new Backend();
+
+ foreach ($arguments as $name) {
+ $val = $this->request->getPost($name);
+ if (is_array($val) and $name == 'sort') {
+ $sort = key(array_slice($val, 0, 1));
+ $sort_dir = $val[$sort];
+ $command[] = $sort;
+ $command[] = $sort_dir;
+ continue;
+ }
+ $command[] = $val;
+ }
+
+ $command = array_map(function ($value) {
+ return escapeshellarg(empty($value = trim($value)) ? null : $value);
+ }, $command);
+
+ return trim($backend->configdRun("haproxy " . join(" ", $command)));
+ }
+
+ /**
+ * Executes a backend command to get data
+ * @param array $command
+ * @param array $arguments
+ * @return string|string[]
+ */
+ protected function getData(array $command, array $arguments = [])
+ {
+ if ($this->request->isPost()) {
+ return $this->safeBackendCmd($command, $arguments);
+ }
+ return ["status" => "unavailable"];
+ }
+
+ /**
+ * Executes a backend command to save data
+ * @param array $command
+ * @param array $arguments
+ * @return array|string[]
+ */
+ protected function saveData(array $command, array $arguments = [])
+ {
+ if ($this->request->isPost()) {
+ if ($error = $this->safeBackendCmd($command, $arguments)) {
+ return [
+ "status" => "error",
+ "message" => $error
+ ];
+ } else {
+ return ["status" => "ok"];
+ }
+ }
+ return [
+ "status" => 'unavailable',
+ "message" => 'only accept POST Requests.'
+ ];
+ }
+}
diff --git a/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/MaintenanceController.php b/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/MaintenanceController.php
new file mode 100644
index 000000000..d5a073cc7
--- /dev/null
+++ b/net/haproxy/src/opnsense/mvc/app/controllers/OPNsense/HAProxy/MaintenanceController.php
@@ -0,0 +1,45 @@
+view->pick('OPNsense/HAProxy/maintenance');
+ }
+}
diff --git a/net/haproxy/src/opnsense/mvc/app/models/OPNsense/HAProxy/Menu/Menu.xml b/net/haproxy/src/opnsense/mvc/app/models/OPNsense/HAProxy/Menu/Menu.xml
index 750fe00af..8d12e55ab 100644
--- a/net/haproxy/src/opnsense/mvc/app/models/OPNsense/HAProxy/Menu/Menu.xml
+++ b/net/haproxy/src/opnsense/mvc/app/models/OPNsense/HAProxy/Menu/Menu.xml
@@ -28,7 +28,10 @@
-
+
+
+
+
diff --git a/net/haproxy/src/opnsense/mvc/app/views/OPNsense/HAProxy/maintenance.volt b/net/haproxy/src/opnsense/mvc/app/views/OPNsense/HAProxy/maintenance.volt
new file mode 100644
index 000000000..33332c27b
--- /dev/null
+++ b/net/haproxy/src/opnsense/mvc/app/views/OPNsense/HAProxy/maintenance.volt
@@ -0,0 +1,286 @@
+{#
+
+Copyright (C) 2021 Andreas Stuerz
+OPNsense® is Copyright © 2014 – 2016 by Deciso B.V.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES,
+INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
+OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+#}
+
+
+
+
+
+
+
+
+
+
+
+ | {{ lang._('id') }} |
+ {{ lang._('Proxy') }} |
+ {{ lang._('Server') }} |
+ {{ lang._('Address') }} |
+ {{ lang._('Status') }} |
+ {{ lang._('Check Status') }} |
+ {{ lang._('Weight') }} |
+ {{ lang._('Sessions') }} |
+ {{ lang._('Bytes in') }} |
+ {{ lang._('Bytes out') }} |
+ {{ lang._('Active') }} |
+ {{ lang._('Downtime') }} |
+ {{ lang._('Last Change') }} |
+ {{ lang._('Commands') }} |
+
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+{{ partial("layout_partials/base_dialog_processing") }}
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/__init__.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/__init__.py
new file mode 100644
index 000000000..c7f37eafa
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/__init__.py
@@ -0,0 +1,3 @@
+"""haproxy lib for socket commands.
+Based on: https://github.com/neurogeek/haproxyctl"""
+__version__ = "1.0"
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/cmds.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/cmds.py
new file mode 100644
index 000000000..0316bd99e
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/cmds.py
@@ -0,0 +1,237 @@
+# pylint: disable=locally-disabled, too-few-public-methods, no-self-use, invalid-name
+"""cmds.py - Implementations of the different HAProxy commands"""
+
+import re
+import csv
+import json
+from io import StringIO
+
+
+class Cmd():
+ """Cmd - Command base class"""
+ req_args = []
+ args = {}
+ cmdTxt = ""
+ helpTxt = ""
+
+ # pylint: disable=unused-argument
+ def __init__(self, *args, **kwargs):
+ """Argument to the command are given in kwargs only. We ignore *args."""
+ self.args = kwargs
+ valid_kwargs = [k for (k, v) in kwargs.items() if v is not None]
+
+ if not all([a in valid_kwargs for a in self.req_args]):
+ raise Exception(f"Wrong number of arguments. Required arguments are: {self.WhatArgs()}")
+
+ def WhatArgs(self):
+ """Returns a formatted string of arguments to this command."""
+ return ",".join(self.req_args)
+
+ @classmethod
+ def getHelp(cls):
+ """Get formatted help string for this command."""
+ txtArgs = ",".join(cls.req_args)
+
+ if not txtArgs:
+ txtArgs = "None"
+ return " ".join((cls.helpTxt, "Arguments: %s" % txtArgs))
+
+ def getCmd(self):
+ """Gets the command line for this command.
+ The default behavior is to apply the args dict to cmdTxt
+ """
+ return self.cmdTxt % self.args
+
+ def getResult(self, res):
+ """Returns raw results gathered from HAProxy"""
+ if res == '\n':
+ res = None
+ return res
+
+ def getResultObj(self, res):
+ """Returns refined output from HAProxy, packed inside a Python obj i.e. a dict()"""
+ return res
+
+
+class setServerAgent(Cmd):
+ """Set server agent command."""
+ cmdTxt = "set server %(backend)s/%(server)s agent %(value)s\r\n"
+ req_args = ['backend', 'server', 'value']
+ helpTxt = "Force a server's agent to a new state."
+
+
+class setServerHealth(Cmd):
+ """Set server health command."""
+ cmdTxt = "set server %(backend)s/%(server)s health %(value)s\r\n"
+ req_args = ['backend', 'server', 'value']
+ helpTxt = "Force a server's health to a new state."
+
+
+class setServerState(Cmd):
+ """Set server state command."""
+ cmdTxt = "set server %(backend)s/%(server)s state %(value)s\r\n"
+ req_args = ['backend', 'server', 'value']
+ helpTxt = "Force a server's administrative state to a new state."
+
+
+class setServerWeight(Cmd):
+ """Set server weight command."""
+ cmdTxt = "set server %(backend)s/%(server)s weight %(value)s\r\n"
+ req_args = ['backend', 'server', 'value']
+ helpTxt = "Force a server's weight to a new state."
+
+
+class showFBEnds(Cmd):
+ """Base class for getting a listing Frontends and Backends"""
+ switch = ""
+ cmdTxt = "show stat\r\n"
+
+ def getResult(self, res):
+ return "\n".join(self._getResult(res))
+
+ def getResultObj(self, res):
+ return self._getResult(res)
+
+ def _getResult(self, res):
+ """Show Frontend/Backends. To do this, we extract info from
+ the stat command and filter out by a specific
+ switch (FRONTEND/BACKEND)"""
+
+ if not self.switch:
+ raise Exception("No action specified")
+
+ result = []
+ lines = res.split('\n')
+ cl = re.compile("^[^,].+," + self.switch.upper() + ",.*$")
+
+ for e in lines:
+ me = re.match(cl, e)
+ if me:
+ result.append(e.split(",")[0])
+ return result
+
+
+class showFrontends(showFBEnds):
+ """Show frontends command."""
+ switch = "frontend"
+ helpTxt = "List all Frontends."
+
+
+class showBackends(showFBEnds):
+ """Show backends command."""
+ switch = "backend"
+ helpTxt = "List all Backends."
+
+
+class showInfo(Cmd):
+ """Show info HAProxy command"""
+ cmdTxt = "show info\r\n"
+ helpTxt = "Show info on HAProxy instance."
+
+ def getResultObj(self, res):
+ resDict = {}
+ for line in res.split('\n'):
+ k, v = line.split(':')
+ resDict[k] = v
+
+ return resDict
+
+
+class showSessions(Cmd):
+ """Show sess HAProxy command"""
+ cmdTxt = "show sess\r\n"
+ helpTxt = "Show HAProxy sessions."
+
+ def getResultObj(self, res):
+ return res.split('\n')
+
+
+class baseStat(Cmd):
+ """Base class for stats commands."""
+
+ def getDict(self, res):
+ # clean response
+ res = re.sub(r'^# ', '', res, re.MULTILINE)
+ res = re.sub(r',\n', '\n', res, re.MULTILINE)
+ res = re.sub(r',\n\n', '\n', res, re.MULTILINE)
+
+ csv_string = StringIO(res)
+ return csv.DictReader(csv_string, delimiter=',')
+
+ def getBootstrapOutput(self, **kwargs):
+ rows = kwargs['rows']
+ # search
+ if kwargs['search']:
+ filtered_rows = []
+ for row in rows:
+ def inner(row):
+ for k, v in row.items():
+ if kwargs['search'] in v:
+ return row
+ return None
+
+ match = inner(row)
+ if match:
+ filtered_rows.append(match)
+ rows = filtered_rows
+
+ # sort
+ rows.sort(key=lambda k: k[kwargs['sort_col']], reverse=True if kwargs['sort_dir'] == 'desc' else False)
+
+ # pager
+ total = len(rows)
+ pages = [rows[i:i + kwargs['page_rows']] for i in range(0, total, kwargs['page_rows'])]
+ if pages and (kwargs['page'] > len(pages) or kwargs['page'] < 1):
+ raise KeyError(f"Current page {kwargs['page']} does not exist. Available pages: {len(pages)}")
+ page = pages[kwargs['page'] - 1] if pages else []
+
+ return json.dumps({
+ "rows": page,
+ "total": total,
+ "rowCount": kwargs['page_rows'],
+ "current": kwargs['page']
+ })
+
+
+class showServers(baseStat):
+ """Show all servers. If backend is given, show only servers for this backend. """
+ cmdTxt = "show stat\r\n"
+ helpTxt = "Lists all servers. Filter for servers in backend, if set."
+
+ def getResult(self, res):
+ if self.args['output'] == 'json':
+ return json.dumps(self.getResultObj(res))
+
+ if self.args['output'] == 'bootstrap':
+ rows = self.getResultObj(res)
+ args = {
+ "rows": rows,
+ "page": int(self.args['page']) if self.args['page'] != None else 1,
+ "page_rows": int(self.args['page_rows']) if self.args['page_rows'] != None else len(rows),
+ "search": self.args['search'],
+ "sort_col": self.args['sort_col'] if self.args['sort_col'] else 'id',
+ "sort_dir": self.args['sort_dir'],
+ }
+ return self.getBootstrapOutput(**args)
+
+ return self.getResultObj(res)
+
+ def getResultObj(self, res):
+ servers = []
+
+ reader = self.getDict(res)
+ for row in reader:
+ # show only server
+ if row['svname'] in ['BACKEND', 'FRONTEND']:
+ continue
+
+ # filter server for given backend
+ if self.args['backend'] and row['pxname'] != self.args['backend']:
+ continue
+
+ # add id
+ row['id'] = f"{row['pxname']}/{row['svname']}"
+ row.move_to_end('id', last=False)
+ servers.append(dict(row))
+
+ return servers
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/conn.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/conn.py
new file mode 100644
index 000000000..962a15cf5
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/conn.py
@@ -0,0 +1,83 @@
+# pylint: disable=locally-disabled, too-few-public-methods, no-self-use, invalid-name
+"""conn.py - Connection module."""
+import re
+from socket import socket, AF_INET, AF_UNIX, SOCK_STREAM
+from haproxy import const
+
+class HapError(Exception):
+ """Generic exception for haproxyctl."""
+ pass
+
+class HaPConn(object):
+ """HAProxy Socket object.
+ This class abstract the socket interface so
+ commands can be sent to HAProxy and results received and
+ parse by the command objects"""
+
+ def __init__(self, sfile, socket_module=socket):
+ """Initializes an HAProxy and opens a connection to it
+ (sfile, type) -> Path for the UNIX socket"""
+
+ self.sock = None
+ sfile = sfile.strip()
+ stype = AF_UNIX
+ self.socket_module = socket_module
+
+ mobj = re.match(
+ '(?Punix://|tcp://)(?P[^:]+):*(?P[0-9]*)$', sfile)
+
+ if mobj:
+ proto = mobj.groupdict().get('proto', None)
+ addr = mobj.groupdict().get('addr', None)
+ port = mobj.groupdict().get('port', '')
+
+ if not addr or not proto:
+ raise HapError('Could not determine type of socket.')
+
+ if proto == const.HAP_TCP_PATH:
+ if not port:
+ raise HapError('When using a tcp socket, a port is needed.')
+ stype = AF_INET
+ sfile = (addr, int(port))
+
+ if proto == const.HAP_UNIX_PATH:
+ stype = AF_UNIX
+ sfile = addr
+
+ # Fallback should be sfile/AF_UNIX by default
+ self.sfile = (sfile, stype)
+ self.open()
+
+ def open(self):
+ """Opens a connection for the socket.
+ This function should only be called if
+ self.closed() method was called"""
+
+ sfile, stype = self.sfile
+ self.sock = self.socket_module(stype, SOCK_STREAM)
+ self.sock.connect(sfile)
+
+ def sendCmd(self, cmd, objectify=False):
+ """Receives a command obj and sends it to the socket. Receives the output and passes it
+ through the command to parse it.
+ objectify -> Return an object instead of plain text"""
+
+ res = ""
+ try:
+ self.sock.send(cmd.getCmd())
+ except TypeError:
+ self.sock.send(bytearray(cmd.getCmd(), 'ASCII'))
+ output = self.sock.recv(const.HAP_BUFSIZE)
+
+ while output:
+ res += output.decode('ASCII')
+ output = self.sock.recv(const.HAP_BUFSIZE)
+
+ if objectify:
+ return cmd.getResultObj(res)
+
+ return cmd.getResult(res)
+
+ def close(self):
+ """Closes the socket"""
+ self.sock.close()
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/const.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/const.py
new file mode 100644
index 000000000..ebd60d8c8
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/const.py
@@ -0,0 +1,7 @@
+"""const.py - Constants for haproxyctl."""
+HAP_OK = 1
+HAP_ERR = 2
+HAP_SOCK_ERR = 3
+HAP_BUFSIZE = 8192
+HAP_UNIX_PATH = 'unix://'
+HAP_TCP_PATH = 'tcp://'
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/__init__.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_cmds.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_cmds.py
new file mode 100644
index 000000000..032954583
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_cmds.py
@@ -0,0 +1,73 @@
+# pylint: disable=star-args, locally-disabled, too-few-public-methods, no-self-use, invalid-name
+"""test_cmds.py - Unittests related to command implementations."""
+import sys, os, unittest
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
+from haproxy import cmds
+
+class TestCommands(unittest.TestCase):
+ """Tests all of the commands."""
+ def setUp(self):
+
+ self.Resp = {"disable" : "disable server redis-ro/redis-ro0",
+ "set-server-agent" : "set server redis-ro/redis-ro0 agent up",
+ "set-server-health" : "set server redis-ro/redis-ro0 health stopping",
+ "set-server-state" : "set server redis-ro/redis-ro0 state drain",
+ "set-server-weight" : "set server redis-ro/redis-ro0 weight 10",
+ "frontends" : "show stat",
+ "info" : "show info",
+ "sessions" : "show sess",
+ "servers" : "show stat",
+
+ }
+
+ self.Resp = dict([(k, v + "\r\n") for k, v in self.Resp.items()])
+
+ def test_setServerAgent(self):
+ """Test 'set server agent' command"""
+ args = {"backend": "redis-ro", "server" : "redis-ro0", "value": "up"}
+ cmdSetServerAgent = cmds.setServerAgent(**args).getCmd()
+ self.assertEqual(cmdSetServerAgent, self.Resp["set-server-agent"])
+
+ def test_setServerHealth(self):
+ """Test 'set server health' command"""
+ args = {"backend": "redis-ro", "server" : "redis-ro0", "value": "stopping"}
+ cmdSetServerHealth = cmds.setServerHealth(**args).getCmd()
+ self.assertEqual(cmdSetServerHealth, self.Resp["set-server-health"])
+
+ def test_setServerState(self):
+ """Test 'set server state' command"""
+ args = {"backend": "redis-ro", "server" : "redis-ro0", "value": "drain"}
+ cmdSetServerState = cmds.setServerState(**args).getCmd()
+ self.assertEqual(cmdSetServerState, self.Resp["set-server-state"])
+
+ def test_setServerWeight(self):
+ """Test 'set server weight' command"""
+ args = {"backend": "redis-ro", "server" : "redis-ro0", "value": "10"}
+ cmdSetServerState = cmds.setServerWeight(**args).getCmd()
+ self.assertEqual(cmdSetServerState, self.Resp["set-server-weight"])
+
+ def test_showFrontends(self):
+ """Test 'frontends/backends' commands"""
+ args = {}
+ cmdFrontends = cmds.showFrontends(**args).getCmd()
+ self.assertEqual(cmdFrontends, self.Resp["frontends"])
+
+ def test_showInfo(self):
+ """Test 'show info' command"""
+ cmdShowInfo = cmds.showInfo().getCmd()
+ self.assertEqual(cmdShowInfo, self.Resp["info"])
+
+ def test_showSessions(self):
+ """Test 'show info' command"""
+ cmdShowInfo = cmds.showSessions().getCmd()
+ self.assertEqual(cmdShowInfo, self.Resp["sessions"])
+
+ def test_showServers(self):
+ """Test 'show info' command"""
+ args = {"backend": "redis-ro"}
+ cmdShowInfo = cmds.showServers(**args).getCmd()
+ self.assertEqual(cmdShowInfo, self.Resp["servers"])
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_conn.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_conn.py
new file mode 100644
index 000000000..fc6aac966
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/lib/haproxy/tests/test_conn.py
@@ -0,0 +1,59 @@
+# pylint: disable=locally-disabled, too-few-public-methods, no-self-use, invalid-name, broad-except
+"""test_conn.py - Unittests related to connections to HAProxy."""
+import sys, os
+sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..'))
+from haproxy import conn
+import unittest
+from socket import AF_INET, AF_UNIX
+
+class SimpleConnMock(object):
+ """Simple socket mock."""
+ def __init__(self, stype, stream):
+ self.stype = stype
+ self.stream = stream
+
+ def connect(self, addr):
+ """Mocked socket.connect method."""
+ pass
+
+class TestConnection(unittest.TestCase):
+ """Tests different aspects of haproxyctl's connections to HAProxy."""
+
+ def testConnSimple(self):
+ """Tests that connection to non-protocol path works and fallsback to UNIX socket."""
+ sfile = "/some/path/to/socket.sock"
+ c = conn.HaPConn(sfile, socket_module=SimpleConnMock)
+ addr, stype = c.sfile
+ self.assertEqual(sfile, addr)
+ self.assertEqual(stype, AF_UNIX)
+
+ def testConnUnixString(self):
+ """Tests that unix:// protocol works and connects to a socket."""
+ sfile = "unix:///some/path/to/socket.socket"
+ c = conn.HaPConn(sfile, socket_module=SimpleConnMock)
+ addr, stype = c.sfile
+ self.assertEqual("/some/path/to/socket.socket", addr)
+ self.assertEqual(stype, AF_UNIX)
+
+ def testConnTCPString(self):
+ """Tests that tcp:// protocol works and connects to an IP."""
+ sfile = "tcp://1.2.3.4:8080"
+ c = conn.HaPConn(sfile, socket_module=SimpleConnMock)
+ addr, stype = c.sfile
+ ip, port = addr
+ self.assertEqual("1.2.3.4", ip)
+ self.assertEqual(8080, port)
+ self.assertEqual(stype, AF_INET)
+
+ def testConnTCPStringNoPort(self):
+ """Tests that passing a tcp:// address with no port, raises an Exception."""
+ sfile = "tcp://1.2.3.4"
+ # Not using assertRaises because we still support 2.6
+ try:
+ conn.HaPConn(sfile, socket_module=SimpleConnMock)
+ raise Exception('Connection should have thrown an exception')
+ except conn.HapError:
+ pass
+
+if __name__ == '__main__':
+ unittest.main()
\ No newline at end of file
diff --git a/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/socketCommand.py b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
new file mode 100755
index 000000000..fc42c7c14
--- /dev/null
+++ b/net/haproxy/src/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+import os
+import sys
+import argparse
+import traceback
+
+sys.path.append(os.path.join(os.path.dirname(__file__), 'lib'))
+from haproxy.conn import HaPConn
+from haproxy import cmds
+
+SOCKET = '/var/run/haproxy.socket'
+VALID_COMMANDS = {
+ "set-server-agent": cmds.setServerAgent,
+ "set-server-health": cmds.setServerHealth,
+ "set-server-state": cmds.setServerState,
+ "set-server-weight": cmds.setServerWeight,
+ "show-frontends": cmds.showFrontends,
+ "show-backends": cmds.showBackends,
+ "show-info": cmds.showInfo,
+ "show-sessions": cmds.showSessions,
+ "show-servers": cmds.showServers,
+}
+
+def get_args():
+ parser = argparse.ArgumentParser(description='Send haproxy commands via socket.')
+ parser.add_argument(
+ 'command',
+ choices=list(VALID_COMMANDS),
+ help='The command to execute via haproxy socket'
+ )
+ parser.add_argument(
+ '--backend',
+ help='Attempt action on given backend.',
+ default=None
+ )
+ parser.add_argument(
+ '--server',
+ help='Attempt action on given server.',
+ default=None
+ )
+ parser.add_argument(
+ '--server-ids',
+ help='Attempt action on a list of server, specified as a comma seperated list e.g. back1/server1,back2/server3',
+ default=None
+ )
+ parser.add_argument(
+ '--value',
+ help='Specify value for a set command.',
+ default=None
+ )
+ parser.add_argument(
+ '--output',
+ help='Specify output format.',
+ choices=['json', 'bootstrap'],
+ default=None
+ )
+ parser.add_argument(
+ '--page-rows',
+ help='Limit output to the specified numbers of rows per page.',
+ default=None
+ )
+ parser.add_argument(
+ '--page',
+ help='Output page number.',
+ default=None
+ )
+ parser.add_argument(
+ '--search',
+ help='Search for string.',
+ default=None
+ )
+ parser.add_argument(
+ '--sort-col',
+ help='Sort output on this column.',
+ default=None
+ )
+ parser.add_argument(
+ '--sort-dir',
+ help='Sort output in this direction.',
+ default=None
+ )
+ parser.add_argument(
+ '--debug',
+ type=bool,
+ help='Show debug output.',
+ default=False
+ )
+
+ return parser.parse_args()
+
+args = get_args()
+command_class = VALID_COMMANDS.get(args.command, None)
+command_args = {key: val for key, val in vars(args).items() if key != "command"}
+
+try:
+ if args.server_ids:
+ # bulk
+ command_bulk_args = command_args
+ command_bulk_args.pop('server_ids', None)
+ for server_id in args.server_ids.split(","):
+ command_bulk_args.update({
+ 'backend': server_id.split("/")[0],
+ 'server': server_id.split("/")[1]
+ })
+ con = HaPConn(SOCKET)
+ if con:
+ result = con.sendCmd(command_class(**command_bulk_args), objectify=False)
+ if result:
+ print(f"{server_id}: {result.strip()}")
+ con.close()
+
+ else:
+ # single
+ con = HaPConn(SOCKET)
+ if con:
+ result = con.sendCmd(command_class(**command_args), objectify=False)
+ if result:
+ print(result.strip())
+ else:
+ print(f"Could not open socket {SOCKET}")
+
+except Exception as exc:
+ print(f"While talking to {SOCKET}: {exc}")
+ if args['debug']:
+ tb = traceback.format_exc()
+ print(tb)
diff --git a/net/haproxy/src/opnsense/service/conf/actions.d/actions_haproxy.conf b/net/haproxy/src/opnsense/service/conf/actions.d/actions_haproxy.conf
index 02896ebe0..ce1ef790b 100644
--- a/net/haproxy/src/opnsense/service/conf/actions.d/actions_haproxy.conf
+++ b/net/haproxy/src/opnsense/service/conf/actions.d/actions_haproxy.conf
@@ -45,3 +45,33 @@ command:/usr/local/opnsense/scripts/OPNsense/HAProxy/queryStats.php
parameters:%s
type:script_output
message:requesting haproxy statistics
+
+[server_status_list]
+command:/usr/local/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
+parameters: show-servers --output bootstrap --page-rows %s --page %s --search %s --sort-col %s --sort-dir %s
+type:script_output
+message:show server status list
+
+[server_state]
+command:/usr/local/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
+parameters: set-server-state --backend %s --server %s --value %s
+type:script_output
+message:change haproxy server state
+
+[server_weight]
+command:/usr/local/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
+parameters: set-server-weight --backend %s --server %s --value %s
+type:script_output
+message:change haproxy server weight
+
+[server_state_bulk]
+command:/usr/local/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
+parameters: set-server-state --server-ids %s --value %s
+type:script_output
+message:change haproxy state for multiple server
+
+[server_weight_bulk]
+command:/usr/local/opnsense/scripts/OPNsense/HAProxy/socketCommand.py
+parameters: set-server-weight --server-ids %s --value %s
+type:script_output
+message:change haproxy weight for multiple server
\ No newline at end of file