Compare commits

..

21 Commits

Author SHA1 Message Date
Monviech
c48353cdc1 dnsmasq: Change add_mac OptionValue from default to standard to satisfy lint 2025-03-28 20:57:42 +01:00
Monviech
8d6ca1fa98
dnsmasq: Add full dhcp-host support for IPv4 and IPv6 (#8497)
* dnsmasq: Add full dhcp-host support for IPv4 and IPv6

* dnsmasq: Cleanup previous in dnsmasq.inc

* dnsmasq: Change comma placement in template to reduce one condition

* dnsmasq: Add validation to client_id

* dnsmasq: There can be multiple hardware addresses so change label accordingly

* dnsmasq: Change hostname validation so that client_id is also a valid choice without hostname defined.

* dnsmasq: Add validation that prevents duplicate IP addresses in dhcp-host set

* remove one stray newline

* Services: Dnsmasq DNS & DHCP - minor cleanups in https://github.com/opnsense/core/pull/8497

o fix possible race condition in validations
o simplify jinja template

---------

Co-authored-by: Ad Schellevis <ad@opnsense.org>
2025-03-28 19:48:33 +01:00
Ad Schellevis
ad09e7aa6c Services: Unbound DNS: Blocklist - drop "exclude" phrase from log entry as it doesn't make much sense anymore (as a result of aa2cff3e66) 2025-03-28 17:53:39 +01:00
Franco Fichtner
b2dc6fed7c firmware: add cleanup to audits, small refactor to avoid controller repetition; closes #8154 2025-03-28 13:42:13 +01:00
Franco Fichtner
d8ecd8c31b firmware: hook cleanup as hidden "f"lush command in console #8154
Some may argue the hidden commands are not good, but they are really
only to be intended to be called upon request.  None of these things
magically fix firmware updates on their own, but can be useful (and
copying console output into the forum can also be more difficult).
2025-03-28 12:51:50 +01:00
Franco Fichtner
3a9e9edefe pkg: fix plist 2025-03-28 12:20:04 +01:00
Franco Fichtner
433d8d62b3 unbound: model style 2025-03-28 12:19:33 +01:00
Franco Fichtner
51a5118d6e ipsec: pre-shared key permission fix
PR: https://forum.opnsense.org/index.php?topic=46595.0
2025-03-28 12:17:51 +01:00
Franco Fichtner
2774a9b498 firmware: add cleanup script #8154 2025-03-28 09:51:53 +01:00
Ad Schellevis
e4203d81eb Reporting / Insight - cleanup frontend code and move some processing to the backend for easier handling.
Eventually we want to replace the d3 graphs, but before doing that, it's likely a good idea to cleanup the code for readability.
2025-03-27 20:53:28 +01:00
Stephan de Wit
de5dd5f527
bootgrid: resizable columns (#8496) 2025-03-27 16:10:29 +01:00
Franco Fichtner
7fc2ab43a4 dnsmasq: style sweep 2025-03-27 11:29:05 +01:00
Ad Schellevis
a7cb604301 System: Gateways: Group - fix typo in trigger level, loss or latency is actually both combined. 2025-03-27 11:24:55 +01:00
Monviech
92881adb40
firewall/filter: Use fetch_options from opnsense_ui.js to build interface_select selectpicker (#8493)
* firewall/filter: Use fetch_options from opnsense_ui.js to build interface_select selectpicker

* Update src/opnsense/mvc/app/controllers/OPNsense/Firewall/Api/FilterController.php

Co-authored-by: Franco Fichtner <franco@opnsense.org>

---------

Co-authored-by: Franco Fichtner <franco@opnsense.org>
2025-03-27 11:23:35 +01:00
Ad Schellevis
d73ec9feae Reporting / Insight - move single_pass to command line parameters for easier debugging 2025-03-27 11:22:33 +01:00
Monviech
8db4e28614
dnsmasq: Add filter function for interfaces and tags with multiselect (#8465)
* dnsmasq: Add filter function for interfaces and tags with multiselect

* dnsmasq: Small cleanup in filter selectpicker previous

* Refactor search actions and tag filtering

- Use single helper function for building filter
- Use tag UUIDs instead of names for filtering
- Avoid building filter functions when filters are empty
- Pass null to searchBase() when no filtering is required
- Use UUID-based filtering for dhcp_tags via record attributes

* dnsmasq: Make tags and interfaces dropdown just a tad nicer

* Services: Dnsmasq DNS & DHCP - cleanups for https://github.com/opnsense/core/pull/8465

simplify recurring pattern for tag search and move select options generation into common jquery function.

---------

Co-authored-by: Ad Schellevis <ad@opnsense.org>
2025-03-26 18:05:00 +01:00
Franco Fichtner
b163c68bf9 backend: restore missing Python module
PR: https://forum.opnsense.org/index.php?topic=46556.0
2025-03-26 15:40:41 +01:00
Franco Fichtner
7dae89eadf system: small audit of auth.inc 2025-03-26 08:55:41 +01:00
Franco Fichtner
fd98874ce7 firewall: use the established "; exit 0" idiom here
Eventually it may be more helpful to have a property such as...

    errors: no
2025-03-26 07:37:42 +01:00
Franco Fichtner
e57aeea3e8 openvpn: whitespace in comment 2025-03-26 07:35:01 +01:00
Ad Schellevis
7f9444f754 Reporting / Insight - remove some unused imports 2025-03-25 21:36:11 +01:00
34 changed files with 1019 additions and 332 deletions

2
plist
View File

@ -1095,6 +1095,7 @@
/usr/local/opnsense/scripts/firmware/bogons.sh /usr/local/opnsense/scripts/firmware/bogons.sh
/usr/local/opnsense/scripts/firmware/changelog.sh /usr/local/opnsense/scripts/firmware/changelog.sh
/usr/local/opnsense/scripts/firmware/check.sh /usr/local/opnsense/scripts/firmware/check.sh
/usr/local/opnsense/scripts/firmware/cleanup.sh
/usr/local/opnsense/scripts/firmware/config.sh /usr/local/opnsense/scripts/firmware/config.sh
/usr/local/opnsense/scripts/firmware/connection.sh /usr/local/opnsense/scripts/firmware/connection.sh
/usr/local/opnsense/scripts/firmware/details.sh /usr/local/opnsense/scripts/firmware/details.sh
@ -1472,6 +1473,7 @@
/usr/local/opnsense/site-python/daemonize.py /usr/local/opnsense/site-python/daemonize.py
/usr/local/opnsense/site-python/duckdb_helper.py /usr/local/opnsense/site-python/duckdb_helper.py
/usr/local/opnsense/site-python/log_helper.py /usr/local/opnsense/site-python/log_helper.py
/usr/local/opnsense/site-python/params.py
/usr/local/opnsense/site-python/sqlite3_helper.py /usr/local/opnsense/site-python/sqlite3_helper.py
/usr/local/opnsense/site-python/tls_helper.py /usr/local/opnsense/site-python/tls_helper.py
/usr/local/opnsense/site-python/watchers/__init__.py /usr/local/opnsense/site-python/watchers/__init__.py

View File

@ -1,7 +1,7 @@
<?php <?php
/* /*
* Copyright (C) 2014-2023 Deciso B.V. * Copyright (C) 2014-2025 Deciso B.V.
* Copyright (C) 2010 Ermal Luçi * Copyright (C) 2010 Ermal Luçi
* Copyright (C) 2007-2008 Scott Ullrich <sullrich@gmail.com> * Copyright (C) 2007-2008 Scott Ullrich <sullrich@gmail.com>
* Copyright (C) 2005-2006 Bill Marquette <bill.marquette@gmail.com> * Copyright (C) 2005-2006 Bill Marquette <bill.marquette@gmail.com>
@ -50,6 +50,7 @@ $userindex = index_users();
function isAuthLocalIP($http_host) function isAuthLocalIP($http_host)
{ {
global $config; global $config;
if (isset($config['virtualip']['vip'])) { if (isset($config['virtualip']['vip'])) {
foreach ($config['virtualip']['vip'] as $vip) { foreach ($config['virtualip']['vip'] as $vip) {
if ($vip['subnet'] == $http_host) { if ($vip['subnet'] == $http_host) {
@ -57,6 +58,7 @@ function isAuthLocalIP($http_host)
} }
} }
} }
$address_in_list = function ($interface_list_ips, $http_host) { $address_in_list = function ($interface_list_ips, $http_host) {
foreach ($interface_list_ips as $ilips => $ifname) { foreach ($interface_list_ips as $ilips => $ifname) {
// remove scope from link-local IPv6 addresses // remove scope from link-local IPv6 addresses
@ -66,11 +68,13 @@ function isAuthLocalIP($http_host)
} }
} }
}; };
// try using cached addresses // try using cached addresses
$interface_list_ips = get_cached_json_content("/tmp/isAuthLocalIP.cache.json"); $interface_list_ips = get_cached_json_content("/tmp/isAuthLocalIP.cache.json");
if (!empty($interface_list_ips) && $address_in_list($interface_list_ips, $http_host)) { if (!empty($interface_list_ips) && $address_in_list($interface_list_ips, $http_host)) {
return true; return true;
} }
// fetch addresses and store in cache // fetch addresses and store in cache
$interface_list_ips = get_configured_ip_addresses(); $interface_list_ips = get_configured_ip_addresses();
file_put_contents("/tmp/isAuthLocalIP.cache.json", json_encode($interface_list_ips)); file_put_contents("/tmp/isAuthLocalIP.cache.json", json_encode($interface_list_ips));
@ -82,9 +86,11 @@ function index_groups()
{ {
global $config, $groupindex; global $config, $groupindex;
$groupindex = array(); $groupindex = [];
if (isset($config['system']['group'])) { if (isset($config['system']['group'])) {
$i = 0; $i = 0;
foreach ($config['system']['group'] as $groupent) { foreach ($config['system']['group'] as $groupent) {
if (isset($groupent['name'])) { if (isset($groupent['name'])) {
$groupindex[$groupent['name']] = $i; $groupindex[$groupent['name']] = $i;
@ -93,7 +99,7 @@ function index_groups()
} }
} }
return ($groupindex); return $groupindex;
} }
function index_users() function index_users()
@ -104,10 +110,12 @@ function index_users()
if (!empty($config['system']['user'])) { if (!empty($config['system']['user'])) {
$i = 0; $i = 0;
foreach ($config['system']['user'] as $userent) { foreach ($config['system']['user'] as $userent) {
if (!empty($userent) && !empty($userent['name'])) { if (!empty($userent) && !empty($userent['name'])) {
$userindex[$userent['name']] = $i; $userindex[$userent['name']] = $i;
} }
$i++; $i++;
} }
} }
@ -118,7 +126,9 @@ function index_users()
function getUserGroups($username) function getUserGroups($username)
{ {
global $config; global $config;
$member_groups = array();
$member_groups = [];
$user = getUserEntry($username); $user = getUserEntry($username);
if ($user !== false) { if ($user !== false) {
$allowed_groups = local_user_get_groups($user); $allowed_groups = local_user_get_groups($user);
@ -130,18 +140,20 @@ function getUserGroups($username)
} }
} }
} }
return $member_groups; return $member_groups;
} }
function &getUserEntry($name) function &getUserEntry($name)
{ {
global $config, $userindex; global $config, $userindex;
$false = false;
if (isset($userindex[$name])) { if (isset($userindex[$name])) {
return $config['system']['user'][$userindex[$name]]; return $config['system']['user'][$userindex[$name]];
} else {
return $false;
} }
$ret = false; /* XXX "fixes" return by reference */
return $ret;
} }
function &getUserEntryByUID($uid) function &getUserEntryByUID($uid)
@ -156,7 +168,8 @@ function &getUserEntryByUID($uid)
} }
} }
return false; $ret = false; /* XXX "fixes" return by reference */
return $ret;
} }
function &getGroupEntry($name) function &getGroupEntry($name)
@ -167,7 +180,8 @@ function &getGroupEntry($name)
return $config['system']['group'][$groupindex[$name]]; return $config['system']['group'][$groupindex[$name]];
} }
return array(); $ret = []; /* XXX "fixes" return by reference */
return $ret;
} }
function get_user_privileges(&$user) function get_user_privileges(&$user)
@ -189,6 +203,7 @@ function get_user_privileges(&$user)
} }
} }
} }
return $privs; return $privs;
} }
@ -212,9 +227,7 @@ function userHasPrivilege($userent, $privid = false)
function userIsAdmin($username) function userIsAdmin($username)
{ {
$user = getUserEntry($username); return userHasPrivilege(getUserEntry($username), 'page-all');
return userHasPrivilege($user, 'page-all');
} }
function local_sync_accounts() function local_sync_accounts()
@ -289,8 +302,6 @@ function local_sync_accounts()
function local_user_set(&$user, $force_password = false, $userattrs = null) function local_user_set(&$user, $force_password = false, $userattrs = null)
{ {
global $config;
if (empty($user['password'])) { if (empty($user['password'])) {
auth_log("Cannot set user {$user['name']}: password is missing"); auth_log("Cannot set user {$user['name']}: password is missing");
return; return;
@ -305,7 +316,7 @@ function local_user_set(&$user, $force_password = false, $userattrs = null)
$user_pass = $force_password ? $user['password'] : '*'; $user_pass = $force_password ? $user['password'] : '*';
$user_name = $user['name']; $user_name = $user['name'];
$user_uid = $user['uid']; $user_uid = $user['uid'];
$comment = str_replace(array(':', '!', '@'), ' ', $user['descr']); $comment = str_replace([':', '!', '@'], ' ', $user['descr']);
$lock_account = 'lock'; $lock_account = 'lock';
@ -398,7 +409,7 @@ function local_user_set(&$user, $force_password = false, $userattrs = null)
@unlink("{$user_home}/.ssh/authorized_keys"); @unlink("{$user_home}/.ssh/authorized_keys");
} }
mwexecf('/usr/sbin/pw %s %s', array($lock_account, $user_name), true); mwexecf('/usr/sbin/pw %s %s', [$lock_account, $user_name], true);
} }
function local_user_set_password(&$user, $password = null) function local_user_set_password(&$user, $password = null)
@ -494,7 +505,16 @@ function local_group_set($group)
$group_op = 'groupmod'; $group_op = 'groupmod';
} }
mwexecf('/usr/sbin/pw %s %s -g %s -M %s', array($group_op, $group_name, $group_gid, $group_members)); mwexecf('/usr/sbin/pw %s %s -g %s -M %s', [$group_op, $group_name, $group_gid, $group_members]);
}
function auth_get_authserver_local()
{
return [
'host' => $config['system']['hostname'],
'name' => gettext('Local Database'),
'type' => 'local',
];
} }
/** /**
@ -505,12 +525,8 @@ function auth_get_authserver($name)
{ {
global $config; global $config;
if ($name == "Local Database") { if ($name == 'Local Database') {
return array( return auth_get_authserver_local();
"name" => gettext("Local Database"),
"type" => "local",
"host" => $config['system']['hostname']
);
} }
if (!empty($config['system']['authserver'])) { if (!empty($config['system']['authserver'])) {
@ -537,7 +553,7 @@ function auth_get_authserver_list()
{ {
global $config; global $config;
$list = array(); $list = [];
if (!empty($config['system']['authserver'])) { if (!empty($config['system']['authserver'])) {
foreach ($config['system']['authserver'] as $authcfg) { foreach ($config['system']['authserver'] as $authcfg) {
@ -546,7 +562,8 @@ function auth_get_authserver_list()
} }
} }
$list["Local Database"] = array( "name" => gettext("Local Database"), "type" => "local", "host" => $config['system']['hostname']); $list['Local Database'] = auth_get_authserver_local();
return $list; return $list;
} }

View File

@ -180,19 +180,22 @@ function _dnsmasq_add_host_entries()
} }
foreach ($dnsmasqcfg['hosts'] as $host) { foreach ($dnsmasqcfg['hosts'] as $host) {
if (!empty($host['host']) && empty($host['domain'])) { /* The host.ip field supports multiple IPv4 and IPv6 addresses */
$lhosts .= "{$host['ip']}\t{$host['host']}\n"; foreach (explode(',', $host['ip']) as $ip) {
} elseif (!empty($host['host'])) { if (!empty($host['host']) && empty($host['domain'])) {
/* XXX: The question is if we do want "host" as a global alias */ $lhosts .= "{$ip}\t{$host['host']}\n";
$lhosts .= "{$host['ip']}\t{$host['host']}.{$host['domain']} {$host['host']}\n"; } elseif (!empty($host['host'])) {
} /* XXX: The question is if we do want "host" as a global alias */
if (!empty($host['aliases'])) { $lhosts .= "{$ip}\t{$host['host']}.{$host['domain']} {$host['host']}\n";
foreach (explode(",", $host['aliases']) as $alias) { }
/** if (!empty($host['aliases'])) {
* XXX: pre migration all hosts where added here as alias, when we combine host.domain we foreach (explode(",", $host['aliases']) as $alias) {
* miss some information, which is likely not a very good idea anyway. /**
*/ * XXX: pre migration all hosts where added here as alias, when we combine host.domain we
$lhosts .= "{$host['ip']}\t{$alias}\n"; * miss some information, which is likely not a very good idea anyway.
*/
$lhosts .= "{$ip}\t{$alias}\n";
}
} }
} }
} }

View File

@ -1,7 +1,7 @@
<?php <?php
/* /*
* Copyright (c) 2015-2023 Franco Fichtner <franco@opnsense.org> * Copyright (c) 2015-2025 Franco Fichtner <franco@opnsense.org>
* Copyright (c) 2015-2018 Deciso B.V. * Copyright (c) 2015-2018 Deciso B.V.
* All rights reserved. * All rights reserved.
* *
@ -492,6 +492,36 @@ class FirmwareController extends ApiMutableModelControllerBase
return $response; return $response;
} }
/**
* run an audit in the backend
* @return array status
* @throws \Exception
*/
private function auditHelper(string $audit): array
{
$backend = new Backend();
$response = [];
if ($this->request->isPost()) {
$response['status'] = 'ok';
$response['msg_uuid'] = trim($backend->configdRun("firmware $audit", true));
} else {
$response['status'] = 'failure';
}
return $response;
}
/**
* run a cleanup task
* @return array status
* @throws \Exception
*/
public function cleanupAction()
{
return $this->auditHelper('cleanup');
}
/** /**
* run a connection check * run a connection check
* @return array status * @return array status
@ -499,17 +529,7 @@ class FirmwareController extends ApiMutableModelControllerBase
*/ */
public function connectionAction() public function connectionAction()
{ {
$backend = new Backend(); return $this->auditHelper('connection');
$response = array();
if ($this->request->isPost()) {
$response['status'] = 'ok';
$response['msg_uuid'] = trim($backend->configdRun("firmware connection", true));
} else {
$response['status'] = 'failure';
}
return $response;
} }
/** /**
@ -519,17 +539,7 @@ class FirmwareController extends ApiMutableModelControllerBase
*/ */
public function healthAction() public function healthAction()
{ {
$backend = new Backend(); return $this->auditHelper('health');
$response = array();
if ($this->request->isPost()) {
$response['status'] = 'ok';
$response['msg_uuid'] = trim($backend->configdRun("firmware health", true));
} else {
$response['status'] = 'failure';
}
return $response;
} }
/* /*
@ -539,18 +549,11 @@ class FirmwareController extends ApiMutableModelControllerBase
*/ */
public function auditAction() public function auditAction()
{ {
$backend = new Backend();
$response = array();
if ($this->request->isPost()) { if ($this->request->isPost()) {
$this->getLogger('audit')->notice(sprintf("[Firmware] User %s executed a security audit", $this->getUserName())); $this->getLogger('audit')->notice(sprintf("[Firmware] User %s executed a security audit", $this->getUserName()));
$response['status'] = 'ok';
$response['msg_uuid'] = trim($backend->configdRun("firmware audit", true));
} else {
$response['status'] = 'failure';
} }
return $response; return $this->auditHelper('audit');
} }
/** /**

View File

@ -1,7 +1,7 @@
<?php <?php
/** /**
* Copyright (C) 2016 Deciso B.V. * Copyright (C) 2016-2025 Deciso B.V.
* *
* All rights reserved. * All rights reserved.
* *
@ -70,6 +70,7 @@ class NetworkinsightController extends ApiControllerBase
$to_date = $filter->sanitize($to_date, "int"); $to_date = $filter->sanitize($to_date, "int");
$resolution = $filter->sanitize($resolution, "int"); $resolution = $filter->sanitize($resolution, "int");
$field = $filter->sanitize($field, "string"); $field = $filter->sanitize($field, "string");
$interfaces = $this->getInterfacesAction();
$result = array(); $result = array();
if ($this->request->isGet()) { if ($this->request->isGet()) {
@ -78,11 +79,6 @@ class NetworkinsightController extends ApiControllerBase
$response = $backend->configdRun( $response = $backend->configdRun(
"netflow aggregate fetch {$provider} {$from_date} {$to_date} {$resolution} {$field}" "netflow aggregate fetch {$provider} {$from_date} {$to_date} {$resolution} {$field}"
); );
// for test, request random data
//$response = $backend->configdRun(
// "netflow aggregate fetch {$provider} {$from_date} {$to_date} {$resolution} {$field} " .
// "em0,in~em0,out~em1,in~em1,out~em2,in~em2,out~em3,in~em3,out"
//);
$graph_data = json_decode($response, true); $graph_data = json_decode($response, true);
if ($graph_data != null) { if ($graph_data != null) {
ksort($graph_data); ksort($graph_data);
@ -124,7 +120,19 @@ class NetworkinsightController extends ApiControllerBase
} }
} }
foreach ($timeseries as $timeserie_key => $data) { foreach ($timeseries as $timeserie_key => $data) {
$result[] = array("key" => $timeserie_key, "values" => $data); $record = [
"key" => $timeserie_key,
"values" => $data
];
if (in_array($provider, ['FlowInterfaceTotals'])) {
$tmp = explode(',', $timeserie_key);
if (!empty($interfaces[$tmp[0]])) {
$record['interface'] = $interfaces[$tmp[0]];
}
$record['direction'] = $tmp[1] ?? '';
}
$result[] = $record;
} }
} }
} }
@ -159,6 +167,8 @@ class NetworkinsightController extends ApiControllerBase
$max_hits = $filter->sanitize($max_hits, "int"); $max_hits = $filter->sanitize($max_hits, "int");
if ($this->request->isGet()) { if ($this->request->isGet()) {
$protocols = $this->getProtocolsAction();
$services = $this->getServicesAction();
if ($this->request->get("filter_field") != null && $this->request->get("filter_value") != null) { if ($this->request->get("filter_field") != null && $this->request->get("filter_value") != null) {
$filter_fields = explode(',', $this->request->get("filter_field")); $filter_fields = explode(',', $this->request->get("filter_field"));
$filter_values = explode(',', $this->request->get("filter_value")); $filter_values = explode(',', $this->request->get("filter_value"));
@ -181,7 +191,26 @@ class NetworkinsightController extends ApiControllerBase
$configd_cmd .= " {$measure} {$data_filter} {$max_hits}"; $configd_cmd .= " {$measure} {$data_filter} {$max_hits}";
$response = $backend->configdRun($configd_cmd); $response = $backend->configdRun($configd_cmd);
$graph_data = json_decode($response, true); $graph_data = json_decode($response, true);
if ($graph_data != null) { if (is_array($graph_data)) {
foreach ($graph_data as &$record) {
if (isset($record['dst_port']) || isset($record['service_port'])) {
$portnum = $record['dst_port'] ?? $record['service_port'];
$label = $portnum;
$protocol = '';
if (isset($record['protocol']) && isset($protocols[$record['protocol']])) {
$protocol = sprintf(" (%s)", $protocols[$record['protocol']]);
}
if (isset($services[$portnum])) {
$label = $services[$portnum];
}
$record['last_seen_str'] = '';
if (!empty($record['last_seen'])) {
$record['last_seen_str'] = date('Y-m-d H:i:s', $record['last_seen']);
}
$record['label'] = $label . $protocol;
}
}
return $graph_data; return $graph_data;
} }
} }

View File

@ -37,6 +37,39 @@ class SettingsController extends ApiMutableModelControllerBase
protected static $internalModelClass = '\OPNsense\Dnsmasq\Dnsmasq'; protected static $internalModelClass = '\OPNsense\Dnsmasq\Dnsmasq';
protected static $internalModelUseSafeDelete = true; protected static $internalModelUseSafeDelete = true;
/**
* Tags and interface filter function.
* Interfaces are tags too, in the sense of dnsmasq.
*
* @return callable|null
*/
private function buildFilterFunction(): ?callable
{
$filterValues = $this->request->get('tags') ?? [];
$fieldNames = ['interface', 'set_tag', 'tag'];
if (empty($filterValues)) {
return null;
}
return function ($record) use ($filterValues, $fieldNames) {
foreach ($fieldNames as $fieldName) {
// Skip this field if not present in current record
if (!isset($record->{$fieldName})) {
continue;
}
// Match field values against filter list
foreach (array_map('trim', explode(',', (string)$record->{$fieldName})) as $value) {
if (in_array($value, $filterValues, true)) {
return true;
}
}
}
return false;
};
}
/** /**
* @inheritdoc * @inheritdoc
*/ */
@ -51,7 +84,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* hosts */ /* hosts */
public function searchHostAction() public function searchHostAction()
{ {
return $this->searchBase('hosts'); return $this->searchBase('hosts', null, null, $this->buildFilterFunction());
} }
public function getHostAction($uuid = null) public function getHostAction($uuid = null)
@ -137,7 +170,18 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp tags */ /* dhcp tags */
public function searchTagAction() public function searchTagAction()
{ {
return $this->searchBase('dhcp_tags'); $filters = $this->request->get('tags') ?? [];
$filter_funct = null;
if (!empty($filters)) {
$filter_funct = function ($record) use ($filters) {
$attributes = $record->getAttributes();
$uuid = $attributes['uuid'] ?? null;
return in_array($uuid, $filters, true);
};
}
return $this->searchBase('dhcp_tags', null, null, $filter_funct);
} }
public function getTagAction($uuid = null) public function getTagAction($uuid = null)
@ -163,7 +207,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp ranges */ /* dhcp ranges */
public function searchRangeAction() public function searchRangeAction()
{ {
return $this->searchBase('dhcp_ranges'); return $this->searchBase('dhcp_ranges', null, null, $this->buildFilterFunction());
} }
public function getRangeAction($uuid = null) public function getRangeAction($uuid = null)
@ -189,7 +233,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp options */ /* dhcp options */
public function searchOptionAction() public function searchOptionAction()
{ {
return $this->searchBase('dhcp_options'); return $this->searchBase('dhcp_options', null, null, $this->buildFilterFunction());
} }
public function getOptionAction($uuid = null) public function getOptionAction($uuid = null)
@ -215,7 +259,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp match options */ /* dhcp match options */
public function searchMatchAction() public function searchMatchAction()
{ {
return $this->searchBase('dhcp_options_match'); return $this->searchBase('dhcp_options_match', null, null, $this->buildFilterFunction());
} }
public function getMatchAction($uuid = null) public function getMatchAction($uuid = null)
@ -241,7 +285,7 @@ class SettingsController extends ApiMutableModelControllerBase
/* dhcp boot options */ /* dhcp boot options */
public function searchBootAction() public function searchBootAction()
{ {
return $this->searchBase('dhcp_boot'); return $this->searchBase('dhcp_boot', null, null, $this->buildFilterFunction());
} }
public function getBootAction($uuid = null) public function getBootAction($uuid = null)
@ -263,4 +307,50 @@ class SettingsController extends ApiMutableModelControllerBase
{ {
return $this->delBase('dhcp_boot', $uuid); return $this->delBase('dhcp_boot', $uuid);
} }
/**
* Return selectpicker options for interfaces and tags
*/
public function getTagListAction()
{
$result = [
'tags' => [
'label' => gettext('Tags'),
'icon' => 'fa fa-tag text-primary',
'items' => []
],
'interfaces' => [
'label' => gettext('Interfaces'),
'icon' => 'fa fa-ethernet text-info',
'items' => []
]
];
// Interfaces
foreach (Config::getInstance()->object()->interfaces->children() as $key => $intf) {
if ((string)$intf->type === 'group') {
continue;
}
$result['interfaces']['items'][] = [
'value' => $key,
'label' => empty($intf->descr) ? strtoupper($key) : (string)$intf->descr
];
}
// Tags
foreach ($this->getModel()->dhcp_tags->iterateItems() as $uuid => $tag) {
$result['tags']['items'][] = [
'value' => $uuid,
'label' => (string)$tag->tag
];
}
foreach (array_keys($result) as $key) {
usort($result[$key]['items'], fn($a, $b) => strcasecmp($a['label'], $b['label']));
}
// Assemble result
return $result;
}
} }

View File

@ -17,15 +17,18 @@
</field> </field>
<field> <field>
<id>host.ip</id> <id>host.ip</id>
<label>IP address</label> <label>IP addresses</label>
<type>text</type> <type>select_multiple</type>
<help>IP address of the host, e.g. 192.168.100.100 or fd00:abcd::1</help> <style>tokenize</style>
<allownew>true</allownew>
<help>IP addresses of the host, e.g. 192.168.100.100 or fd00:abcd::1. Can be multiple IPv4 and IPv6 addresses for dual stack configurations. Setting multiple addresses will automatically assign the best match based on the subnet of the interface receiving the DHCP Discover.</help>
</field> </field>
<field> <field>
<id>host.aliases</id> <id>host.aliases</id>
<label>Aliases</label> <label>Aliases</label>
<type>select_multiple</type> <type>select_multiple</type>
<style>tokenize</style> <style>tokenize</style>
<allownew>true</allownew>
<help>list of aliases (fqdn)</help> <help>list of aliases (fqdn)</help>
<grid_view> <grid_view>
<visible>false</visible> <visible>false</visible>
@ -36,16 +39,48 @@
<label>DHCP</label> <label>DHCP</label>
</field> </field>
<field> <field>
<id>host.hwaddr</id> <id>host.client_id</id>
<label>Hardware address</label> <label>Client identifier</label>
<type>text</type> <type>text</type>
<help>When offered and the client requests an address via dhcp, assign the address provided here</help> <help>Match the identifier of the client, e.g., DUID for DHCPv6. Setting the special character "*" will ignore the client identifier for DHCPv4 leases if a client offers both as choice.</help>
<grid_view>
<visible>false</visible>
</grid_view>
</field>
<field>
<id>host.hwaddr</id>
<label>Hardware addresses</label>
<type>select_multiple</type>
<style>tokenize</style>
<allownew>true</allownew>
<help>Match the hardware address of the client. Can be multiple addresses, e.g., if the client has multiple network cards. Though keep in mind that DNSmasq cannot assume which address is the correct one when multiple send DHCP Discover at the same time.</help>
</field>
<field>
<id>host.lease_time</id>
<label>Lease time</label>
<type>text</type>
<hint>86400</hint>
<help>Defines how long the addresses (leases) given out by the server are valid (in seconds)</help>
<grid_view>
<visible>false</visible>
</grid_view>
</field> </field>
<field> <field>
<id>host.set_tag</id> <id>host.set_tag</id>
<label>Tag [set]</label> <label>Tag [set]</label>
<type>dropdown</type> <type>dropdown</type>
<help>Optional tag to set for requests matching this range which can be used to selectively match dhcp options</help> <help>Optional tag to set for requests matching this range which can be used to selectively match dhcp options. Can be left empty if options with an interface tag exist, since the client automatically receives this tag based on the interface receiving the DHCP Discover.</help>
</field>
<field>
<id>host.ignore</id>
<label>Ignore</label>
<type>checkbox</type>
<help>Ignore any DHCP packets of this host. Useful if it should get served by a different DHCP server.</help>
<grid_view>
<visible>false</visible>
<type>boolean</type>
<formatter>boolean</formatter>
</grid_view>
</field> </field>
<field> <field>
<type>header</type> <type>header</type>

View File

@ -342,7 +342,8 @@ class FilterController extends FilterBaseController
'items' => [ 'items' => [
[ [
'value' => '', 'value' => '',
'label' => gettext('Any') 'label' => gettext('Any'),
'selected' => true,
] ]
] ]
], ],

View File

@ -55,24 +55,46 @@ class Dnsmasq extends BaseModel
$messages = parent::performValidation($validateFullModel); $messages = parent::performValidation($validateFullModel);
$usedDhcpIpAddresses = [];
foreach ($this->hosts->iterateItems() as $host) {
if (!$host->hwaddr->isEmpty() || !$host->client_id->isEmpty()) {
foreach (explode(',', (string)$host->ip) as $ip) {
$usedDhcpIpAddresses[$ip] = isset($usedDhcpIpAddresses[$ip]) ? $usedDhcpIpAddresses[$ip] + 1 : 1;
}
}
}
foreach ($this->hosts->iterateItems() as $host) { foreach ($this->hosts->iterateItems() as $host) {
if (!$validateFullModel && !$host->isFieldChanged()) { if (!$validateFullModel && !$host->isFieldChanged()) {
continue; continue;
} }
$key = $host->__reference; $key = $host->__reference;
if (!$host->hwaddr->isEmpty() && strpos($host->ip->getCurrentValue(), ':') !== false) {
$messages->appendMessage( // all dhcp-host IP addresses must be unique, host overrides can have duplicate IP addresses
new Message( if (!$host->hwaddr->isEmpty() || !$host->client_id->isEmpty()) {
gettext("Only IPv4 reservations are currently supported"), foreach (explode(',', (string)$host->ip) as $ip) {
$key . ".ip" if ($usedDhcpIpAddresses[$ip] > 1) {
) $messages->appendMessage(
); new Message(
sprintf(gettext("'%s' is already used in another DHCP host entry."), $ip),
$key . ".ip"
)
);
}
}
} }
if ($host->host->isEmpty() && $host->hwaddr->isEmpty()) { if (
$host->host->isEmpty() &&
$host->hwaddr->isEmpty() &&
$host->client_id->isEmpty()
) {
$messages->appendMessage( $messages->appendMessage(
new Message( new Message(
gettext("Hostnames my only be omitted when a hardware address is offered."), gettext(
"Hostnames may only be omitted when either a hardware address " .
"or a client identifier is provided."
),
$key . ".host" $key . ".host"
) )
); );

View File

@ -35,7 +35,7 @@
</local_ttl> </local_ttl>
<add_mac type="OptionField"> <add_mac type="OptionField">
<OptionValues> <OptionValues>
<default>default</default> <standard>standard</standard>
<base64>base64</base64> <base64>base64</base64>
<text>text</text> <text>text</text>
</OptionValues> </OptionValues>
@ -82,8 +82,21 @@
<ip type="NetworkField"> <ip type="NetworkField">
<Required>Y</Required> <Required>Y</Required>
<NetMaskAllowed>N</NetMaskAllowed> <NetMaskAllowed>N</NetMaskAllowed>
<FieldSeparator>,</FieldSeparator>
<AsList>Y</AsList>
</ip> </ip>
<hwaddr type="MacAddressField"/> <client_id type="TextField">
<Mask>/^(?:\*|(?:[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2})+))$/</Mask>
<ValidationMessage>Value must be a colon-separated hexadecimal sequence (e.g., 01:02:f3) or "*".</ValidationMessage>
</client_id>
<hwaddr type="MacAddressField">
<FieldSeparator>,</FieldSeparator>
<AsList>Y</AsList>
</hwaddr>
<lease_time type="IntegerField">
<MinimumValue>1</MinimumValue>
</lease_time>
<ignore type="BooleanField"/>
<set_tag type="ModelRelationField"> <set_tag type="ModelRelationField">
<Model> <Model>
<tag> <tag>

View File

@ -46,7 +46,7 @@
<name>VPN: IPsec: Edit Pre-Shared Keys</name> <name>VPN: IPsec: Edit Pre-Shared Keys</name>
<patterns> <patterns>
<pattern>ui/ipsec/pre_shared_keys</pattern> <pattern>ui/ipsec/pre_shared_keys</pattern>
<pattern>api/ipsec/pre_shared_keys</pattern> <pattern>api/ipsec/pre_shared_keys/*</pattern>
</patterns> </patterns>
</page-vpn-ipsec-editkeys> </page-vpn-ipsec-editkeys>
<page-vpn-ipsec-mobile> <page-vpn-ipsec-mobile>

View File

@ -9,7 +9,7 @@
<Mobile order="20" VisibleName="Mobile Clients (legacy)" url="/vpn_ipsec_mobile.php"> <Mobile order="20" VisibleName="Mobile Clients (legacy)" url="/vpn_ipsec_mobile.php">
<Act url="/vpn_ipsec_mobile.php*" visibility="hidden"/> <Act url="/vpn_ipsec_mobile.php*" visibility="hidden"/>
</Mobile> </Mobile>
<Keys order="30" VisibleName="Pre-Shared Keys" url="/ui/ipsec/pre_shared_keys/"/> <Keys order="30" VisibleName="Pre-Shared Keys" url="/ui/ipsec/pre_shared_keys"/>
<KeyPairs order="40" VisibleName="Key Pairs" url="/ui/ipsec/key_pairs" /> <KeyPairs order="40" VisibleName="Key Pairs" url="/ui/ipsec/key_pairs" />
<Settings order="50" VisibleName="Mobile &amp; Advanced Settings" url="/ui/ipsec/connections/settings"/> <Settings order="50" VisibleName="Mobile &amp; Advanced Settings" url="/ui/ipsec/connections/settings"/>
<Status order="60" VisibleName="Status Overview" url="/ui/ipsec/sessions"/> <Status order="60" VisibleName="Status Overview" url="/ui/ipsec/sessions"/>

View File

@ -344,7 +344,6 @@
</Constraints> </Constraints>
</mx> </mx>
<ttl type="IntegerField"> <ttl type="IntegerField">
<Required>N</Required>
<!-- https://datatracker.ietf.org/doc/html/rfc2181 --> <!-- https://datatracker.ietf.org/doc/html/rfc2181 -->
<MaximumValue>2147483647</MaximumValue> <MaximumValue>2147483647</MaximumValue>
<MinimumValue>0</MinimumValue> <MinimumValue>0</MinimumValue>

View File

@ -1,5 +1,5 @@
{# {#
# Copyright (c) 2015-2023 Franco Fichtner <franco@opnsense.org> # Copyright (c) 2015-2025 Franco Fichtner <franco@opnsense.org>
# Copyright (c) 2015-2018 Deciso B.V. # Copyright (c) 2015-2018 Deciso B.V.
# All rights reserved. # All rights reserved.
# #
@ -619,9 +619,10 @@
$("#plugin_see").click(function () { $('#plugintab > a').tab('show'); }); $("#plugin_see").click(function () { $('#plugintab > a').tab('show'); });
$("#plugin_get").click(function () { backend('syncPlugins'); }); $("#plugin_get").click(function () { backend('syncPlugins'); });
$("#plugin_set").click(function () { backend('resyncPlugins'); }); $("#plugin_set").click(function () { backend('resyncPlugins'); });
$('#audit_security').click(function () { backend('audit'); }); $('#audit_cleanup').click(function () { backend('cleanup'); });
$('#audit_connection').click(function () { backend('connection'); }); $('#audit_connection').click(function () { backend('connection'); });
$('#audit_health').click(function () { backend('health'); }); $('#audit_health').click(function () { backend('health'); });
$('#audit_security').click(function () { backend('audit'); });
$('#audit_upgrade').click(function () { $('#audit_upgrade').click(function () {
ajaxCall('/api/core/firmware/log/0', {}, function (data, status) { ajaxCall('/api/core/firmware/log/0', {}, function (data, status) {
if (data['log'] != undefined) { if (data['log'] != undefined) {
@ -904,6 +905,7 @@
<i class="fa fa-lock"></i> {{ lang._('Run an audit') }} <i class="caret"></i> <i class="fa fa-lock"></i> {{ lang._('Run an audit') }} <i class="caret"></i>
</button> </button>
<ul class="dropdown-menu" role="menu"> <ul class="dropdown-menu" role="menu">
<li><a id="audit_cleanup" href="#">{{ lang._('Cleanup') }}</a></li>
<li><a id="audit_connection" href="#">{{ lang._('Connectivity') }}</a></li> <li><a id="audit_connection" href="#">{{ lang._('Connectivity') }}</a></li>
<li><a id="audit_health" href="#">{{ lang._('Health') }}</a></li> <li><a id="audit_health" href="#">{{ lang._('Health') }}</a></li>
<li><a id="audit_security" href="#">{{ lang._('Security') }}</a></li> <li><a id="audit_security" href="#">{{ lang._('Security') }}</a></li>

View File

@ -1,6 +1,6 @@
{# {#
OPNsense® is Copyright © 2016 by Deciso B.V. OPNsense® is Copyright © 2016-2025 by Deciso B.V.
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, Redistribution and use in source and binary forms, with or without modification,
@ -27,7 +27,7 @@ POSSIBILITY OF SUCH DAMAGE.
#} #}
<style type="text/css"> <style type="text/css">
.panel-heading-sm{ .panel-heading-sm {
height: 28px; height: 28px;
padding: 4px 10px; padding: 4px 10px;
} }
@ -55,15 +55,8 @@ POSSIBILITY OF SUCH DAMAGE.
// collect all chars for resize update // collect all chars for resize update
var pageCharts = {}; var pageCharts = {};
// form metadata definitions
var interface_names = [];
var service_names = [];
var protocol_names = [];
/** function do_startup()
* load shared metadata (interfaces, protocols, )
*/
function get_metadata()
{ {
var dfObj = new $.Deferred(); var dfObj = new $.Deferred();
ajaxGet('/api/diagnostics/netflow/isEnabled', {}, function(is_enabled, status){ ajaxGet('/api/diagnostics/netflow/isEnabled', {}, function(is_enabled, status){
@ -73,33 +66,30 @@ POSSIBILITY OF SUCH DAMAGE.
} }
// fetch interface names // fetch interface names
ajaxGet('/api/diagnostics/networkinsight/getInterfaces',{}, function(intf_names, status){ ajaxGet('/api/diagnostics/networkinsight/getInterfaces',{}, function(intf_names, status){
interface_names = intf_names; for (var key in intf_names) {
// fetch protocol names $('#interface_select').append($("<option></option>").attr("value",key).text(intf_names[key]));
ajaxGet('/api/diagnostics/networkinsight/getProtocols',{}, function(protocols, status) { $('#interface_select_detail').append($("<option></option>").attr("value",key).text(intf_names[key]));
protocol_names = protocols; }
// fetch service names $('#interface_select').selectpicker('refresh');
ajaxGet('/api/diagnostics/networkinsight/getServices',{}, function(services, status) { $('#interface_select_detail').selectpicker('refresh');
service_names = services; // return promise, no need to wait for getMetadata
// return promise, no need to wait for getMetadata dfObj.resolve();
dfObj.resolve(); // fetch aggregators
// fetch aggregators ajaxGet('/api/diagnostics/networkinsight/getMetadata',{}, function(metadata, status) {
ajaxGet('/api/diagnostics/networkinsight/getMetadata',{}, function(metadata, status) { Object.keys(metadata['aggregators']).forEach(function (agg_name) {
Object.keys(metadata['aggregators']).forEach(function (agg_name) { var res = metadata['aggregators'][agg_name]['resolutions'].join(',');
var res = metadata['aggregators'][agg_name]['resolutions'].join(','); $("#export_collection").append($("<option data-resolutions='"+res+"'/>").val(agg_name).text(agg_name));
$("#export_collection").append($("<option data-resolutions='"+res+"'/>").val(agg_name).text(agg_name));
});
$("#export_collection").change(function(){
$("#export_resolution").html("");
var resolutions = String($(this).find('option:selected').data('resolutions'));
resolutions.split(',').map(function(item) {
$("#export_resolution").append($("<option/>").val(item).text(item));
});
$("#export_resolution").selectpicker('refresh');
});
$("#export_collection").change();
$("#export_collection").selectpicker('refresh');
});
}); });
$("#export_collection").change(function(){
$("#export_resolution").html("");
var resolutions = String($(this).find('option:selected').data('resolutions'));
resolutions.split(',').map(function(item) {
$("#export_resolution").append($("<option/>").val(item).text(item));
});
$("#export_resolution").selectpicker('refresh');
});
$("#export_collection").change();
$("#export_collection").selectpicker('refresh');
}); });
}); });
}); });
@ -123,51 +113,9 @@ POSSIBILITY OF SUCH DAMAGE.
function get_time_select() function get_time_select()
{ {
// current time stamp // current time stamp
var timestamp_now = Math.round((new Date()).getTime() / 1000); let timestamp_now = Math.round((new Date()).getTime() / 1000);
var duration = 0; let duration = parseInt($("#total_time_select > option:selected").data('duration'));
var resolution = 0; let resolution = parseInt($("#total_time_select > option:selected").data('resolution'));
switch ($("#total_time_select").val()) {
case "2h":
duration = 60*60*2;
resolution = 30;
break;
case "8h":
duration = 60*60*8;
resolution = 300;
break;
case "24h":
duration = 60*60*24;
resolution = 300;
break;
case "7d":
duration = 60*60*24*7;
resolution = 3600;
break;
case "14d":
duration = 60*60*24*14;
resolution = 3600;
break;
case "30d":
duration = 60*60*24*30;
resolution = 86400;
break;
case "60d":
duration = 60*60*24*60;
resolution = 86400;
break;
case "90d":
duration = 60*60*24*90;
resolution = 86400;
break;
case "182d":
duration = 60*60*24*182;
resolution = 86400;
break;
case "1y":
duration = 60*60*24*365;
resolution = 86400;
break;
}
// always round from timestamp to nearest hour // always round from timestamp to nearest hour
const from_timestamp = Math.floor((timestamp_now -duration) / 3600 ) * 3600; const from_timestamp = Math.floor((timestamp_now -duration) / 3600 ) * 3600;
return {resolution: resolution, from: from_timestamp, to: timestamp_now}; return {resolution: resolution, from: from_timestamp, to: timestamp_now};
@ -217,17 +165,9 @@ POSSIBILITY OF SUCH DAMAGE.
let chart_data = []; let chart_data = [];
data.map(function(item){ data.map(function(item){
let item_dir = item.key.split(',').pop(); if (direction == item.direction) {
let item_intf = item.key.split(',')[0]; item.key = item.interface ?? '-';
if (item_intf != '0' && item_intf != 'lo0' ) { chart_data.push(item);
if (direction == item_dir) {
if (interface_names[item_intf] != undefined) {
item.key = interface_names[item_intf];
} else {
item.key = item_intf;
}
chart_data.push(item);
}
} }
}); });
@ -273,19 +213,7 @@ POSSIBILITY OF SUCH DAMAGE.
let chart_data = []; let chart_data = [];
data.map(function(item){ data.map(function(item){
var label = "(other)"; chart_data.push({'label': item.label, 'value': item.total});
var proto = "";
if (item.protocol != "") {
if (item.protocol in protocol_names) {
proto = ' (' + protocol_names[item.protocol] + ')';
}
if (item.dst_port in service_names) {
label = service_names[item.dst_port];
} else {
label = item.dst_port
}
}
chart_data.push({'label': label + proto, 'value': item.total});
}); });
var diag = d3.select("#chart_top_ports svg") var diag = d3.select("#chart_top_ports svg")
@ -486,51 +414,24 @@ POSSIBILITY OF SUCH DAMAGE.
}); });
// dump rows // dump rows
data.map(function(item){ data.map(function(item){
let proto = ''; let percentage = parseInt((item.total /grand_total) * 100);
if (item.protocol in protocol_names) { let perc_text = ((item.total /grand_total) * 100).toFixed(2);
proto = ' (' + protocol_names[item.protocol] + ')'; html.push($("<tr/>").append([
} $("<td/>").text(item.label),
let service_port; $("<td/>").text(item.src_addr),
if (item.service_port in service_names) { $("<td/>").text(item.dst_addr),
service_port = service_names[item.service_port]; $("<td/>").text(byteFormat(item.total)),
} else { $("<td/>").text(item.last_seen_str),
service_port = item.service_port $("<td>").html(
} '<div class="progress-bar progress-bar-warning progress-bar-striped" role="progressbar" aria-valuenow="'+
let tr_str = '<tr>'; percentage+
if (service_port != "") { '" aria-valuemin="0" aria-valuemax="100" style="color: black; min-width: 2em; width:'+
tr_str += '<td> <span data-toggle="tooltip" title="'+proto+'/'+item.service_port+'">'+service_port+' </span> '+proto+'</td>'; percentage+'%;">'+perc_text+'&nbsp;%'
} else { )
tr_str += "<td>{{ lang._('(other)') }}</td>"; ]));
}
tr_str += '<td>' + item['src_addr'] + '</td>';
tr_str += '<td>' + item['dst_addr'] + '</td>';
tr_str += '<td>' + byteFormat(item['total']) + ' ' + '</td>';
if (item['last_seen'] != "") {
tr_str += '<td>' + d3.time.format('%b %e %H:%M:%S')(new Date(item['last_seen']*1000)) + '</td>';
} else {
tr_str += '<td></td>'
}
let percentage = parseInt((item['total'] /grand_total) * 100);
let perc_text = ((item['total'] /grand_total) * 100).toFixed(2);
tr_str += '<td>';
tr_str += '<div class="progress-bar progress-bar-warning progress-bar-striped" role="progressbar" ';
tr_str += 'aria-valuenow="'+percentage+'" aria-valuemin="0" aria-valuemax="100" style="color: black; min-width: 2em; width:' ;
tr_str += percentage+'%;">'+perc_text+'&nbsp;%</div>';
tr_str += '</td>';
tr_str += '</tr>';
html.push(tr_str);
}); });
$("#netflow_details > tbody").html(html.join('')); $("#netflow_details > tbody").empty().append(html);
if (grand_total > 0) { $("#netflow_details_total").html(byteFormat(grand_total));
$("#netflow_details_total").html(byteFormat(grand_total));
} else {
$("#netflow_details_total").html("");
}
// link tooltips
$('[data-toggle="tooltip"]').tooltip();
} }
}); });
} }
@ -596,15 +497,7 @@ POSSIBILITY OF SUCH DAMAGE.
// trigger initial tab load // trigger initial tab load
get_metadata().done(function(){ do_startup().done(function(){
// known interfaces
for (var key in interface_names) {
$('#interface_select').append($("<option></option>").attr("value",key).text(interface_names[key]));
$('#interface_select_detail').append($("<option></option>").attr("value",key).text(interface_names[key]));
}
$('#interface_select').selectpicker('refresh');
$('#interface_select_detail').selectpicker('refresh');
// generate date selection (utc start, end times) // generate date selection (utc start, end times)
var now = new Date; var now = new Date;
var date_begin = Date.UTC(now.getUTCFullYear(),now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0); var date_begin = Date.UTC(now.getUTCFullYear(),now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0);
@ -670,16 +563,16 @@ POSSIBILITY OF SUCH DAMAGE.
<div id="totals" class="tab-pane fade in active"> <div id="totals" class="tab-pane fade in active">
<div class="pull-right"> <div class="pull-right">
<select class="selectpicker" id="total_time_select"> <select class="selectpicker" id="total_time_select">
<option value="2h">{{ lang._('Last 2 hours, 30 second average') }}</option> <option data-duration="7200" data-resolution="30" value="2h">{{ lang._('Last 2 hours, 30 second average') }}</option>
<option value="8h">{{ lang._('Last 8 hours, 5 minute average') }}</option> <option data-duration="28800" data-resolution="300" value="8h">{{ lang._('Last 8 hours, 5 minute average') }}</option>
<option value="24h">{{ lang._('Last 24 hours, 5 minute average') }}</option> <option data-duration="86400" data-resolution="300" value="24h">{{ lang._('Last 24 hours, 5 minute average') }}</option>
<option value="7d">{{ lang._('7 days, 1 hour average') }}</option> <option data-duration="604800" data-resolution="3600" value="7d">{{ lang._('7 days, 1 hour average') }}</option>
<option value="14d">{{ lang._('14 days, 1 hour average') }}</option> <option data-duration="1209600" data-resolution="3600" value="14d">{{ lang._('14 days, 1 hour average') }}</option>
<option value="30d">{{ lang._('30 days, 24 hour average') }}</option> <option data-duration="2592000" data-resolution="86400" value="30d">{{ lang._('30 days, 24 hour average') }}</option>
<option value="60d">{{ lang._('60 days, 24 hour average') }}</option> <option data-duration="5184000" data-resolution="86400" value="60d">{{ lang._('60 days, 24 hour average') }}</option>
<option value="90d">{{ lang._('90 days, 24 hour average') }}</option> <option data-duration="7776000" data-resolution="86400" value="90d">{{ lang._('90 days, 24 hour average') }}</option>
<option value="182d">{{ lang._('182 days, 24 hour average') }}</option> <option data-duration="15724800" data-resolution="86400" value="182d">{{ lang._('182 days, 24 hour average') }}</option>
<option value="1y">{{ lang._('Last year, 24 hour average') }}</option> <option data-duration="31536000" data-resolution="86400" value="1y">{{ lang._('Last year, 24 hour average') }}</option>
</select> </select>
</div> </div>
<br/> <br/>

View File

@ -73,7 +73,18 @@
'get':'/api/dnsmasq/settings/get_' + grid_id + '/', 'get':'/api/dnsmasq/settings/get_' + grid_id + '/',
'set':'/api/dnsmasq/settings/set_' + grid_id + '/', 'set':'/api/dnsmasq/settings/set_' + grid_id + '/',
'add':'/api/dnsmasq/settings/add_' + grid_id + '/', 'add':'/api/dnsmasq/settings/add_' + grid_id + '/',
'del':'/api/dnsmasq/settings/del_' + grid_id + '/' 'del':'/api/dnsmasq/settings/del_' + grid_id + '/',
options: {
triggerEditFor: getUrlHash('edit'),
initialSearchPhrase: getUrlHash('search'),
requestHandler: function(request) {
const selectedTags = $('#tag_select').val();
if (selectedTags && selectedTags.length > 0) {
request['tags'] = selectedTags;
}
return request;
}
}
}); });
/* insert headers when multiple grids exist on a single tab */ /* insert headers when multiple grids exist on a single tab */
let header = $("#" + grid_id + "-header"); let header = $("#" + grid_id + "-header");
@ -90,6 +101,16 @@
} }
} else { } else {
all_grids[grid_id].bootgrid('reload'); all_grids[grid_id].bootgrid('reload');
}
// insert tag selectpicker in all grids that use tags or interfaces, boot excluded cause two grids in same tab
if (!['domain', 'boot'].includes(grid_id)) {
let header = $("#" + grid_id + "-header");
let $actionBar = header.find('.actionBar');
if ($actionBar.length) {
$('#tag_select_container').detach().insertBefore($actionBar.find('.search'));
$('#tag_select_container').show();
}
} }
}); });
} }
@ -150,6 +171,18 @@
}); });
}); });
// Populate tag selectpicker
$('#tag_select').fetch_options('/api/dnsmasq/settings/get_tag_list');
$('#tag_select').change(function () {
Object.keys(all_grids).forEach(function (grid_id) {
// boot is not excluded here, as it reloads in same tab as options
if (!['domain'].includes(grid_id)) {
all_grids[grid_id].bootgrid('reload');
}
});
});
}); });
</script> </script>
@ -157,6 +190,9 @@
tbody.collapsible > tr > td:first-child { tbody.collapsible > tr > td:first-child {
padding-left: 30px; padding-left: 30px;
} }
#tag_select_container {
margin-right: 20px;
}
</style> </style>
<div style="display: none;" id="hosts_tfoot_append"> <div style="display: none;" id="hosts_tfoot_append">
@ -172,6 +208,11 @@
<button id="download_hosts" type="button" title="{{ lang._('Export as csv') }}" data-toggle="tooltip" class="btn btn-xs"><span class="fa fa-fw fa-table"></span></button> <button id="download_hosts" type="button" title="{{ lang._('Export as csv') }}" data-toggle="tooltip" class="btn btn-xs"><span class="fa fa-fw fa-table"></span></button>
</div> </div>
<div id="tag_select_container" class="btn-group" style="display: none;">
<select id="tag_select" class="selectpicker" multiple data-title="{{ lang._('Tags & Interfaces') }}" data-show-subtext="true" data-live-search="true" data-size="15" data-width="200px" data-container="body">
</select>
</div>
<!-- Navigation bar --> <!-- Navigation bar -->
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs"> <ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
<li><a data-toggle="tab" href="#general">{{ lang._('General') }}</a></li> <li><a data-toggle="tab" href="#general">{{ lang._('General') }}</a></li>

View File

@ -66,6 +66,7 @@
del:'/api/firewall/filter/del_rule/', del:'/api/firewall/filter/del_rule/',
toggle:'/api/firewall/filter/toggle_rule/', toggle:'/api/firewall/filter/toggle_rule/',
options: { options: {
resizableColumns: true,
triggerEditFor: getUrlHash('edit'), triggerEditFor: getUrlHash('edit'),
initialSearchPhrase: getUrlHash('search'), initialSearchPhrase: getUrlHash('search'),
rowCount: [20,50,100,200,500,1000], rowCount: [20,50,100,200,500,1000],
@ -500,38 +501,8 @@
}); });
// Populate interface selectpicker // Populate interface selectpicker
$('#interface_select').fetch_options('/api/firewall/filter/get_interface_list');
$("#interface_select_container").show(); $("#interface_select_container").show();
ajaxCall('/api/firewall/filter/get_interface_list', {},
function(data, status) {
const $select = $('#interface_select');
$select.empty();
for (const [groupkey, group] of Object.entries(data)) {
if (group.items.length > 0) {
let $optgroup = $('<optgroup>', {
"label": `${group.label}`,
"data-icon": `${group.icon}`
});
group.items.forEach(function(iface) {
let optprops = {
value: iface.value,
'data-subtext': group.label,
text: iface.label
};
if (iface.value === '') {
/* floating selected by default */
optprops['selected'] = 'selected';
}
$optgroup.append($('<option>', optprops));
});
$select.append($optgroup);
}
}
$select.selectpicker('refresh');
},
function(xhr, textStatus, errorThrown) {
console.error("Failed to load interface list:", textStatus, errorThrown);
}
);
// move selectpickers into action bar // move selectpickers into action bar
$("#interface_select_container").detach().insertBefore('#{{formGridFilterRule["table_id"]}}-header > .row > .actionBar > .search'); $("#interface_select_container").detach().insertBefore('#{{formGridFilterRule["table_id"]}}-header > .row > .actionBar > .search');

View File

@ -0,0 +1,40 @@
#!/bin/sh
# Copyright (C) 2025 Franco Fichtner <franco@opnsense.org>
# 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.
REQUEST="CLEANUP"
. /usr/local/opnsense/scripts/firmware/config.sh
output_txt ">>> Removing unused packages from system"
output_cmd ${PKG} autoremove -y
output_txt ">>> Removing stale update files from system"
output_cmd opnsense-update -F
output_txt ">>> Removing package files from cache"
output_cmd ${PKG} clean -ya
output_done

View File

@ -41,6 +41,7 @@ COMMANDS="
bogons bogons
changelog changelog
check check
cleanup
connection connection
details details
health health

View File

@ -127,10 +127,12 @@ class Main(object):
def set_config(cls, config): def set_config(cls, config):
cls.config = config cls.config = config
def __init__(self): def __init__(self, single_pass=False):
""" construct, hook signal handler and run aggregators """ construct, hook signal handler and run aggregators
:param single_pass: exit after one pass
:return: None :return: None
""" """
self.single_pass = single_pass
self.running = True self.running = True
signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGTERM, self.signal_handler)
self.run() self.run()
@ -168,7 +170,7 @@ class Main(object):
check_rotate(self.config.flowd_source) check_rotate(self.config.flowd_source)
# wait for next pass, exit on sigterm # wait for next pass, exit on sigterm
if Main.config.single_pass: if self.single_pass:
break break
else: else:
# calculate time to wait in between parses. since tailing flowd.log is quite time consuming # calculate time to wait in between parses. since tailing flowd.log is quite time consuming
@ -195,6 +197,7 @@ if __name__ == '__main__':
parser.add_argument('--console', dest='console', help='run in console', action='store_true') parser.add_argument('--console', dest='console', help='run in console', action='store_true')
parser.add_argument('--profile', dest='profile', help='enable profiler', action='store_true') parser.add_argument('--profile', dest='profile', help='enable profiler', action='store_true')
parser.add_argument('--repair', dest='repair', help='init repair', action='store_true') parser.add_argument('--repair', dest='repair', help='init repair', action='store_true')
parser.add_argument('--single_pass', dest='single_pass', help='exit after first pass', action='store_true')
cmd_args = parser.parse_args() cmd_args = parser.parse_args()
Main.set_config(load_config()) Main.set_config(load_config())
@ -210,7 +213,7 @@ if __name__ == '__main__':
pr = cProfile.Profile(builtins=False) pr = cProfile.Profile(builtins=False)
pr.enable() pr.enable()
Main() Main(True)
pr.disable() pr.disable()
s = io.StringIO() s = io.StringIO()
sortby = 'cumulative' sortby = 'cumulative'
@ -218,7 +221,7 @@ if __name__ == '__main__':
ps.print_stats() ps.print_stats()
print (s.getvalue()) print (s.getvalue())
else: else:
Main() Main(cmd_args.single_pass)
elif cmd_args.repair: elif cmd_args.repair:
# force a database repair, when # force a database repair, when
try: try:

View File

@ -44,7 +44,6 @@ class Config(object):
pid_filename = '/var/run/flowd_aggregate.pid' pid_filename = '/var/run/flowd_aggregate.pid'
flowd_source = '/var/log/flowd.log' flowd_source = '/var/log/flowd.log'
database_dir = '/var/netflow' database_dir = '/var/netflow'
single_pass = False
def __init__(self, **kwargs): def __init__(self, **kwargs):
for key in kwargs: for key in kwargs:

View File

@ -27,9 +27,7 @@
parse flowd log files parse flowd log files
""" """
import glob import glob
import tempfile
import subprocess import subprocess
import os
import re import re
from lib.flowparser import FlowParser from lib.flowparser import FlowParser

View File

@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Copyright (c) 2015-2022 Franco Fichtner <franco@opnsense.org> # Copyright (c) 2015-2025 Franco Fichtner <franco@opnsense.org>
# #
# Redistribution and use in source and binary forms, with or without # Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions # modification, are permitted provided that the following conditions
@ -100,6 +100,10 @@ ${RELEASE:-y})
run_action connection run_action connection
exit 0 exit 0
;; ;;
[fF])
run_action cleanup
exit 0
;;
*) *)
exit 0 exit 0
;; ;;

View File

@ -50,7 +50,7 @@ class DefaultBlocklistHandler(BaseBlocklistHandler):
def get_blocklist(self): def get_blocklist(self):
result = {} result = {}
for blocklist, bl_shortcode in self._blocklists_in_config(): for blocklist, bl_shortcode in self._blocklists_in_config():
per_file_stats = {'uri': blocklist, 'skip': 0, 'blocklist': 0, 'wildcard': 0} per_file_stats = {'uri': blocklist, 'blocklist': 0, 'wildcard': 0}
for domain in self._domains_in_blocklist(blocklist): for domain in self._domains_in_blocklist(blocklist):
if self.domain_pattern.match(domain): if self.domain_pattern.match(domain):
per_file_stats['blocklist'] += 1 per_file_stats['blocklist'] += 1
@ -66,11 +66,9 @@ class DefaultBlocklistHandler(BaseBlocklistHandler):
per_file_stats['wildcard'] += 1 per_file_stats['wildcard'] += 1
else: else:
result[domain] = {'bl': bl_shortcode, 'wildcard': False} result[domain] = {'bl': bl_shortcode, 'wildcard': False}
else:
per_file_stats['skip'] += 1
syslog.syslog( syslog.syslog(
syslog.LOG_NOTICE, syslog.LOG_NOTICE,
'blocklist: %(uri)s (exclude: %(skip)d block: %(blocklist)d wildcard: %(wildcard)d)' % per_file_stats 'blocklist: %(uri)s (block: %(blocklist)d wildcard: %(wildcard)d)' % per_file_stats
) )
if self.cnf and self.cnf.has_section('include'): if self.cnf and self.cnf.has_section('include'):

View File

@ -102,6 +102,12 @@ parameters:
type:script type:script
message:Retrieve health status message:Retrieve health status
[cleanup]
command:/usr/sbin/daemon -f /usr/local/opnsense/scripts/firmware/launcher.sh cleanup
parameters:
type:script
message:Run temporary file cleanup
[connection] [connection]
command:/usr/sbin/daemon -f /usr/local/opnsense/scripts/firmware/launcher.sh connection command:/usr/sbin/daemon -f /usr/local/opnsense/scripts/firmware/launcher.sh connection
parameters: parameters:

View File

@ -1,5 +1,5 @@
[reload] [reload]
command:/etc/rc.d/dnctl start || true command:/etc/rc.d/dnctl start; exit 0
parameters: parameters:
type:script type:script
message:restarting dummynet message:restarting dummynet

View File

@ -59,7 +59,7 @@ bind-interfaces
{% endif %} {% endif %}
{% if dnsmasq.add_mac %} {% if dnsmasq.add_mac %}
add-mac{% if dnsmasq.add_mac != 'default' %}={{dnsmasq.add_mac}}{% endif %} add-mac{% if dnsmasq.add_mac != 'standard' %}={{dnsmasq.add_mac}}{% endif %}
{% endif %} {% endif %}
{% if dnsmasq.add_subnet %} {% if dnsmasq.add_subnet %}
@ -186,9 +186,32 @@ ra-param={{ helpers.physical_interface(dhcp_range.interface) }}
{% endfor %} {% endfor %}
{% for host in helpers.toList('dnsmasq.hosts') %} {% for host in helpers.toList('dnsmasq.hosts') %}
{% if host.hwaddr and host.hwaddr.find(':') == -1%} {# Skip if MAC is missing or invalid (no colon), unless client_id is present #}
dhcp-host={{host.hwaddr}}{% if host.set_tag%},set:{{host.set_tag|replace('-','')}}{% endif %},{{host.ip}} {% if not host.client_id and (not host.hwaddr or host.hwaddr.find(':') == -1) %}
{% endif %} {% continue %}
{% endif %}
dhcp-host=
{%- if host.client_id -%}
id:{{ host.client_id }},
{%- endif -%}
{%- if host.hwaddr -%}
{{ host.hwaddr }},
{%- endif -%}
{%- if host.set_tag -%}
set:{{ host.set_tag|replace('-', '') }},
{%- endif -%}
{%- for ip in host.ip.split(',')|map('trim') if host.ip -%}
{{ '[' ~ ip ~ ']' if ':' in ip else ip }}{%- if not loop.last %},{% endif %}
{%- endfor -%}
{%- if host.host -%}
,{{ host.host }}
{%- endif -%}
{%- if host.lease_time -%}
,{{ host.lease_time }}{% else %},86400
{%- endif -%}
{%- if host.ignore|default('0') == '1' -%}
,ignore
{%- endif +%}
{% endfor %} {% endfor %}
{% set has_default=[] %} {% set has_default=[] %}

View File

@ -0,0 +1,46 @@
"""
Copyright (c) 2015-2016 Ad Schellevis <ad@opnsense.org>
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 sys
def update_params(parameters):
""" update predefined parameters with given list from shell (as switches)
for example /a valA /b valB
converts to
{'a':'valA','b':'valB'}
(assuming parameters contains both a and b)
:param parameters: parameter dictionary
:return:
"""
cmd = None
for arg in sys.argv[1:]:
if cmd is None:
cmd = arg[1:]
else:
if cmd in parameters and arg.strip() != '':
parameters[cmd] = arg.strip()
cmd = None

View File

@ -74,7 +74,8 @@
cursor: not-allowed; cursor: not-allowed;
} }
.bootgrid-table { .bootgrid-table {
table-layout: fixed; table-layout: auto;
border-collapse: separate;
} }
.bootgrid-table a { .bootgrid-table a {
outline: 0; outline: 0;
@ -165,3 +166,33 @@
text-overflow: inherit !important; text-overflow: inherit !important;
white-space: inherit !important; white-space: inherit !important;
} }
.bootgrid-rc-container {
position: relative;
overflow: none;
}
.bootgrid-rc-handle {
position: absolute;
width: 7px;
cursor: ew-resize;
margin-left: -3px;
z-index: 2;
}
table.bootgrid-rc-resizing {
cursor: ew-resize;
}
.bootgrid-rc-handle::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.highlight,
.highlight-move {
border-right: 1px solid rgba(128, 128, 128, 1);
}
.no-hover * {
pointer-events: none;
}

View File

@ -84,6 +84,7 @@ function init()
renderTableHeader.call(this); renderTableHeader.call(this);
renderSearchField.call(this); renderSearchField.call(this);
renderActions.call(this); renderActions.call(this);
initializeResizableColumns.call(this);
loadData.call(this); loadData.call(this);
this.element.trigger("initialized" + namespace); this.element.trigger("initialized" + namespace);
@ -140,9 +141,11 @@ function loadColumns()
setStaged: false, setStaged: false,
unsetStaged: false, unsetStaged: false,
width: ($.isNumeric(data.width)) ? data.width + "px" : width: ($.isNumeric(data.width)) ? data.width + "px" :
(typeof(data.width) === "string") ? data.width : null (typeof(data.width) === "string") ? data.width : null,
calculatedWidth: null
}; };
that.columns.push(column); that.columns.push(column);
that.columnMap.set(column.id, column);
if (column.order != null) if (column.order != null)
{ {
that.sortDictionary[column.id] = column.order; that.sortDictionary[column.id] = column.order;
@ -205,9 +208,15 @@ function update(rows, total)
if (shouldRenderHeader) { if (shouldRenderHeader) {
/* multiple columns have been set/unset prior to this reload */ /* multiple columns have been set/unset prior to this reload */
renderTableHeader.call(that); renderTableHeader.call(that);
resetHandleOverlay.call(that);
} }
renderRows.call(that, rows); renderRows.call(that, rows);
window.setTimeout(function () {
syncHandlePositions.call(that);
}, 10);
renderInfos.call(that); renderInfos.call(that);
renderPagination.call(that); renderPagination.call(that);
@ -272,7 +281,6 @@ function loadData(soft=false)
that.current = response.current; that.current = response.current;
that.cachedResponse = response; that.cachedResponse = response;
update.call(that, response.rows, response.total); update.call(that, response.rows, response.total);
this.noResultsRendered = false;
}, },
error: function (jqXHR, textStatus, errorThrown) error: function (jqXHR, textStatus, errorThrown)
{ {
@ -414,6 +422,7 @@ function renderActions()
e.stopPropagation(); e.stopPropagation();
Object.keys(localStorage) Object.keys(localStorage)
.filter(key => .filter(key =>
key.startsWith(`columnSizes[${that.uid}`) ||
key.startsWith(`visibleColumns[${that.uid}`) || key.startsWith(`visibleColumns[${that.uid}`) ||
key.startsWith(`rowCount[${that.uid}`) || key.startsWith(`rowCount[${that.uid}`) ||
key.startsWith(`sortColumns[${that.uid}`) key.startsWith(`sortColumns[${that.uid}`)
@ -458,12 +467,12 @@ function renderColumnSelection(actions)
if (!checkbox.prop("disabled")) if (!checkbox.prop("disabled"))
{ {
column.visible = localStorage.getItem('visibleColumns[' + that.uid + '][' + column.id + ']') === 'true'; column.visible = localStorage.getItem('visibleColumns[' + that.uid + '][' + column.id + ']') === 'true';
column.visible ? column.setStaged = true : column.unsetStaged = true;
var enable = that.columns.where(isVisible).length > 1; var enable = that.columns.where(isVisible).length > 1;
$this.parents(itemsSelector).find(selector + ":has(" + checkboxSelector + ":checked)") $this.parents(itemsSelector).find(selector + ":has(" + checkboxSelector + ":checked)")
._bgEnableAria(enable).find(checkboxSelector)._bgEnableField(enable); ._bgEnableAria(enable).find(checkboxSelector)._bgEnableField(enable);
that.element.find("tbody").empty(); // Fixes an column visualization bug that.element.find("tbody").empty(); // Fixes an column visualization bug
renderTableHeader.call(that);
that.columnSelectForceReload ? loadData.call(that) that.columnSelectForceReload ? loadData.call(that)
: update.call(that, that.cachedResponse.rows, that.cachedResponse.total); : update.call(that, that.cachedResponse.rows, that.cachedResponse.total);
} }
@ -1038,8 +1047,304 @@ function sortRows()
this.rows.sort(sort); this.rows.sort(sort);
} }
} }
}
// RESIZABLE COLUMN INTERNAL FUNCTIONS
// ====================
function initializeResizableColumns() {
if (!this.options.resizableColumns) {
return;
} }
resetHandleOverlay.call(this);
syncHandlePositions.call(this);
bindEvents($(window), ['resize'], null, syncHandlePositions.bind(this));
}
function resetHandleOverlay() {
let that = this;
if (!this.options.resizableColumns) {
return;
}
if (!this.columns.length) {
return;
}
this.$tableHeaders = this.element.find(`tr:first > th:visible${this.options.rowSelect ? ':gt(0)' : ''}`);
// annotate initial column width
this.$tableHeaders.each(function (i, el) {
let $el = $(el);
let col = that.columnMap.get($el.data('column-id'));
let pxWidth = 0;
if (!col || col.width == null) {
pxWidth = $el.outerWidth();
} else {
pxWidth = toPixels(col.width);
$el.data('minWidth', pxWidth);
}
if (col) {
let stored = localStorage.getItem(`columnSizes[${that.uid}][${col.id}]`);
if (stored != null) {
setWidth.call(that, el, parseFloat(stored));
} else if (col.calculatedWidth != null) {
setWidth.call(that, el, col.calculatedWidth);
} else {
setWidth.call(that, el, pxWidth);
}
}
});
// setup handle container overlay
if (this.$resizableHandleContainer != null) {
this.$resizableHandleContainer.remove();
}
this.$resizableHandleContainer = $(`<div class="bootgrid-rc-container"/>`);
this.element.before(this.$resizableHandleContainer);
this.$tableHeaders.each(function (i, el) {
let $current = that.$tableHeaders.eq(i);
let $next = that.$tableHeaders.eq(i + 1);
if ($next.length === 0 || $current.is('[data-noresize]') || $next.is('[data-noresize]')) {
return;
}
$(`<div class="bootgrid-rc-handle"/>`).data('th', $(el)).data('id', $(el).data('column-id'))
.hover(
function() {
$(el).addClass('highlight');
that.element.find('tr').map(function() {
$(this).find('td').eq($(el).index()).addClass('highlight');
});
},
function() {
$(el).removeClass('highlight');
that.element.find('tr').map(function() {
$(this).find('td').eq($(el).index()).removeClass('highlight');
});
}
)
.appendTo(that.$resizableHandleContainer);
});
bindEvents(
this.$resizableHandleContainer,
['mousedown', 'touchstart'],
'.bootgrid-rc-handle',
onMouseDown.bind(this)
);
}
function syncHandlePositions() {
let that = this;
if (!this.options.resizableColumns) {
return;
}
// sync handle container to table width
this.$resizableHandleContainer.width(this.element.width());
// sync individual handles
this.$resizableHandleContainer.find('.bootgrid-rc-handle').each(function (i, el) {
let $el = $(el);
let $originalHeader = $el.data('th');
var left = $originalHeader.outerWidth() + ($originalHeader.offset().left - that.$resizableHandleContainer.offset().left);
$el.css({ left: left, height: that.element.find('thead').height() })
});
}
function onMouseDown(event) {
let $currentHandle = $(event.currentTarget);
let idx = $currentHandle.index();
let $leftCol = this.$tableHeaders.eq(idx).not('[data-noresize]');
let leftCol = this.columnMap.get($leftCol.data('column-id'));
let $rightCol = this.$tableHeaders.eq(idx + 1).not('[data-noresize]');
let rightCol = this.columnMap.get($rightCol.data('column-id'));
if ($currentHandle.is('[data-noresize]') || !leftCol || !rightCol) {
return;
}
let leftColWidth = leftCol.calculatedWidth;
let rightColWidth = rightCol.calculatedWidth;
if (this.resizeOp) {
onMouseUp.call(this, event);
}
// start operation
this.resizeOp = {
$leftCol: $leftCol,
$rightCol: $rightCol,
$currentHandle, $currentHandle,
startX: getPointerX(event),
widths: {
left: leftColWidth,
right: rightColWidth
},
newWidths: {
left: leftColWidth,
right: rightColWidth
}
}
bindEvents(
$(this.element[0].ownerDocument),
['mousemove', 'touchmove'],
null,
onMouseMove.bind(this)
);
bindEvents(
$(this.element[0].ownerDocument),
['mouseup', 'touchend'],
null,
onMouseUp.bind(this)
);
this.$resizableHandleContainer.add(this.element).addClass('bootgrid-rc-resizing');
$leftCol.add($rightCol).addClass('bootgrid-rc-resizing');
$leftCol.addClass('highlight-move');
this.element.find('tr').map(function() {
let i = $currentHandle.data('th').index();
$(this).find('td').eq(i).addClass('highlight-move');
});
// prevent global hover effects when resizing
$('body').addClass('no-hover');
event.preventDefault();
}
function onMouseMove(event) {
let op = this.resizeOp
if (!this.resizeOp) {
return;
}
let diff = (getPointerX(event) - op.startX)
if (diff === 0) {
return;
}
let leftCol = op.$leftCol[0];
let rightCol = op.$rightCol[0];
let widthLeft = undefined;
let widthRight = undefined;
if (diff > 0) {
widthLeft = constrainWidth.call(this, op.widths.left + (op.widths.right - op.newWidths.right), op.$leftCol);
widthRight = constrainWidth.call(this, op.widths.right - diff, op.$rightCol);
} else if (diff < 0) {
widthLeft = constrainWidth.call(this, op.widths.left + diff, op.$leftCol);
widthRight = constrainWidth.call(this, op.widths.right + (op.widths.left - op.newWidths.left), op.$rightCol);
}
if (leftCol && widthRight >= 0) {
setWidth.call(this, leftCol, widthLeft);
}
if (rightCol && widthLeft >= 0) {
setWidth.call(this, rightCol, widthRight);
}
op.newWidths.left = widthLeft;
op.newWidths.right = widthRight;
}
function onMouseUp(event) {
let that = this;
let idx = this.resizeOp.$currentHandle.index();
if (!this.resizeOp) {
return;
}
unbindEvents($(this.element[0].ownerDocument), ['mouseup', 'touchend', 'mousemove', 'touchmove']);
this.$resizableHandleContainer.add(this.element).removeClass('bootgrid-rc-resizing');
this.resizeOp.$leftCol.add(this.resizeOp.$rightCol).add(this.resizeOp.$currentHandle).removeClass('bootgrid-rc-resizing');
this.resizeOp.$leftCol.removeClass('highlight-move');
this.element.find('tr').map(function() {
let i = that.resizeOp.$currentHandle.data('th').index();
$(this).find('td').eq(i).removeClass('highlight-move');
});
$('body').removeClass('no-hover');
this.columns.where(isVisible).forEach(function(col) {
localStorage.setItem(`columnSizes[${that.uid}][${col.id}]`, col.calculatedWidth);
})
syncHandlePositions.call(this);
this.resizeOp = null;
}
function bindEvents($target, events, data, callback) {
events = events.join(namespace + ' ') + namespace;
$target.on(events, data, callback);
}
function unbindEvents($target, events) {
events = events.join(namespace + ' ') + namespace;
$target.off(events);
}
function getPointerX(event) {
if (event.type.indexOf('touch') === 0) {
return (event.originalEvent.touches[0] || event.originalEvent.changedTouches[0]).pageX;
}
return event.pageX;
}
function constrainWidth(width, $el) {
let options = this.options.resizableColumnSettings;
let min = $el.data('minWidth');
if (options.minWidth != undefined) {
width = Math.max(options.minWidth, width);
}
if (min != undefined) {
width = Math.max(min, width);
}
if (options.maxWidth != undefined) {
width = Math.min(options.maxWidth, width);
}
return width;
}
function setWidth(element, width) {
let col = this.columnMap.get($(element).data('column-id'));
width = width > 0 ? width : 0;
element.style.width = width + 'px';
col.calculatedWidth = width;
}
function toPixels(value) {
const tempElement = document.createElement("div");
tempElement.style.width = value;
document.body.appendChild(tempElement);
const pixels = window.getComputedStyle(tempElement).width;
document.body.removeChild(tempElement);
return parseFloat(pixels);
}
// GRID PUBLIC CLASS DEFINITION // GRID PUBLIC CLASS DEFINITION
// ==================== // ====================
@ -1060,6 +1365,7 @@ var Grid = function(element, options)
// overrides rowCount explicitly because deep copy ($.extend) leads to strange behaviour // overrides rowCount explicitly because deep copy ($.extend) leads to strange behaviour
var rowCount = this.options.rowCount = this.element.data().rowCount || options.rowCount || this.options.rowCount; var rowCount = this.options.rowCount = this.element.data().rowCount || options.rowCount || this.options.rowCount;
this.columns = []; this.columns = [];
this.columnMap = new Map();
this.current = 1; this.current = 1;
this.currentRows = []; this.currentRows = [];
this.identifier = null; // The first column ID that is marked as identifier this.identifier = null; // The first column ID that is marked as identifier
@ -1088,6 +1394,10 @@ var Grid = function(element, options)
this.xqr = null; this.xqr = null;
this.uid = window.location.pathname + "#" + this.element.attr('id'); this.uid = window.location.pathname + "#" + this.element.attr('id');
this.$tableHeaders = [];
this.$resizableHandleContainer = null;
this.resizeOp = null;
// todo: implement cache // todo: implement cache
}; };
@ -1225,6 +1535,13 @@ Grid.defaults = {
method: "POST" method: "POST"
}, },
resizableColumns: false,
resizableColumnSettings: {
minWidth: 50,
maxWidth: null
},
/** /**
* Enriches the request object with additional properties. Either a `PlainObject` or a `Function` * Enriches the request object with additional properties. Either a `PlainObject` or a `Function`
* that returns a `PlainObject` can be passed. Default value is `{}`. * that returns a `PlainObject` can be passed. Default value is `{}`.
@ -1517,7 +1834,7 @@ Grid.defaults = {
actionDropDownCheckboxItem: "<li><label class=\"{{css.dropDownItem}}\"><input id=\"{{ctx.id}}\" name=\"{{ctx.name}}\" type=\"checkbox\" value=\"1\" class=\"{{css.dropDownItemCheckbox}}\" {{ctx.checked}} /> {{ctx.label}}</label></li>", actionDropDownCheckboxItem: "<li><label class=\"{{css.dropDownItem}}\"><input id=\"{{ctx.id}}\" name=\"{{ctx.name}}\" type=\"checkbox\" value=\"1\" class=\"{{css.dropDownItemCheckbox}}\" {{ctx.checked}} /> {{ctx.label}}</label></li>",
actions: "<div class=\"{{css.actions}}\"></div>", actions: "<div class=\"{{css.actions}}\"></div>",
body: "<tbody></tbody>", body: "<tbody></tbody>",
cell: "<td class=\"{{ctx.css}}\" style=\"{{ctx.style}}\">{{ctx.content}}</td>", cell: "<td class=\"bootgrid-rc-column {{ctx.css}}\" style=\"{{ctx.style}}\">{{ctx.content}}</td>",
footer: "<div id=\"{{ctx.id}}\" class=\"{{css.footer}}\"><div class=\"row\"><div class=\"col-sm-6\"><p class=\"{{css.pagination}}\"></p></div><div class=\"col-sm-6 infoBar\"><p class=\"{{css.infos}}\"></p></div></div></div>", footer: "<div id=\"{{ctx.id}}\" class=\"{{css.footer}}\"><div class=\"row\"><div class=\"col-sm-6\"><p class=\"{{css.pagination}}\"></p></div><div class=\"col-sm-6 infoBar\"><p class=\"{{css.infos}}\"></p></div></div></div>",
header: "<div id=\"{{ctx.id}}\" class=\"{{css.header}}\"><div class=\"row\"><div class=\"col-sm-12 actionBar\"><p class=\"{{css.search}}\"></p><p class=\"{{css.actions}}\"></p></div></div></div>", header: "<div id=\"{{ctx.id}}\" class=\"{{css.header}}\"><div class=\"row\"><div class=\"col-sm-12 actionBar\"><p class=\"{{css.search}}\"></p><p class=\"{{css.actions}}\"></p></div></div></div>",
headerCell: "<th data-column-id=\"{{ctx.column.id}}\" class=\"{{ctx.css}}\" style=\"{{ctx.style}}\"><a href=\"javascript:void(0);\" class=\"{{css.columnHeaderAnchor}} {{ctx.sortable}}\"><span class=\"{{css.columnHeaderText}}\">{{ctx.column.headerText}}</span>{{ctx.icon}}</a></th>", headerCell: "<th data-column-id=\"{{ctx.column.id}}\" class=\"{{ctx.css}}\" style=\"{{ctx.style}}\"><a href=\"javascript:void(0);\" class=\"{{css.columnHeaderAnchor}} {{ctx.sortable}}\"><span class=\"{{css.columnHeaderText}}\">{{ctx.column.headerText}}</span>{{ctx.icon}}</a></th>",

View File

@ -622,6 +622,75 @@ $.fn.SimpleActionButton = function (params) {
}); });
} }
/**
* fetch option list for remote url, when a list is returned, expects either a list of items formatted like
* [
* {'label': 'my_label', value: 'my_value'}
* ]
* or an option group like:
* {
* group_value: {
* label: 'my_group_label',
* icon: 'fa fa-tag text-primary',
* items: [{'label': 'my_label', value: 'my_value'}]
* }
* }
*
* When data is formatted differently, the data_callback can be used to re-format.
*
* @param url
* @param params
* @param data_callback callout to cleanse data before usage
* @param store_data store data in data attribute (in its original form)
*/
$.fn.fetch_options = function(url, params, data_callback, store_data) {
var deferred = $.Deferred();
var $obj = $(this);
$obj.empty();
ajaxGet(url, params ?? {}, function(data){
if (store_data === true) {
$obj.data("store", data);
}
if (typeof data_callback === "function") {
data = data_callback(data);
}
if (Array.isArray(data)) {
data.map(function (item) {
$obj.append($("<option/>").attr({"value": item.value}).text(item.label));
});
} else {
for (const groupKey in data) {
const group = data[groupKey];
if (group.items.length > 0) {
const $optgroup = $('<optgroup>', {
label: group.label,
'data-icon': group.icon
});
for (const item of group.items) {
$optgroup.append(
$('<option>', {
value: item.value,
text: item.label,
'data-subtext': group.label,
selected: item.selected ? 'selected' : undefined
})
);
}
$obj.append($optgroup);
}
}
}
if ($obj.hasClass('selectpicker')) {
$obj.selectpicker('refresh');
}
$obj.change();
deferred.resolve();
});
return deferred.promise();
};
/** /**
* File upload dialog, constructs a modal, asks for a file to upload and sets {'payload': ..,, 'filename': ...} * File upload dialog, constructs a modal, asks for a file to upload and sets {'payload': ..,, 'filename': ...}

View File

@ -107,7 +107,7 @@ export default class OpenVPNClients extends BaseTableWidget {
} }
} }
//store all ip addresses // store all ip addresses
let ip_list = [ let ip_list = [
client.real_address, client.real_address,
client.virtual_address, client.virtual_address,

View File

@ -73,7 +73,8 @@
cursor: not-allowed; cursor: not-allowed;
} }
.bootgrid-table { .bootgrid-table {
table-layout: fixed; table-layout: auto;
border-collapse: separate;
} }
.bootgrid-table a { .bootgrid-table a {
outline: 0; outline: 0;
@ -163,3 +164,33 @@
text-overflow: inherit !important; text-overflow: inherit !important;
white-space: inherit !important; white-space: inherit !important;
} }
.bootgrid-rc-container {
position: relative;
overflow: none;
}
.bootgrid-rc-handle {
position: absolute;
width: 7px;
cursor: ew-resize;
margin-left: -3px;
z-index: 2;
}
table.bootgrid-rc-resizing {
cursor: ew-resize;
}
.bootgrid-rc-handle::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.highlight,
.highlight-move {
border-right: 1px solid rgba(128, 128, 128, 1);
}
.no-hover * {
pointer-events: none;
}

View File

@ -237,7 +237,7 @@ $( document ).ready(function() {
<option value="down" <?=$pconfig['trigger'] == "down" ? "selected=\"selected\"" :"";?> ><?=gettext("Member Down");?></option> <option value="down" <?=$pconfig['trigger'] == "down" ? "selected=\"selected\"" :"";?> ><?=gettext("Member Down");?></option>
<option value="downloss" <?=$pconfig['trigger'] == "downloss" ? "selected=\"selected\"" :"";?> ><?=gettext("Packet Loss");?></option> <option value="downloss" <?=$pconfig['trigger'] == "downloss" ? "selected=\"selected\"" :"";?> ><?=gettext("Packet Loss");?></option>
<option value="downlatency" <?=$pconfig['trigger'] == "downlatency" ? "selected=\"selected\"" :"";?> ><?=gettext("High Latency");?></option> <option value="downlatency" <?=$pconfig['trigger'] == "downlatency" ? "selected=\"selected\"" :"";?> ><?=gettext("High Latency");?></option>
<option value="downlosslatency" <?=$pconfig['trigger'] == "downlosslatency" ? "selected=\"selected\"" :"";?> ><?=gettext("Packet Loss or High Latency");?></option> <option value="downlosslatency" <?=$pconfig['trigger'] == "downlosslatency" ? "selected=\"selected\"" :"";?> ><?=gettext("Packet Loss and High Latency");?></option>
</select> </select>
<div data-for="help_for_triggerlvl" class="hidden"> <div data-for="help_for_triggerlvl" class="hidden">
<?=gettext("When to trigger exclusion of a member"); ?> <?=gettext("When to trigger exclusion of a member"); ?>