www/caddy: Add Layer4 openvpn, winbox and quic matcher (#4325)

* www/caddy: Add CRUD for Layer4 OpenVPN matcher with mode and static key support.

* www/caddy: Export static keys to the filesystem as uuid.key

* www/caddy: Remove validation that checks for multiple keys, help text is enough.

* www/caddy: Expand layer4 template for all supported OpenVPN modes.

* www/caddy: Prevent multiple static keys for modes other than crypt2_client. Fix helptexts.

* www/caddy: Add unique constraint to description of openvpn static key

* www/caddy: Changelog and version bump

* www/caddy: Make static key optional when choosing the tls mode in openvpn matcher

* www/caddy: Prepare new Layer7 Matcher Tab for more customizable matchers in the future.

* www/caddy: Add Layer4 QUIC matcher.

* www/caddy: Rename matcherTab

* www/caddy: Revert a4ea0cb3 since its non operational and will not be needed for a while anyway

* www/caddy: Changelog
This commit is contained in:
Monviech
2024-11-04 17:26:24 +01:00
committed by GitHub
parent e806ea3fd6
commit 72e09d54d1
11 changed files with 316 additions and 55 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
PLUGIN_NAME= caddy
PLUGIN_VERSION= 1.7.3
PLUGIN_VERSION= 1.7.4
PLUGIN_DEPENDS= caddy-custom
PLUGIN_COMMENT= Modern Reverse Proxy with Automatic HTTPS, Dynamic DNS and Layer4 Routing
PLUGIN_MAINTAINER= cedrik@pischem.com
+12
View File
@@ -13,6 +13,18 @@ DOC: https://docs.opnsense.org/manual/how-tos/caddy.html
Plugin Changelog
================
1.7.4
* Add: Layer4 OpenVPN matcher with mode, digest and static key support
* Add: Layer4 Winbox matcher
* Add: Layer4 QUIC matcher
* Build: Update dependency to lang/go123
* Build: Update caddy-l4 and caddy-dynamicdns module
* Build: Fix that caddy-l4 does not stop when ssh is proxied
* Build: DNS Providers: Update porkbun, dnsmadeeasy
* Cleanup: Layer4 default route in listener_wrappers has been removed (obsolete)
* Fix: Error when same Access List is set to wildcard and subdomain
1.7.3
* Add: Clear All button to Filter by Domain selectpicker
@@ -209,40 +209,6 @@ class ReverseProxyController extends ApiMutableModelControllerBase
return $this->toggleBase("reverseproxy.handle", $uuid, $enabled);
}
// Layer4 Section
public function searchLayer4Action()
{
return $this->searchBase("reverseproxy.layer4", null, 'description');
}
public function setLayer4Action($uuid)
{
return $this->setBase("layer4", "reverseproxy.layer4", $uuid);
}
public function addLayer4Action()
{
return $this->addBase("layer4", "reverseproxy.layer4");
}
public function getLayer4Action($uuid = null)
{
return $this->getBase("layer4", "reverseproxy.layer4", $uuid);
}
public function delLayer4Action($uuid)
{
return $this->delBase("reverseproxy.layer4", $uuid);
}
public function toggleLayer4Action($uuid, $enabled = null)
{
return $this->toggleBase("reverseproxy.layer4", $uuid, $enabled);
}
// AccessList Section
public function searchAccessListAction()
@@ -349,4 +315,70 @@ class ReverseProxyController extends ApiMutableModelControllerBase
{
return $this->delBase("reverseproxy.header", $uuid);
}
// Layer4 Proxy Section
public function searchLayer4Action()
{
return $this->searchBase("reverseproxy.layer4", null, 'description');
}
public function setLayer4Action($uuid)
{
return $this->setBase("layer4", "reverseproxy.layer4", $uuid);
}
public function addLayer4Action()
{
return $this->addBase("layer4", "reverseproxy.layer4");
}
public function getLayer4Action($uuid = null)
{
return $this->getBase("layer4", "reverseproxy.layer4", $uuid);
}
public function delLayer4Action($uuid)
{
return $this->delBase("reverseproxy.layer4", $uuid);
}
public function toggleLayer4Action($uuid, $enabled = null)
{
return $this->toggleBase("reverseproxy.layer4", $uuid, $enabled);
}
// Layer4 OpenVPN Section
public function searchLayer4OpenvpnAction()
{
return $this->searchBase("reverseproxy.layer4openvpn", null, 'description');
}
public function setLayer4OpenvpnAction($uuid)
{
return $this->setBase("layer4openvpn", "reverseproxy.layer4openvpn", $uuid);
}
public function addLayer4OpenvpnAction()
{
return $this->addBase("layer4openvpn", "reverseproxy.layer4openvpn");
}
public function getLayer4OpenvpnAction($uuid = null)
{
return $this->getBase("layer4openvpn", "reverseproxy.layer4openvpn", $uuid);
}
public function delLayer4OpenvpnAction($uuid)
{
return $this->delBase("reverseproxy.layer4openvpn", $uuid);
}
public function toggleLayer4OpenvpnAction($uuid, $enabled = null)
{
return $this->toggleBase("reverseproxy.layer4openvpn", $uuid, $enabled);
}
}
@@ -38,5 +38,6 @@ class Layer4Controller extends IndexController
{
$this->view->pick('OPNsense/Caddy/layer4');
$this->view->formDialogLayer4 = $this->getForm("dialogLayer4");
$this->view->formDialogLayer4Openvpn = $this->getForm("dialogLayer4Openvpn");
}
}
@@ -62,10 +62,26 @@
<id>layer4.FromDomain</id>
<label>Domain</label>
<type>select_multiple</type>
<style>style_matchers tokenize</style>
<style>style_matchers tokenize matchers_domain</style>
<allownew>true</allownew>
<help><![CDATA[Enter one or multiple domains to route via SNI or Host Header. Wildcard domains and host wildcards are allowed, e.g. "*.example.com" and "*".]]></help>
</field>
<field>
<id>layer4.FromOpenvpnModes</id>
<label>OpenVPN Mode</label>
<type>dropdown</type>
<style>style_matchers matchers_openvpn</style>
<help><![CDATA[Select the mode matching the OpenVPN Server or Client.]]></help>
</field>
<field>
<id>layer4.FromOpenvpnStaticKey</id>
<label>OpenVPN Static Key</label>
<type>select_multiple</type>
<style>style_matchers selectpicker matchers_openvpn</style>
<hint>Any</hint>
<size>5</size>
<help><![CDATA[Select a Static Key to match. Multiple Static Keys are only supported for tls-crypt2_client mode.]]></help>
</field>
<field>
<id>layer4.InvertMatchers</id>
<label>Invert Matchers</label>
@@ -0,0 +1,13 @@
<fields>
<field>
<id>layer4openvpn.description</id>
<label>Description</label>
<type>text</type>
</field>
<field>
<id>layer4openvpn.StaticKey</id>
<label>Static Key</label>
<type>textbox</type>
<help>Paste an OpenVPN Static key.</help>
</field>
</fields>
@@ -180,7 +180,7 @@ class Caddy extends BaseModel
foreach ($this->reverseproxy->layer4->iterateItems() as $item) {
if ($item->isFieldChanged()) {
$key = $item->__reference;
if (in_array((string)$item->Matchers, ['httphost', 'tlssni']) && empty((string)$item->FromDomain)) {
if (in_array((string)$item->Matchers, ['httphost', 'tlssni', 'quicsni']) && empty((string)$item->FromDomain)) {
$messages->appendMessage(new Message(
sprintf(
gettext(
@@ -191,7 +191,7 @@ class Caddy extends BaseModel
$key . ".FromDomain"
));
} elseif (
!in_array((string)$item->Matchers, ['httphost', 'tlssni']) &&
!in_array((string)$item->Matchers, ['httphost', 'tlssni', 'quicsni']) &&
(
!empty((string)$item->FromDomain) &&
(string)$item->FromDomain != '*'
@@ -208,6 +208,30 @@ class Caddy extends BaseModel
));
}
if ((string)$item->Matchers !== 'openvpn' && !empty((string)$item->FromOpenvpnModes)) {
$messages->appendMessage(new Message(
sprintf(
gettext(
'When "%s" matcher is selected, field must be empty.'
),
$item->Matchers
),
$key . ".FromOpenvpnModes"
));
}
if ((string)$item->Matchers !== 'openvpn' && !empty((string)$item->FromOpenvpnStaticKey)) {
$messages->appendMessage(new Message(
sprintf(
gettext(
'When "%s" matcher is selected, field must be empty.'
),
$item->Matchers
),
$key . ".FromOpenvpnStaticKey"
));
}
if ((string)$item->Type === 'global' && empty((string)$item->FromPort)) {
$messages->appendMessage(new Message(
sprintf(
@@ -246,13 +270,14 @@ class Caddy extends BaseModel
(string)$item->Type !== 'global' &&
(
(string)$item->Matchers == 'tls' ||
(string)$item->Matchers == 'http'
(string)$item->Matchers == 'http' ||
(string)$item->Matchers == 'quic'
)
) {
$messages->appendMessage(new Message(
sprintf(
gettext(
'When routing type is "%s", matchers "HTTP" or "TLS" cannot be chosen.'
'When routing type is "%s", matchers "HTTP", "TLS" or "QUIC" cannot be chosen.'
),
$item->Type
),
@@ -489,6 +489,29 @@
<AsList>Y</AsList>
<ValidationMessage>Please enter one or multiple hostnames or FQDNs.</ValidationMessage>
</FromDomain>
<FromOpenvpnModes type="OptionField">
<BlankDesc>Any</BlankDesc>
<OptionValues>
<auth_sha256_normal>tls-auth_sha256_normal</auth_sha256_normal>
<auth_sha256_inverse>tls-auth_sha256_inverse</auth_sha256_inverse>
<auth_sha512_normal>tls-auth_sha512_normal</auth_sha512_normal>
<auth_sha512_inverse>tls-auth_sha512_inverse</auth_sha512_inverse>
<crypt>tls-crypt</crypt>
<crypt2_client>tls-crypt2_client</crypt2_client>
<crypt2_server>tls-crypt2_server</crypt2_server>
</OptionValues>
</FromOpenvpnModes>
<FromOpenvpnStaticKey type="ModelRelationField">
<Model>
<reverseproxy>
<source>OPNsense.Caddy.Caddy</source>
<items>reverseproxy.layer4openvpn</items>
<display>description</display>
<display_format>%s</display_format>
</reverseproxy>
</Model>
<Multiple>Y</Multiple>
</FromOpenvpnStaticKey>
<Matchers type="OptionField">
<Required>Y</Required>
<Default>tlssni</Default>
@@ -497,14 +520,18 @@
<dns>DNS</dns>
<http>HTTP</http>
<httphost>HTTP (Host Header)</httphost>
<openvpn>OpenVPN</openvpn>
<postgres>Postgres</postgres>
<proxy_protocol>Proxy Protocol</proxy_protocol>
<quic>QUIC</quic>
<quicsni>QUIC (SNI Client Hello)</quicsni>
<rdp>RDP</rdp>
<socks4>SOCKSv4</socks4>
<socks5>SOCKSv5</socks5>
<ssh>SSH</ssh>
<tls>TLS</tls>
<tlssni>TLS (SNI Client Hello)</tlssni>
<winbox>Winbox</winbox>
<wireguard>Wireguard</wireguard>
<xmpp>XMPP</xmpp>
</OptionValues>
@@ -539,6 +566,20 @@
</RemoteIp>
<description type="DescriptionField"/>
</layer4>
<layer4openvpn type="ArrayField">
<StaticKey type="TextField">
<Required>Y</Required>
</StaticKey>
<description type="DescriptionField">
<Required>Y</Required>
<Constraints>
<check001>
<ValidationMessage>Description must be unique.</ValidationMessage>
<type>UniqueConstraint</type>
</check001>
</Constraints>
</description>
</layer4openvpn>
</reverseproxy>
</items>
</model>
@@ -36,6 +36,15 @@
toggle:'/api/caddy/ReverseProxy/toggleLayer4/',
});
$("#Layer4OpenvpnGrid").UIBootgrid({
search:'/api/caddy/ReverseProxy/searchLayer4Openvpn/',
get:'/api/caddy/ReverseProxy/getLayer4Openvpn/',
set:'/api/caddy/ReverseProxy/setLayer4Openvpn/',
add:'/api/caddy/ReverseProxy/addLayer4Openvpn/',
del:'/api/caddy/ReverseProxy/delLayer4Openvpn/',
toggle:'/api/caddy/ReverseProxy/toggleLayer4Openvpn/',
});
/**
* Displays an alert message to the user.
*
@@ -86,11 +95,17 @@
}
});
// Hide all elements with style_matchers initially
$(".style_matchers").closest('tr').hide();
$("#layer4\\.Matchers").change(function() {
if ($(this).val() !== "tlssni" && $(this).val() !== "httphost") {
$(".style_matchers").closest('tr').hide();
} else {
$(".style_matchers").closest('tr').show();
$(".style_matchers").closest('tr').hide();
const selectedVal = $(this).val();
if (selectedVal === "tlssni" || selectedVal === "httphost" || selectedVal === "quicsni") {
$(".matchers_domain").closest('tr').show();
} else if (selectedVal === "openvpn") {
$(".matchers_openvpn").closest('tr').show();
}
});
@@ -108,6 +123,7 @@
<ul class="nav nav-tabs" data-tabs="tabs" id="maintabs">
<li id="tab-layer4" class="active"><a data-toggle="tab" href="#layer4Tab">{{ lang._('Layer4 Routes') }}</a></li>
<li id="tab-matcher"><a data-toggle="tab" href="#matcherTab">{{ lang._('Layer7 Matcher Settings') }}</a></li>
</ul>
<div class="tab-content content-box">
@@ -115,7 +131,7 @@
<div id="layer4Tab" class="tab-pane fade active in">
<div style="padding-left: 16px;">
<h1 class="custom-header">{{ lang._('Layer4 Routes') }}</h1>
<div style="display: block;"> <!-- Common container -->
<div style="display: block;">
<table id="Layer4Grid" class="table table-condensed table-hover table-striped" data-editDialog="DialogLayer4" data-editAlert="ConfigurationChangeMessage">
<thead>
<tr>
@@ -125,9 +141,11 @@
<th data-column-id="Type" data-type="string" data-visible="false">{{ lang._('Routing Type') }}</th>
<th data-column-id="Protocol" data-type="string">{{ lang._('Protocol') }}</th>
<th data-column-id="FromPort" data-type="string" data-visible="false">{{ lang._('Local Port') }}</th>
<th data-column-id="FromDomain" data-type="string">{{ lang._('Domain') }}</th>
<th data-column-id="Matchers" data-type="string">{{ lang._('Matchers') }}</th>
<th data-column-id="InvertMatchers" data-type="boolean" data-formatter="boolean" data-visible="false">{{ lang._('Invert Matchers') }}</th>
<th data-column-id="FromDomain" data-type="string">{{ lang._('Domain') }}</th>
<th data-column-id="FromOpenvpnModes" data-type="string" data-visible="false">{{ lang._('OpenVPN Modes') }}</th>
<th data-column-id="FromOpenvpnStaticKey" data-type="string" data-visible="false">{{ lang._('OpenVPN Static Key') }}</th>
<th data-column-id="ToDomain" data-type="string">{{ lang._('Upstream Domain') }}</th>
<th data-column-id="ToPort" data-type="string">{{ lang._('Upstream Port') }}</th>
<th data-column-id="RemoteIp" data-type="string" data-visible="false">{{ lang._('Remote IP') }}</th>
@@ -152,8 +170,39 @@
</div>
</div>
</div>
<!-- Layer7 Tab -->
<div id="matcherTab" class="tab-pane fade">
<div style="padding-left: 16px;">
<!-- OpenVPN Matcher -->
<h1 class="custom-header">{{ lang._('OpenVPN Static Keys') }}</h1>
<div style="display: block;">
<table id="Layer4OpenvpnGrid" class="table table-condensed table-hover table-striped" data-editDialog="DialogLayer4Openvpn" data-editAlert="ConfigurationChangeMessage">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-identifier="true" data-visible="false">{{ lang._('ID') }}</th>
<th data-column-id="description" data-type="string">{{ lang._('Description') }}</th>
<th data-column-id="commands" data-width="7em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td></td>
<td>
<button id="addLayer4OpenvpnBtn" data-action="add" type="button" class="btn btn-xs btn-primary"><span class="fa fa-plus"></span></button>
<button data-action="deleteSelected" type="button" class="btn btn-xs btn-default"><span class="fa fa-trash-o"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
<!-- Reconfigure Button -->
<section class="page-content-main">
<div class="content-box">
@@ -177,3 +226,4 @@
</section>
{{ partial("layout_partials/base_dialog",['fields':formDialogLayer4,'id':'DialogLayer4','label':lang._('Edit Layer4 Route')])}}
{{ partial("layout_partials/base_dialog",['fields':formDialogLayer4Openvpn,'id':'DialogLayer4Openvpn','label':lang._('Edit OpenVPN Static Key')])}}
@@ -36,22 +36,22 @@ $temp_dir = '/var/db/caddy/data/caddy/certificates/temp/';
// Traverse through certificates
foreach ($configObj->cert as $cert) {
$cert_refid = (string) $cert->refid;
$cert_content = base64_decode((string) $cert->crt);
$key_content = base64_decode((string) $cert->prv);
$cert_refid = (string)$cert->refid;
$cert_content = base64_decode((string)$cert->crt);
$key_content = base64_decode((string)$cert->prv);
$cert_chain = $cert_content;
// Handle CA and possible intermediate CA to create a certificate bundle
if (!empty($cert->caref)) {
foreach ($configObj->ca as $ca) {
if ((string) $cert->caref === (string) $ca->refid) {
$ca_content = base64_decode((string) $ca->crt);
if ((string)$cert->caref === (string)$ca->refid) {
$ca_content = base64_decode((string)$ca->crt);
$cert_chain .= "\n" . $ca_content;
if (!empty($ca->caref)) {
foreach ($configObj->ca as $parent_ca) {
if ((string) $ca->caref === (string) $parent_ca->refid) {
$parent_ca_content = base64_decode((string) $parent_ca->crt);
if ((string)$ca->caref === (string)$parent_ca->refid) {
$parent_ca_content = base64_decode((string)$parent_ca->crt);
$cert_chain .= "\n" . $parent_ca_content;
break;
}
@@ -68,9 +68,20 @@ foreach ($configObj->cert as $cert) {
// Traverse through CA certificates and save them
foreach ($configObj->ca as $ca) {
$ca_refid = (string) $ca->refid;
$ca_content = base64_decode((string) $ca->crt);
$ca_refid = (string)$ca->refid;
$ca_content = base64_decode((string)$ca->crt);
// Save the CA certificate
file_put_contents($temp_dir . $ca_refid . '.pem', $ca_content);
}
// Traverse through layer4 OpenVPN static keys and save them as files
if (isset($configObj->Pischem->caddy->reverseproxy->layer4openvpn)) {
foreach ($configObj->Pischem->caddy->reverseproxy->layer4openvpn as $openvpn) {
$uuid = (string) $openvpn['uuid'];
$static_key = (string) $openvpn->StaticKey;
// Save the static key
file_put_contents($temp_dir . $uuid . '.key', $static_key);
}
}
@@ -70,6 +70,66 @@
{{ invert_prefix }}http host {{ layer4.FromDomain.replace(',', ' ') }}
{% elif layer4.Matchers == 'tlssni' %}
{{ invert_prefix }}tls sni {{ layer4.FromDomain.replace(',', ' ') }}
{% elif layer4.Matchers == 'quicsni' %}
{{ invert_prefix }}quic sni {{ layer4.FromDomain.replace(',', ' ') }}
{% elif layer4.Matchers == 'openvpn' and layer4.FromOpenvpnModes %}
{% for mode in layer4.FromOpenvpnModes.split(',') %}
{% set mode_clean = mode.strip() %}
{% if layer4.FromOpenvpnStaticKey %}
{% set key_list = layer4.FromOpenvpnStaticKey.split(',') %}
{% endif %}
{% if mode_clean.startswith('auth') %}
{% if key_list|length > 1 %}
{% set key_list = key_list[:1] %}
{% endif %}
{% set digest = 'sha256' if 'sha256' in mode_clean else 'sha512' %}
{% set direction = 'normal' if 'normal' in mode_clean else 'inverse' %}
{{ invert_prefix }}openvpn {
modes auth
auth_digest {{ digest }}
{% if layer4.FromOpenvpnStaticKey %}
group_key_direction {{ direction }}
{% for key_uuid in key_list %}
group_key_file /var/db/caddy/data/caddy/certificates/temp/{{ key_uuid.strip() }}.key
{% endfor %}
{% endif %}
}
{% elif mode_clean == 'crypt' %}
{% if key_list|length > 1 %}
{% set key_list = key_list[:1] %}
{% endif %}
{{ invert_prefix }}openvpn {
modes crypt
{% if layer4.FromOpenvpnStaticKey %}
{% for key_uuid in key_list %}
group_key_file /var/db/caddy/data/caddy/certificates/temp/{{ key_uuid.strip() }}.key
{% endfor %}
{% endif %}
}
{% elif mode_clean == 'crypt2_client' %}
{# Multiple keys are allowed for crypt2_client #}
{{ invert_prefix }}openvpn {
modes crypt2
{% if layer4.FromOpenvpnStaticKey %}
{% for key_uuid in key_list %}
client_key_file /var/db/caddy/data/caddy/certificates/temp/{{ key_uuid.strip() }}.key
{% endfor %}
{% endif %}
}
{% elif mode_clean == 'crypt2_server' %}
{% if key_list|length > 1 %}
{% set key_list = key_list[:1] %}
{% endif %}
{{ invert_prefix }}openvpn {
modes crypt2
{% if layer4.FromOpenvpnStaticKey %}
{% for key_uuid in key_list %}
server_key_file /var/db/caddy/data/caddy/certificates/temp/{{ key_uuid.strip() }}.key
{% endfor %}
{% endif %}
}
{% endif %}
{% endfor %}
{% else %}
{{ invert_prefix }}{{ layer4.Matchers }}
{% endif %}