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:
dasunNimantha 2026-04-02 18:31:34 +05:30
parent d1ebcc49ad
commit 7b7bcb55b4
25 changed files with 3097 additions and 0 deletions

7
security/xproxy/Makefile Normal file
View 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"

View 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

View 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;
}

View file

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

View file

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

View file

@ -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];
}
}

View file

@ -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';
}

View file

@ -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');
}
}

View file

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

View file

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

View file

@ -0,0 +1,9 @@
<acl>
<page-xproxy>
<name>VPN: Xproxy</name>
<patterns>
<pattern>ui/xproxy/*</pattern>
<pattern>api/xproxy/*</pattern>
</patterns>
</page-xproxy>
</acl>

View file

@ -0,0 +1,5 @@
<menu>
<VPN>
<Xproxy order="50" url="/ui/xproxy/" VisibleName="Xproxy" cssClass="fa fa-shield fa-fw"/>
</VPN>
</menu>

View file

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

View file

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

View file

@ -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 += '&bull; ' + $('<span/>').text(data.errors[i]).html() + '<br/>';
}
if (data.errors.length > 10) {
msg += '&hellip; ' + (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 += '&bull; ' + $('<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')])}}

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

File diff suppressed because it is too large Load diff

View 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"

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

View 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);

View file

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

View file

@ -0,0 +1 @@
rc.conf.d:/etc/rc.conf.d/xray

View file

@ -0,0 +1 @@
xray_enable="NO"

View file

@ -0,0 +1,2 @@
# Rotate xproxy log: max 1MB, keep 3 archives, compress old logs
/var/log/xproxy.log 640 3 1000 * GZ

View 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