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:
Monviech
2026-10-09 13:54:22 +02:00
committed by GitHub
parent 05f3f126b6
commit 53533c8732
23 changed files with 973 additions and 787 deletions
+5
View File
@@ -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
+3 -4
View File
@@ -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"
+12
View File
@@ -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;
}
-13
View File
@@ -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"
@@ -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'];
}
/**
@@ -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'];
}
}
@@ -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();
}
}
@@ -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>
@@ -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">&nbsp;</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
View File
@@ -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