Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMHSanaei <ho3ein.sanaei@gmail.com>2026-05-04 17:36:33 +0300
committerMHSanaei <ho3ein.sanaei@gmail.com>2026-05-04 17:39:29 +0300
commitc90f8a05bf792e61db250f210834cdabcc0b7906 (patch)
treed2d610e9dc3e1a856a6f9be2510f4a323ee0126c /web
parent9f96ef83ece25934dfadec69aff3fe91e14301cd (diff)
fix(security): sanitize remote IP headers and escape log viewer output
#4135
Diffstat (limited to 'web')
-rw-r--r--web/controller/util.go62
-rw-r--r--web/html/component/aCustomStatistic.html2
-rw-r--r--web/html/component/aPersianDatepicker.html2
-rw-r--r--web/html/component/aSidebar.html2
-rw-r--r--web/html/component/aTableSortable.html2
-rw-r--r--web/html/component/aThemeSwitch.html4
-rw-r--r--web/html/form/inbound.html28
-rw-r--r--web/html/form/protocol/dokodemo.html2
-rw-r--r--web/html/form/protocol/hysteria.html2
-rw-r--r--web/html/form/protocol/shadowsocks.html2
-rw-r--r--web/html/form/protocol/trojan.html2
-rw-r--r--web/html/form/protocol/vless.html2
-rw-r--r--web/html/form/protocol/vmess.html2
-rw-r--r--web/html/form/stream/stream_settings.html18
-rw-r--r--web/html/form/tls_settings.html2
-rw-r--r--web/html/inbounds.html16
-rw-r--r--web/html/index.html50
-rw-r--r--web/html/login.html6
-rw-r--r--web/html/modals/client_modal.html2
-rw-r--r--web/html/modals/inbound_modal.html2
-rw-r--r--web/html/modals/xray_outbound_modal.html2
-rw-r--r--web/html/settings.html2
-rw-r--r--web/html/xray.html18
23 files changed, 147 insertions, 85 deletions
diff --git a/web/controller/util.go b/web/controller/util.go
index 3d266f29..e1d53ba6 100644
--- a/web/controller/util.go
+++ b/web/controller/util.go
@@ -1,8 +1,10 @@
package controller
import (
+ "fmt"
"net"
"net/http"
+ "net/netip"
"strings"
"github.com/mhsanaei/3x-ui/v2/config"
@@ -14,18 +16,58 @@ import (
// getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string {
- value := c.GetHeader("X-Real-IP")
- if value != "" {
- return value
+ if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
+ return ip
}
- value = c.GetHeader("X-Forwarded-For")
- if value != "" {
- ips := strings.Split(value, ",")
- return ips[0]
+
+ if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
+ for _, part := range strings.Split(xff, ",") {
+ if ip, ok := extractTrustedIP(part); ok {
+ return ip
+ }
+ }
+ }
+
+ if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
+ return ip
+ }
+
+ return "unknown"
+}
+
+func extractTrustedIP(value string) (string, bool) {
+ candidate := strings.TrimSpace(value)
+ if candidate == "" {
+ return "", false
+ }
+
+ if ip, ok := parseIPCandidate(candidate); ok {
+ return ip.String(), true
+ }
+
+ if host, _, err := net.SplitHostPort(candidate); err == nil {
+ if ip, ok := parseIPCandidate(host); ok {
+ return ip.String(), true
+ }
+ }
+
+ if strings.Count(candidate, ":") == 1 {
+ if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil {
+ if ip, ok := parseIPCandidate(host); ok {
+ return ip.String(), true
+ }
+ }
+ }
+
+ return "", false
+}
+
+func parseIPCandidate(value string) (netip.Addr, bool) {
+ ip, err := netip.ParseAddr(strings.TrimSpace(value))
+ if err != nil {
+ return netip.Addr{}, false
}
- addr := c.Request.RemoteAddr
- ip, _, _ := net.SplitHostPort(addr)
- return ip
+ return ip.Unmap(), true
}
// jsonMsg sends a JSON response with a message and error status.
diff --git a/web/html/component/aCustomStatistic.html b/web/html/component/aCustomStatistic.html
index e9bfe83b..be20a39a 100644
--- a/web/html/component/aCustomStatistic.html
+++ b/web/html/component/aCustomStatistic.html
@@ -38,7 +38,7 @@
required: false
}
},
- template: `{{template "component/customStatistic"}}`,
+ template: `{{template "component/customStatistic" .}}`,
});
</script>
{{end}} \ No newline at end of file
diff --git a/web/html/component/aPersianDatepicker.html b/web/html/component/aPersianDatepicker.html
index e8b09b92..cb4c2918 100644
--- a/web/html/component/aPersianDatepicker.html
+++ b/web/html/component/aPersianDatepicker.html
@@ -34,7 +34,7 @@
required: false,
},
},
- template: `{{template "component/persianDatepickerTemplate"}}`,
+ template: `{{template "component/persianDatepickerTemplate" .}}`,
data() {
return {
date: '',
diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html
index 9c89a96d..08b39dc3 100644
--- a/web/html/component/aSidebar.html
+++ b/web/html/component/aSidebar.html
@@ -96,7 +96,7 @@
}
}
},
- template: `{{template "component/sidebar/content"}}`,
+ template: `{{template "component/sidebar/content" .}}`,
});
</script>
{{end}} \ No newline at end of file
diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html
index b3606527..925adbb5 100644
--- a/web/html/component/aTableSortable.html
+++ b/web/html/component/aTableSortable.html
@@ -175,7 +175,7 @@
}
});
Vue.component('a-table-sort-trigger', {
- template: `{{template "component/sortableTableTrigger"}}`,
+ template: `{{template "component/sortableTableTrigger" .}}`,
props: {
'item-index': {
type: undefined,
diff --git a/web/html/component/aThemeSwitch.html b/web/html/component/aThemeSwitch.html
index 2107e5a8..2712b1f7 100644
--- a/web/html/component/aThemeSwitch.html
+++ b/web/html/component/aThemeSwitch.html
@@ -95,7 +95,7 @@
}
const themeSwitcher = createThemeSwitcher();
Vue.component('a-theme-switch', {
- template: `{{template "component/themeSwitchTemplate"}}`,
+ template: `{{template "component/themeSwitchTemplate" .}}`,
data: () => ({
themeSwitcher
}),
@@ -107,7 +107,7 @@
}
});
Vue.component('a-theme-switch-login', {
- template: `{{template "component/themeSwitchTemplateLogin"}}`,
+ template: `{{template "component/themeSwitchTemplateLogin" .}}`,
data: () => ({
themeSwitcher
}),
diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html
index 736a1fd4..61d7bc57 100644
--- a/web/html/form/inbound.html
+++ b/web/html/form/inbound.html
@@ -102,69 +102,69 @@
<!-- vmess settings -->
<template v-if="inbound.protocol === Protocols.VMESS">
- {{template "form/vmess"}}
+ {{template "form/vmess" .}}
</template>
<!-- vless settings -->
<template v-if="inbound.protocol === Protocols.VLESS">
- {{template "form/vless"}}
+ {{template "form/vless" .}}
</template>
<!-- trojan settings -->
<template v-if="inbound.protocol === Protocols.TROJAN">
- {{template "form/trojan"}}
+ {{template "form/trojan" .}}
</template>
<!-- shadowsocks -->
<template v-if="inbound.protocol === Protocols.SHADOWSOCKS">
- {{template "form/shadowsocks"}}
+ {{template "form/shadowsocks" .}}
</template>
<!-- tunnel -->
<template v-if="inbound.protocol === Protocols.TUNNEL">
- {{template "form/tunnel"}}
+ {{template "form/tunnel" .}}
</template>
<!-- mixed -->
<template v-if="inbound.protocol === Protocols.MIXED">
- {{template "form/mixed"}}
+ {{template "form/mixed" .}}
</template>
<!-- http -->
<template v-if="inbound.protocol === Protocols.HTTP">
- {{template "form/http"}}
+ {{template "form/http" .}}
</template>
<!-- wireguard -->
<template v-if="inbound.protocol === Protocols.WIREGUARD">
- {{template "form/wireguard"}}
+ {{template "form/wireguard" .}}
</template>
<!-- tun -->
<template v-if="inbound.protocol === Protocols.TUN">
- {{template "form/tun"}}
+ {{template "form/tun" .}}
</template>
<!-- hysteria -->
<template v-if="inbound.protocol === Protocols.HYSTERIA">
- {{template "form/hysteria"}}
+ {{template "form/hysteria" .}}
</template>
<!-- stream settings -->
<template v-if="inbound.canEnableStream()">
- {{template "form/streamSettings"}}
- {{template "form/externalProxy" }}
+ {{template "form/streamSettings" .}}
+ {{template "form/externalProxy" .}}
</template>
<!-- tls settings -->
<template v-if="inbound.canEnableTls()">
- {{template "form/tlsSettings"}}
+ {{template "form/tlsSettings" .}}
</template>
<!-- sniffing -->
<a-collapse>
<a-collapse-panel header='Sniffing'>
- {{template "form/sniffing"}}
+ {{template "form/sniffing" .}}
</a-collapse-panel>
</a-collapse>
diff --git a/web/html/form/protocol/dokodemo.html b/web/html/form/protocol/dokodemo.html
index 4437a3e3..1dbace29 100644
--- a/web/html/form/protocol/dokodemo.html
+++ b/web/html/form/protocol/dokodemo.html
@@ -32,6 +32,6 @@
</a-form>
<!-- sockopt -->
<template>
- {{template "form/streamSockopt"}}
+ {{template "form/streamSockopt" .}}
</template>
{{end}} \ No newline at end of file
diff --git a/web/html/form/protocol/hysteria.html b/web/html/form/protocol/hysteria.html
index 557ebb43..5613dfc5 100644
--- a/web/html/form/protocol/hysteria.html
+++ b/web/html/form/protocol/hysteria.html
@@ -1,7 +1,7 @@
{{define "form/hysteria"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.hysterias.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
diff --git a/web/html/form/protocol/shadowsocks.html b/web/html/form/protocol/shadowsocks.html
index 8112222c..12371399 100644
--- a/web/html/form/protocol/shadowsocks.html
+++ b/web/html/form/protocol/shadowsocks.html
@@ -2,7 +2,7 @@
<template v-if="inbound.isSSMultiUser">
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
diff --git a/web/html/form/protocol/trojan.html b/web/html/form/protocol/trojan.html
index 4d99ff43..5d36808d 100644
--- a/web/html/form/protocol/trojan.html
+++ b/web/html/form/protocol/trojan.html
@@ -1,7 +1,7 @@
{{define "form/trojan"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html
index 1801026e..f8ee1542 100644
--- a/web/html/form/protocol/vless.html
+++ b/web/html/form/protocol/vless.html
@@ -1,7 +1,7 @@
{{define "form/vless"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
diff --git a/web/html/form/protocol/vmess.html b/web/html/form/protocol/vmess.html
index 9e13da50..1beb4259 100644
--- a/web/html/form/protocol/vmess.html
+++ b/web/html/form/protocol/vmess.html
@@ -1,7 +1,7 @@
{{define "form/vmess"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
diff --git a/web/html/form/stream/stream_settings.html b/web/html/form/stream/stream_settings.html
index a3df1a22..2a918305 100644
--- a/web/html/form/stream/stream_settings.html
+++ b/web/html/form/stream/stream_settings.html
@@ -17,42 +17,42 @@
<!-- tcp -->
<template v-if="inbound.stream.network === 'tcp'">
- {{template "form/streamTCP"}}
+ {{template "form/streamTCP" .}}
</template>
<!-- kcp -->
<template v-if="inbound.stream.network === 'kcp'">
- {{template "form/streamKCP"}}
+ {{template "form/streamKCP" .}}
</template>
<!-- ws -->
<template v-if="inbound.stream.network === 'ws'">
- {{template "form/streamWS"}}
+ {{template "form/streamWS" .}}
</template>
<!-- grpc -->
<template v-if="inbound.stream.network === 'grpc'">
- {{template "form/streamGRPC"}}
+ {{template "form/streamGRPC" .}}
</template>
<!-- hysteria -->
<template v-if="inbound.stream.network === 'hysteria'">
- {{template "form/streamHysteria"}}
+ {{template "form/streamHysteria" .}}
</template>
<!-- httpupgrade -->
<template v-if="inbound.stream.network === 'httpupgrade'">
- {{template "form/streamHTTPUpgrade"}}
+ {{template "form/streamHTTPUpgrade" .}}
</template>
<!-- xhttp -->
<template v-if="inbound.stream.network === 'xhttp'">
- {{template "form/streamXHTTP"}}
+ {{template "form/streamXHTTP" .}}
</template>
<!-- sockopt -->
-<template> {{template "form/streamSockopt"}} </template>
+<template> {{template "form/streamSockopt" .}} </template>
<!-- finalmask -->
-<template> {{template "form/streamFinalMask"}} </template>
+<template> {{template "form/streamFinalMask" .}} </template>
{{end}} \ No newline at end of file
diff --git a/web/html/form/tls_settings.html b/web/html/form/tls_settings.html
index 26c67aa3..c34a6b2b 100644
--- a/web/html/form/tls_settings.html
+++ b/web/html/form/tls_settings.html
@@ -132,7 +132,7 @@
<!-- reality settings -->
<template v-if="inbound.stream.isReality">
- {{template "form/realitySettings"}}
+ {{template "form/realitySettings" .}}
</template>
</a-form>
{{end}} \ No newline at end of file
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index d46c6b49..3265a742 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -645,7 +645,7 @@
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
- {{template "component/aClientTable"}}
+ {{template "component/aClientTable" .}}
</a-table>
</template>
</a-table>
@@ -668,13 +668,13 @@
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
{{template "component/aPersianDatepicker" .}}
-{{template "modals/inboundModal"}}
-{{template "modals/promptModal"}}
-{{template "modals/qrcodeModal"}}
-{{template "modals/textModal"}}
-{{template "modals/inboundInfoModal"}}
-{{template "modals/clientsModal"}}
-{{template "modals/clientsBulkModal"}}
+{{template "modals/inboundModal" .}}
+{{template "modals/promptModal" .}}
+{{template "modals/qrcodeModal" .}}
+{{template "modals/textModal" .}}
+{{template "modals/inboundInfoModal" .}}
+{{template "modals/clientsModal" .}}
+{{template "modals/clientsBulkModal" .}}
<a-modal id="copy-clients-modal" :title="copyClientsModal.title" :visible="copyClientsModal.visible"
:confirm-loading="copyClientsModal.confirmLoading" ok-text='{{ i18n "pages.client.copySelected" }}'
cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme" :closable="true" :mask-closable="false"
diff --git a/web/html/index.html b/web/html/index.html
index ee5109ea..60608315 100644
--- a/web/html/index.html
+++ b/web/html/index.html
@@ -564,7 +564,7 @@
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
-{{template "modals/textModal"}}
+{{template "modals/textModal" .}}
<script>
// Tiny Sparkline component using an inline SVG polyline
Vue.component('sparkline', {
@@ -963,6 +963,18 @@
},
};
+ const escapeHtml = (value) => {
+ if (value === null || value === undefined) {
+ return '';
+ }
+ return String(value)
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+ };
+
const logModal = {
visible: false,
logs: [],
@@ -986,24 +998,28 @@
if (index > 0) formattedLogs += '<br>';
if (parts.length === 3) {
- const d = parts[0];
- const t = parts[1];
- const level = parts[2];
- const levelIndex = levels.indexOf(level, levels) || 5;
+ const d = escapeHtml(parts[0]);
+ const t = escapeHtml(parts[1]);
+ const levelRaw = parts[2];
+ const level = escapeHtml(levelRaw);
+ const idx = levels.indexOf(levelRaw);
+ const levelIndex = idx >= 0 ? idx : 5;
//formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
} else {
- const levelIndex = levels.indexOf(data, levels) || 5;
- formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
+ const idx = levels.indexOf(data);
+ const levelIndex = idx >= 0 ? idx : 5;
+ formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${escapeHtml(data)}</span>`;
}
if (message) {
- if (message.startsWith("XRAY:"))
- message = "<b>XRAY: </b>" + message.substring(5);
- else
- message = "<b>X-UI: </b>" + message;
+ if (message.startsWith("XRAY:")) {
+ message = "<b>XRAY: </b>" + escapeHtml(message.substring(5));
+ } else {
+ message = "<b>X-UI: </b>" + escapeHtml(message);
+ }
}
formattedLogs += message ? ' - ' + message : '';
@@ -1063,16 +1079,16 @@
let text = ``;
if (log.Email !== "") {
- text = `<td>${log.Email}</td>`;
+ text = `<td>${escapeHtml(log.Email)}</td>`;
}
formattedLogs += `
<tr ${outboundColor}>
- <td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
- <td>${log.FromAddress}</td>
- <td>${log.ToAddress}</td>
- <td>${log.Inbound}</td>
- <td>${log.Outbound}</td>
+ <td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>
+ <td>${escapeHtml(log.FromAddress)}</td>
+ <td>${escapeHtml(log.ToAddress)}</td>
+ <td>${escapeHtml(log.Inbound)}</td>
+ <td>${escapeHtml(log.Outbound)}</td>
${text}
</tr>
`;
diff --git a/web/html/login.html b/web/html/login.html
index 78bffd30..2e03a4c5 100644
--- a/web/html/login.html
+++ b/web/html/login.html
@@ -150,7 +150,11 @@
},
initHeadline() {
const animationDelay = 2000;
- const headlines = this.$el.querySelectorAll('.headline');
+ const rootEl = this.$el instanceof Element ? this.$el : document.getElementById('app');
+ if (!rootEl || typeof rootEl.querySelectorAll !== 'function') {
+ return;
+ }
+ const headlines = rootEl.querySelectorAll('.headline');
headlines.forEach((headline) => {
const first = headline.querySelector('.is-visible');
if (!first) return;
diff --git a/web/html/modals/client_modal.html b/web/html/modals/client_modal.html
index f66c01e6..65a481f6 100644
--- a/web/html/modals/client_modal.html
+++ b/web/html/modals/client_modal.html
@@ -7,7 +7,7 @@
:style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account
is (Expired|Traffic Ended) And Disabled</a-tag>
</template>
- {{template "form/client"}}
+ {{template "form/client" .}}
</a-modal>
<script>
const clientModal = {
diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html
index 73918f60..ca896096 100644
--- a/web/html/modals/inbound_modal.html
+++ b/web/html/modals/inbound_modal.html
@@ -2,7 +2,7 @@
<a-modal id="inbound-modal" v-model="inModal.visible" :title="inModal.title" :dialog-style="{ top: '20px' }"
@ok="inModal.ok" :confirm-loading="inModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme" :ok-text="inModal.okText" cancel-text='{{ i18n "close" }}'>
- {{template "form/inbound"}}
+ {{template "form/inbound" .}}
</a-modal>
<script>
// Make inModal globally available to ensure it works with any base path
diff --git a/web/html/modals/xray_outbound_modal.html b/web/html/modals/xray_outbound_modal.html
index 89005069..eb536be3 100644
--- a/web/html/modals/xray_outbound_modal.html
+++ b/web/html/modals/xray_outbound_modal.html
@@ -3,7 +3,7 @@
:confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-button-props="{ props: { disabled: !outModal.isValid } }" :style="{ overflow: 'hidden' }"
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
- {{template "form/outbound"}}
+ {{template "form/outbound" .}}
</a-modal>
<script>
const outModal = {
diff --git a/web/html/settings.html b/web/html/settings.html
index 3295e2a0..22645b3a 100644
--- a/web/html/settings.html
+++ b/web/html/settings.html
@@ -103,7 +103,7 @@
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aSettingListItem" .}}
-{{template "modals/twoFactorModal"}}
+{{template "modals/twoFactorModal" .}}
<script>
const app = new Vue({
delimiters: ['[[', ']]'],
diff --git a/web/html/xray.html b/web/html/xray.html
index e229350d..97876361 100644
--- a/web/html/xray.html
+++ b/web/html/xray.html
@@ -133,15 +133,15 @@
{{template "component/aThemeSwitch" .}}
{{template "component/aTableSortable" .}}
{{template "component/aSettingListItem" .}}
-{{template "modals/ruleModal"}}
-{{template "modals/outModal"}}
-{{template "modals/reverseModal"}}
-{{template "modals/balancerModal"}}
-{{template "modals/dnsModal"}}
-{{template "modals/dnsPresetsModal"}}
-{{template "modals/fakednsModal"}}
-{{template "modals/warpModal"}}
-{{template "modals/nordModal"}}
+{{template "modals/ruleModal" .}}
+{{template "modals/outModal" .}}
+{{template "modals/reverseModal" .}}
+{{template "modals/balancerModal" .}}
+{{template "modals/dnsModal" .}}
+{{template "modals/dnsPresetsModal" .}}
+{{template "modals/fakednsModal" .}}
+{{template "modals/warpModal" .}}
+{{template "modals/nordModal" .}}
<script>
const rulesColumns = [{
title: "#",