mirror of
https://github.com/opnsense/plugins.git
synced 2026-10-12 02:20:37 +00:00
benchmarks/iperf: modernize server mode (#5773)
* benchmarks/iperf: modernize server mode Replace the legacy Ruby manager and its persistent socket service with a short-lived Python helper invoked through configd. Store transient jobs and JSON results in /tmp, reject duplicate ports, and support removal of running instances. Move the server UI to an in-memory Instance model and client-style Bootgrid view under /ui/iperf/index/instance. Drop firewall manipulation, service registration, and Ruby dependencies. * Update benchmarks/iperf/src/opnsense/mvc/app/controllers/OPNsense/iperf/Api/InstanceController.php * server should start persistently, not one shot, and always show the last test result. That is more user friendly * use json-stream so we can distinguish between a running and waiting iperf server * change status icons slightly * Add start/stop commands to client and server * use a filter on the commands * For client lets also use the error as stop status * Lets keep client and server config and log inside /var/db/iperf * Report bytes per second in the API and format with byteFormatter in the front end * simplify error handling a bit, they cannot be translated anyway so at least make them machine readable * Delegate client job creation completely to configd to avoid issues with permissions later * Lets pass these parameters as json object so its generalized * Add changelog and copyright headers * copilot review * normalize the API between client and server, and some other smaller fixes * Expose the json results for the GUI and API, and make sure theyre all passed through .text() so no XSS * the response is already escaped by Response
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
/usr/bin/install -d -o root -g wheel -m 0755 /var/db/iperf
|
||||
/usr/bin/install -d -o root -g wheel -m 0750 /var/db/iperf/client
|
||||
/usr/bin/install -d -o root -g wheel -m 0750 /var/db/iperf/server
|
||||
@@ -1,8 +1,7 @@
|
||||
PLUGIN_NAME= iperf
|
||||
PLUGIN_VERSION= 1.0
|
||||
PLUGIN_REVISION= 2
|
||||
PLUGIN_VERSION= 2.0
|
||||
PLUGIN_COMMENT= Connection speed tester
|
||||
PLUGIN_DEPENDS= iperf3 ruby rubygem-rexml
|
||||
PLUGIN_MAINTAINER= franz.fabian.94@gmail.com
|
||||
PLUGIN_DEPENDS= iperf3
|
||||
PLUGIN_MAINTAINER= cedrik@pischem.com
|
||||
|
||||
.include "../../Mk/plugins.mk"
|
||||
|
||||
@@ -9,3 +9,15 @@ Sciences Network (ESnet). Among its goals were a smaller, simpler
|
||||
code base (compared to its predecessor, iperf2) and a library version
|
||||
of the functionality that can be used in other programs. Note that
|
||||
iperf3 does not interoperate with with iperf 2.x.
|
||||
|
||||
Plugin Changelog
|
||||
================
|
||||
|
||||
2.0
|
||||
|
||||
* Added client mode (contributed by François Maymil)
|
||||
* Reworked server mode
|
||||
|
||||
Note: The API has changed.
|
||||
Note: Stored jobs and results persist across reboots, but running server and
|
||||
client processes are not restarted.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
Copyright (C) 2017 Fabian Franz
|
||||
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.
|
||||
*/
|
||||
|
||||
|
||||
function iperf_firewall($fw)
|
||||
{
|
||||
$fw->registerAnchor('iperf', 'fw');
|
||||
}
|
||||
|
||||
function iperf_services()
|
||||
{
|
||||
$services = array();
|
||||
|
||||
$services[] = array(
|
||||
'description' => gettext('iperf Performance Test'),
|
||||
'configd' => array(
|
||||
'restart' => array('iperf restart'),
|
||||
'start' => array('iperf start'),
|
||||
'stop' => array('iperf stop'),
|
||||
),
|
||||
'name' => 'iperf',
|
||||
'pidfile' => '/var/run/iperf.pid'
|
||||
);
|
||||
return $services;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/sh
|
||||
# REQUIRE: LOGIN DAEMON
|
||||
. /etc/rc.subr
|
||||
name=iperf
|
||||
rcvar=iperf_enable
|
||||
command_interpreter=/usr/local/bin/ruby
|
||||
command=/usr/local/opnsense/scripts/iperf/ruby_iperf.rb
|
||||
iperf_user=root
|
||||
iperf_pidfile=/var/run/iperf.pid
|
||||
start_cmd="/usr/sbin/daemon -u $iperf_user -p $iperf_pidfile -f $command"
|
||||
load_rc_config $name
|
||||
: ${iperf_enable="YES"}
|
||||
run_rc_command "$1"
|
||||
+53
-44
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2026 Cedrik Pischem
|
||||
* Copyright (C) 2026 François Maymil
|
||||
* All rights reserved.
|
||||
*
|
||||
@@ -31,47 +32,43 @@ namespace OPNsense\iperf\Api;
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\Core\Config;
|
||||
use OPNsense\Core\File;
|
||||
|
||||
class ClientController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelName = 'client';
|
||||
protected static $internalModelClass = 'OPNsense\iperf\Client';
|
||||
private static $job_dir = '/tmp/iperf';
|
||||
|
||||
/**
|
||||
* create client job
|
||||
* Validate the settings and start an iperf client job.
|
||||
*/
|
||||
public function setAction()
|
||||
{
|
||||
$result = parent::setAction();
|
||||
if ($result['result'] != 'failed') {
|
||||
$mdl = $this->getModel();
|
||||
$result['result'] = 'ok';
|
||||
$result['uuid'] = $mdl->settings->generateUUID();
|
||||
@mkdir(self::$job_dir);
|
||||
$nodes = $mdl->settings->getNodes();
|
||||
foreach ($nodes as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$items = [];
|
||||
foreach ($value as $itemkey => $itemval) {
|
||||
if (!empty($itemval['selected'])) {
|
||||
$items[] = $itemkey;
|
||||
}
|
||||
}
|
||||
$nodes[$key] = implode(',', $items);
|
||||
}
|
||||
}
|
||||
if (!empty($nodes['interface'])) {
|
||||
/* the script binds to an address, it needs the device name */
|
||||
$nodes['interface'] = (string)Config::getInstance()->object()->interfaces->{$nodes['interface']}->if;
|
||||
}
|
||||
File::file_put_contents(
|
||||
sprintf('%s/%s.json', self::$job_dir, $result['uuid']),
|
||||
json_encode($nodes)
|
||||
);
|
||||
if ($result['result'] === 'failed') {
|
||||
return $result;
|
||||
}
|
||||
return $result;
|
||||
|
||||
$nodes = $this->getModel()->settings->getNodes();
|
||||
foreach ($nodes as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$items = [];
|
||||
foreach ($value as $itemkey => $itemval) {
|
||||
if (!empty($itemval['selected'])) {
|
||||
$items[] = $itemkey;
|
||||
}
|
||||
}
|
||||
$nodes[$key] = implode(',', $items);
|
||||
}
|
||||
}
|
||||
if (!empty($nodes['interface'])) {
|
||||
/* the script binds to an address, it needs the device name */
|
||||
$nodes['interface'] = (string)Config::getInstance()->object()->interfaces->{$nodes['interface']}->if;
|
||||
}
|
||||
$payload = json_decode((new Backend())->configdpRun(
|
||||
'iperf client create',
|
||||
[base64_encode(json_encode($nodes))]
|
||||
), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,14 +76,24 @@ class ClientController extends ApiMutableModelControllerBase
|
||||
*/
|
||||
public function startAction($jobid)
|
||||
{
|
||||
$result = ['status' => 'failed'];
|
||||
if ($this->request->isPost()) {
|
||||
$payload = json_decode((new Backend())->configdpRun('iperf client start', [$jobid]), true);
|
||||
if (!empty($payload)) {
|
||||
$result = $payload;
|
||||
}
|
||||
return $this->runJobAction($jobid, 'start');
|
||||
}
|
||||
|
||||
/**
|
||||
* stop client job
|
||||
*/
|
||||
public function stopAction($jobid)
|
||||
{
|
||||
return $this->runJobAction($jobid, 'stop');
|
||||
}
|
||||
|
||||
private function runJobAction($jobid, $action)
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return ['status' => 'failed'];
|
||||
}
|
||||
return $result;
|
||||
$payload = json_decode((new Backend())->configdpRun("iperf client $action", [$jobid]), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,14 +101,16 @@ class ClientController extends ApiMutableModelControllerBase
|
||||
*/
|
||||
public function removeAction($jobid)
|
||||
{
|
||||
$result = ['status' => 'failed'];
|
||||
if ($this->request->isPost()) {
|
||||
$payload = json_decode((new Backend())->configdpRun('iperf client remove', [$jobid]), true);
|
||||
if (!empty($payload)) {
|
||||
$result = $payload;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
return $this->runJobAction($jobid, 'remove');
|
||||
}
|
||||
|
||||
/**
|
||||
* view the latest client result
|
||||
*/
|
||||
public function viewAction($jobid)
|
||||
{
|
||||
$payload = json_decode((new Backend())->configdpRun('iperf client view', [$jobid]), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+61
-72
@@ -1,104 +1,93 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
* Copyright (C) 2026 Cedrik Pischem
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
* All rights reserved.
|
||||
*
|
||||
* All rights reserved.
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* 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\iperf\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\Core\Config;
|
||||
use OPNsense\Iperf\FakeInstance;
|
||||
|
||||
class InstanceController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelClass = '\OPNsense\iperf\FakeInstance';
|
||||
protected static $internalModelName = 'instance';
|
||||
private static $SOCKET_PATH = "unix:///var/run/iperf-manager.sock";
|
||||
protected static $internalModelClass = 'OPNsense\iperf\Instance';
|
||||
|
||||
// override base to set model - not used here
|
||||
/**
|
||||
* Validate the requested port and start an iperf server instance.
|
||||
*/
|
||||
public function setAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$result = parent::setAction();
|
||||
if ($result['result'] === 'failed') {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// if no socket file exist, we know that the service is not running
|
||||
if (!file_exists("/var/run/iperf-manager.sock")) {
|
||||
$backend->configdRun('iperf start');
|
||||
}
|
||||
if (!isset($_POST['instance']['interface'])) {
|
||||
return array('status' => 'error',
|
||||
'error' => 'interface parameter is missing');
|
||||
}
|
||||
$interface_name = $_POST['instance']['interface'];
|
||||
if ($interface = $this->get_real_interface_name($interface_name)) {
|
||||
// start iperf
|
||||
return $this->send_command("start $interface", $backend);
|
||||
} else {
|
||||
return array('status' => 'error',
|
||||
'error' => 'interface is unknown');
|
||||
}
|
||||
$payload = json_decode((new Backend())->configdpRun(
|
||||
'iperf server create',
|
||||
[base64_encode(json_encode($this->getModel()->getNodes()))]
|
||||
), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
|
||||
public function queryAction()
|
||||
public function searchJobsAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
return $this->send_command('query', $backend);
|
||||
$payload = json_decode((new Backend())->configdRun('iperf server list'), true);
|
||||
$records = !empty($payload['jobs']) ? $payload['jobs'] : [];
|
||||
return $this->searchRecordsetBase($records);
|
||||
}
|
||||
|
||||
private function send_command($command, $backend)
|
||||
public function startAction($jobId)
|
||||
{
|
||||
try {
|
||||
$socket = @stream_socket_client(InstanceController::$SOCKET_PATH, $error_code, $error_msg);
|
||||
} catch (\Exception $e) {
|
||||
$socket = null;
|
||||
}
|
||||
if (!$socket) {
|
||||
// in case of an error: try to restart the service and if that fails too
|
||||
// don't retry anymore
|
||||
$backend->configdRun('iperf restart');
|
||||
$socket = @stream_socket_client(InstanceController::$SOCKET_PATH, $error_code, $error_msg);
|
||||
if (!$socket) {
|
||||
return array('state' => 'error', 'code' => $error_code, 'msg' => $error_msg);
|
||||
}
|
||||
}
|
||||
fwrite($socket, "$command\n");
|
||||
$data = fgets($socket);
|
||||
fwrite($socket, "bye\n");
|
||||
fgets($socket);
|
||||
fclose($socket);
|
||||
return json_decode($data, true);
|
||||
return $this->runJobAction($jobId, 'start');
|
||||
}
|
||||
private function get_real_interface_name($name)
|
||||
|
||||
public function stopAction($jobId)
|
||||
{
|
||||
$config = Config::getInstance()->toArray();
|
||||
if (isset($config['interfaces'][$name])) {
|
||||
return $config['interfaces'][$name]['if'];
|
||||
return $this->runJobAction($jobId, 'stop');
|
||||
}
|
||||
|
||||
private function runJobAction($jobId, $action)
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return ['status' => 'failed'];
|
||||
}
|
||||
return null;
|
||||
$payload = json_decode((new Backend())->configdpRun("iperf server $action", [$jobId]), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
|
||||
public function removeAction($jobId)
|
||||
{
|
||||
return $this->runJobAction($jobId, 'remove');
|
||||
}
|
||||
|
||||
public function viewAction($jobId)
|
||||
{
|
||||
$payload = json_decode((new Backend())->configdpRun('iperf server view', [$jobId]), true);
|
||||
return !empty($payload) ? $payload : ['status' => 'failed'];
|
||||
}
|
||||
}
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
* 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\iperf\Api;
|
||||
|
||||
use OPNsense\Base\ApiControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
|
||||
class ServiceController extends ApiControllerBase
|
||||
{
|
||||
public function statusAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$result = array('result' => 'failed');
|
||||
$res = $backend->configdRun('iperf status');
|
||||
if (stripos($res, 'is running')) {
|
||||
$result['result'] = 'running';
|
||||
} else {
|
||||
$result['result'] = 'stopped';
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function startAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$result = array('result' => $backend->configdRun('iperf start'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function stopAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$result = array("result" => $backend->configdRun('iperf stop'));
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function restartAction()
|
||||
{
|
||||
$this->stopAction();
|
||||
return $this->startAction();
|
||||
}
|
||||
}
|
||||
+28
-31
@@ -1,34 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|
||||
Copyright (C) 2017 Fabian Franz
|
||||
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.
|
||||
|
||||
|
||||
*/
|
||||
|
||||
* Copyright (C) 2026 Cedrik Pischem
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
* 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\iperf;
|
||||
|
||||
@@ -38,10 +35,10 @@ namespace OPNsense\iperf;
|
||||
*/
|
||||
class IndexController extends \OPNsense\Base\IndexController
|
||||
{
|
||||
public function indexAction()
|
||||
public function instanceAction()
|
||||
{
|
||||
$this->view->instance_settings = $this->getForm("instance_settings");
|
||||
$this->view->pick('OPNsense/iperf/index');
|
||||
$this->view->instanceForm = $this->getForm("instance");
|
||||
$this->view->pick('OPNsense/iperf/instance');
|
||||
}
|
||||
|
||||
public function clientAction()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<form>
|
||||
<field>
|
||||
<id>instance.port</id>
|
||||
<label>Port</label>
|
||||
<type>text</type>
|
||||
<help>Start a listener that remains available until removed. Each completed test updates the results below. An appropriate firewall rule is required. Use 0 to select an available port, then allow the port shown below.</help>
|
||||
</field>
|
||||
</form>
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
<form>
|
||||
<field>
|
||||
<id>instance.interface</id>
|
||||
<label>Interface</label>
|
||||
<type>dropdown</type>
|
||||
<help>Choose the interface on which the port should be opened.</help>
|
||||
</field>
|
||||
</form>
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
*
|
||||
* 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\iperf;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class FakeInstance extends BaseModel
|
||||
{
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<model>
|
||||
<mount>//OPNsense/Iperf3</mount>
|
||||
<description>Fake model for the API - will be never stored to config (only used for defaults, validation etc.).</description>
|
||||
<items>
|
||||
<interface type="InterfaceField">
|
||||
<Default>lan</Default>
|
||||
<Required>Y</Required>
|
||||
<Multiple>N</Multiple>
|
||||
</interface>
|
||||
</items>
|
||||
</model>
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2026 Cedrik Pischem
|
||||
* Copyright (C) 2017 Fabian Franz
|
||||
* 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\iperf;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class Instance extends BaseModel
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<model>
|
||||
<mount>:memory:</mount>
|
||||
<version>1.0.0</version>
|
||||
<description>iperf server instance</description>
|
||||
<items>
|
||||
<port type="IntegerField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
<MinimumValue>0</MinimumValue>
|
||||
<MaximumValue>65535</MaximumValue>
|
||||
</port>
|
||||
</items>
|
||||
</model>
|
||||
@@ -1,7 +1,7 @@
|
||||
<menu>
|
||||
<Interfaces>
|
||||
<Diagnostics>
|
||||
<Iperf VisibleName="Iperf Server" url="/ui/iperf" />
|
||||
<Iperf VisibleName="Iperf Server" url="/ui/iperf/index/instance" />
|
||||
<IperfClient VisibleName="Iperf Client" url="/ui/iperf/index/client" />
|
||||
</Diagnostics>
|
||||
</Interfaces>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{#
|
||||
# Copyright (C) 2026 Cedrik Pischem
|
||||
# Copyright (C) 2026 François Maymil
|
||||
# All rights reserved.
|
||||
#
|
||||
@@ -27,6 +28,41 @@
|
||||
<script>
|
||||
$( document ).ready(function() {
|
||||
let poll = null;
|
||||
let errorMessage = function(data) {
|
||||
const value = data.error !== undefined ? data.error : data.status;
|
||||
return $("<span />").text(htmlDecode(value === undefined ? '' : String(value)));
|
||||
};
|
||||
let jobAction = function(action, jobId) {
|
||||
ajaxCall("/api/iperf/client/" + action + "/" + jobId, {}, function (data) {
|
||||
if (data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Client') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
}
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
});
|
||||
};
|
||||
let viewResult = function(jobId) {
|
||||
ajaxGet("/api/iperf/client/view/" + jobId, {}, function (data) {
|
||||
if (data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Client') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
} else {
|
||||
BootstrapDialog.show({
|
||||
size: BootstrapDialog.SIZE_WIDE,
|
||||
title: "{{ lang._('Iperf Client Results') }}",
|
||||
message: $("<pre style='white-space:pre-wrap;word-break:break-word;' />").text(
|
||||
htmlDecode(JSON.stringify(data.data, null, 2))
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
$("#grid-jobs").UIBootgrid({
|
||||
search: '/api/iperf/client/search_jobs',
|
||||
datakey: 'id',
|
||||
@@ -36,6 +72,17 @@
|
||||
"direction": function (column, row) {
|
||||
return row.reverse == '1' ? "{{ lang._('Download') }}" : "{{ lang._('Upload') }}";
|
||||
},
|
||||
"error": function (column, row) {
|
||||
const value = row.error == 'no_result' && row.error_seconds !== undefined ?
|
||||
row.error + ' (' + row.error_seconds + 's)' : row.error;
|
||||
return document.createTextNode(
|
||||
htmlDecode(value === undefined ? '' : String(value))
|
||||
);
|
||||
},
|
||||
"rate": function (column, row) {
|
||||
return row[column.id] === '' ? '' :
|
||||
byteFormat(row[column.id], 2, true).replace(/ B$/, ' G') + 'bit/s';
|
||||
},
|
||||
"status": function (column, row) {
|
||||
if (row.status == 'running') {
|
||||
return '<i class="fa fa-fw fa-spinner fa-pulse"></i>';
|
||||
@@ -48,12 +95,36 @@
|
||||
}
|
||||
},
|
||||
commands: {
|
||||
start: {
|
||||
filter: (cell) => commandFilter(cell, 'start'),
|
||||
title: "{{ lang._('Start') }}",
|
||||
method: function() {
|
||||
jobAction('start', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-play',
|
||||
requires: []
|
||||
},
|
||||
stop: {
|
||||
filter: (cell) => commandFilter(cell, 'stop'),
|
||||
title: "{{ lang._('Stop') }}",
|
||||
method: function() {
|
||||
jobAction('stop', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-stop',
|
||||
requires: []
|
||||
},
|
||||
view: {
|
||||
title: "{{ lang._('View results') }}",
|
||||
method: function() {
|
||||
viewResult($(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-file-text-o',
|
||||
requires: []
|
||||
},
|
||||
delete: {
|
||||
title: "{{ lang._('Remove') }}",
|
||||
method: function() {
|
||||
ajaxCall("/api/iperf/client/remove/" + $(this).data('row-id'), {}, function () {
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
});
|
||||
jobAction('remove', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-trash-o',
|
||||
requires: []
|
||||
@@ -67,6 +138,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
function commandFilter(cell, action) {
|
||||
const active = cell.getData().status == 'running';
|
||||
return action == 'stop' ? active : !active;
|
||||
}
|
||||
|
||||
mapDataToFormUI({'frm_ClientSettings': "/api/iperf/client/get"}).done(function(){
|
||||
$('.selectpicker').selectpicker('refresh');
|
||||
});
|
||||
@@ -75,18 +151,14 @@
|
||||
onPreAction: function() {
|
||||
const dfObj = new $.Deferred();
|
||||
let callb = function (data) {
|
||||
if (data.result && data.result === 'ok') {
|
||||
ajaxCall("/api/iperf/client/start/" + data.uuid, {}, function(data){
|
||||
if (data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Client') }}",
|
||||
message: data.status_msg !== undefined ? data.status_msg : data.status
|
||||
});
|
||||
}
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
if (data.status !== undefined && data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Client') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
}
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
dfObj.reject(); /* do not execute regular data_endpoint */
|
||||
}
|
||||
saveFormToEndpoint("/api/iperf/client/set", 'frm_ClientSettings', callb, true, callb);
|
||||
@@ -113,10 +185,10 @@
|
||||
<th data-column-id="protocol" data-type="string">{{ lang._('Protocol') }}</th>
|
||||
<th data-column-id="parallel" data-type="string">{{ lang._('Streams') }}</th>
|
||||
<th data-column-id="reverse" data-type="string" data-formatter="direction">{{ lang._('Direction') }}</th>
|
||||
<th data-column-id="sent" data-type="string">{{ lang._('Sent (Mbit/s)') }}</th>
|
||||
<th data-column-id="received" data-type="string">{{ lang._('Received (Mbit/s)') }}</th>
|
||||
<th data-column-id="error" data-type="string">{{ lang._('Error') }}</th>
|
||||
<th data-column-id="commands" data-width="4em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
|
||||
<th data-column-id="sent" data-type="numeric" data-formatter="rate">{{ lang._('Sent') }}</th>
|
||||
<th data-column-id="received" data-type="numeric" data-formatter="rate">{{ lang._('Received') }}</th>
|
||||
<th data-column-id="error" data-type="string" data-formatter="error">{{ lang._('Error') }}</th>
|
||||
<th data-column-id="commands" data-width="12em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
{#
|
||||
|
||||
Copyright © 2017 Fabian Franz
|
||||
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>
|
||||
|
||||
function table_tr_kv(key, value) {
|
||||
return "<tr><td>" + key + "</td><td>" + value + "</td></tr>";
|
||||
}
|
||||
function table_tr_transpose(key, key_name, list, arr) {
|
||||
data = "<tr><td>" + key + "</td>";
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
data += "<td>" + arr[list[i]][key_name] + "</td>";
|
||||
}
|
||||
data += "</tr>";
|
||||
return data;
|
||||
}
|
||||
|
||||
function result_to_html(elements) {
|
||||
var output = '';
|
||||
for (var element_cnt = 0; element_cnt < elements.length; element_cnt++) {
|
||||
var element = elements[element_cnt];
|
||||
output += "{{ lang._('%sResult %s%s') }}".replace('%s','<h2>').replace('%s',element_cnt + 1).replace('%s','</h2>');
|
||||
output += '<table class="table table-striped"><tr><td>{{ lang._('Interface') }}</td><td>' + element.interface + '</td></tr>' +
|
||||
'<tr><td>{{ lang._('Start Time') }}</td><td>' + element.start_time + '</td></tr>' +
|
||||
'<tr><td>{{ lang._('Port') }}</td><td>' + element.port + '</td></tr></table>';
|
||||
|
||||
// only if test did already run
|
||||
if ('result' in element) {
|
||||
var result = element.result;
|
||||
if ('error' in result) {
|
||||
// We can't assume that any other fields exist when there's an error
|
||||
output += "<h3>{{ lang._('Error') }}</h3>";
|
||||
output += '<table class="table table-striped">';
|
||||
output += table_tr_kv("{{ lang._('Error message') }}", result.error);
|
||||
output += "</table>";
|
||||
} else {
|
||||
var start = result.start,
|
||||
connection = start.connected[0],
|
||||
intervals = result.intervals,
|
||||
test_end = result.end,
|
||||
cpu = test_end.cpu_utilization_percent;
|
||||
// General
|
||||
output += "<h3>{{ lang._('General') }}</h3>";
|
||||
output += '<table class="table table-striped">';
|
||||
output += table_tr_kv("{{ lang._('Time') }}", start.timestamp.time);
|
||||
output += table_tr_kv("{{ lang._('Duration') }}", start.test_start.duration);
|
||||
output += table_tr_kv("{{ lang._('Block Size') }}", start.test_start.blksize);
|
||||
output += "</table>";
|
||||
// connection
|
||||
output += "<h3>{{ lang._('Connection') }}</h3>";
|
||||
output += '<table class="table table-striped">';
|
||||
output += table_tr_kv("{{ lang._('Local Host') }}", connection.local_host);
|
||||
output += table_tr_kv("{{ lang._('Local Port') }}", connection.local_port);
|
||||
output += table_tr_kv("{{ lang._('Remote Host') }}", connection.remote_host);
|
||||
output += table_tr_kv("{{ lang._('Remote Port') }}", connection.remote_port);
|
||||
output += "</table>";
|
||||
// CPU Usage
|
||||
output += "<h3>{{ lang._('CPU Usage') }}</h3>";
|
||||
output += '<table class="table table-striped">';
|
||||
output += table_tr_kv("{{ lang._('Host Total') }}", cpu.host_total.toFixed(2));
|
||||
output += table_tr_kv("{{ lang._('Host User') }}", cpu.host_user.toFixed(2));
|
||||
output += table_tr_kv("{{ lang._('Host System') }}", cpu.host_system.toFixed(2));
|
||||
output += table_tr_kv("{{ lang._('Remote Total') }}", cpu.remote_total.toFixed(2));
|
||||
output += table_tr_kv("{{ lang._('Remote User') }}", cpu.remote_user.toFixed(2));
|
||||
output += table_tr_kv("{{ lang._('Remote System') }}", cpu.remote_system.toFixed(2));
|
||||
output += "</table>";
|
||||
// performance data
|
||||
output += "<h3>{{ lang._('Performance Data') }}</h3>";
|
||||
output += '<table class="table table-striped">';
|
||||
var fields = ['sum_sent', 'sum_received'];
|
||||
output += table_tr_transpose("{{ lang._('Start') }}","start",fields, test_end);
|
||||
output += table_tr_transpose("{{ lang._('End') }}","end",fields, test_end);
|
||||
output += table_tr_transpose("{{ lang._('Seconds') }}","seconds",fields, test_end);
|
||||
output += table_tr_transpose("{{ lang._('Bytes') }}","bytes",fields, test_end);
|
||||
output += table_tr_transpose("{{ lang._('Bits Per Second') }}","bits_per_second",fields, test_end);
|
||||
output += "</table>";
|
||||
}
|
||||
}
|
||||
}
|
||||
$('#resultcontainer').html(output);
|
||||
}
|
||||
|
||||
function update_results() {
|
||||
ajaxCall(url="/api/iperf/instance/query", sendData={}, callback=function(data,status) {
|
||||
result_to_html(data);
|
||||
});
|
||||
}
|
||||
|
||||
$( document ).ready(function() {
|
||||
var data_get_map = {'instance': '/api/iperf/instance/get'};
|
||||
mapDataToFormUI(data_get_map).done(function(data){
|
||||
formatTokenizersUI();
|
||||
$('select').selectpicker('refresh');
|
||||
});
|
||||
|
||||
ajaxCall(url="/api/iperf/service/status", sendData={}, callback=function(data,status) {
|
||||
updateServiceStatusUI(data['result']);
|
||||
});
|
||||
update_results();
|
||||
setInterval(update_results, 10000);
|
||||
|
||||
// link save button to API set action
|
||||
$("#create_instance_action").click(function(){
|
||||
$("#create_instance_action_progress").addClass("fa fa-spinner fa-pulse");
|
||||
saveFormToEndpoint(url="/api/iperf/instance/set", formid='instance',callback_ok=function(){
|
||||
$("#create_instance_action_progress").removeClass("fa fa-spinner fa-pulse");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<div class="content-box" style="padding-bottom: 1.5em;">
|
||||
{{ partial("layout_partials/base_form",['fields': instance_settings,'id':'instance'])}}
|
||||
<div class="col-md-12">
|
||||
<hr />
|
||||
<button class="btn btn-primary" id="create_instance_action" type="button"><b>{{ lang._('Create Instance') }}</b> <i id="create_instance_action_progress"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="resultcontainer" class="content-box"></div>
|
||||
@@ -0,0 +1,193 @@
|
||||
{#
|
||||
# Copyright (C) 2026 Cedrik Pischem
|
||||
# Copyright (C) 2017 Fabian Franz
|
||||
# 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() {
|
||||
let poll = null;
|
||||
let errorMessage = function(data) {
|
||||
const value = data.error !== undefined ? data.error : data.status;
|
||||
return $("<span />").text(htmlDecode(value === undefined ? '' : String(value)));
|
||||
};
|
||||
let jobAction = function(action, jobId) {
|
||||
ajaxCall("/api/iperf/instance/" + action + "/" + jobId, {}, function (data) {
|
||||
if (data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Server') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
}
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
});
|
||||
};
|
||||
let viewResult = function(jobId) {
|
||||
ajaxGet("/api/iperf/instance/view/" + jobId, {}, function (data) {
|
||||
if (data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Server') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
} else {
|
||||
BootstrapDialog.show({
|
||||
size: BootstrapDialog.SIZE_WIDE,
|
||||
title: "{{ lang._('Iperf Server Results') }}",
|
||||
message: $("<pre style='white-space:pre-wrap;word-break:break-word;' />").text(
|
||||
htmlDecode(JSON.stringify(data.data, null, 2))
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
$("#grid-jobs").UIBootgrid({
|
||||
search: '/api/iperf/instance/search_jobs',
|
||||
datakey: 'id',
|
||||
options: {
|
||||
selection: false,
|
||||
formatters: {
|
||||
"error": function (column, row) {
|
||||
const value = row.error === undefined ? '' : String(row.error);
|
||||
return document.createTextNode(htmlDecode(value));
|
||||
},
|
||||
"rate": function (column, row) {
|
||||
return row[column.id] === '' ? '' :
|
||||
byteFormat(row[column.id], 2, true).replace(/ B$/, ' G') + 'bit/s';
|
||||
},
|
||||
"status": function (column, row) {
|
||||
if (row.status == 'running') {
|
||||
return '<i class="fa fa-fw fa-spinner fa-pulse"></i>';
|
||||
} else if (row.status == 'listening') {
|
||||
return '<i class="fa fa-fw fa-circle text-success" title="{{ lang._('Listening') }}"></i>';
|
||||
} else if (row.status == 'stopped') {
|
||||
return '<i class="fa fa-fw fa-circle text-danger" title="{{ lang._('Stopped') }}"></i>';
|
||||
} else if (row.status == 'error') {
|
||||
return '<i class="fa fa-fw fa-circle text-danger" title="{{ lang._('Error') }}"></i>';
|
||||
} else {
|
||||
return '<i class="fa fa-fw fa-check"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
commands: {
|
||||
start: {
|
||||
filter: (cell) => commandFilter(cell, 'start'),
|
||||
title: "{{ lang._('Start') }}",
|
||||
method: function() {
|
||||
jobAction('start', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-play',
|
||||
requires: []
|
||||
},
|
||||
stop: {
|
||||
filter: (cell) => commandFilter(cell, 'stop'),
|
||||
title: "{{ lang._('Stop') }}",
|
||||
method: function() {
|
||||
jobAction('stop', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-stop',
|
||||
requires: []
|
||||
},
|
||||
view: {
|
||||
title: "{{ lang._('View results') }}",
|
||||
method: function() {
|
||||
viewResult($(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-file-text-o',
|
||||
requires: []
|
||||
},
|
||||
delete: {
|
||||
title: "{{ lang._('Remove') }}",
|
||||
method: function() {
|
||||
jobAction('remove', $(this).data('row-id'));
|
||||
},
|
||||
classname: 'fa fa-fw fa-trash-o',
|
||||
requires: []
|
||||
}
|
||||
}
|
||||
}).on("loaded.rs.jquery.bootgrid", function () {
|
||||
/* refresh while a server is listening or handling a test */
|
||||
clearTimeout(poll);
|
||||
if ($("#grid-jobs").bootgrid("getCurrentRows").some(
|
||||
row => row.status == 'listening' || row.status == 'running'
|
||||
)) {
|
||||
poll = setTimeout(function(){ $("#grid-jobs").bootgrid("reload"); }, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
function commandFilter(cell, action) {
|
||||
const status = cell.getData().status;
|
||||
const active = status == 'listening' || status == 'running';
|
||||
return action == 'stop' ? active : !active;
|
||||
}
|
||||
|
||||
mapDataToFormUI({'frm_InstanceSettings': "/api/iperf/instance/get"}).done(function(){
|
||||
$('.selectpicker').selectpicker('refresh');
|
||||
});
|
||||
|
||||
$("#btn_start_new").SimpleActionButton({
|
||||
onPreAction: function() {
|
||||
const dfObj = new $.Deferred();
|
||||
let callb = function (data) {
|
||||
if (data.status !== undefined && data.status !== 'ok') {
|
||||
BootstrapDialog.show({
|
||||
type: BootstrapDialog.TYPE_WARNING,
|
||||
title: "{{ lang._('Iperf Server') }}",
|
||||
message: errorMessage(data)
|
||||
});
|
||||
}
|
||||
$("#grid-jobs").bootgrid("reload");
|
||||
dfObj.reject(); /* do not execute regular data_endpoint */
|
||||
}
|
||||
saveFormToEndpoint("/api/iperf/instance/set", 'frm_InstanceSettings', callb, true, callb);
|
||||
return dfObj;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="content-box">
|
||||
{{ partial("layout_partials/base_form",['fields':instanceForm,'id':'frm_InstanceSettings'])}}
|
||||
{{ partial('layout_partials/base_apply_button', {'button_id': 'btn_start_new', 'data_endpoint': '', 'data_label': lang._('Start')}) }}
|
||||
</div>
|
||||
<div class="content-box">
|
||||
<table id="grid-jobs" class="table table-condensed table-hover table-striped table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-column-id="status" data-width="2em" data-sortable="false" data-formatter="status"> </th>
|
||||
<th data-column-id="id" data-type="string" data-sortable="false" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
|
||||
<th data-column-id="started" data-type="string" data-order="desc">{{ lang._('Started') }}</th>
|
||||
<th data-column-id="port" data-type="string">{{ lang._('Port') }}</th>
|
||||
<th data-column-id="sent" data-type="numeric" data-formatter="rate">{{ lang._('Sent') }}</th>
|
||||
<th data-column-id="received" data-type="numeric" data-formatter="rate">{{ lang._('Received') }}</th>
|
||||
<th data-column-id="error" data-type="string" data-formatter="error">{{ lang._('Error') }}</th>
|
||||
<th data-column-id="commands" data-width="12em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/local/bin/python3
|
||||
|
||||
"""
|
||||
Copyright (C) 2026 Cedrik Pischem
|
||||
Copyright (C) 2026 François Maymil
|
||||
All rights reserved.
|
||||
|
||||
@@ -25,21 +26,30 @@ 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.
|
||||
--------------------------------------------------------------------------------------
|
||||
run iperf3 in client mode as a background job, settings and results are kept in /tmp
|
||||
run iperf3 in client mode as a background job
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import fcntl
|
||||
import glob
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import ujson
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
JOB_DIR = '/tmp/iperf/'
|
||||
JOB_DIR = '/var/db/iperf/client/'
|
||||
# iperf3 can wait forever on a server that accepts the connection but never starts the test
|
||||
GRACE_TIME = 30
|
||||
|
||||
|
||||
def job_path(jobid, extension):
|
||||
return os.path.join(JOB_DIR, '%s.%s' % (jobid, extension))
|
||||
|
||||
|
||||
def job_pids(jobid):
|
||||
pids = []
|
||||
args = ['/bin/pgrep', '-f', "%s%s" % (JOB_DIR, jobid)]
|
||||
@@ -49,6 +59,16 @@ def job_pids(jobid):
|
||||
return pids
|
||||
|
||||
|
||||
def stop_job(jobid):
|
||||
for pid in job_pids(jobid):
|
||||
subprocess.run(['kill', pid])
|
||||
for _ in range(20):
|
||||
if not job_pids(jobid):
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def load_json(filename):
|
||||
try:
|
||||
with open(filename, 'r') as f:
|
||||
@@ -66,12 +86,85 @@ def interface_address(intf, family):
|
||||
return None
|
||||
|
||||
|
||||
def failed(error, **details):
|
||||
return {'status': 'failed', 'error': error, **details}
|
||||
|
||||
|
||||
def start_job(jobid, filename):
|
||||
settings = load_json(filename) or {}
|
||||
try:
|
||||
duration = int(settings.get('duration', 10))
|
||||
port = int(settings.get('port', 5201))
|
||||
except (TypeError, ValueError):
|
||||
return failed('invalid_settings')
|
||||
if job_pids(jobid):
|
||||
return failed('in_use')
|
||||
|
||||
logfile = job_path(jobid, 'log')
|
||||
args = [
|
||||
'/usr/sbin/daemon', '-f',
|
||||
'/usr/bin/timeout', '-s', 'KILL', str(duration + GRACE_TIME),
|
||||
'/usr/local/bin/iperf3', '-J',
|
||||
'-c', str(settings.get('server', '')),
|
||||
'-p', str(port),
|
||||
'-t', str(duration),
|
||||
'-P', str(settings.get('parallel', 1)),
|
||||
'--logfile', logfile
|
||||
]
|
||||
if settings.get('protocol') == 'udp':
|
||||
args.append('-u')
|
||||
if str(settings.get('reverse')) == '1':
|
||||
args.append('-R')
|
||||
if settings.get('interface', '') != '':
|
||||
# iperf3 connects to the first address the server resolves to, -B must be of the same family
|
||||
try:
|
||||
inet6 = socket.getaddrinfo(settings.get('server', ''), None)[0][0] == socket.AF_INET6
|
||||
except socket.gaierror:
|
||||
inet6 = False
|
||||
address = interface_address(settings['interface'], 'inet6' if inet6 else 'inet')
|
||||
if address is None:
|
||||
return failed(
|
||||
'source_unavailable',
|
||||
family='IPv6' if inet6 else 'IPv4',
|
||||
interface=settings['interface']
|
||||
)
|
||||
args += ['-B', address]
|
||||
|
||||
if os.path.exists(logfile):
|
||||
os.remove(logfile)
|
||||
if subprocess.run(args).returncode != 0:
|
||||
return failed('start_failed')
|
||||
settings['port'] = port
|
||||
with open(filename, 'w') as output:
|
||||
ujson.dump(settings, output)
|
||||
stopped = job_path(jobid, 'stop')
|
||||
if os.path.exists(stopped):
|
||||
os.remove(stopped)
|
||||
return {'status': 'ok', 'id': jobid, 'port': port}
|
||||
|
||||
|
||||
def view_job(jobid):
|
||||
output = load_json(job_path(jobid, 'log'))
|
||||
if not isinstance(output, dict):
|
||||
return failed('no_result')
|
||||
data = {key: output[key] for key in ('start', 'end', 'error') if key in output}
|
||||
return {'status': 'ok', 'id': jobid, 'data': data} if data else failed('no_result')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
result = dict()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--job', help='job id', default=None)
|
||||
parser.add_argument('action', help='action to perform', choices=['list', 'start', 'remove'])
|
||||
parser.add_argument('--settings', default='')
|
||||
parser.add_argument(
|
||||
'action',
|
||||
help='action to perform',
|
||||
choices=['list', 'create', 'start', 'stop', 'remove', 'view']
|
||||
)
|
||||
cmd_args = parser.parse_args()
|
||||
os.makedirs(JOB_DIR, mode=0o750, exist_ok=True)
|
||||
lock = open(os.path.join(JOB_DIR, '.lock'), 'w')
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
|
||||
all_jobs = {}
|
||||
for filename in glob.glob("%s*.json" % JOB_DIR):
|
||||
@@ -88,71 +181,62 @@ if __name__ == '__main__':
|
||||
continue
|
||||
job = load_json(all_jobs[jobid]) or {}
|
||||
job['id'] = jobid
|
||||
job['started'] = datetime.fromtimestamp(started).isoformat(timespec='seconds')
|
||||
job['started'] = datetime.fromtimestamp(started).astimezone().isoformat(timespec='seconds')
|
||||
job['sent'] = job['received'] = job['error'] = ''
|
||||
if len(job_pids(jobid)) > 0:
|
||||
if os.path.exists("%s%s.stop" % (JOB_DIR, jobid)):
|
||||
job['status'] = 'error'
|
||||
job['error'] = 'stopped'
|
||||
elif len(job_pids(jobid)) > 0:
|
||||
job['status'] = 'running'
|
||||
else:
|
||||
output = load_json("%s%s.log" % (JOB_DIR, jobid))
|
||||
if output is None:
|
||||
job['status'] = 'error'
|
||||
job['error'] = 'no result, the test was stopped after %s seconds' % (
|
||||
int(job.get('duration', 0)) + GRACE_TIME
|
||||
)
|
||||
job['error'] = 'no_result'
|
||||
job['error_seconds'] = int(job.get('duration', 0)) + GRACE_TIME
|
||||
elif output.get('error'):
|
||||
job['status'] = 'error'
|
||||
job['error'] = output['error']
|
||||
else:
|
||||
job['status'] = 'done'
|
||||
job['sent'] = round(output['end']['sum_sent']['bits_per_second'] / 1000000, 2)
|
||||
job['received'] = round(output['end']['sum_received']['bits_per_second'] / 1000000, 2)
|
||||
job['sent'] = output['end']['sum_sent']['bits_per_second']
|
||||
job['received'] = output['end']['sum_received']['bits_per_second']
|
||||
result['jobs'].append(job)
|
||||
elif cmd_args.action == 'create':
|
||||
try:
|
||||
settings = ujson.loads(base64.b64decode(cmd_args.settings, validate=True).decode())
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
result = failed('invalid_settings')
|
||||
else:
|
||||
jobid = uuid.uuid4().hex
|
||||
filename = job_path(jobid, 'json')
|
||||
with open(filename, 'w') as output:
|
||||
ujson.dump(settings, output)
|
||||
result = start_job(jobid, filename)
|
||||
if result['status'] != 'ok':
|
||||
os.remove(filename)
|
||||
elif cmd_args.action == 'start' and cmd_args.job in all_jobs:
|
||||
settings = load_json(all_jobs[cmd_args.job]) or {}
|
||||
logfile = "%s%s.log" % (JOB_DIR, cmd_args.job)
|
||||
args = [
|
||||
'/usr/sbin/daemon', '-f',
|
||||
'/usr/bin/timeout', '-s', 'KILL', str(int(settings.get('duration', 10)) + GRACE_TIME),
|
||||
'/usr/local/bin/iperf3', '-J',
|
||||
'-c', settings.get('server', ''),
|
||||
'-p', settings.get('port', '5201'),
|
||||
'-t', settings.get('duration', '10'),
|
||||
'-P', settings.get('parallel', '1'),
|
||||
'--logfile', logfile
|
||||
]
|
||||
if settings.get('protocol') == 'udp':
|
||||
args.append('-u')
|
||||
if settings.get('reverse') == '1':
|
||||
args.append('-R')
|
||||
result['status'] = 'ok'
|
||||
if settings.get('interface', '') != '':
|
||||
# iperf3 connects to the first address the server resolves to, -B must be of the same family
|
||||
try:
|
||||
inet6 = socket.getaddrinfo(settings.get('server', ''), None)[0][0] == socket.AF_INET6
|
||||
except socket.gaierror:
|
||||
inet6 = False
|
||||
address = interface_address(settings['interface'], 'inet6' if inet6 else 'inet')
|
||||
if address is None:
|
||||
result['status'] = 'failed'
|
||||
result['status_msg'] = 'no %s address on %s' % ('IPv6' if inet6 else 'IPv4', settings['interface'])
|
||||
else:
|
||||
args += ['-B', address]
|
||||
if len(job_pids(cmd_args.job)) > 0:
|
||||
result['status'] = 'failed'
|
||||
result['status_msg'] = 'already running'
|
||||
if result['status'] == 'ok':
|
||||
if os.path.exists(logfile):
|
||||
os.remove(logfile)
|
||||
if subprocess.run(args).returncode != 0:
|
||||
result['status'] = 'failed'
|
||||
result['status_msg'] = 'unable to start iperf3'
|
||||
result = start_job(cmd_args.job, all_jobs[cmd_args.job])
|
||||
elif cmd_args.action == 'stop' and cmd_args.job in all_jobs:
|
||||
if stop_job(cmd_args.job):
|
||||
result['status'] = 'ok'
|
||||
with open("%s%s.stop" % (JOB_DIR, cmd_args.job), 'w'):
|
||||
pass
|
||||
else:
|
||||
result = failed('stop_failed')
|
||||
elif cmd_args.action == 'remove' and cmd_args.job in all_jobs:
|
||||
result['status'] = 'ok'
|
||||
for pid in job_pids(cmd_args.job):
|
||||
subprocess.run(['kill', pid])
|
||||
for filename in glob.glob("%s%s*" % (JOB_DIR, cmd_args.job)):
|
||||
os.remove(filename)
|
||||
if stop_job(cmd_args.job):
|
||||
result['status'] = 'ok'
|
||||
for filename in glob.glob("%s%s*" % (JOB_DIR, cmd_args.job)):
|
||||
os.remove(filename)
|
||||
else:
|
||||
result = failed('stop_failed')
|
||||
elif cmd_args.action == 'view' and cmd_args.job in all_jobs:
|
||||
result = view_job(cmd_args.job)
|
||||
else:
|
||||
result['status'] = 'failed'
|
||||
result = failed('not_found')
|
||||
|
||||
lock.close()
|
||||
print(ujson.dumps(result))
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
#!/usr/local/bin/ruby
|
||||
|
||||
=begin
|
||||
Copyright (C) 2017 Fabian Franz
|
||||
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.
|
||||
=end
|
||||
|
||||
require 'open3'
|
||||
require 'pp'
|
||||
require 'json'
|
||||
require 'logger'
|
||||
require 'rexml/document'
|
||||
require 'timeout'
|
||||
require 'socket'
|
||||
|
||||
$instances = {}
|
||||
SOCKET_FILE = '/var/run/iperf-manager.sock'
|
||||
ONE_HOUR = 3600
|
||||
KEY_START_TIME = 'start_time'
|
||||
KEY_PORT = 'port'
|
||||
LF = "\n"
|
||||
|
||||
def execute_firewall_port(rule)
|
||||
Open3.popen3('pfctl -a iperf -f -') do |stdin, stdout, stderr, wait_thr|
|
||||
stdin.puts rule
|
||||
stdin.close
|
||||
stdout.close
|
||||
stderr.close
|
||||
end
|
||||
end
|
||||
|
||||
def flush_firewall_rules
|
||||
`pfctl -a iperf -F rules`
|
||||
end
|
||||
|
||||
def create_open_firewall_port_rule(interface, port, log = 'log')
|
||||
"pass in #{log} quick on #{interface} inet proto tcp from {any} to {(self)} port {#{port}} keep state"
|
||||
end
|
||||
|
||||
def gen_firewall_rules
|
||||
rules = ''
|
||||
$instances.each do |thread, entry|
|
||||
pp (["thread alive?", thread.alive?, entry])
|
||||
if thread.alive?
|
||||
if entry.has_key?('interface') && entry.has_key?('port') && !entry.has_key?('result')
|
||||
rules << create_open_firewall_port_rule(entry['interface'], entry['port']) << LF
|
||||
end
|
||||
end
|
||||
end
|
||||
execute_firewall_port(rules) if rules.length > 0
|
||||
end
|
||||
|
||||
def open_ports
|
||||
`sockstat -l`.lines.map do |x|
|
||||
# scan for integers after :
|
||||
x.scan(/.*:(\d+).*/)&.first&.first&.to_i
|
||||
end.uniq.reject(&:nil?).sort
|
||||
end
|
||||
|
||||
def forwarded_ports
|
||||
config = REXML::Document.new(File.new("/conf/config.xml"))
|
||||
xml_firewall = config.elements['opnsense/nat'].children.select do |x|
|
||||
x.node_type == :element && x.name == 'rule'
|
||||
end
|
||||
xml_firewall.map do |x|
|
||||
x&.elements['local-port']&.text&.to_i
|
||||
end.sort.uniq
|
||||
|
||||
end
|
||||
|
||||
def find_open_ports
|
||||
ports = (1024..65000).to_a
|
||||
# remove the ports open by the firewall itself
|
||||
begin
|
||||
ports -= open_ports
|
||||
rescue
|
||||
print $!
|
||||
end
|
||||
# remove the nat ports
|
||||
begin
|
||||
ports -= forwarded_ports
|
||||
rescue
|
||||
print $!
|
||||
end
|
||||
ports
|
||||
end
|
||||
|
||||
def find_open_port
|
||||
find_open_ports.sample
|
||||
end
|
||||
|
||||
def run_iperf3(port)
|
||||
output = pid = exit_status = ''
|
||||
Open3.popen3(['iperf3', '-J', '-f', 'M', '-V', '-s', '-1', '-p', port].join ' ') do |stdin, stdout, stderr, wait_thr|
|
||||
pid = wait_thr.pid # pid of the started process.
|
||||
stdin.close
|
||||
exit_status = wait_thr.value
|
||||
output = JSON.parse(stdout.read)
|
||||
stdout.close
|
||||
stderr.close
|
||||
end
|
||||
output
|
||||
end
|
||||
|
||||
def run_test(interface = 'any', data)
|
||||
ret = nil
|
||||
data[KEY_PORT] = port = find_open_port
|
||||
# regenerate ruleset
|
||||
flush_firewall_rules
|
||||
gen_firewall_rules
|
||||
# do perform test
|
||||
begin
|
||||
# timeout 10 min
|
||||
begin
|
||||
Timeout.timeout(600) do
|
||||
data['result'] = ret = run_iperf3 port
|
||||
end
|
||||
rescue Timeout::Error
|
||||
data['result'] = ret = [-1,{'error' => 'timeout'}]
|
||||
end
|
||||
rescue
|
||||
puts $!
|
||||
end
|
||||
# end perform test
|
||||
# regenerate ruleset
|
||||
flush_firewall_rules
|
||||
gen_firewall_rules
|
||||
ret
|
||||
end
|
||||
|
||||
def run_test_thread(interface = 'any')
|
||||
data = {}
|
||||
t = Thread.new do
|
||||
data[KEY_START_TIME] = Time.now
|
||||
data['interface'] = interface
|
||||
run_test(interface, data)
|
||||
end
|
||||
$instances[t] = data
|
||||
end
|
||||
|
||||
Thread.new do
|
||||
loop do
|
||||
$instances.each do |key, value|
|
||||
current_time = Time.now
|
||||
if (current_time - value[KEY_START_TIME]) > ONE_HOUR
|
||||
$instances.delete(key)
|
||||
key.kill unless key.stop?
|
||||
end
|
||||
end
|
||||
sleep 10
|
||||
end
|
||||
end
|
||||
|
||||
# delete stale socket file
|
||||
File.unlink(SOCKET_FILE) if File.exist? SOCKET_FILE
|
||||
|
||||
server = UNIXServer.new(SOCKET_FILE)
|
||||
begin
|
||||
loop do
|
||||
Thread.start(server.accept) do |connection|
|
||||
until connection.closed?
|
||||
begin
|
||||
command = connection.gets.strip.split(' ')
|
||||
case command.shift
|
||||
when 'start'
|
||||
interface = 'any'
|
||||
if command.length > 0
|
||||
intf = command.shift
|
||||
# check if a valid interface was given
|
||||
interface = intf if intf =~ /^[a-z0-9_-]+$/
|
||||
end
|
||||
data = run_test_thread interface
|
||||
connection.puts '{"status": "queued job"}'
|
||||
when 'query'
|
||||
connection.puts $instances.values.to_json
|
||||
when 'bye'
|
||||
connection.puts '{"status": "disconnecting"}'
|
||||
connection.close
|
||||
else
|
||||
connection.puts '{"status": "unknown command"}'
|
||||
end
|
||||
rescue
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue
|
||||
server.close
|
||||
end
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/local/bin/python3
|
||||
|
||||
"""
|
||||
Copyright (C) 2026 Cedrik Pischem
|
||||
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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import fcntl
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
JOB_DIR = '/var/db/iperf/server'
|
||||
STARTUP_GRACE = 5
|
||||
|
||||
|
||||
def job_path(job_id, extension):
|
||||
return os.path.join(JOB_DIR, '%s.%s' % (job_id, extension))
|
||||
|
||||
|
||||
def load_json(filename):
|
||||
try:
|
||||
with open(filename, 'r') as handle:
|
||||
return json.load(handle)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_job(job_id):
|
||||
try:
|
||||
if uuid.UUID(job_id).hex != job_id:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return load_json(job_path(job_id, 'json'))
|
||||
|
||||
|
||||
def load_events(filename):
|
||||
events = []
|
||||
try:
|
||||
with open(filename, 'r') as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
event = json.loads(line)
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return events
|
||||
|
||||
|
||||
def startup_pending(filename):
|
||||
try:
|
||||
return time.time() - os.path.getmtime(filename) < STARTUP_GRACE
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def job_pids(job_id):
|
||||
output = subprocess.run(
|
||||
['/bin/pgrep', '-f', job_path(job_id, 'log')],
|
||||
capture_output=True,
|
||||
text=True
|
||||
).stdout
|
||||
return [pid for pid in output.split() if pid.isdigit()]
|
||||
|
||||
|
||||
def stop_processes(job_id):
|
||||
for pid in job_pids(job_id):
|
||||
subprocess.run(['/bin/kill', pid])
|
||||
for _ in range(20):
|
||||
if not job_pids(job_id):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
return not job_pids(job_id)
|
||||
|
||||
|
||||
def available_port(port):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(('', port))
|
||||
return probe.getsockname()[1]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def rate(result, field):
|
||||
value = result.get(field, {}).get('bits_per_second')
|
||||
return value if isinstance(value, (int, float)) else ''
|
||||
|
||||
|
||||
def failed(error, **details):
|
||||
return {'status': 'failed', 'error': error, **details}
|
||||
|
||||
|
||||
def list_jobs():
|
||||
jobs = []
|
||||
for filename in glob.glob(os.path.join(JOB_DIR, '*.json')):
|
||||
job_id = os.path.basename(filename).split('.')[0]
|
||||
job = load_json(filename)
|
||||
if job is None:
|
||||
continue
|
||||
job.update({'id': job_id, 'sent': '', 'received': '', 'error': ''})
|
||||
events = load_events(job_path(job_id, 'log'))
|
||||
last_event = events[-1] if events else {}
|
||||
last_result = next(
|
||||
(event.get('data', {}) for event in reversed(events) if event.get('event') == 'end'),
|
||||
None
|
||||
)
|
||||
running = bool(job_pids(job_id))
|
||||
if running:
|
||||
active = last_event.get('event') in ('start', 'interval')
|
||||
job['status'] = 'running' if active else 'listening'
|
||||
elif os.path.exists(job_path(job_id, 'stop')):
|
||||
job['status'] = 'stopped'
|
||||
elif last_event.get('event') == 'error':
|
||||
job['status'] = 'error'
|
||||
elif not events and startup_pending(filename):
|
||||
job['status'] = 'listening'
|
||||
else:
|
||||
job['status'] = 'stopped'
|
||||
if last_result is not None:
|
||||
job['sent'] = rate(last_result, 'sum_sent')
|
||||
job['received'] = rate(last_result, 'sum_received')
|
||||
if last_event.get('event') == 'error':
|
||||
error = last_event.get('data')
|
||||
if error is not None:
|
||||
job['error'] = error.get('error', str(error)) if isinstance(error, dict) else str(error)
|
||||
jobs.append(job)
|
||||
return {'status': 'ok', 'jobs': jobs}
|
||||
|
||||
|
||||
def view_job(job_id):
|
||||
if load_job(job_id) is None:
|
||||
return failed('not_found')
|
||||
events = load_events(job_path(job_id, 'log'))
|
||||
if not events:
|
||||
return failed('no_result')
|
||||
return {'status': 'ok', 'id': job_id, 'data': events}
|
||||
|
||||
|
||||
def launch(job_id, port):
|
||||
logfile = job_path(job_id, 'log')
|
||||
if os.path.exists(logfile):
|
||||
os.remove(logfile)
|
||||
command = [
|
||||
'/usr/sbin/daemon', '-f',
|
||||
'/usr/local/bin/iperf3', '--json-stream', '--forceflush', '-f', 'M', '-s',
|
||||
'-p', str(port), '--logfile', logfile
|
||||
]
|
||||
result = subprocess.run(command, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
return failed('start_failed', detail=result.stderr.strip())
|
||||
metadata = load_job(job_id)
|
||||
metadata['started'] = datetime.now().astimezone().isoformat(timespec='seconds')
|
||||
with open(job_path(job_id, 'json'), 'w') as handle:
|
||||
json.dump(metadata, handle)
|
||||
stopped = job_path(job_id, 'stop')
|
||||
if os.path.exists(stopped):
|
||||
os.remove(stopped)
|
||||
return {'status': 'ok', 'id': job_id, 'port': port}
|
||||
|
||||
|
||||
def create(settings):
|
||||
try:
|
||||
port = int(settings.get('port', 0))
|
||||
except (TypeError, ValueError):
|
||||
return failed('invalid_settings')
|
||||
port = available_port(port)
|
||||
if port is None:
|
||||
return failed('in_use')
|
||||
for filename in glob.glob(os.path.join(JOB_DIR, '*.json')):
|
||||
job_id = os.path.basename(filename).split('.')[0]
|
||||
job = load_json(filename) or {}
|
||||
stopped = os.path.exists(job_path(job_id, 'stop'))
|
||||
if job.get('port') == port and not stopped and (job_pids(job_id) or startup_pending(filename)):
|
||||
return failed('in_use')
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
metadata = dict(settings)
|
||||
metadata['started'] = datetime.now().astimezone().isoformat(timespec='seconds')
|
||||
metadata['port'] = port
|
||||
with open(job_path(job_id, 'json'), 'w') as handle:
|
||||
json.dump(metadata, handle)
|
||||
result = launch(job_id, port)
|
||||
if result['status'] != 'ok':
|
||||
os.remove(job_path(job_id, 'json'))
|
||||
return result
|
||||
|
||||
|
||||
def start(job_id):
|
||||
job = load_job(job_id)
|
||||
if job is None:
|
||||
return failed('not_found')
|
||||
if job_pids(job_id):
|
||||
return failed('in_use')
|
||||
if available_port(job['port']) is None:
|
||||
return failed('in_use')
|
||||
return launch(job_id, job['port'])
|
||||
|
||||
|
||||
def stop(job_id):
|
||||
if load_job(job_id) is None:
|
||||
return failed('not_found')
|
||||
if not stop_processes(job_id):
|
||||
return failed('stop_failed')
|
||||
with open(job_path(job_id, 'stop'), 'w'):
|
||||
pass
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
def remove(job_id):
|
||||
if load_job(job_id) is None:
|
||||
return failed('not_found')
|
||||
if not stop_processes(job_id):
|
||||
return failed('stop_failed')
|
||||
for filename in glob.glob(os.path.join(JOB_DIR, '%s.*' % job_id)):
|
||||
os.remove(filename)
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--job')
|
||||
parser.add_argument('--settings', default='')
|
||||
parser.add_argument('action', choices=['list', 'create', 'start', 'stop', 'remove', 'view'])
|
||||
args = parser.parse_args()
|
||||
os.makedirs(JOB_DIR, mode=0o750, exist_ok=True)
|
||||
with open(os.path.join(JOB_DIR, '.lock'), 'w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
if args.action == 'list':
|
||||
response = list_jobs()
|
||||
elif args.action == 'create':
|
||||
try:
|
||||
settings = json.loads(base64.b64decode(args.settings, validate=True).decode())
|
||||
if not isinstance(settings, dict):
|
||||
raise ValueError
|
||||
except (binascii.Error, UnicodeDecodeError, ValueError):
|
||||
response = failed('invalid_settings')
|
||||
else:
|
||||
response = create(settings)
|
||||
elif args.action == 'start':
|
||||
response = start(args.job or '')
|
||||
elif args.action == 'stop':
|
||||
response = stop(args.job or '')
|
||||
elif args.action == 'remove':
|
||||
response = remove(args.job or '')
|
||||
else:
|
||||
response = view_job(args.job or '')
|
||||
print(json.dumps(response))
|
||||
@@ -1,26 +1,38 @@
|
||||
[start]
|
||||
command:/usr/local/etc/rc.d/iperf start
|
||||
parameters:
|
||||
type:script
|
||||
message:starting iperf daemon
|
||||
|
||||
[stop]
|
||||
command:/usr/local/etc/rc.d/iperf stop
|
||||
parameters:
|
||||
type:script
|
||||
message:stopping iperf daemon
|
||||
|
||||
[restart]
|
||||
command:/usr/local/etc/rc.d/iperf restart
|
||||
parameters:
|
||||
type:script
|
||||
message:restarting iperf daemon
|
||||
|
||||
[status]
|
||||
command:/usr/local/etc/rc.d/iperf status
|
||||
[server.list]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py list
|
||||
parameters:
|
||||
type:script_output
|
||||
message:request iperf daemon status
|
||||
message:list iperf server jobs
|
||||
|
||||
[server.create]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py
|
||||
parameters: --settings %s create
|
||||
type:script_output
|
||||
message:create iperf server job
|
||||
|
||||
[server.start]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py
|
||||
parameters: --job %s start
|
||||
type:script_output
|
||||
message:start iperf server job %s
|
||||
|
||||
[server.stop]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py
|
||||
parameters: --job %s stop
|
||||
type:script_output
|
||||
message:stop iperf server job %s
|
||||
|
||||
[server.remove]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py
|
||||
parameters: --job %s remove
|
||||
type:script_output
|
||||
message:remove iperf server job %s
|
||||
|
||||
[server.view]
|
||||
command:/usr/local/opnsense/scripts/iperf/server.py
|
||||
parameters: --job %s view
|
||||
type:script_output
|
||||
message:view iperf server job %s
|
||||
|
||||
[client.list]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py list
|
||||
@@ -28,14 +40,32 @@ parameters:
|
||||
type:script_output
|
||||
message:list iperf client jobs
|
||||
|
||||
[client.create]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py
|
||||
parameters: --settings %s create
|
||||
type:script_output
|
||||
message:create iperf client job
|
||||
|
||||
[client.start]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py
|
||||
parameters: --job %s start
|
||||
type:script_output
|
||||
message:start iperf client job %s
|
||||
|
||||
[client.stop]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py
|
||||
parameters: --job %s stop
|
||||
type:script_output
|
||||
message:stop iperf client job %s
|
||||
|
||||
[client.remove]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py
|
||||
parameters: --job %s remove
|
||||
type:script_output
|
||||
message:remove iperf client job %s
|
||||
|
||||
[client.view]
|
||||
command:/usr/local/opnsense/scripts/iperf/client.py
|
||||
parameters: --job %s view
|
||||
type:script_output
|
||||
message:view iperf client job %s
|
||||
|
||||
Reference in New Issue
Block a user