mirror of
https://github.com/opnsense/plugins.git
synced 2026-05-28 04:34:15 -04:00
security/xproxy: add new plugin
Xray-core proxy client with transparent LAN routing via hev-socks5-tunnel TUN interface. Supports VLESS (with XTLS-Vision / Reality), VMess, Shadowsocks, and Trojan protocols with URI import and policy-based routing. Closes #5347
This commit is contained in:
parent
d1ebcc49ad
commit
7b7bcb55b4
25 changed files with 3097 additions and 0 deletions
7
security/xproxy/Makefile
Normal file
7
security/xproxy/Makefile
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
PLUGIN_NAME= xproxy
|
||||
PLUGIN_VERSION= 0.2
|
||||
PLUGIN_COMMENT= Multi-protocol proxy client with transparent routing
|
||||
PLUGIN_DEPENDS= xray-core
|
||||
PLUGIN_MAINTAINER= community@opnsense.org
|
||||
|
||||
.include "../../Mk/plugins.mk"
|
||||
6
security/xproxy/pkg-descr
Normal file
6
security/xproxy/pkg-descr
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Xproxy is a multi-protocol proxy client for OPNsense that manages
|
||||
xray-core with support for VLESS, VMess, Shadowsocks, and Trojan
|
||||
protocols. It provides transparent proxying via hev-socks5-tunnel and
|
||||
supports importing server configurations from URIs and subscriptions.
|
||||
|
||||
WWW: https://github.com/XTLS/Xray-core
|
||||
233
security/xproxy/src/etc/inc/plugins.inc.d/xproxy.inc
Normal file
233
security/xproxy/src/etc/inc/plugins.inc.d/xproxy.inc
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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 xproxy_configure()
|
||||
{
|
||||
return array(
|
||||
'bootup' => array('xproxy_configure_do')
|
||||
);
|
||||
}
|
||||
|
||||
function xproxy_sync_gateway_config()
|
||||
{
|
||||
$script = '/usr/local/opnsense/scripts/xproxy/sync_gateway.php';
|
||||
if (is_file($script)) {
|
||||
exec('/usr/local/bin/php ' . escapeshellarg($script) . ' 2>/dev/null');
|
||||
}
|
||||
}
|
||||
|
||||
function xproxy_configure_do($verbose = false)
|
||||
{
|
||||
xproxy_sync_gateway_config();
|
||||
|
||||
$mdl = new \OPNsense\Xproxy\Xproxy();
|
||||
if ((string)$mdl->general->enabled == '1') {
|
||||
if ($verbose) {
|
||||
echo 'Starting Xproxy...';
|
||||
}
|
||||
configd_run('xproxy setup');
|
||||
configd_run('template reload OPNsense/Xproxy');
|
||||
configd_run('xproxy start');
|
||||
configd_run('filter reload');
|
||||
} else {
|
||||
configd_run('xproxy stop');
|
||||
configd_run('filter reload');
|
||||
}
|
||||
}
|
||||
|
||||
function xproxy_policy_route_enabled()
|
||||
{
|
||||
if (!file_exists('/var/run/xproxy_service.active')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mdl = new \OPNsense\Xproxy\Xproxy();
|
||||
if ((string)$mdl->general->enabled != '1') {
|
||||
return false;
|
||||
}
|
||||
if ((string)$mdl->general->policy_route_lan === '0') {
|
||||
return false;
|
||||
}
|
||||
$active = (string)$mdl->general->active_server;
|
||||
if (empty($active)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($mdl->servers->server->iterateItems() as $uuid => $srv) {
|
||||
if ($uuid === $active) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function xproxy_firewall(\OPNsense\Firewall\Plugin $fw)
|
||||
{
|
||||
global $config;
|
||||
|
||||
if (!xproxy_policy_route_enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaults = array(
|
||||
'ipprotocol' => 'inet',
|
||||
'type' => 'pass',
|
||||
'quick' => true,
|
||||
'statetype' => 'keep',
|
||||
'disablereplyto' => true,
|
||||
);
|
||||
|
||||
if (empty($config['interfaces']) || !is_array($config['interfaces'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mdl = new \OPNsense\Xproxy\Xproxy();
|
||||
$bypassRaw = (string)$mdl->general->bypass_ips;
|
||||
$bypassCidrs = array();
|
||||
if (!empty($bypassRaw)) {
|
||||
foreach (explode(',', $bypassRaw) as $cidr) {
|
||||
$cidr = trim($cidr);
|
||||
if (!empty($cidr)) {
|
||||
$bypassCidrs[] = $cidr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$selectedIfs = array();
|
||||
$routeIfRaw = (string)$mdl->general->route_interfaces;
|
||||
if (!empty($routeIfRaw)) {
|
||||
foreach (explode(',', $routeIfRaw) as $entry) {
|
||||
$entry = trim($entry);
|
||||
if (!empty($entry)) {
|
||||
$selectedIfs[] = $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($config['interfaces'] as $ifent => $iface) {
|
||||
if (!is_array($iface) || empty($iface['enable'])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($iface['virtual'])) {
|
||||
continue;
|
||||
}
|
||||
if ($ifent === 'wan' || $ifent === 'xproxytun') {
|
||||
continue;
|
||||
}
|
||||
if (!empty($selectedIfs) && !in_array($ifent, $selectedIfs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow access to the firewall itself (anti-lockout)
|
||||
$fw->registerFilterRule(
|
||||
150,
|
||||
array(
|
||||
'interface' => $ifent,
|
||||
'descr' => 'Xproxy: allow access to this firewall (' . $ifent . ')',
|
||||
'from' => $ifent,
|
||||
'to' => '(self)',
|
||||
'direction' => 'in',
|
||||
),
|
||||
$defaults
|
||||
);
|
||||
|
||||
// Bypass: let traffic to local/private networks pass directly
|
||||
foreach ($bypassCidrs as $cidr) {
|
||||
$fw->registerFilterRule(
|
||||
175,
|
||||
array(
|
||||
'interface' => $ifent,
|
||||
'descr' => 'Xproxy: bypass tunnel for ' . $cidr . ' (' . $ifent . ')',
|
||||
'from' => $ifent,
|
||||
'to' => $cidr,
|
||||
'direction' => 'in',
|
||||
),
|
||||
$defaults
|
||||
);
|
||||
}
|
||||
|
||||
// Bypass traffic destined for the same interface network
|
||||
$fw->registerFilterRule(
|
||||
176,
|
||||
array(
|
||||
'interface' => $ifent,
|
||||
'descr' => 'Xproxy: bypass tunnel for same-subnet (' . $ifent . ')',
|
||||
'from' => $ifent,
|
||||
'to' => $ifent,
|
||||
'direction' => 'in',
|
||||
),
|
||||
$defaults
|
||||
);
|
||||
|
||||
// Route all other IPv4 traffic through the tunnel
|
||||
$fw->registerFilterRule(
|
||||
200,
|
||||
array(
|
||||
'interface' => $ifent,
|
||||
'descr' => 'Xproxy: route IPv4 via tunnel (' . $ifent . ')',
|
||||
'from' => $ifent,
|
||||
'to' => 'any',
|
||||
'direction' => 'in',
|
||||
'gateway' => 'XPROXY_TUN',
|
||||
),
|
||||
$defaults
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function xproxy_devices()
|
||||
{
|
||||
$mdl = new \OPNsense\Xproxy\Xproxy();
|
||||
if ((string)$mdl->general->enabled != '1') {
|
||||
return array();
|
||||
}
|
||||
$dev = (string)$mdl->general->tun_device;
|
||||
if ($dev === '') {
|
||||
$dev = 'tun9';
|
||||
}
|
||||
return array(array('pattern' => '^' . preg_quote($dev, '/') . '$', 'volatile' => true));
|
||||
}
|
||||
|
||||
function xproxy_services()
|
||||
{
|
||||
$services = array();
|
||||
$mdl = new \OPNsense\Xproxy\Xproxy();
|
||||
if ((string)$mdl->general->enabled == '1') {
|
||||
$services[] = array(
|
||||
'description' => gettext('Xproxy'),
|
||||
'configd' => array(
|
||||
'restart' => array('xproxy restart'),
|
||||
'start' => array('xproxy start'),
|
||||
'stop' => array('xproxy stop'),
|
||||
),
|
||||
'name' => 'xproxy',
|
||||
'pidfile' => '/var/run/xproxy_xray.pid',
|
||||
);
|
||||
}
|
||||
return $services;
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy\Api;
|
||||
|
||||
use OPNsense\Base\ApiControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
use OPNsense\Core\Config;
|
||||
use OPNsense\Xproxy\Xproxy;
|
||||
|
||||
class ImportController extends ApiControllerBase
|
||||
{
|
||||
private const MAX_IMPORT_BYTES = 2097152;
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $servers
|
||||
* @return array{added: int, skipped: int}
|
||||
*/
|
||||
private function mergeServersIntoModel(Xproxy $mdl, array $servers): array
|
||||
{
|
||||
$existing = [];
|
||||
foreach ($mdl->servers->server->iterateItems() as $item) {
|
||||
$ru = (string)$item->raw_uri;
|
||||
if ($ru !== '') {
|
||||
$existing[$ru] = true;
|
||||
}
|
||||
}
|
||||
$added = 0;
|
||||
$skipped = 0;
|
||||
$fieldMap = [
|
||||
'enabled', 'description', 'protocol', 'address', 'port',
|
||||
'user_id', 'password', 'encryption', 'flow', 'transport',
|
||||
'transport_host', 'transport_path', 'security', 'sni',
|
||||
'fingerprint', 'alpn', 'reality_pubkey', 'reality_short_id', 'raw_uri',
|
||||
];
|
||||
foreach ($servers as $srv) {
|
||||
if (!is_array($srv)) {
|
||||
continue;
|
||||
}
|
||||
$raw = isset($srv['raw_uri']) ? (string)$srv['raw_uri'] : '';
|
||||
if ($raw !== '' && !empty($existing[$raw])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$node = $mdl->servers->server->Add();
|
||||
foreach ($fieldMap as $field) {
|
||||
if (!array_key_exists($field, $srv) || $srv[$field] === '' || $srv[$field] === null) {
|
||||
continue;
|
||||
}
|
||||
$node->$field = (string)$srv[$field];
|
||||
}
|
||||
if ($raw !== '') {
|
||||
$existing[$raw] = true;
|
||||
}
|
||||
$added++;
|
||||
}
|
||||
return ['added' => $added, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import servers from proxy URI strings (vless://, vmess://, ss://, trojan://).
|
||||
*/
|
||||
public function urisAction()
|
||||
{
|
||||
$result = array("result" => "failed", "count" => 0);
|
||||
if ($this->request->isPost()) {
|
||||
$uris = $this->request->getPost('uris');
|
||||
if ($uris === null) {
|
||||
$uris = '';
|
||||
}
|
||||
if (!is_string($uris)) {
|
||||
$uris = '';
|
||||
}
|
||||
if ($uris !== '') {
|
||||
if (strlen($uris) > self::MAX_IMPORT_BYTES) {
|
||||
$result["message"] = "Import payload too large (max 2 MiB).";
|
||||
return $result;
|
||||
}
|
||||
$tmpFile = tempnam('/tmp', 'xproxy_import_');
|
||||
if ($tmpFile === false) {
|
||||
$result["message"] = "Could not create temporary file.";
|
||||
return $result;
|
||||
}
|
||||
chmod($tmpFile, 0600);
|
||||
try {
|
||||
file_put_contents($tmpFile, $uris);
|
||||
$backend = new Backend();
|
||||
$response = trim($backend->configdRun("xproxy import " . escapeshellarg($tmpFile)));
|
||||
} finally {
|
||||
@unlink($tmpFile);
|
||||
}
|
||||
$parsed = json_decode($response, true);
|
||||
if (is_array($parsed) && isset($parsed['servers']) && is_array($parsed['servers'])) {
|
||||
$mdl = new Xproxy();
|
||||
$merge = $this->mergeServersIntoModel($mdl, $parsed['servers']);
|
||||
if ($merge['added'] > 0) {
|
||||
if (empty((string)$mdl->general->active_server)) {
|
||||
foreach ($mdl->servers->server->iterateItems() as $srvUuid => $srvItem) {
|
||||
$mdl->general->active_server = $srvUuid;
|
||||
$result["auto_selected"] = (string)$srvItem->description;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$mdl->serializeToConfig();
|
||||
Config::getInstance()->save();
|
||||
$result["result"] = "saved";
|
||||
$result["count"] = $merge['added'];
|
||||
if ($merge['skipped'] > 0) {
|
||||
$result["skipped"] = $merge['skipped'];
|
||||
}
|
||||
} else {
|
||||
$result["message"] = "No new servers to add (duplicates skipped).";
|
||||
if ($merge['skipped'] > 0) {
|
||||
$result["skipped"] = $merge['skipped'];
|
||||
}
|
||||
}
|
||||
if (!empty($parsed['errors'])) {
|
||||
$result["errors"] = $parsed['errors'];
|
||||
}
|
||||
} else {
|
||||
$result["message"] = "Failed to parse import response.";
|
||||
}
|
||||
} else {
|
||||
$result["message"] = "No URIs provided.";
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
|
||||
class ServersController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelName = 'xproxy';
|
||||
protected static $internalModelClass = 'OPNsense\Xproxy\Xproxy';
|
||||
|
||||
public function searchItemAction()
|
||||
{
|
||||
return $this->searchBase(
|
||||
"servers.server",
|
||||
array('description', 'protocol', 'address', 'port', 'security'),
|
||||
"description"
|
||||
);
|
||||
}
|
||||
|
||||
public function setItemAction($uuid)
|
||||
{
|
||||
return $this->setBase("server", "servers.server", $uuid);
|
||||
}
|
||||
|
||||
public function addItemAction()
|
||||
{
|
||||
$result = $this->addBase("server", "servers.server");
|
||||
if (isset($result['uuid']) && isset($result['result']) && $result['result'] === 'saved') {
|
||||
$mdl = $this->getModel();
|
||||
if (empty((string)$mdl->general->active_server)) {
|
||||
$mdl->general->active_server = $result['uuid'];
|
||||
$mdl->serializeToConfig();
|
||||
\OPNsense\Core\Config::getInstance()->save();
|
||||
$node = $mdl->getNodeByReference("servers.server." . $result['uuid']);
|
||||
if ($node !== null) {
|
||||
$result['auto_selected'] = (string)$node->description;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getItemAction($uuid = null)
|
||||
{
|
||||
return $this->getBase("server", "servers.server", $uuid);
|
||||
}
|
||||
|
||||
public function delItemAction($uuid)
|
||||
{
|
||||
$mdl = $this->getModel();
|
||||
$wasActive = ((string)$mdl->general->active_server === $uuid);
|
||||
if ($wasActive) {
|
||||
$mdl->general->active_server = '';
|
||||
$mdl->serializeToConfig();
|
||||
\OPNsense\Core\Config::getInstance()->save();
|
||||
}
|
||||
$result = $this->delBase("servers.server", $uuid);
|
||||
if ($wasActive && isset($result['result']) && $result['result'] === 'deleted') {
|
||||
$mdl = $this->getModel();
|
||||
foreach ($mdl->servers->server->iterateItems() as $remainingUuid => $item) {
|
||||
$mdl->general->active_server = $remainingUuid;
|
||||
$mdl->serializeToConfig();
|
||||
\OPNsense\Core\Config::getInstance()->save();
|
||||
$result['auto_selected'] = (string)$item->description;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a server as the active outbound proxy.
|
||||
*/
|
||||
public function setActiveAction($uuid)
|
||||
{
|
||||
$result = array("result" => "failed");
|
||||
if ($this->request->isPost()) {
|
||||
if (!is_string($uuid) || !preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i', $uuid)) {
|
||||
return $result;
|
||||
}
|
||||
$mdl = $this->getModel();
|
||||
$node = $mdl->getNodeByReference("servers.server." . $uuid);
|
||||
if ($node != null) {
|
||||
$mdl->general->active_server = $uuid;
|
||||
$valMsgs = $mdl->performValidation();
|
||||
if (count($valMsgs) == 0) {
|
||||
$mdl->serializeToConfig();
|
||||
\OPNsense\Core\Config::getInstance()->save();
|
||||
$result["result"] = "saved";
|
||||
} else {
|
||||
$result["validations"] = array();
|
||||
foreach ($valMsgs as $msg) {
|
||||
$result["validations"][$msg->getField()] = $msg->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableServiceControllerBase;
|
||||
use OPNsense\Core\Backend;
|
||||
|
||||
class ServiceController extends ApiMutableServiceControllerBase
|
||||
{
|
||||
protected static $internalServiceClass = '\OPNsense\Xproxy\Xproxy';
|
||||
protected static $internalServiceEnabled = 'general.enabled';
|
||||
protected static $internalServiceTemplate = 'OPNsense/Xproxy';
|
||||
protected static $internalServiceName = 'xproxy';
|
||||
|
||||
protected function reconfigureForceRestart()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function logAction()
|
||||
{
|
||||
$backend = new Backend();
|
||||
$response = $backend->configdRun('xproxy log');
|
||||
return ['response' => $response];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy\Api;
|
||||
|
||||
use OPNsense\Base\ApiMutableModelControllerBase;
|
||||
|
||||
class SettingsController extends ApiMutableModelControllerBase
|
||||
{
|
||||
protected static $internalModelClass = 'OPNsense\Xproxy\Xproxy';
|
||||
protected static $internalModelName = 'xproxy';
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy;
|
||||
|
||||
class IndexController extends \OPNsense\Base\IndexController
|
||||
{
|
||||
public function indexAction()
|
||||
{
|
||||
$this->view->formGeneral = $this->getForm("general");
|
||||
$this->view->formDialogServer = $this->getForm("dialogServer");
|
||||
$this->view->formGridServer = $this->getFormGrid("dialogServer");
|
||||
$this->view->pick('OPNsense/Xproxy/index');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<form>
|
||||
<field>
|
||||
<id>server.enabled</id>
|
||||
<label>Enabled</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable or disable this server profile.</help>
|
||||
<grid_view>
|
||||
<visible>false</visible>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.description</id>
|
||||
<label>Description</label>
|
||||
<type>text</type>
|
||||
<help>Friendly name for this server.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.protocol</id>
|
||||
<label>Protocol</label>
|
||||
<type>dropdown</type>
|
||||
<help>Proxy protocol used by the remote server.</help>
|
||||
<grid_view>
|
||||
<width>8em</width>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.address</id>
|
||||
<label>Server Address</label>
|
||||
<type>text</type>
|
||||
<help>IP address or hostname of the remote proxy server.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.port</id>
|
||||
<label>Server Port</label>
|
||||
<type>text</type>
|
||||
<help>Port of the remote proxy server.</help>
|
||||
<grid_view>
|
||||
<width>6em</width>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.user_id</id>
|
||||
<label>UUID</label>
|
||||
<type>text</type>
|
||||
<help>User ID for VLESS/VMess protocols.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.password</id>
|
||||
<label>Password</label>
|
||||
<type>password</type>
|
||||
<help>Password for Shadowsocks/Trojan protocols.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.encryption</id>
|
||||
<label>Encryption</label>
|
||||
<type>text</type>
|
||||
<help>Encryption method. Use "none" for VLESS, "auto" for VMess, or the cipher name for Shadowsocks.</help>
|
||||
<grid_view>
|
||||
<visible>false</visible>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.flow</id>
|
||||
<label>Flow Control</label>
|
||||
<type>dropdown</type>
|
||||
<help>XTLS flow control (VLESS only).</help>
|
||||
<grid_view>
|
||||
<visible>false</visible>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>Transport</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.transport</id>
|
||||
<label>Transport</label>
|
||||
<type>dropdown</type>
|
||||
<help>Network transport protocol.</help>
|
||||
<grid_view>
|
||||
<visible>false</visible>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.transport_host</id>
|
||||
<label>Transport Host</label>
|
||||
<type>text</type>
|
||||
<help>Host header for WebSocket/HTTP/2 transport.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.transport_path</id>
|
||||
<label>Transport Path</label>
|
||||
<type>text</type>
|
||||
<help>Path for WebSocket/HTTP/2/gRPC transport.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>TLS / Security</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.security</id>
|
||||
<label>Security</label>
|
||||
<type>dropdown</type>
|
||||
<help>TLS security mode.</help>
|
||||
<grid_view>
|
||||
<width>7em</width>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.sni</id>
|
||||
<label>SNI</label>
|
||||
<type>text</type>
|
||||
<help>Server Name Indication for TLS handshake.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.fingerprint</id>
|
||||
<label>Fingerprint</label>
|
||||
<type>text</type>
|
||||
<help>uTLS client fingerprint (e.g. chrome, firefox, safari).</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.alpn</id>
|
||||
<label>ALPN</label>
|
||||
<type>text</type>
|
||||
<help>Application-Layer Protocol Negotiation values (comma-separated).</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.reality_pubkey</id>
|
||||
<label>Reality Public Key</label>
|
||||
<type>text</type>
|
||||
<help>Public key for REALITY protocol.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.reality_short_id</id>
|
||||
<label>Reality Short ID</label>
|
||||
<type>text</type>
|
||||
<help>Short ID for REALITY protocol.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
<field>
|
||||
<type>header</type>
|
||||
<label>URI</label>
|
||||
</field>
|
||||
<field>
|
||||
<id>server.raw_uri</id>
|
||||
<label>Share URL</label>
|
||||
<type>text</type>
|
||||
<help>The original proxy URI for this server. You can copy this to share or back up the configuration.</help>
|
||||
<grid_view>
|
||||
<ignore>true</ignore>
|
||||
</grid_view>
|
||||
</field>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<form>
|
||||
<field>
|
||||
<id>xproxy.general.enabled</id>
|
||||
<label>Enable Xproxy</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable the Xproxy service.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.active_server</id>
|
||||
<label>Active Server</label>
|
||||
<type>dropdown</type>
|
||||
<help>Select which server to connect to when the service is enabled. The chosen server must be enabled and use a supported protocol (VLESS, VMess, Shadowsocks, Trojan).</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.socks_port</id>
|
||||
<label>SOCKS5 Port</label>
|
||||
<type>text</type>
|
||||
<help>Local SOCKS5 proxy port for xray-core.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.http_port</id>
|
||||
<label>HTTP Proxy Port</label>
|
||||
<type>text</type>
|
||||
<help>Local HTTP proxy port for xray-core.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.socks_listen</id>
|
||||
<label>SOCKS5 Listen Address</label>
|
||||
<type>text</type>
|
||||
<help>Address xray binds for SOCKS5. Use 127.0.0.1 for router-only (default). Set to this firewall's LAN IP or 0.0.0.0 so PCs on your network can use SOCKS5 at firewall_IP:SOCKS5_Port — then add a firewall rule allowing TCP to that port on This Firewall. Does not affect the tunnel component (it always uses localhost).</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.http_listen</id>
|
||||
<label>HTTP Proxy Listen Address</label>
|
||||
<type>text</type>
|
||||
<help>Address xray binds for the HTTP proxy (same choices as SOCKS5).</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.policy_route_lan</id>
|
||||
<label>Route LAN through tunnel</label>
|
||||
<type>checkbox</type>
|
||||
<help>Automatically route IPv4 traffic through the TUN tunnel. LAN devices need no proxy settings. A system gateway XPROXY_TUN and firewall rules are managed automatically. Disable if you only want local SOCKS/HTTP on the firewall.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.route_interfaces</id>
|
||||
<label>Tunnel Interfaces</label>
|
||||
<type>select_multiple</type>
|
||||
<style>selectpicker</style>
|
||||
<help>Select which interfaces to route through the tunnel. Leave empty to route all non-WAN interfaces (default). Use this to route only specific networks (e.g. Guest, IoT) while keeping your main LAN direct.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.tun_device</id>
|
||||
<label>TUN Device</label>
|
||||
<type>text</type>
|
||||
<help>Name of the TUN device (e.g. tun9).</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.tun_address</id>
|
||||
<label>TUN Address</label>
|
||||
<type>text</type>
|
||||
<help>IP address assigned to the TUN interface (near side).</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.tun_gateway</id>
|
||||
<label>TUN Gateway</label>
|
||||
<type>text</type>
|
||||
<help>Far gateway address for the TUN interface. Use this as the OPNsense gateway address.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.bypass_ips</id>
|
||||
<label>Bypass IPs</label>
|
||||
<type>text</type>
|
||||
<help>Comma-separated list of CIDRs to route directly (bypass proxy). RFC1918 ranges are included by default.</help>
|
||||
</field>
|
||||
<field>
|
||||
<id>xproxy.general.metrics_exporter</id>
|
||||
<label>Prometheus Exporter</label>
|
||||
<type>checkbox</type>
|
||||
<help>Enable a Prometheus metrics exporter (port 9101) that exposes xray-core and tunnel statistics for monitoring with Grafana or similar tools.</help>
|
||||
</field>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<acl>
|
||||
<page-xproxy>
|
||||
<name>VPN: Xproxy</name>
|
||||
<patterns>
|
||||
<pattern>ui/xproxy/*</pattern>
|
||||
<pattern>api/xproxy/*</pattern>
|
||||
</patterns>
|
||||
</page-xproxy>
|
||||
</acl>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<menu>
|
||||
<VPN>
|
||||
<Xproxy order="50" url="/ui/xproxy/" VisibleName="Xproxy" cssClass="fa fa-shield fa-fw"/>
|
||||
</VPN>
|
||||
</menu>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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\Xproxy;
|
||||
|
||||
use OPNsense\Base\BaseModel;
|
||||
|
||||
class Xproxy extends BaseModel
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<model>
|
||||
<mount>//OPNsense/xproxy</mount>
|
||||
<version>1.0.0</version>
|
||||
<description>Xproxy multi-protocol proxy client</description>
|
||||
<items>
|
||||
<general>
|
||||
<enabled type="BooleanField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
</enabled>
|
||||
<active_server type="ModelRelationField">
|
||||
<Model>
|
||||
<servers>
|
||||
<source>OPNsense.Xproxy.Xproxy</source>
|
||||
<items>servers.server</items>
|
||||
<display>description</display>
|
||||
</servers>
|
||||
</Model>
|
||||
<BlankDesc>-- Select Server --</BlankDesc>
|
||||
<Required>N</Required>
|
||||
</active_server>
|
||||
<socks_port type="PortField">
|
||||
<Default>10808</Default>
|
||||
<Required>Y</Required>
|
||||
</socks_port>
|
||||
<http_port type="PortField">
|
||||
<Default>10809</Default>
|
||||
<Required>Y</Required>
|
||||
</http_port>
|
||||
<socks_listen type="TextField">
|
||||
<Default>127.0.0.1</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^(\*|0\.0\.0\.0|::|127\.0\.0\.1|::1|localhost|[0-9a-fA-F:.%\[\]]+)$/</Mask>
|
||||
<ValidationMessage>Enter a valid bind address (IPv4, IPv6, 0.0.0.0, ::, localhost, or *).</ValidationMessage>
|
||||
</socks_listen>
|
||||
<http_listen type="TextField">
|
||||
<Default>127.0.0.1</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^(\*|0\.0\.0\.0|::|127\.0\.0\.1|::1|localhost|[0-9a-fA-F:.%\[\]]+)$/</Mask>
|
||||
<ValidationMessage>Enter a valid bind address (IPv4, IPv6, 0.0.0.0, ::, localhost, or *).</ValidationMessage>
|
||||
</http_listen>
|
||||
<policy_route_lan type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</policy_route_lan>
|
||||
<route_interfaces type="InterfaceField">
|
||||
<Multiple>Y</Multiple>
|
||||
<Required>N</Required>
|
||||
<AllowDynamic>Y</AllowDynamic>
|
||||
</route_interfaces>
|
||||
<tun_device type="TextField">
|
||||
<Default>tun9</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^tun[0-9]{1,3}$/</Mask>
|
||||
<ValidationMessage>Use a TUN device name such as tun0–tun999 (e.g. tun9).</ValidationMessage>
|
||||
</tun_device>
|
||||
<tun_address type="TextField">
|
||||
<Default>10.255.0.1</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/</Mask>
|
||||
<ValidationMessage>Enter a valid IPv4 address for the TUN interface.</ValidationMessage>
|
||||
</tun_address>
|
||||
<tun_gateway type="TextField">
|
||||
<Default>10.255.0.2</Default>
|
||||
<Required>Y</Required>
|
||||
<Mask>/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/</Mask>
|
||||
<ValidationMessage>Enter a valid IPv4 gateway for the TUN interface.</ValidationMessage>
|
||||
</tun_gateway>
|
||||
<bypass_ips type="TextField">
|
||||
<Default>10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8</Default>
|
||||
<Required>N</Required>
|
||||
<Mask>/^(\s*(([0-9]{1,3}\.){3}[0-9]{1,3}(\/[0-9]{1,2})?)\s*(,\s*(([0-9]{1,3}\.){3}[0-9]{1,3}(\/[0-9]{1,2})?)\s*)*)?$/</Mask>
|
||||
<ValidationMessage>Enter a comma-separated list of IPv4 CIDRs (e.g. 10.0.0.0/8,192.168.0.0/16).</ValidationMessage>
|
||||
</bypass_ips>
|
||||
<metrics_exporter type="BooleanField">
|
||||
<Default>0</Default>
|
||||
<Required>Y</Required>
|
||||
</metrics_exporter>
|
||||
</general>
|
||||
<servers>
|
||||
<server type="ArrayField">
|
||||
<enabled type="BooleanField">
|
||||
<Default>1</Default>
|
||||
<Required>Y</Required>
|
||||
</enabled>
|
||||
<description type="TextField">
|
||||
<Required>N</Required>
|
||||
<Mask>/^([\t\n\v\f\r 0-9a-zA-Z.\-,_\x{00A0}-\x{FFFF}]){0,255}$/u</Mask>
|
||||
<ValidationMessage>Description should be a string between 0 and 255 characters.</ValidationMessage>
|
||||
</description>
|
||||
<protocol type="OptionField">
|
||||
<Default>vless</Default>
|
||||
<Required>Y</Required>
|
||||
<OptionValues>
|
||||
<vless>VLESS</vless>
|
||||
<vmess>VMess</vmess>
|
||||
<shadowsocks>Shadowsocks</shadowsocks>
|
||||
<trojan>Trojan</trojan>
|
||||
</OptionValues>
|
||||
</protocol>
|
||||
<address type="HostnameField">
|
||||
<Required>Y</Required>
|
||||
<IpAllowed>Y</IpAllowed>
|
||||
</address>
|
||||
<port type="PortField">
|
||||
<Default>443</Default>
|
||||
<Required>Y</Required>
|
||||
</port>
|
||||
<user_id type="TextField">
|
||||
<Required>N</Required>
|
||||
</user_id>
|
||||
<password type="TextField">
|
||||
<Required>N</Required>
|
||||
</password>
|
||||
<encryption type="TextField">
|
||||
<Default>none</Default>
|
||||
<Required>N</Required>
|
||||
</encryption>
|
||||
<flow type="OptionField">
|
||||
<Required>N</Required>
|
||||
<BlankDesc>None</BlankDesc>
|
||||
<OptionValues>
|
||||
<xtls_rprx_vision>xtls-rprx-vision</xtls_rprx_vision>
|
||||
</OptionValues>
|
||||
</flow>
|
||||
<transport type="OptionField">
|
||||
<Default>tcp</Default>
|
||||
<Required>Y</Required>
|
||||
<OptionValues>
|
||||
<tcp>TCP</tcp>
|
||||
<ws>WebSocket</ws>
|
||||
<grpc>gRPC</grpc>
|
||||
<h2>HTTP/2</h2>
|
||||
<httpupgrade>HTTPUpgrade</httpupgrade>
|
||||
</OptionValues>
|
||||
</transport>
|
||||
<transport_host type="TextField">
|
||||
<Required>N</Required>
|
||||
</transport_host>
|
||||
<transport_path type="TextField">
|
||||
<Required>N</Required>
|
||||
</transport_path>
|
||||
<security type="OptionField">
|
||||
<Default>none</Default>
|
||||
<Required>Y</Required>
|
||||
<OptionValues>
|
||||
<none>None</none>
|
||||
<tls>TLS</tls>
|
||||
<reality>Reality</reality>
|
||||
</OptionValues>
|
||||
</security>
|
||||
<sni type="TextField">
|
||||
<Required>N</Required>
|
||||
</sni>
|
||||
<fingerprint type="TextField">
|
||||
<Default>chrome</Default>
|
||||
<Required>N</Required>
|
||||
</fingerprint>
|
||||
<alpn type="TextField">
|
||||
<Required>N</Required>
|
||||
</alpn>
|
||||
<reality_pubkey type="TextField">
|
||||
<Required>N</Required>
|
||||
</reality_pubkey>
|
||||
<reality_short_id type="TextField">
|
||||
<Required>N</Required>
|
||||
</reality_short_id>
|
||||
<raw_uri type="TextField">
|
||||
<Required>N</Required>
|
||||
</raw_uri>
|
||||
</server>
|
||||
</servers>
|
||||
</items>
|
||||
</model>
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
{#
|
||||
# Copyright (C) 2025 OPNsense Community
|
||||
# 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.
|
||||
#}
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const data_get_map = {'frm_general_settings': "/api/xproxy/settings/get"};
|
||||
var gridId = "#{{formGridServer['table_id']}}";
|
||||
var generalDirty = true;
|
||||
|
||||
var excludeInterfaces = ['wan', 'lo0', 'xproxytun'];
|
||||
var tunFields = [
|
||||
'route_interfaces', 'tun_device', 'tun_address', 'tun_gateway', 'bypass_ips'
|
||||
];
|
||||
|
||||
function filterTunnelInterfaces() {
|
||||
var sel = $('#xproxy\\.general\\.route_interfaces');
|
||||
sel.find('option').each(function() {
|
||||
var val = $(this).val();
|
||||
if (excludeInterfaces.indexOf(val) !== -1 || val.match(/^tun\d/)) {
|
||||
$(this).remove();
|
||||
}
|
||||
});
|
||||
sel.attr('title', 'All interfaces (default)');
|
||||
sel.selectpicker('refresh');
|
||||
}
|
||||
|
||||
function toggleTunFields() {
|
||||
var checked = $('#xproxy\\.general\\.policy_route_lan').is(':checked');
|
||||
$.each(tunFields, function(_, fld) {
|
||||
var row = $('#xproxy\\.general\\.' + fld).closest('tr');
|
||||
if (checked) {
|
||||
row.show();
|
||||
} else {
|
||||
row.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshGeneralForm() {
|
||||
generalDirty = false;
|
||||
return mapDataToFormUI(data_get_map).done(function() {
|
||||
formatTokenizersUI();
|
||||
filterTunnelInterfaces();
|
||||
$('.selectpicker').selectpicker('refresh');
|
||||
toggleTunFields();
|
||||
$('#xproxy\\.general\\.policy_route_lan').off('change.tun').on('change.tun', toggleTunFields);
|
||||
});
|
||||
}
|
||||
|
||||
function markGeneralDirty() {
|
||||
generalDirty = true;
|
||||
}
|
||||
|
||||
refreshGeneralForm();
|
||||
|
||||
$(gridId).UIBootgrid({
|
||||
search: '/api/xproxy/servers/search_item',
|
||||
get: '/api/xproxy/servers/get_item/',
|
||||
set: '/api/xproxy/servers/set_item/',
|
||||
add: '/api/xproxy/servers/add_item/',
|
||||
del: '/api/xproxy/servers/del_item/',
|
||||
});
|
||||
|
||||
$(gridId).on('loaded.rs.jquery.bootgrid', function() {
|
||||
markGeneralDirty();
|
||||
});
|
||||
|
||||
function updateServerDialogFields() {
|
||||
var dlg = $('#' + "{{formGridServer['edit_dialog_id']}}");
|
||||
var proto = dlg.find('#server\\.protocol').val();
|
||||
var security = dlg.find('#server\\.security').val();
|
||||
var transport = dlg.find('#server\\.transport').val();
|
||||
|
||||
var showUuid = (proto === 'vless' || proto === 'vmess');
|
||||
var showPassword = (proto === 'shadowsocks' || proto === 'trojan');
|
||||
var showFlow = (proto === 'vless');
|
||||
var showEncryption = (proto === 'vless' || proto === 'vmess' || proto === 'shadowsocks');
|
||||
var showReality = (security === 'reality');
|
||||
var showTlsFields = (security === 'tls' || security === 'reality');
|
||||
var showTransportDetail = (transport === 'ws' || transport === 'h2' || transport === 'grpc' || transport === 'httpupgrade');
|
||||
|
||||
dlg.find('#server\\.user_id').closest('.form-group').toggle(showUuid);
|
||||
dlg.find('#server\\.password').closest('.form-group').toggle(showPassword);
|
||||
dlg.find('#server\\.flow').closest('.form-group').toggle(showFlow);
|
||||
dlg.find('#server\\.encryption').closest('.form-group').toggle(showEncryption);
|
||||
dlg.find('#server\\.reality_pubkey').closest('.form-group').toggle(showReality);
|
||||
dlg.find('#server\\.reality_short_id').closest('.form-group').toggle(showReality);
|
||||
dlg.find('#server\\.sni').closest('.form-group').toggle(showTlsFields);
|
||||
dlg.find('#server\\.fingerprint').closest('.form-group').toggle(showTlsFields);
|
||||
dlg.find('#server\\.alpn').closest('.form-group').toggle(showTlsFields && !showReality);
|
||||
dlg.find('#server\\.transport_host').closest('.form-group').toggle(showTransportDetail);
|
||||
dlg.find('#server\\.transport_path').closest('.form-group').toggle(showTransportDetail);
|
||||
}
|
||||
|
||||
$(document).on('change', '#server\\.protocol, #server\\.security, #server\\.transport', updateServerDialogFields);
|
||||
$(document).on('shown.bs.modal', '#' + "{{formGridServer['edit_dialog_id']}}", function() {
|
||||
setTimeout(updateServerDialogFields, 50);
|
||||
});
|
||||
|
||||
$("#reconfigureAct").SimpleActionButton({
|
||||
onPreAction: function() {
|
||||
const dfObj = new $.Deferred();
|
||||
saveFormToEndpoint("/api/xproxy/settings/set", 'frm_general_settings', function() {
|
||||
dfObj.resolve();
|
||||
});
|
||||
return dfObj;
|
||||
}
|
||||
});
|
||||
|
||||
updateServiceControlUI('xproxy');
|
||||
|
||||
// Import tab
|
||||
var importRunning = false;
|
||||
$("#importAct").click(function() {
|
||||
if (importRunning) {
|
||||
return;
|
||||
}
|
||||
var uris = $("#import_uris_text").val();
|
||||
if (!uris || uris.trim() === '') {
|
||||
BootstrapDialog.alert('{{ lang._("Please paste at least one proxy URI.") }}');
|
||||
return;
|
||||
}
|
||||
importRunning = true;
|
||||
$("#importAct").prop('disabled', true);
|
||||
$("#importAct_progress").addClass("fa fa-spinner fa-pulse");
|
||||
ajaxCall('/api/xproxy/import/uris', {uris: uris}, function(data, status) {
|
||||
$("#importAct_progress").removeClass("fa fa-spinner fa-pulse");
|
||||
$("#importAct").prop('disabled', false);
|
||||
importRunning = false;
|
||||
if (status !== 'success' || data === undefined || data === null) {
|
||||
BootstrapDialog.alert('{{ lang._("Import request failed (network or server error).") }}');
|
||||
return;
|
||||
}
|
||||
if (data.result === 'saved') {
|
||||
var msg = '{{ lang._("Imported") }} ' + data.count + ' {{ lang._("server(s).") }}';
|
||||
if (data.skipped) {
|
||||
msg += ' (' + data.skipped + ' {{ lang._("duplicate(s) skipped") }})';
|
||||
}
|
||||
if (data.auto_selected) {
|
||||
msg += '<br/>{{ lang._("Auto-selected:") }} <b>' + data.auto_selected + '</b>';
|
||||
}
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
msg += '<br/><br/><small class="text-warning">{{ lang._("Parse errors:") }}<br/>';
|
||||
for (var i = 0; i < data.errors.length && i < 10; i++) {
|
||||
msg += '• ' + $('<span/>').text(data.errors[i]).html() + '<br/>';
|
||||
}
|
||||
if (data.errors.length > 10) {
|
||||
msg += '… ' + (data.errors.length - 10) + ' {{ lang._("more") }}';
|
||||
}
|
||||
msg += '</small>';
|
||||
}
|
||||
BootstrapDialog.alert({type: BootstrapDialog.TYPE_SUCCESS, message: msg});
|
||||
$("#import_uris_text").val('');
|
||||
$(gridId).bootgrid('reload');
|
||||
markGeneralDirty();
|
||||
} else {
|
||||
var errMsg = data.message || 'unknown error';
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
errMsg += '<br/><br/><small>';
|
||||
for (var j = 0; j < data.errors.length && j < 10; j++) {
|
||||
errMsg += '• ' + $('<span/>').text(data.errors[j]).html() + '<br/>';
|
||||
}
|
||||
errMsg += '</small>';
|
||||
}
|
||||
BootstrapDialog.alert('{{ lang._("Import failed: ") }}' + errMsg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Log tab
|
||||
var logTimer = null;
|
||||
var allowedHashes = ['#servers', '#general', '#import', '#log'];
|
||||
$('a[data-toggle="tab"]').on('shown.bs.tab', function(e) {
|
||||
var tab = $(e.target).attr('href');
|
||||
if (tab === '#log') {
|
||||
refreshLog();
|
||||
if (logTimer) {
|
||||
clearInterval(logTimer);
|
||||
}
|
||||
logTimer = setInterval(refreshLog, 5000);
|
||||
} else {
|
||||
if (logTimer) {
|
||||
clearInterval(logTimer);
|
||||
logTimer = null;
|
||||
}
|
||||
}
|
||||
if (tab === '#servers') {
|
||||
$(gridId).bootgrid('reload');
|
||||
}
|
||||
if (tab === '#general' && generalDirty) {
|
||||
refreshGeneralForm();
|
||||
}
|
||||
if (tab === '#servers' || tab === '#import' || tab === '#log') {
|
||||
$('#reconfigureAct').closest('.content-box').hide();
|
||||
} else {
|
||||
$('#reconfigureAct').closest('.content-box').show();
|
||||
}
|
||||
});
|
||||
|
||||
function refreshLog() {
|
||||
ajaxGet('/api/xproxy/service/log', {}, function(data, status) {
|
||||
if (status !== 'success') {
|
||||
return;
|
||||
}
|
||||
if (data && data.response) {
|
||||
var el = document.getElementById('xproxy_log_output');
|
||||
var atBottom = el && (el.scrollHeight - el.scrollTop - el.clientHeight < 30);
|
||||
$("#xproxy_log_output").text(data.response);
|
||||
if (el && atBottom) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(window).on('beforeunload', function() {
|
||||
if (logTimer) {
|
||||
clearInterval(logTimer);
|
||||
}
|
||||
});
|
||||
|
||||
var h = window.location.hash;
|
||||
if (h && allowedHashes.indexOf(h) !== -1) {
|
||||
$('a[href="' + h + '"]').trigger('click');
|
||||
}
|
||||
if (!h || h !== '#general') {
|
||||
$('#reconfigureAct').closest('.content-box').hide();
|
||||
}
|
||||
$('.nav-tabs a').on('shown.bs.tab', function(e) {
|
||||
history.pushState(null, null, e.target.hash);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
|
||||
<li class="active"><a data-toggle="tab" id="tab_servers" href="#servers">{{ lang._('Servers') }}</a></li>
|
||||
<li><a data-toggle="tab" id="tab_general" href="#general">{{ lang._('General') }}</a></li>
|
||||
<li><a data-toggle="tab" id="tab_import" href="#import">{{ lang._('Import') }}</a></li>
|
||||
<li><a data-toggle="tab" id="tab_log" href="#log">{{ lang._('Log') }}</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content content-box">
|
||||
<div id="servers" class="tab-pane fade in active">
|
||||
{{ partial('layout_partials/base_bootgrid_table', formGridServer)}}
|
||||
</div>
|
||||
<div id="general" class="tab-pane fade in">
|
||||
{{ partial("layout_partials/base_form",['fields':formGeneral,'id':'frm_general_settings'])}}
|
||||
</div>
|
||||
<div id="import" class="tab-pane fade in">
|
||||
<div class="col-md-12" style="padding-top: 15px;">
|
||||
<div class="form-group">
|
||||
<label for="import_uris_text">{{ lang._('Proxy URIs') }}</label>
|
||||
<textarea class="form-control" id="import_uris_text" rows="8" style="resize: vertical;"
|
||||
placeholder="{{ lang._('Paste proxy URIs here, one per line (vless://, vmess://, ss://, trojan://)') }}"></textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="importAct" type="button" style="margin-bottom: 15px;">
|
||||
<b>{{ lang._('Import') }}</b> <i id="importAct_progress"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="log" class="tab-pane fade in">
|
||||
<div class="col-md-12" style="padding-top: 15px;">
|
||||
<pre id="xproxy_log_output" style="max-height: 500px; overflow-y: auto; font-size: 12px; background: #1e1e1e; color: #d4d4d4; padding: 10px;">{{ lang._('Loading...') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ partial('layout_partials/base_apply_button', {'data_endpoint': '/api/xproxy/service/reconfigure', 'data_service_widget': 'xproxy'}) }}
|
||||
{{ partial("layout_partials/base_dialog",['fields':formDialogServer,'id':formGridServer['edit_dialog_id'],'label':lang._('Edit Server')])}}
|
||||
269
security/xproxy/src/opnsense/scripts/xproxy/import_uris.py
Normal file
269
security/xproxy/src/opnsense/scripts/xproxy/import_uris.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
#!/usr/local/bin/python3
|
||||
|
||||
"""
|
||||
Parse proxy URI strings (vless://, vmess://, ss://, trojan://) into
|
||||
structured server definitions for the Xproxy OPNsense plugin.
|
||||
|
||||
Usage: import_uris.py <file_with_uris>
|
||||
Output: JSON on stdout with {"servers": [...], "errors": [...]}
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
MAX_INPUT_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def pad_b64(s):
|
||||
return s + '=' * (-len(s) % 4)
|
||||
|
||||
|
||||
def parse_vless(uri):
|
||||
"""Parse vless://uuid@host:port?params#description"""
|
||||
rest = uri[len('vless://'):]
|
||||
fragment = ''
|
||||
if '#' in rest:
|
||||
rest, fragment = rest.rsplit('#', 1)
|
||||
fragment = unquote(fragment)
|
||||
|
||||
if '@' not in rest:
|
||||
raise ValueError("vless URI missing '@'")
|
||||
|
||||
userinfo, hostport = rest.split('@', 1)
|
||||
query = ''
|
||||
if '?' in hostport:
|
||||
hostport, query = hostport.split('?', 1)
|
||||
|
||||
if ':' in hostport:
|
||||
host, port = hostport.rsplit(':', 1)
|
||||
else:
|
||||
host, port = hostport, '443'
|
||||
|
||||
params = parse_qs(query)
|
||||
|
||||
def p(k, d=''):
|
||||
return params.get(k, [d])[0]
|
||||
|
||||
flow_raw = p('flow', '')
|
||||
|
||||
return {
|
||||
'enabled': '1',
|
||||
'protocol': 'vless',
|
||||
'description': fragment or host,
|
||||
'address': host,
|
||||
'port': port,
|
||||
'user_id': userinfo,
|
||||
'encryption': p('encryption', 'none'),
|
||||
'flow': flow_raw.replace('-', '_') if flow_raw else '',
|
||||
'transport': p('type', 'tcp'),
|
||||
'transport_host': p('host'),
|
||||
'transport_path': p('path'),
|
||||
'security': p('security', 'none'),
|
||||
'sni': p('sni'),
|
||||
'fingerprint': p('fp', 'chrome'),
|
||||
'alpn': p('alpn'),
|
||||
'reality_pubkey': p('pbk'),
|
||||
'reality_short_id': p('sid'),
|
||||
'raw_uri': uri,
|
||||
}
|
||||
|
||||
|
||||
def parse_vmess(uri):
|
||||
"""Parse vmess://base64json"""
|
||||
encoded = uri[len('vmess://'):]
|
||||
try:
|
||||
decoded = base64.b64decode(pad_b64(encoded)).decode('utf-8')
|
||||
cfg = json.loads(decoded)
|
||||
except Exception:
|
||||
raise ValueError("Invalid vmess base64 payload")
|
||||
|
||||
transport = cfg.get('net', 'tcp')
|
||||
security = 'tls' if cfg.get('tls') == 'tls' else 'none'
|
||||
|
||||
return {
|
||||
'enabled': '1',
|
||||
'protocol': 'vmess',
|
||||
'description': cfg.get('ps', cfg.get('add', '')),
|
||||
'address': cfg.get('add', ''),
|
||||
'port': str(cfg.get('port', 443)),
|
||||
'user_id': cfg.get('id', ''),
|
||||
'encryption': cfg.get('scy', 'auto'),
|
||||
'flow': '',
|
||||
'transport': transport,
|
||||
'transport_host': cfg.get('host', ''),
|
||||
'transport_path': cfg.get('path', ''),
|
||||
'security': security,
|
||||
'sni': cfg.get('sni', cfg.get('host', '')),
|
||||
'fingerprint': cfg.get('fp', 'chrome'),
|
||||
'alpn': cfg.get('alpn', ''),
|
||||
'reality_pubkey': '',
|
||||
'reality_short_id': '',
|
||||
'raw_uri': uri,
|
||||
}
|
||||
|
||||
|
||||
def parse_shadowsocks(uri):
|
||||
"""Parse ss://base64(method:password)@host:port#description or ss://base64(...)#desc"""
|
||||
rest = uri[len('ss://'):]
|
||||
fragment = ''
|
||||
if '#' in rest:
|
||||
rest, fragment = rest.rsplit('#', 1)
|
||||
fragment = unquote(fragment)
|
||||
|
||||
if '@' in rest:
|
||||
userinfo, hostport = rest.split('@', 1)
|
||||
try:
|
||||
decoded = base64.b64decode(pad_b64(userinfo)).decode('utf-8')
|
||||
except Exception:
|
||||
decoded = userinfo
|
||||
if ':' in decoded:
|
||||
method, password = decoded.split(':', 1)
|
||||
else:
|
||||
method, password = 'aes-256-gcm', decoded
|
||||
if ':' in hostport:
|
||||
host, port = hostport.rsplit(':', 1)
|
||||
else:
|
||||
host, port = hostport, '443'
|
||||
else:
|
||||
try:
|
||||
decoded = base64.b64decode(pad_b64(rest)).decode('utf-8')
|
||||
except Exception:
|
||||
raise ValueError("Invalid ss base64 payload")
|
||||
if '@' in decoded:
|
||||
cred, hostport = decoded.split('@', 1)
|
||||
method, password = cred.split(':', 1) if ':' in cred else ('aes-256-gcm', cred)
|
||||
host, port = hostport.rsplit(':', 1) if ':' in hostport else (hostport, '443')
|
||||
else:
|
||||
raise ValueError("Cannot parse ss URI")
|
||||
|
||||
return {
|
||||
'enabled': '1',
|
||||
'protocol': 'shadowsocks',
|
||||
'description': fragment or host,
|
||||
'address': host,
|
||||
'port': port,
|
||||
'user_id': '',
|
||||
'password': password,
|
||||
'encryption': method,
|
||||
'flow': '',
|
||||
'transport': 'tcp',
|
||||
'transport_host': '',
|
||||
'transport_path': '',
|
||||
'security': 'none',
|
||||
'sni': '',
|
||||
'fingerprint': '',
|
||||
'alpn': '',
|
||||
'reality_pubkey': '',
|
||||
'reality_short_id': '',
|
||||
'raw_uri': uri,
|
||||
}
|
||||
|
||||
|
||||
def parse_trojan(uri):
|
||||
"""Parse trojan://password@host:port?params#description"""
|
||||
rest = uri[len('trojan://'):]
|
||||
fragment = ''
|
||||
if '#' in rest:
|
||||
rest, fragment = rest.rsplit('#', 1)
|
||||
fragment = unquote(fragment)
|
||||
|
||||
if '@' not in rest:
|
||||
raise ValueError("trojan URI missing '@'")
|
||||
|
||||
password, hostport = rest.split('@', 1)
|
||||
query = ''
|
||||
if '?' in hostport:
|
||||
hostport, query = hostport.split('?', 1)
|
||||
|
||||
if ':' in hostport:
|
||||
host, port = hostport.rsplit(':', 1)
|
||||
else:
|
||||
host, port = hostport, '443'
|
||||
|
||||
params = parse_qs(query)
|
||||
|
||||
def p(k, d=''):
|
||||
return params.get(k, [d])[0]
|
||||
|
||||
security = p('security', 'tls')
|
||||
if security not in ('tls', 'reality', 'none'):
|
||||
security = 'tls'
|
||||
|
||||
return {
|
||||
'enabled': '1',
|
||||
'protocol': 'trojan',
|
||||
'description': fragment or host,
|
||||
'address': host,
|
||||
'port': port,
|
||||
'user_id': '',
|
||||
'password': password,
|
||||
'encryption': '',
|
||||
'flow': '',
|
||||
'transport': p('type', 'tcp'),
|
||||
'transport_host': p('host'),
|
||||
'transport_path': p('path'),
|
||||
'security': security,
|
||||
'sni': p('sni', host),
|
||||
'fingerprint': p('fp', 'chrome'),
|
||||
'alpn': p('alpn'),
|
||||
'reality_pubkey': p('pbk'),
|
||||
'reality_short_id': p('sid'),
|
||||
'raw_uri': uri,
|
||||
}
|
||||
|
||||
|
||||
PARSERS = {
|
||||
'vless://': parse_vless,
|
||||
'vmess://': parse_vmess,
|
||||
'ss://': parse_shadowsocks,
|
||||
'trojan://': parse_trojan,
|
||||
}
|
||||
|
||||
|
||||
def parse_uri(line):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
for prefix, parser in PARSERS.items():
|
||||
if line.startswith(prefix):
|
||||
return parser(line)
|
||||
raise ValueError("Unknown URI scheme: " + line[:20])
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"servers": [], "errors": ["No input file specified."]}))
|
||||
sys.exit(0)
|
||||
|
||||
filepath = sys.argv[1]
|
||||
try:
|
||||
with open(filepath, 'r', errors='replace') as f:
|
||||
content = f.read(MAX_INPUT_BYTES + 1)
|
||||
except IOError as e:
|
||||
print(json.dumps({"servers": [], "errors": [str(e)]}))
|
||||
sys.exit(0)
|
||||
|
||||
if len(content) > MAX_INPUT_BYTES:
|
||||
print(json.dumps({"servers": [], "errors": ["Input file too large (max 2 MiB)."]}))
|
||||
sys.exit(0)
|
||||
|
||||
servers = []
|
||||
errors = []
|
||||
for line in content.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
srv = parse_uri(line)
|
||||
if srv:
|
||||
servers.append(srv)
|
||||
except Exception as e:
|
||||
errors.append("Failed to parse: %s (%s)" % (line[:60], str(e)))
|
||||
|
||||
print(json.dumps({"servers": servers, "errors": errors}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1047
security/xproxy/src/opnsense/scripts/xproxy/service_control.py
Normal file
1047
security/xproxy/src/opnsense/scripts/xproxy/service_control.py
Normal file
File diff suppressed because it is too large
Load diff
100
security/xproxy/src/opnsense/scripts/xproxy/setup.sh
Normal file
100
security/xproxy/src/opnsense/scripts/xproxy/setup.sh
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Download hev-socks5-tunnel prebuilt binary for FreeBSD.
|
||||
# Supports retry logic, version tracking, and architecture detection.
|
||||
|
||||
set -e
|
||||
|
||||
HEV_BIN="/usr/local/bin/hev-socks5-tunnel"
|
||||
HEV_REPO="heiher/hev-socks5-tunnel"
|
||||
VERSION_FILE="/usr/local/etc/xproxy/.hev-version"
|
||||
MAX_RETRIES=3
|
||||
RETRY_DELAY=3
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$ARCH" in
|
||||
amd64|x86_64)
|
||||
ASSET="hev-socks5-tunnel-freebsd-x86_64"
|
||||
;;
|
||||
aarch64|arm64)
|
||||
ASSET="hev-socks5-tunnel-freebsd-aarch64"
|
||||
;;
|
||||
*)
|
||||
echo "Error: unsupported architecture: $ARCH" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
fetch_tag() {
|
||||
fetch -qo - "https://api.github.com/repos/${HEV_REPO}/releases/latest" \
|
||||
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
|
||||
}
|
||||
|
||||
TAG=""
|
||||
attempt=1
|
||||
while [ $attempt -le $MAX_RETRIES ]; do
|
||||
TAG=$(fetch_tag)
|
||||
if [ -n "$TAG" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt $attempt/$MAX_RETRIES: failed to fetch latest release tag, retrying in ${RETRY_DELAY}s..." >&2
|
||||
sleep $RETRY_DELAY
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "Error: could not determine latest release tag after $MAX_RETRIES attempts" >&2
|
||||
if [ -x "$HEV_BIN" ]; then
|
||||
echo "Keeping existing binary at $HEV_BIN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Skip download if already at this version
|
||||
if [ -x "$HEV_BIN" ] && [ -f "$VERSION_FILE" ]; then
|
||||
INSTALLED=$(cat "$VERSION_FILE" 2>/dev/null)
|
||||
if [ "$INSTALLED" = "$TAG" ]; then
|
||||
echo "hev-socks5-tunnel $TAG already installed (up to date)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
URL="https://github.com/${HEV_REPO}/releases/download/${TAG}/${ASSET}"
|
||||
echo "Downloading hev-socks5-tunnel ${TAG} for ${ARCH}..."
|
||||
|
||||
TMP_BIN="${HEV_BIN}.download"
|
||||
attempt=1
|
||||
while [ $attempt -le $MAX_RETRIES ]; do
|
||||
if fetch -o "$TMP_BIN" "$URL"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt $attempt/$MAX_RETRIES: download failed, retrying in ${RETRY_DELAY}s..." >&2
|
||||
rm -f "$TMP_BIN"
|
||||
sleep $RETRY_DELAY
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
if [ ! -f "$TMP_BIN" ]; then
|
||||
echo "Error: download failed after $MAX_RETRIES attempts" >&2
|
||||
if [ -x "$HEV_BIN" ]; then
|
||||
echo "Keeping existing binary at $HEV_BIN"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate the downloaded file is a real binary (not an HTML error page)
|
||||
if file "$TMP_BIN" | grep -q "HTML\|text"; then
|
||||
echo "Error: downloaded file is not a valid binary (got HTML/text)" >&2
|
||||
rm -f "$TMP_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod 0755 "$TMP_BIN"
|
||||
mv -f "$TMP_BIN" "$HEV_BIN"
|
||||
|
||||
mkdir -p "$(dirname "$VERSION_FILE")"
|
||||
echo "$TAG" > "$VERSION_FILE"
|
||||
|
||||
echo "hev-socks5-tunnel ${TAG} installed to $HEV_BIN"
|
||||
40
security/xproxy/src/opnsense/scripts/xproxy/show_log.py
Normal file
40
security/xproxy/src/opnsense/scripts/xproxy/show_log.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/local/bin/python3
|
||||
|
||||
"""
|
||||
Return the last N lines of the xproxy log file as JSON.
|
||||
Uses bounded memory (deque) instead of loading the entire file.
|
||||
Usage: show_log.py [lines]
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from collections import deque
|
||||
|
||||
LOG_FILE = '/var/log/xproxy.log'
|
||||
DEFAULT_LINES = 200
|
||||
MAX_LINES = 10000
|
||||
|
||||
|
||||
def tail(filepath, n):
|
||||
if not os.path.exists(filepath):
|
||||
return ''
|
||||
n = max(1, min(int(n), MAX_LINES))
|
||||
try:
|
||||
with open(filepath, 'r', errors='replace') as f:
|
||||
return ''.join(deque(f, maxlen=n))
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
|
||||
def main():
|
||||
n = DEFAULT_LINES
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
n = int(sys.argv[1])
|
||||
except ValueError:
|
||||
pass
|
||||
print(tail(LOG_FILE, n), end='')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
147
security/xproxy/src/opnsense/scripts/xproxy/sync_gateway.php
Normal file
147
security/xproxy/src/opnsense/scripts/xproxy/sync_gateway.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
#!/usr/local/bin/php
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Copyright (C) 2025 OPNsense Community
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create/remove optional interface xproxytun + gateway XPROXY_TUN for policy routing.
|
||||
* Uses a single Config::save() to avoid re-entrancy / lock contention from chained saves.
|
||||
*/
|
||||
|
||||
@include_once('config.inc');
|
||||
|
||||
use OPNsense\Core\Config;
|
||||
use OPNsense\Routing\Gateways;
|
||||
use OPNsense\Xproxy\Xproxy;
|
||||
|
||||
const XPROXY_IFKEY = 'xproxytun';
|
||||
const XPROXY_GWNAME = 'XPROXY_TUN';
|
||||
const XPROXY_MARK = 'Xproxy (plugin)';
|
||||
|
||||
function xproxy_remove_gateway_mvc(): void
|
||||
{
|
||||
$mdl = new Gateways();
|
||||
$uuid = null;
|
||||
foreach ($mdl->gateway_item->iterateItems() as $item) {
|
||||
if ((string)$item->name === XPROXY_GWNAME) {
|
||||
$uuid = (string)$item->getAttributes()['uuid'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($uuid !== null) {
|
||||
$mdl->gateway_item->del($uuid);
|
||||
$mdl->serializeToConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function xproxy_disable_interface_xml(): void
|
||||
{
|
||||
$cfg = Config::getInstance()->object();
|
||||
if (empty($cfg->interfaces->{XPROXY_IFKEY})) {
|
||||
return;
|
||||
}
|
||||
$cfg->interfaces->{XPROXY_IFKEY}->enable = '0';
|
||||
}
|
||||
|
||||
function xproxy_ensure_interface_xml(string $tunDev): void
|
||||
{
|
||||
$cfg = Config::getInstance()->object();
|
||||
if (empty($cfg->interfaces)) {
|
||||
return;
|
||||
}
|
||||
$ifn = XPROXY_IFKEY;
|
||||
if (empty($cfg->interfaces->$ifn)) {
|
||||
$node = $cfg->interfaces->addChild($ifn);
|
||||
$node->addChild('enable', '1');
|
||||
$node->addChild('if', $tunDev);
|
||||
$node->addChild('descr', XPROXY_MARK);
|
||||
$node->addChild('ipaddr', 'none');
|
||||
} else {
|
||||
$cfg->interfaces->$ifn->enable = '1';
|
||||
$cfg->interfaces->$ifn->if = $tunDev;
|
||||
if (empty((string)$cfg->interfaces->$ifn->descr)) {
|
||||
$cfg->interfaces->$ifn->descr = XPROXY_MARK;
|
||||
}
|
||||
if (empty((string)$cfg->interfaces->$ifn->ipaddr)) {
|
||||
$cfg->interfaces->$ifn->ipaddr = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function xproxy_ensure_gateway_mvc(string $tunGw): void
|
||||
{
|
||||
$mdl = new Gateways();
|
||||
$uuid = null;
|
||||
foreach ($mdl->gateway_item->iterateItems() as $item) {
|
||||
if ((string)$item->name === XPROXY_GWNAME) {
|
||||
$uuid = (string)$item->getAttributes()['uuid'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
$fields = [
|
||||
'name' => XPROXY_GWNAME,
|
||||
'interface' => XPROXY_IFKEY,
|
||||
'ipprotocol' => 'inet',
|
||||
'gateway' => $tunGw,
|
||||
'descr' => XPROXY_MARK,
|
||||
'defaultgw' => '0',
|
||||
'monitor_disable' => '1',
|
||||
'priority' => '255',
|
||||
];
|
||||
$mdl->createOrUpdateGateway($fields, $uuid);
|
||||
$mdl->serializeToConfig();
|
||||
}
|
||||
|
||||
$xp = new Xproxy();
|
||||
$enabled = (string)$xp->general->enabled === '1';
|
||||
/* Default on when unset (older configs): only explicit "0" disables */
|
||||
$policy = (string)$xp->general->policy_route_lan !== '0';
|
||||
$tunDev = (string)$xp->general->tun_device;
|
||||
if ($tunDev === '') {
|
||||
$tunDev = 'tun9';
|
||||
}
|
||||
$tunGw = (string)$xp->general->tun_gateway;
|
||||
if ($tunGw === '') {
|
||||
$tunGw = '10.255.0.2';
|
||||
}
|
||||
|
||||
if (!$enabled || !$policy) {
|
||||
xproxy_remove_gateway_mvc();
|
||||
xproxy_disable_interface_xml();
|
||||
Config::getInstance()->save();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (!preg_match('/^tun[0-9]{1,3}$/', $tunDev) || filter_var($tunGw, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
|
||||
syslog(LOG_ERR, 'xproxy sync_gateway: invalid tun device or gateway, aborting gateway sync');
|
||||
exit(1);
|
||||
}
|
||||
|
||||
xproxy_ensure_interface_xml($tunDev);
|
||||
xproxy_ensure_gateway_mvc($tunGw);
|
||||
Config::getInstance()->save();
|
||||
exit(0);
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
[start]
|
||||
command:/usr/local/bin/php /usr/local/opnsense/scripts/xproxy/sync_gateway.php;/usr/local/opnsense/scripts/xproxy/service_control.py start
|
||||
parameters:
|
||||
type:script
|
||||
message:starting xproxy
|
||||
|
||||
[stop]
|
||||
command:/usr/local/opnsense/scripts/xproxy/service_control.py stop
|
||||
parameters:
|
||||
type:script
|
||||
message:stopping xproxy
|
||||
|
||||
[restart]
|
||||
command:/usr/local/bin/php /usr/local/opnsense/scripts/xproxy/sync_gateway.php;/usr/local/opnsense/scripts/xproxy/service_control.py restart
|
||||
parameters:
|
||||
type:script
|
||||
message:restarting xproxy
|
||||
description:Restart Xproxy
|
||||
|
||||
[reconfigure]
|
||||
command:/usr/local/bin/php /usr/local/opnsense/scripts/xproxy/sync_gateway.php;/usr/local/opnsense/scripts/xproxy/service_control.py reconfigure
|
||||
parameters:
|
||||
type:script
|
||||
message:reconfiguring xproxy
|
||||
|
||||
[reload]
|
||||
command:/usr/local/bin/php /usr/local/opnsense/scripts/xproxy/sync_gateway.php;/usr/local/opnsense/scripts/xproxy/service_control.py reconfigure
|
||||
parameters:
|
||||
type:script
|
||||
message:reloading xproxy
|
||||
|
||||
[status]
|
||||
command:/usr/local/opnsense/scripts/xproxy/service_control.py status
|
||||
parameters:
|
||||
type:script_output
|
||||
message:request xproxy status
|
||||
|
||||
[log]
|
||||
command:/usr/local/opnsense/scripts/xproxy/show_log.py
|
||||
parameters:
|
||||
type:script_output
|
||||
message:request xproxy log
|
||||
|
||||
[import]
|
||||
command:/usr/local/opnsense/scripts/xproxy/import_uris.py
|
||||
parameters:%s
|
||||
type:script_output
|
||||
message:importing proxy URIs
|
||||
|
||||
[setup]
|
||||
command:/usr/local/opnsense/scripts/xproxy/setup.sh
|
||||
parameters:
|
||||
type:script
|
||||
message:setting up xproxy dependencies
|
||||
|
|
@ -0,0 +1 @@
|
|||
rc.conf.d:/etc/rc.conf.d/xray
|
||||
|
|
@ -0,0 +1 @@
|
|||
xray_enable="NO"
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Rotate xproxy log: max 1MB, keep 3 archives, compress old logs
|
||||
/var/log/xproxy.log 640 3 1000 * GZ
|
||||
11
security/xproxy/src/usr/local/etc/sysctl.d/xproxy.conf
Normal file
11
security/xproxy/src/usr/local/etc/sysctl.d/xproxy.conf
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# xproxy: TCP tuning for high-throughput proxy workloads
|
||||
# BDP target: ~500 Mbps at 50ms RTT = 3.1 MB
|
||||
kern.ipc.maxsockbuf=16777216
|
||||
net.inet.tcp.recvbuf_max=8388608
|
||||
net.inet.tcp.sendbuf_max=8388608
|
||||
net.inet.tcp.recvspace=262144
|
||||
net.inet.tcp.sendspace=262144
|
||||
net.inet.tcp.initcwnd_segments=44
|
||||
net.inet.tcp.cc.algorithm=cdg
|
||||
net.inet.tcp.fast_finwait2_recycle=1
|
||||
net.inet.tcp.finwait2_timeout=5000
|
||||
Loading…
Reference in a new issue