net/haproxy: change server state and weight on-the-fly (#2213)

* add Frontend Controller
add service with script

* set single server state
get haproxy server status list for jquery bootstrap

* set single server weight
add venv folder to .gitignore

* set bulk server weight
set bulk server state

* add copyrights

* set single server weight
add venv folder to .gitignore
This commit is contained in:
Andreas Stürz 2021-02-03 14:22:51 +01:00 committed by GitHub
parent a971e25370
commit aa194e874a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1124 additions and 1 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
*.pyc
.idea
venv
/*/*/work

View file

@ -0,0 +1,170 @@
<?php
/**
* Copyright (C) 2021 Andreas Stuerz
* Copyright (C) 2015 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.
*
*/
namespace OPNsense\HAProxy\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\HAProxy\HAProxy;
/**
* Class MaintenanceController
* @package OPNsense\HAProxy
*/
class MaintenanceController extends ApiControllerBase
{
/**
* jQuery bootstrap server list
* @return array|mixed
*/
public function searchServerAction()
{
return $this->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.'
];
}
}

View file

@ -0,0 +1,45 @@
<?php
/**
* Copyright (C) 2021 Andreas Stuerz
* Copyright (C) 2015 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.
*
*/
namespace OPNsense\HAProxy;
/**
* Class MaintenanceController
* @package OPNsense\HAProxy
*/
class MaintenanceController extends \OPNsense\Base\IndexController
{
public function indexAction()
{
// choose template
$this->view->pick('OPNsense/HAProxy/maintenance');
}
}

View file

@ -28,7 +28,10 @@
<Counters VisibleName="Counters" url="/ui/haproxy/statistics#counters"/>
<StickTables VisibleName="Stick Tables" url="/ui/haproxy/statistics#tables"/>
</Statistics>
<Log VisibleName="Log File" order="30" url="/ui/diagnostics/log/core/haproxy"/>
<Maintenance VisibleName="Maintenance" order="30" url="/ui/haproxy/maintenance">
<Server VisibleName="Server" url="/ui/haproxy/maintenance#server"/>
</Maintenance>
<Log VisibleName="Log File" order="40" url="/ui/diagnostics/log/core/haproxy"/>
</HAProxy>
</Services>
</menu>

View file

@ -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.
#}
<script>
$( document ).ready(function() {
$("#grid-status").bootgrid('destroy');
var grid_status = $("#grid-status").UIBootgrid({
search: '/api/haproxy/maintenance/searchServer',
options: {
ajax: true,
selection: true,
multiSelect: true,
keepSelection: true,
rowCount:[10,25,50,100,500,1000],
searchSettings: {
delay: 250,
characters: 1
},
formatters: {
"commands": function (column, row) {
buttons = ""
buttons += "<button type=\"button\" title=\"Set administrative state to ready. Puts the server in normal mode.\" class=\"btn btn-xs btn-default command-set-state\" data-state=\"ready\" data-row-id=\"" + row.id + "\"><span class=\"fa fa-check\"></span></button>"
buttons += " <button type=\"button\" title=\"Set administrative state to drain. Removes the server from load balancing but still allows it to be health checked and to accept new persistent connections\" class=\"btn btn-xs btn-default command-set-state\" data-state=\"drain\" data-row-id=\"" + row.id + "\"><span class=\"fa fa-sort-amount-desc\"></span></button>"
buttons += " <button type=\"button\" title=\"Set administrative state to maintenance. Disables any traffic to the server as well as any health checks.\" class=\"btn btn-xs btn-default command-set-state\" data-state=\"maint\" data-row-id=\"" + row.id + "\"><span class=\"fa fa-wrench\"></span></button>"
buttons += " <button type=\"button\" title=\"Change a server's weight.\" class=\"btn btn-xs btn-default command-set-weight\" data-weight=\"" + row.weight + "\" data-row-id=\"" + row.id + "\"><span class=\"fa fa-balance-scale\"></span></button>"
return buttons;
},
},
}
}).on("loaded.rs.jquery.bootgrid", function(){
// set single - server state
grid_status.find(".command-set-state").off().on("click", function(e) {
var uuid = $(this).data("row-id");
var backend = uuid.split("/")[0];
var server = uuid.split("/")[1];
var state = $(this).data("state");
var payload = {
'backend': backend,
'server': server,
'state': state
};
question = '<b>{{ lang._('Server: ') }}' + uuid + '</b></br>';
question += '<b>{{ lang._('State: ') }}' + state + '</b></br></br>';
question += '{{ lang._('Set administrative state for this server?') }} </br></br>';
stdDialogConfirm('{{ lang._('Confirmation Required') }}',
question,
'{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() {
$.post('/api/haproxy/maintenance/serverState', payload, function(data) {
if (data.status != 'ok') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_DANGER,
title: "{{ lang._('Error setting HAProxy server administrative state') }}",
message: data.message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialog){
dialog.close();
}
}]
});
} else {
$("#grid-status").bootgrid("reload");
}
});
});
});
// set single - server weight
grid_status.find(".command-set-weight").off().on("click", function(e) {
var uuid = $(this).data("row-id");
var backend = uuid.split("/")[0];
var server = uuid.split("/")[1];
var currentWeight = $(this).data("weight");
question = '<b>{{ lang._('Server: ') }}' + uuid + '</b></br></br>';
question += '<b>{{ lang._('Weight: ') }}</b>';
question += '<div class="form-group" style="display: block;">';
question += '<input class="form-control" id="newWeight" value="' + currentWeight + '" type="text"/>';
question += '</div>';
question += '{{ lang._('Set weight for this server?') }} </br></br>';
stdDialogConfirm('{{ lang._('Confirmation Required') }}',
question,
'{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() {
var payload = {
'backend': backend,
'server': server,
'weight': $("#newWeight").val()
};
$.post('/api/haproxy/maintenance/serverWeight', payload, function(data) {
if (data.status != 'ok') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_DANGER,
title: "{{ lang._('Error setting HAProxy server weight') }}",
message: data.message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialog){
dialog.close();
}
}]
});
} else {
$("#grid-status").bootgrid("reload");
}
});
});
});
// set bulk - server state
grid_status.find("*[data-action=setStateBulk]").off().on("click", function(e) {
var rows = $("#grid-status").bootgrid("getSelectedRows");
var server_ids = rows.join()
var state = $(this).data("state");
var payload = {
'server_ids': server_ids,
'state': state
};
if (rows != undefined && rows.length > 0) {
question = '<b>{{ lang._('Selected server: ') }}</b></br>';
question += '<ul>';
$.each(rows, function(key, id){
question += '<li>' + id + '</li>';
});
question += '</ul>';
question += '<b>{{ lang._('State: ') }}' + state + '</b></br></br>';
question += '{{ lang._('Set administrative state for all selected server?') }} </br></br>';
stdDialogConfirm('{{ lang._('Confirmation Required') }}',
question,
'{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() {
$.post('/api/haproxy/maintenance/serverStateBulk', payload, function(data) {
if (data.status != 'ok') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_DANGER,
title: "{{ lang._('Error setting HAProxy server administrative state') }}",
message: data.message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialog){
dialog.close();
// reload - because some are successfully executed
$("#grid-status").bootgrid("reload");
}
}]
});
} else {
$("#grid-status").bootgrid("deselect");
$("#grid-status").bootgrid("reload");
}
});
});
}
});
// set bulk - server weight
grid_status.find("*[data-action=setWeightBulk]").off().on("click", function(e) {
var rows = $("#grid-status").bootgrid("getSelectedRows");
var server_ids = rows.join()
if (rows != undefined && rows.length > 0) {
question = '<b>{{ lang._('Selected server: ') }}</b></br>';
question += '<ul>';
$.each(rows, function(key, id){
question += '<li>' + id + '</li>';
});
question += '</ul>';
question += '<b>{{ lang._('Weight: ') }}</b>';
question += '<div class="form-group" style="display: block;">';
question += '<input class="form-control" id="newBulkWeight" value="" type="text"/>';
question += '</div>';
question += '{{ lang._('Set weight for all selected server?') }} </br></br>';
stdDialogConfirm('{{ lang._('Confirmation Required') }}',
question,
'{{ lang._('Yes') }}', '{{ lang._('Cancel') }}', function() {
var payload = {
'server_ids': server_ids,
'weight': $("#newBulkWeight").val()
};
$.post('/api/haproxy/maintenance/serverWeightBulk', payload, function(data) {
if (data.status != 'ok') {
BootstrapDialog.show({
type: BootstrapDialog.TYPE_DANGER,
title: "{{ lang._('Error setting HAProxy server weight') }}",
message: data.message,
buttons: [{
label: '{{ lang._('Close') }}',
action: function(dialog){
dialog.close();
// reload - because some are successfully executed
$("#grid-status").bootgrid("reload");
}
}]
});
} else {
$("#grid-status").bootgrid("deselect");
$("#grid-status").bootgrid("reload");
}
});
});
}
});
});
});
</script>
<ul class="nav nav-tabs" role="tablist" id="maintabs">
<li class="active"><a data-toggle="tab" href="#server"><b>{{ lang._('Server') }}</b></a></li>
</ul>
<div class="content-box tab-content">
<div id="server" class="tab-pane fade in active">
<!-- tab page "server" -->
<table id="grid-status" class="table table-condensed table-hover table-striped table-responsive">
<thead>
<tr>
<th data-column-id="id" data-type="string" data-identifier="true" data-visible="false">{{ lang._('id') }}</th>
<th data-column-id="pxname" data-type="string">{{ lang._('Proxy') }}</th>
<th data-column-id="svname" data-type="string">{{ lang._('Server') }}</th>
<th data-column-id="addr" data-type="string">{{ lang._('Address') }}</th>
<th data-column-id="status" data-type="string">{{ lang._('Status') }}</th>
<th data-column-id="check_status" data-type="string">{{ lang._('Check Status') }}</th>
<th data-column-id="weight" data-type="string">{{ lang._('Weight') }}</th>
<th data-column-id="scur" data-type="string">{{ lang._('Sessions') }}</th>
<th data-column-id="bin" data-type="string">{{ lang._('Bytes in') }}</th>
<th data-column-id="bout" data-type="string">{{ lang._('Bytes out') }}</th>
<th data-column-id="act" data-type="string">{{ lang._('Active') }}</th>
<th data-column-id="downtime" data-type="string">{{ lang._('Downtime') }}</th>
<th data-column-id="lastchg" data-type="string">{{ lang._('Last Change') }}</th>
<th data-column-id="commands" data-width="8em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button data-action="setStateBulk" title="Set administrative state to ready for all selected items." data-state="ready" type="button" class="btn btn-xs btn-default"><span class="fa fa-check"></span></button>
<button data-action="setStateBulk" title="Set administrative state to drain for all selected items." data-state="drain" type="button" class="btn btn-xs btn-default"><span class="fa fa-sort-amount-desc"></span></button>
<button data-action="setStateBulk" title="Set administrative state to maintenance for all selected items." data-state="maint" type="button" class="btn btn-xs btn-default"><span class="fa fa-wrench"></span></button>
<button data-action="setWeightBulk" data-weight="" type="button" class="btn btn-xs btn-default"><span class="fa fa-balance-scale"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
{{ partial("layout_partials/base_dialog_processing") }}

View file

@ -0,0 +1,3 @@
"""haproxy lib for socket commands.
Based on: https://github.com/neurogeek/haproxyctl"""
__version__ = "1.0"

View file

@ -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

View file

@ -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(
'(?P<proto>unix://|tcp://)(?P<addr>[^:]+):*(?P<port>[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()

View file

@ -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://'

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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