Update On Thu Jan 8 19:40:22 CET 2026

This commit is contained in:
github-action[bot]
2026-01-08 19:40:23 +01:00
parent 7b54ccb3c3
commit c8ff5e89ef
87 changed files with 3011 additions and 636 deletions
+1
View File
@@ -1236,3 +1236,4 @@ Update On Sun Jan 4 19:40:49 CET 2026
Update On Mon Jan 5 19:44:37 CET 2026
Update On Tue Jan 6 19:44:08 CET 2026
Update On Wed Jan 7 19:44:04 CET 2026
Update On Thu Jan 8 19:40:15 CET 2026
+32 -44
View File
@@ -2,14 +2,12 @@ package logic
import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/rules/common"
list "github.com/bahlo/generic-list-go"
)
type Logic struct {
@@ -70,7 +68,6 @@ func NewAND(payload string, adapter string, parseRule common.ParseRuleFunc) (*Lo
type Range struct {
start int
end int
index int
}
func (r Range) containRange(preStart, preEnd int) bool {
@@ -89,40 +86,35 @@ func (logic *Logic) payloadToRule(subPayload string, parseRule common.ParseRuleF
}
func (logic *Logic) format(payload string) ([]Range, error) {
stack := list.New[Range]()
num := 0
stack := make([]int, 0)
subRanges := make([]Range, 0)
for i, c := range payload {
if c == '(' {
sr := Range{
start: i,
index: num,
}
num++
stack.PushBack(sr)
stack = append(stack, i) // push
} else if c == ')' {
if stack.Len() == 0 {
if len(stack) == 0 {
return nil, fmt.Errorf("missing '('")
}
sr := stack.Back()
stack.Remove(sr)
sr.Value.end = i
subRanges = append(subRanges, sr.Value)
back := len(stack) - 1
start := stack[back] // back
stack = stack[:back] // pop
subRanges = append(subRanges, Range{
start: start,
end: i,
})
}
}
if stack.Len() != 0 {
if len(stack) != 0 {
return nil, fmt.Errorf("format error is missing )")
}
sortResult := make([]Range, len(subRanges))
for _, sr := range subRanges {
sortResult[sr.index] = sr
}
sort.Slice(subRanges, func(i, j int) bool {
return subRanges[i].start < subRanges[j].start
})
return sortResult, nil
return subRanges, nil
}
func (logic *Logic) findSubRuleRange(payload string, ruleRanges []Range) []Range {
@@ -152,36 +144,32 @@ func (logic *Logic) findSubRuleRange(payload string, ruleRanges []Range) []Range
}
func (logic *Logic) parsePayload(payload string, parseRule common.ParseRuleFunc) error {
regex, err := regexp.Compile("\\(.*\\)")
if !strings.HasPrefix(payload, "(") || !strings.HasSuffix(payload, ")") { // the payload must be "(xxx)" format
return fmt.Errorf("payload format error")
}
subAllRanges, err := logic.format(payload)
if err != nil {
return err
}
if regex.MatchString(payload) {
subAllRanges, err := logic.format(payload)
rules := make([]C.Rule, 0, len(subAllRanges))
subRanges := logic.findSubRuleRange(payload, subAllRanges)
for _, subRange := range subRanges {
subPayload := payload[subRange.start+1 : subRange.end]
rule, err := logic.payloadToRule(subPayload, parseRule)
if err != nil {
return err
}
rules := make([]C.Rule, 0, len(subAllRanges))
subRanges := logic.findSubRuleRange(payload, subAllRanges)
for _, subRange := range subRanges {
subPayload := payload[subRange.start+1 : subRange.end]
rule, err := logic.payloadToRule(subPayload, parseRule)
if err != nil {
return err
}
rules = append(rules, rule)
}
logic.rules = rules
return nil
rules = append(rules, rule)
}
return fmt.Errorf("payload format error")
logic.rules = rules
return nil
}
func (logic *Logic) RuleType() C.RuleType {
+9 -1
View File
@@ -1,13 +1,15 @@
package logic_test
import (
"testing"
// https://github.com/golang/go/wiki/CodeReviewComments#import-dot
. "github.com/metacubex/mihomo/rules/logic"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/rules"
"github.com/stretchr/testify/assert"
"testing"
)
var ParseRule = rules.ParseRule
@@ -38,6 +40,12 @@ func TestNOT(t *testing.T) {
}, C.RuleMatchHelper{})
assert.Equal(t, false, m)
_, err = NewNOT("(DST-PORT,5600-6666)", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
_, err = NewNOT("DST-PORT,5600-6666", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
_, err = NewNOT("((DST-PORT,5600-6666),(DOMAIN,baidu.com))", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
+1 -1
View File
@@ -11,7 +11,7 @@ include $(INCLUDE_DIR)/kernel.mk
PKG_NAME:=cryptodev-linux
PKG_VERSION:=1.14
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_SOURCE_URL:=https://codeload.github.com/$(PKG_NAME)/$(PKG_NAME)/tar.gz/$(PKG_NAME)-$(PKG_VERSION)?
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
@@ -25,7 +25,7 @@ Signed-off-by: Joan Bruguera Micó <joanbrugueram@gmail.com>
--- a/ioctl.c
+++ b/ioctl.c
@@ -1239,7 +1239,9 @@ static struct ctl_table verbosity_ctl_dir[] = {
@@ -1239,7 +1239,9 @@ static struct ctl_table verbosity_ctl_di
.mode = 0644,
.proc_handler = proc_dointvec,
},
@@ -0,0 +1,49 @@
From 08644db02d43478f802755903212f5ee506af73b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joan=20Bruguera=20Mic=C3=B3?= <joanbrugueram@gmail.com>
Date: Sat, 6 Sep 2025 20:36:38 +0000
Subject: [PATCH] Fix build for Linux 6.18-rc1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
It's no longer required to use nth_page() when iterating pages within a
single scatterlist entry.
Note I believe this code path in `sg_advance` is currently unreachable:
It is only called from `get_userbuf_srtp`, passing in a scatterlist
copied from one created by `__get_userbuf`, which only generates
entries such that `sg->offset + sg->length <= PAGE_SIZE`.
On the other hand, this code path in `sg_advance` requires that
`sg->offset + sg->length > sg->offset + consumed >= PAGE_SIZE`.
See also: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=f8f03eb5f0f91fddc9bb8563c7e82bd7d3ba1dd0
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=ce00897b94bc5c62fab962625efcf1ab824d3688
Signed-off-by: Joan Bruguera Micó <joanbrugueram@gmail.com>
---
util.c | 5 +++++
1 file changed, 5 insertions(+)
--- a/util.c
+++ b/util.c
@@ -21,6 +21,7 @@
#include <crypto/scatterwalk.h>
#include <linux/scatterlist.h>
+#include <linux/version.h>
#include "util.h"
/* These were taken from Maxim Levitsky's patch to lkml.
@@ -44,8 +45,12 @@ struct scatterlist *sg_advance(struct sc
sg->length -= consumed;
if (sg->offset >= PAGE_SIZE) {
+#if (LINUX_VERSION_CODE >= KERNEL_VERSION(6, 18, 0))
+ struct page *page = sg_page(sg) + (sg->offset / PAGE_SIZE);
+#else
struct page *page =
nth_page(sg_page(sg), sg->offset / PAGE_SIZE);
+#endif
sg_set_page(sg, page, sg->length, sg->offset % PAGE_SIZE);
}
+32 -44
View File
@@ -2,14 +2,12 @@ package logic
import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/rules/common"
list "github.com/bahlo/generic-list-go"
)
type Logic struct {
@@ -70,7 +68,6 @@ func NewAND(payload string, adapter string, parseRule common.ParseRuleFunc) (*Lo
type Range struct {
start int
end int
index int
}
func (r Range) containRange(preStart, preEnd int) bool {
@@ -89,40 +86,35 @@ func (logic *Logic) payloadToRule(subPayload string, parseRule common.ParseRuleF
}
func (logic *Logic) format(payload string) ([]Range, error) {
stack := list.New[Range]()
num := 0
stack := make([]int, 0)
subRanges := make([]Range, 0)
for i, c := range payload {
if c == '(' {
sr := Range{
start: i,
index: num,
}
num++
stack.PushBack(sr)
stack = append(stack, i) // push
} else if c == ')' {
if stack.Len() == 0 {
if len(stack) == 0 {
return nil, fmt.Errorf("missing '('")
}
sr := stack.Back()
stack.Remove(sr)
sr.Value.end = i
subRanges = append(subRanges, sr.Value)
back := len(stack) - 1
start := stack[back] // back
stack = stack[:back] // pop
subRanges = append(subRanges, Range{
start: start,
end: i,
})
}
}
if stack.Len() != 0 {
if len(stack) != 0 {
return nil, fmt.Errorf("format error is missing )")
}
sortResult := make([]Range, len(subRanges))
for _, sr := range subRanges {
sortResult[sr.index] = sr
}
sort.Slice(subRanges, func(i, j int) bool {
return subRanges[i].start < subRanges[j].start
})
return sortResult, nil
return subRanges, nil
}
func (logic *Logic) findSubRuleRange(payload string, ruleRanges []Range) []Range {
@@ -152,36 +144,32 @@ func (logic *Logic) findSubRuleRange(payload string, ruleRanges []Range) []Range
}
func (logic *Logic) parsePayload(payload string, parseRule common.ParseRuleFunc) error {
regex, err := regexp.Compile("\\(.*\\)")
if !strings.HasPrefix(payload, "(") || !strings.HasSuffix(payload, ")") { // the payload must be "(xxx)" format
return fmt.Errorf("payload format error")
}
subAllRanges, err := logic.format(payload)
if err != nil {
return err
}
if regex.MatchString(payload) {
subAllRanges, err := logic.format(payload)
rules := make([]C.Rule, 0, len(subAllRanges))
subRanges := logic.findSubRuleRange(payload, subAllRanges)
for _, subRange := range subRanges {
subPayload := payload[subRange.start+1 : subRange.end]
rule, err := logic.payloadToRule(subPayload, parseRule)
if err != nil {
return err
}
rules := make([]C.Rule, 0, len(subAllRanges))
subRanges := logic.findSubRuleRange(payload, subAllRanges)
for _, subRange := range subRanges {
subPayload := payload[subRange.start+1 : subRange.end]
rule, err := logic.payloadToRule(subPayload, parseRule)
if err != nil {
return err
}
rules = append(rules, rule)
}
logic.rules = rules
return nil
rules = append(rules, rule)
}
return fmt.Errorf("payload format error")
logic.rules = rules
return nil
}
func (logic *Logic) RuleType() C.RuleType {
+9 -1
View File
@@ -1,13 +1,15 @@
package logic_test
import (
"testing"
// https://github.com/golang/go/wiki/CodeReviewComments#import-dot
. "github.com/metacubex/mihomo/rules/logic"
C "github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/rules"
"github.com/stretchr/testify/assert"
"testing"
)
var ParseRule = rules.ParseRule
@@ -38,6 +40,12 @@ func TestNOT(t *testing.T) {
}, C.RuleMatchHelper{})
assert.Equal(t, false, m)
_, err = NewNOT("(DST-PORT,5600-6666)", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
_, err = NewNOT("DST-PORT,5600-6666", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
_, err = NewNOT("((DST-PORT,5600-6666),(DOMAIN,baidu.com))", "DIRECT", ParseRule)
assert.NotEqual(t, nil, err)
@@ -333,7 +333,7 @@
span.style.color = "#007bff";
span.style.fontWeight = "600";
} else {
span.style.color = "#666";
span.style.color = "";
span.style.fontWeight = "normal";
}
}
@@ -590,7 +590,7 @@
<span class="lv-arrow-right" id="arrow-${cbid}-${lv_idSafe(gname)}"></span>
<b style="margin-left:6px;">${lv_escape_html(gname)}</b>
<span id="group-count-${cbid}-${lv_idSafe(gname)}"
style="margin-left:8px;color:#666;">(0/${items.length})</span>
style="margin-left:8px;">(0/${items.length})</span>
</div>
<ul id="group-${cbid}-${lv_idSafe(gname)}" class="lv-group-list" style="display:none">
`;
@@ -64,12 +64,15 @@ end
%>
<div id="<%=cbid%>" class="cbi-input-select" style="display:inline-block;">
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
<option value>placeholder</option>
</select>
<!-- 搜索框 -->
<input type="text" id="<%=cbid%>.search"
class="mv_search_input cbi-input-text"
placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
<!-- 主容器 -->
<div class="mv_list_container">
<div class="mv_list_container" id="<%=cbid%>.panel">
<ul class="cbi-multi mv_node_list" id="<%=cbid%>.node_list">
<% for _, gname in ipairs(group_order) do local items = groups[gname] %>
<li class="group-block" data-group="<%=idSafe(gname)%>">
@@ -85,7 +88,7 @@ end
end
end
%>
<span id="group-count-<%=self.option%>-<%=idSafe(gname)%>" style="margin-left:8px;color:#007bff;">
<span id="group-count-<%=self.option%>-<%=idSafe(gname)%>" style="margin-left:8px;">
(<%=g_selected%>/<%=#items%>)
</span>
</div>
@@ -58,7 +58,6 @@
align-items: center;
padding: 6px;
margin-bottom: 4px;
background: #f0f0f0;
border-radius: 4px;
white-space: nowrap;
}
@@ -100,6 +99,140 @@
<script type="text/javascript">
//<![CDATA[
// css helper functions
function mv_camelToKebab(str) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
}
function mv_style2Css(styleDeclaration, properties) {
const cssRules = properties.map(prop => {
const kebabCaseProp = mv_camelToKebab(prop);[1, 5]
const value = styleDeclaration[prop]
if (value) {
return `${kebabCaseProp}: ${value};`
}
return ''
})
// Filter out any empty strings and join the rules
return cssRules.filter(Boolean).join(' ')
}
function mv_parseColorToRgba(color) {
if (!color) return null;
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return null;
ctx.fillStyle = color;
const computedColor = ctx.fillStyle;
// Match #RRGGBB or #RRGGBBAA format
if (computedColor.startsWith('#')) {
const hex = computedColor.substring(1);
// #RRGGBB (6 digits)
if (hex.length === 6) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
a: 1
};
}
// #RRGGBBAA (8 digits)
if (hex.length === 8) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
a: parseInt(hex.substring(6, 8), 16) / 255
};
}
}
// Match rgba() or rgb() format (for browsers that return this format)
const rgbaMatch = computedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
if (rgbaMatch) {
return {
r: parseInt(rgbaMatch[1], 10),
g: parseInt(rgbaMatch[2], 10),
b: parseInt(rgbaMatch[3], 10),
a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1
};
}
return null; // Invalid color
}
// Helper to convert back to Hex (for output consistency)
function mv_rgbToHex(r, g, b) {
const toHex = (n) => {
const hex = Math.max(0, Math.min(255, n)).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function mv_isTransparent(color) {
const cleanColor = mv_parseColorToRgba(color);
// check #RRGGBBAA for transparency
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
}
function mv_getColorSchema(color) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return 'unknown'; // Handle invalid colors
// Calculate YIQ brightness (human eye perception)
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
return brightness > 128 ? 'light' : 'dark';
}
function mv_lighter(color, amount) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return color;
// Add amount to each channel
const r = rgb.r + amount;
const g = rgb.g + amount;
const b = rgb.b + amount;
// Convert back to Hex (clamping happens inside rgbToHex)
return mv_rgbToHex(r, g, b);
}
function mv_darker(color, amount) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return color;
// Subtract amount from each channel
const r = rgb.r - amount;
const g = rgb.g - amount;
const b = rgb.b - amount;
return mv_rgbToHex(r, g, b);
}
// copy select styles
function mv_adaptiveStyle(cbid) {
const mainDiv = document.getElementById(cbid);
const hiddenRef = document.getElementById(cbid + ".ref");
const panel = document.getElementById(cbid + ".panel");
if (hiddenRef && mainDiv) {
const elOption = hiddenRef.getElementsByTagName("option")[0]
const styleSelect = window.getComputedStyle(hiddenRef)
const styleOption = window.getComputedStyle(elOption)
const styleBody = window.getComputedStyle(document.body)
const styleNode = document.createElement('style')
const styleNames = ["width", "color", "height", "margin", "lineHeight", "borderRadius", "minWidth", "minHeight"]
document.head.appendChild(styleNode)
// trace back from option -> select -> body for background color
const panelRadius = styleSelect.borderRadius;
const optionColor = !mv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !mv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
const titleColor = mv_getColorSchema(optionColor) === "light" ? mv_darker(optionColor, 30) : mv_lighter(optionColor, 30)
const selectStyleCSS = [`#${CSS.escape(cbid)} {`, mv_style2Css(styleSelect, styleNames), "}"]
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, mv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .group-title {`, `background-color: ${titleColor} !important;`, "}"]
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
}
}
function mv_idSafe(id) {
return id
.trim()
@@ -323,6 +456,23 @@
root.dataset.rendered = "1";
mv_render_multivalue_list(cbid, opt, nodeList, searchInput)
});
mv_registerAdaptive(cbid);
}
const mv_adaptiveControls = new Set();
function mv_registerAdaptive(cbid) {
mv_adaptiveControls.add(cbid);
mv_adaptiveStyle(cbid);
}
let mv_adaptiveTicking = false;
window.addEventListener("resize", () => {
if (!mv_adaptiveTicking) {
mv_adaptiveTicking = true;
requestAnimationFrame(() => {
mv_adaptiveControls.forEach(cbid => mv_adaptiveStyle(cbid));
mv_adaptiveTicking = false;
});
}
});
//]]>
</script>
@@ -320,7 +320,7 @@
span.style.color = "#007bff";
span.style.fontWeight = "600";
} else {
span.style.color = "#666";
span.style.color = "";
span.style.fontWeight = "normal";
}
}
@@ -620,7 +620,7 @@
<span class="v-arrow-right" id="arrow-${cbid}-${v_idSafe(gname)}"></span>
<b style="margin-left:6px;">${v_escape_html(gname)}</b>
<span id="group-count-${cbid}-${v_idSafe(gname)}"
style="margin-left:8px;color:#666;">(0/${items.length})</span>
style="margin-left:8px;">(0/${items.length})</span>
</div>
<ul id="group-${cbid}-${v_idSafe(gname)}" class="v-group-list" style="display:none">
`;
@@ -88,11 +88,28 @@ local api = require "luci.passwall.api"
margin-top: 15px;
margin-bottom: 30px;
}
.up-modal h3 {
background: inherit;
}
</style>
<script>
function getBg(el) {
if (!el) return null;
const style = getComputedStyle(el);
const bgImage = style.backgroundImage;
const bgColor = style.backgroundColor;
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
? style.background
: null;
};
function show_upload_win(btn) {
document.getElementById("upload-modal").style.display = "block";
const uploadDiv = document.getElementById("upload-modal");
uploadDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
uploadDiv.style.display = "block";
document.getElementById("ulfile").focus();
}
function close_upload_win(btn) {
@@ -40,10 +40,21 @@ local api = require "luci.passwall.api"
sendNextChunk();
}
function getBg(el) {
if (!el) return null;
const style = getComputedStyle(el);
const bgImage = style.backgroundImage;
const bgColor = style.backgroundColor;
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
? style.background
: null;
};
function open_add_link_div() {
document.body.classList.add('modal-open');
document.getElementById('modal-mask').style.display = 'block';
const addLinkDiv = document.getElementById("add_link_div");
addLinkDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
addLinkDiv.style.display = "block";
if (!addLinkDiv._dropdown_inited) {
addLinkDiv._dropdown_inited = true;
@@ -105,6 +116,7 @@ local api = require "luci.passwall.api"
document.body.classList.add('modal-open');
document.getElementById('modal-mask').style.display = 'block';
const reassignGroupDiv = document.getElementById("reassign_group_div");
reassignGroupDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
reassignGroupDiv.style.display = "block";
if (!reassignGroupDiv._dropdown_inited) {
reassignGroupDiv._dropdown_inited = true;
@@ -396,6 +408,11 @@ local api = require "luci.passwall.api"
border-radius: 5px;
}
#add_link_div h3,
#reassign_group_div h3 {
background: inherit;
}
#nodes_link_text {
color: red;
font-size: 14px;
@@ -26,10 +26,6 @@ table td, .table .td {
box-shadow: darkgrey 10px 10px 30px 5px;
}
._now_use {
color: red !important;
}
._now_use_bg {
background: #5e72e445 !important;
}
@@ -493,9 +489,9 @@ table td, .table .td {
dom.classList.add("_now_use_bg");
//var v = "<a style='color: red'>当前TCP节点:</a>" + document.getElementById("cbid.passwall." + id + ".remarks").value;
//document.getElementById("cbi-passwall-" + id + "-remarks").innerHTML = v;
var dom_remarks = document.getElementById("cbi-passwall-" + id + "-remarks");
var dom_remarks = dom.querySelector("td.pw-remark");
if (dom_remarks) {
dom_remarks.classList.add("_now_use");
dom_remarks.style.color = 'red';
}
}
}
@@ -509,9 +505,9 @@ table td, .table .td {
dom.title = '<%=api.i18n.translatef("Currently using %s node", "UDP")%>';
}
dom.classList.add("_now_use_bg");
var dom_remarks = document.getElementById("cbi-passwall-" + id + "-remarks");
var dom_remarks = dom.querySelector("td.pw-remark");
if (dom_remarks) {
dom_remarks.classList.add("_now_use");
dom_remarks.style.color = 'red';
}
}
}
+2 -2
View File
@@ -1860,9 +1860,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.179"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libloading"
+4
View File
@@ -2,6 +2,10 @@
icon: material/alert-decagram
---
#### 1.13.0-beta.2
* Fixes and improvements
#### 1.13.0-beta.1
* Add system interface support for Tailscale endpoint **1**
+4 -3
View File
@@ -24,9 +24,10 @@ type PlatformInterface interface {
}
type ConnectionOwner struct {
UserId int32
UserName string
ProcessPath string
UserId int32
UserName string
ProcessPath string
AndroidPackageName string
}
type InterfaceUpdateListener interface {
+4 -3
View File
@@ -198,9 +198,10 @@ func (w *platformInterfaceWrapper) FindConnectionOwner(request *adapter.FindConn
return nil, err
}
return &adapter.ConnectionOwner{
UserId: result.UserId,
UserName: result.UserName,
ProcessPath: result.ProcessPath,
UserId: result.UserId,
UserName: result.UserName,
ProcessPath: result.ProcessPath,
AndroidPackageName: result.AndroidPackageName,
}, nil
}
+2 -2
View File
@@ -71,11 +71,11 @@ func Version() string {
}
func FormatBytes(length int64) string {
return byteformats.FormatBytes(uint64(length))
return byteformats.FormatKBytes(uint64(length))
}
func FormatMemoryBytes(length int64) string {
return byteformats.FormatMemoryBytes(uint64(length))
return byteformats.FormatMemoryKBytes(uint64(length))
}
func FormatDuration(duration int64) string {
+2 -2
View File
@@ -32,7 +32,7 @@ require (
github.com/sagernet/gomobile v0.1.11
github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1
github.com/sagernet/quic-go v0.58.0-sing-box-mod.1
github.com/sagernet/sing v0.8.0-beta.8
github.com/sagernet/sing v0.8.0-beta.9
github.com/sagernet/sing-mux v0.3.3
github.com/sagernet/sing-quic v0.6.0-beta.8
github.com/sagernet/sing-shadowsocks v0.2.8
@@ -41,7 +41,7 @@ require (
github.com/sagernet/sing-tun v0.8.0-beta.11.0.20260107060547-525f783d005b
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
github.com/sagernet/smux v1.5.34-mod.2
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.5
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
github.com/spf13/cobra v1.10.2
+4 -4
View File
@@ -210,8 +210,8 @@ github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/l
github.com/sagernet/quic-go v0.58.0-sing-box-mod.1 h1:E9yZrU0ZxSiW5RrGUnFZeI02EIMdAAv0RxdoxXCqZyk=
github.com/sagernet/quic-go v0.58.0-sing-box-mod.1/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4=
github.com/sagernet/sing v0.6.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.0-beta.8 h1:hUo0wZ2HGTieV1flEIai96HFhF34mMHVnduRqJHQvxg=
github.com/sagernet/sing v0.8.0-beta.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.0-beta.9 h1:LTFjTYiEr6A5NZ04tbrjWLb0T7unSmEJX2ksJkfI1lY=
github.com/sagernet/sing v0.8.0-beta.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-mux v0.3.3 h1:YFgt9plMWzH994BMZLmyKL37PdIVaIilwP0Jg+EcLfw=
github.com/sagernet/sing-mux v0.3.3/go.mod h1:pht8iFY4c9Xltj7rhVd208npkNaeCxzyXCgulDPLUDA=
github.com/sagernet/sing-quic v0.6.0-beta.8 h1:Y0P8WTqWpfg80rLFsDfF22QumM+HEAjRQ2o+8Dv+vDs=
@@ -228,8 +228,8 @@ github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkV
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4=
github.com/sagernet/smux v1.5.34-mod.2/go.mod h1:0KW0+R+ycvA2INW4gbsd7BNyg+HEfLIAxa5N02/28Zc=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.5 h1:nn9or1e5sTDXay/dfsB4E/A4jYaYdPVCXV8mME/maEc=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.5/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6 h1:eYz/OpMqWCvO2++iw3dEuzrlfC2xv78GdlGvprIM6O8=
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.6/go.mod h1:m87GAn4UcesHQF3leaPFEINZETO5za1LGn1GJdNDgNc=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288 h1:E2tZFeg9mGYGQ7E7BbxMv1cU35HxwgRm6tPKI2Pp7DA=
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20250917110311-16510ac47288/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
@@ -117,8 +117,8 @@ const methods = {
fd.close();
}
features.app_version = trim(popen(`${use_apk ? 'apk list -I' : 'opkg list-installed'} luci-app-fchomo | ` +
`awk '{print $${use_apk ? '1' : 'NF'}}' | sed 's|luci-app-fchomo-||'`).read('all')) || null; // @less_25_12
features.app_version = trim(popen(`${use_apk ? 'apk list -I --manifest' : 'opkg list-installed'} luci-app-fchomo | ` +
`awk '{print $NF}'`).read('all')) || null; // @less_25_12
features.has_dnsmasq_full = system(`[ -n "$(${use_apk ? 'apk list -qI' : 'opkg list-installed'} dnsmasq-full)" ]`) == 0 || null; // @less_25_12
features.has_ip_full = access('/usr/libexec/ip-full');
@@ -333,7 +333,7 @@
span.style.color = "#007bff";
span.style.fontWeight = "600";
} else {
span.style.color = "#666";
span.style.color = "";
span.style.fontWeight = "normal";
}
}
@@ -590,7 +590,7 @@
<span class="lv-arrow-right" id="arrow-${cbid}-${lv_idSafe(gname)}"></span>
<b style="margin-left:6px;">${lv_escape_html(gname)}</b>
<span id="group-count-${cbid}-${lv_idSafe(gname)}"
style="margin-left:8px;color:#666;">(0/${items.length})</span>
style="margin-left:8px;">(0/${items.length})</span>
</div>
<ul id="group-${cbid}-${lv_idSafe(gname)}" class="lv-group-list" style="display:none">
`;
@@ -64,12 +64,15 @@ end
%>
<div id="<%=cbid%>" class="cbi-input-select" style="display:inline-block;">
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
<option value>placeholder</option>
</select>
<!-- 搜索框 -->
<input type="text" id="<%=cbid%>.search"
class="mv_search_input cbi-input-text"
placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
<!-- 主容器 -->
<div class="mv_list_container">
<div class="mv_list_container" id="<%=cbid%>.panel">
<ul class="cbi-multi mv_node_list" id="<%=cbid%>.node_list">
<% for _, gname in ipairs(group_order) do local items = groups[gname] %>
<li class="group-block" data-group="<%=idSafe(gname)%>">
@@ -85,7 +88,7 @@ end
end
end
%>
<span id="group-count-<%=self.option%>-<%=idSafe(gname)%>" style="margin-left:8px;color:#007bff;">
<span id="group-count-<%=self.option%>-<%=idSafe(gname)%>" style="margin-left:8px;">
(<%=g_selected%>/<%=#items%>)
</span>
</div>
@@ -58,7 +58,6 @@
align-items: center;
padding: 6px;
margin-bottom: 4px;
background: #f0f0f0;
border-radius: 4px;
white-space: nowrap;
}
@@ -100,6 +99,140 @@
<script type="text/javascript">
//<![CDATA[
// css helper functions
function mv_camelToKebab(str) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
}
function mv_style2Css(styleDeclaration, properties) {
const cssRules = properties.map(prop => {
const kebabCaseProp = mv_camelToKebab(prop);[1, 5]
const value = styleDeclaration[prop]
if (value) {
return `${kebabCaseProp}: ${value};`
}
return ''
})
// Filter out any empty strings and join the rules
return cssRules.filter(Boolean).join(' ')
}
function mv_parseColorToRgba(color) {
if (!color) return null;
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return null;
ctx.fillStyle = color;
const computedColor = ctx.fillStyle;
// Match #RRGGBB or #RRGGBBAA format
if (computedColor.startsWith('#')) {
const hex = computedColor.substring(1);
// #RRGGBB (6 digits)
if (hex.length === 6) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
a: 1
};
}
// #RRGGBBAA (8 digits)
if (hex.length === 8) {
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
a: parseInt(hex.substring(6, 8), 16) / 255
};
}
}
// Match rgba() or rgb() format (for browsers that return this format)
const rgbaMatch = computedColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
if (rgbaMatch) {
return {
r: parseInt(rgbaMatch[1], 10),
g: parseInt(rgbaMatch[2], 10),
b: parseInt(rgbaMatch[3], 10),
a: rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1
};
}
return null; // Invalid color
}
// Helper to convert back to Hex (for output consistency)
function mv_rgbToHex(r, g, b) {
const toHex = (n) => {
const hex = Math.max(0, Math.min(255, n)).toString(16)
return hex.length === 1 ? '0' + hex : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function mv_isTransparent(color) {
const cleanColor = mv_parseColorToRgba(color);
// check #RRGGBBAA for transparency
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
}
function mv_getColorSchema(color) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return 'unknown'; // Handle invalid colors
// Calculate YIQ brightness (human eye perception)
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
return brightness > 128 ? 'light' : 'dark';
}
function mv_lighter(color, amount) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return color;
// Add amount to each channel
const r = rgb.r + amount;
const g = rgb.g + amount;
const b = rgb.b + amount;
// Convert back to Hex (clamping happens inside rgbToHex)
return mv_rgbToHex(r, g, b);
}
function mv_darker(color, amount) {
const rgb = mv_parseColorToRgba(color);
if (!rgb) return color;
// Subtract amount from each channel
const r = rgb.r - amount;
const g = rgb.g - amount;
const b = rgb.b - amount;
return mv_rgbToHex(r, g, b);
}
// copy select styles
function mv_adaptiveStyle(cbid) {
const mainDiv = document.getElementById(cbid);
const hiddenRef = document.getElementById(cbid + ".ref");
const panel = document.getElementById(cbid + ".panel");
if (hiddenRef && mainDiv) {
const elOption = hiddenRef.getElementsByTagName("option")[0]
const styleSelect = window.getComputedStyle(hiddenRef)
const styleOption = window.getComputedStyle(elOption)
const styleBody = window.getComputedStyle(document.body)
const styleNode = document.createElement('style')
const styleNames = ["width", "color", "height", "margin", "lineHeight", "borderRadius", "minWidth", "minHeight"]
document.head.appendChild(styleNode)
// trace back from option -> select -> body for background color
const panelRadius = styleSelect.borderRadius;
const optionColor = !mv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !mv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
const titleColor = mv_getColorSchema(optionColor) === "light" ? mv_darker(optionColor, 30) : mv_lighter(optionColor, 30)
const selectStyleCSS = [`#${CSS.escape(cbid)} {`, mv_style2Css(styleSelect, styleNames), "}"]
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, mv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .group-title {`, `background-color: ${titleColor} !important;`, "}"]
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
}
}
function mv_idSafe(id) {
return id
.trim()
@@ -323,6 +456,23 @@
root.dataset.rendered = "1";
mv_render_multivalue_list(cbid, opt, nodeList, searchInput)
});
mv_registerAdaptive(cbid);
}
const mv_adaptiveControls = new Set();
function mv_registerAdaptive(cbid) {
mv_adaptiveControls.add(cbid);
mv_adaptiveStyle(cbid);
}
let mv_adaptiveTicking = false;
window.addEventListener("resize", () => {
if (!mv_adaptiveTicking) {
mv_adaptiveTicking = true;
requestAnimationFrame(() => {
mv_adaptiveControls.forEach(cbid => mv_adaptiveStyle(cbid));
mv_adaptiveTicking = false;
});
}
});
//]]>
</script>
@@ -320,7 +320,7 @@
span.style.color = "#007bff";
span.style.fontWeight = "600";
} else {
span.style.color = "#666";
span.style.color = "";
span.style.fontWeight = "normal";
}
}
@@ -620,7 +620,7 @@
<span class="v-arrow-right" id="arrow-${cbid}-${v_idSafe(gname)}"></span>
<b style="margin-left:6px;">${v_escape_html(gname)}</b>
<span id="group-count-${cbid}-${v_idSafe(gname)}"
style="margin-left:8px;color:#666;">(0/${items.length})</span>
style="margin-left:8px;">(0/${items.length})</span>
</div>
<ul id="group-${cbid}-${v_idSafe(gname)}" class="v-group-list" style="display:none">
`;
@@ -88,11 +88,28 @@ local api = require "luci.passwall.api"
margin-top: 15px;
margin-bottom: 30px;
}
.up-modal h3 {
background: inherit;
}
</style>
<script>
function getBg(el) {
if (!el) return null;
const style = getComputedStyle(el);
const bgImage = style.backgroundImage;
const bgColor = style.backgroundColor;
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
? style.background
: null;
};
function show_upload_win(btn) {
document.getElementById("upload-modal").style.display = "block";
const uploadDiv = document.getElementById("upload-modal");
uploadDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
uploadDiv.style.display = "block";
document.getElementById("ulfile").focus();
}
function close_upload_win(btn) {
@@ -40,10 +40,21 @@ local api = require "luci.passwall.api"
sendNextChunk();
}
function getBg(el) {
if (!el) return null;
const style = getComputedStyle(el);
const bgImage = style.backgroundImage;
const bgColor = style.backgroundColor;
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
? style.background
: null;
};
function open_add_link_div() {
document.body.classList.add('modal-open');
document.getElementById('modal-mask').style.display = 'block';
const addLinkDiv = document.getElementById("add_link_div");
addLinkDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
addLinkDiv.style.display = "block";
if (!addLinkDiv._dropdown_inited) {
addLinkDiv._dropdown_inited = true;
@@ -105,6 +116,7 @@ local api = require "luci.passwall.api"
document.body.classList.add('modal-open');
document.getElementById('modal-mask').style.display = 'block';
const reassignGroupDiv = document.getElementById("reassign_group_div");
reassignGroupDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
reassignGroupDiv.style.display = "block";
if (!reassignGroupDiv._dropdown_inited) {
reassignGroupDiv._dropdown_inited = true;
@@ -396,6 +408,11 @@ local api = require "luci.passwall.api"
border-radius: 5px;
}
#add_link_div h3,
#reassign_group_div h3 {
background: inherit;
}
#nodes_link_text {
color: red;
font-size: 14px;
@@ -26,10 +26,6 @@ table td, .table .td {
box-shadow: darkgrey 10px 10px 30px 5px;
}
._now_use {
color: red !important;
}
._now_use_bg {
background: #5e72e445 !important;
}
@@ -493,9 +489,9 @@ table td, .table .td {
dom.classList.add("_now_use_bg");
//var v = "<a style='color: red'>当前TCP节点:</a>" + document.getElementById("cbid.passwall." + id + ".remarks").value;
//document.getElementById("cbi-passwall-" + id + "-remarks").innerHTML = v;
var dom_remarks = document.getElementById("cbi-passwall-" + id + "-remarks");
var dom_remarks = dom.querySelector("td.pw-remark");
if (dom_remarks) {
dom_remarks.classList.add("_now_use");
dom_remarks.style.color = 'red';
}
}
}
@@ -509,9 +505,9 @@ table td, .table .td {
dom.title = '<%=api.i18n.translatef("Currently using %s node", "UDP")%>';
}
dom.classList.add("_now_use_bg");
var dom_remarks = document.getElementById("cbi-passwall-" + id + "-remarks");
var dom_remarks = dom.querySelector("td.pw-remark");
if (dom_remarks) {
dom_remarks.classList.add("_now_use");
dom_remarks.style.color = 'red';
}
}
}
+2 -2
View File
@@ -21,13 +21,13 @@ define Download/geoip
HASH:=ed2de9add79623e2e5dbc5930ee39cc7037a7c6e0ecd58ba528b6f73d61457b5
endef
GEOSITE_VER:=20260107071356
GEOSITE_VER:=20260108055940
GEOSITE_FILE:=dlc.dat.$(GEOSITE_VER)
define Download/geosite
URL:=https://github.com/v2fly/domain-list-community/releases/download/$(GEOSITE_VER)/
URL_FILE:=dlc.dat
FILE:=$(GEOSITE_FILE)
HASH:=903ff82c7a8d166ce25c9337959c6d9b53c84da367a0474911e8f7cf6d4dae21
HASH:=9dda6870ebbb6cb779811ae7a4b10adfc3831a1607ac8fc43783bb63838cc710
endef
GEOSITE_IRAN_VER:=202601050049
+266 -262
View File
@@ -88,232 +88,235 @@ public class Global
public const string SingboxLocalDNSTag = "local_local";
public const string SingboxHostsDNSTag = "hosts_dns";
public const string SingboxFakeDNSTag = "fake_dns";
public const string SingboxEchDNSTag = "ech_dns";
public static readonly List<string> IEProxyProtocols =
[
"{ip}:{http_port}",
"socks={ip}:{socks_port}",
"http={ip}:{http_port};https={ip}:{http_port};ftp={ip}:{http_port};socks={ip}:{socks_port}",
"http=http://{ip}:{http_port};https=http://{ip}:{http_port}",
""
"socks={ip}:{socks_port}",
"http={ip}:{http_port};https={ip}:{http_port};ftp={ip}:{http_port};socks={ip}:{socks_port}",
"http=http://{ip}:{http_port};https=http://{ip}:{http_port}",
""
];
public static readonly List<string> SubConvertUrls =
[
@"https://sub.xeton.dev/sub?url={0}",
@"https://api.dler.io/sub?url={0}",
@"http://127.0.0.1:25500/sub?url={0}",
""
@"https://api.dler.io/sub?url={0}",
@"http://127.0.0.1:25500/sub?url={0}",
""
];
public static readonly List<string> SubConvertConfig =
[@"https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online.ini"];
[
@"https://raw.githubusercontent.com/ACL4SSR/ACL4SSR/master/Clash/config/ACL4SSR_Online.ini"
];
public static readonly List<string> SubConvertTargets =
[
"",
"mixed",
"v2ray",
"clash",
"ss"
"mixed",
"v2ray",
"clash",
"ss"
];
public static readonly List<string> SpeedTestUrls =
[
@"https://cachefly.cachefly.net/50mb.test",
@"https://speed.cloudflare.com/__down?bytes=10000000",
@"https://speed.cloudflare.com/__down?bytes=50000000",
@"https://speed.cloudflare.com/__down?bytes=100000000",
];
@"https://speed.cloudflare.com/__down?bytes=10000000",
@"https://speed.cloudflare.com/__down?bytes=50000000",
@"https://speed.cloudflare.com/__down?bytes=100000000",
];
public static readonly List<string> SpeedPingTestUrls =
[
@"https://www.google.com/generate_204",
@"https://www.gstatic.com/generate_204",
@"https://www.apple.com/library/test/success.html",
@"http://www.msftconnecttest.com/connecttest.txt"
@"https://www.gstatic.com/generate_204",
@"https://www.apple.com/library/test/success.html",
@"http://www.msftconnecttest.com/connecttest.txt"
];
public static readonly List<string> GeoFilesSources =
[
"",
@"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/{0}.dat",
@"https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/{0}.dat"
@"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/{0}.dat",
@"https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/{0}.dat"
];
public static readonly List<string> SingboxRulesetSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/sing-box/rule-set-{0}/{1}.srs",
@"https://raw.githubusercontent.com/chocolate4u/Iran-sing-box-rules/rule-set/{1}.srs"
];
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/sing-box/rule-set-{0}/{1}.srs",
@"https://raw.githubusercontent.com/chocolate4u/Iran-sing-box-rules/rule-set/{1}.srs"
];
public static readonly List<string> RoutingRulesSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/template.json",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/template.json"
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/template.json",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/template.json"
];
public static readonly List<string> DNSTemplateSources =
[
"",
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/"
@"https://raw.githubusercontent.com/runetfreedom/russia-v2ray-custom-routing-list/main/v2rayN/",
@"https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/main/v2rayN/"
];
public static readonly Dictionary<string, string> UserAgentTexts = new()
{
{"chrome","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36" },
{"firefox","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" },
{"safari","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" },
{"edge","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70" },
{"none",""}
};
{
{"chrome","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36" },
{"firefox","Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0" },
{"safari","Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" },
{"edge","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70" },
{"none",""}
};
public const string Hysteria2ProtocolShare = "hy2://";
public static readonly Dictionary<EConfigType, string> ProtocolShares = new()
{
{ EConfigType.VMess, "vmess://" },
{ EConfigType.Shadowsocks, "ss://" },
{ EConfigType.SOCKS, "socks://" },
{ EConfigType.VLESS, "vless://" },
{ EConfigType.Trojan, "trojan://" },
{ EConfigType.Hysteria2, "hysteria2://" },
{ EConfigType.TUIC, "tuic://" },
{ EConfigType.WireGuard, "wireguard://" },
{ EConfigType.Anytls, "anytls://" }
};
{
{ EConfigType.VMess, "vmess://" },
{ EConfigType.Shadowsocks, "ss://" },
{ EConfigType.SOCKS, "socks://" },
{ EConfigType.VLESS, "vless://" },
{ EConfigType.Trojan, "trojan://" },
{ EConfigType.Hysteria2, "hysteria2://" },
{ EConfigType.TUIC, "tuic://" },
{ EConfigType.WireGuard, "wireguard://" },
{ EConfigType.Anytls, "anytls://" }
};
public static readonly Dictionary<EConfigType, string> ProtocolTypes = new()
{
{ EConfigType.VMess, "vmess" },
{ EConfigType.Shadowsocks, "shadowsocks" },
{ EConfigType.SOCKS, "socks" },
{ EConfigType.HTTP, "http" },
{ EConfigType.VLESS, "vless" },
{ EConfigType.Trojan, "trojan" },
{ EConfigType.Hysteria2, "hysteria2" },
{ EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" }
};
{
{ EConfigType.VMess, "vmess" },
{ EConfigType.Shadowsocks, "shadowsocks" },
{ EConfigType.SOCKS, "socks" },
{ EConfigType.HTTP, "http" },
{ EConfigType.VLESS, "vless" },
{ EConfigType.Trojan, "trojan" },
{ EConfigType.Hysteria2, "hysteria2" },
{ EConfigType.TUIC, "tuic" },
{ EConfigType.WireGuard, "wireguard" },
{ EConfigType.Anytls, "anytls" }
};
public static readonly List<string> VmessSecurities =
[
"aes-128-gcm",
"chacha20-poly1305",
"auto",
"none",
"zero"
"chacha20-poly1305",
"auto",
"none",
"zero"
];
public static readonly List<string> SsSecurities =
[
"aes-256-gcm",
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"none",
"plain"
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"none",
"plain"
];
public static readonly List<string> SsSecuritiesInXray =
[
"aes-256-gcm",
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"xchacha20-poly1305",
"xchacha20-ietf-poly1305",
"none",
"plain",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
"aes-128-gcm",
"chacha20-poly1305",
"chacha20-ietf-poly1305",
"xchacha20-poly1305",
"xchacha20-ietf-poly1305",
"none",
"plain",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305"
];
public static readonly List<string> SsSecuritiesInSingbox =
[
"aes-256-gcm",
"aes-192-gcm",
"aes-128-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"none",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-192-cfb",
"aes-256-cfb",
"rc4-md5",
"chacha20-ietf",
"xchacha20"
"aes-192-gcm",
"aes-128-gcm",
"chacha20-ietf-poly1305",
"xchacha20-ietf-poly1305",
"none",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-192-cfb",
"aes-256-cfb",
"rc4-md5",
"chacha20-ietf",
"xchacha20"
];
public static readonly List<string> Flows =
[
"",
"xtls-rprx-vision",
"xtls-rprx-vision-udp443"
"xtls-rprx-vision",
"xtls-rprx-vision-udp443"
];
public static readonly List<string> Networks =
[
"tcp",
"kcp",
"ws",
"httpupgrade",
"xhttp",
"h2",
"quic",
"grpc"
"kcp",
"ws",
"httpupgrade",
"xhttp",
"h2",
"quic",
"grpc"
];
public static readonly List<string> KcpHeaderTypes =
[
"srtp",
"utp",
"wechat-video",
"dtls",
"wireguard",
"dns"
"utp",
"wechat-video",
"dtls",
"wireguard",
"dns"
];
public static readonly List<string> CoreTypes =
[
"Xray",
"sing_box"
"sing_box"
];
public static readonly HashSet<EConfigType> XraySupportConfigType =
[
EConfigType.VMess,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
];
public static readonly HashSet<EConfigType> SingboxSupportConfigType =
[
EConfigType.VMess,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.TUIC,
EConfigType.Anytls,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
EConfigType.VLESS,
EConfigType.Shadowsocks,
EConfigType.Trojan,
EConfigType.Hysteria2,
EConfigType.TUIC,
EConfigType.Anytls,
EConfigType.WireGuard,
EConfigType.SOCKS,
EConfigType.HTTP,
];
public static readonly HashSet<EConfigType> SingboxOnlyConfigType = SingboxSupportConfigType.Except(XraySupportConfigType).ToHashSet();
@@ -328,129 +331,129 @@ public class Global
public static readonly List<string> DomainStrategies4Singbox =
[
"ipv4_only",
"ipv6_only",
"prefer_ipv4",
"prefer_ipv6",
""
"ipv6_only",
"prefer_ipv4",
"prefer_ipv6",
""
];
public static readonly List<string> Fingerprints =
[
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"randomized",
""
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"randomized",
""
];
public static readonly List<string> UserAgent =
[
"chrome",
"firefox",
"safari",
"edge",
"none"
"firefox",
"safari",
"edge",
"none"
];
public static readonly List<string> XhttpMode =
[
"auto",
"packet-up",
"stream-up",
"stream-one"
"packet-up",
"stream-up",
"stream-one"
];
public static readonly List<string> AllowInsecure =
[
"true",
"false",
""
"false",
""
];
public static readonly List<string> DomainStrategy4Freedoms =
[
"AsIs",
"UseIP",
"UseIPv4",
"UseIPv6",
""
"UseIP",
"UseIPv4",
"UseIPv6",
""
];
public static readonly List<string> SingboxDomainStrategy4Out =
[
"",
"ipv4_only",
"prefer_ipv4",
"prefer_ipv6",
"ipv6_only"
"ipv4_only",
"prefer_ipv4",
"prefer_ipv6",
"ipv6_only"
];
public static readonly List<string> DomainDirectDNSAddress =
[
"https://dns.alidns.com/dns-query",
"https://doh.pub/dns-query",
"223.5.5.5",
"119.29.29.29",
"localhost"
"https://doh.pub/dns-query",
"223.5.5.5",
"119.29.29.29",
"localhost"
];
public static readonly List<string> DomainRemoteDNSAddress =
[
"https://cloudflare-dns.com/dns-query",
"https://dns.cloudflare.com/dns-query",
"https://dns.google/dns-query",
"https://doh.dns.sb/dns-query",
"https://doh.opendns.com/dns-query",
"https://common.dot.dns.yandex.net",
"8.8.8.8",
"1.1.1.1",
"185.222.222.222",
"208.67.222.222",
"77.88.8.8"
"https://dns.cloudflare.com/dns-query",
"https://dns.google/dns-query",
"https://doh.dns.sb/dns-query",
"https://doh.opendns.com/dns-query",
"https://common.dot.dns.yandex.net",
"8.8.8.8",
"1.1.1.1",
"185.222.222.222",
"208.67.222.222",
"77.88.8.8"
];
public static readonly List<string> DomainPureIPDNSAddress =
[
"223.5.5.5",
"119.29.29.29",
"localhost"
"119.29.29.29",
"localhost"
];
public static readonly List<string> Languages =
[
"zh-Hans",
"zh-Hant",
"en",
"fa-Ir",
"fr",
"ru",
"hu"
"zh-Hant",
"en",
"fa-Ir",
"fr",
"ru",
"hu"
];
public static readonly List<string> Alpns =
[
"h3",
"h2",
"http/1.1",
"h3,h2",
"h2,http/1.1",
"h3,h2,http/1.1",
""
"h2",
"http/1.1",
"h3,h2",
"h2,http/1.1",
"h3,h2,http/1.1",
""
];
public static readonly List<string> LogLevels =
[
"debug",
"info",
"warning",
"error",
"none"
"info",
"warning",
"error",
"none"
];
public static readonly Dictionary<string, string> LogLevelColors = new()
@@ -464,32 +467,32 @@ public class Global
public static readonly List<string> InboundTags =
[
"socks",
"socks2",
"socks3"
"socks2",
"socks3"
];
public static readonly List<string> RuleProtocols =
[
"http",
"tls",
"bittorrent"
"tls",
"bittorrent"
];
public static readonly List<string> RuleNetworks =
[
"",
"tcp",
"udp",
"tcp,udp"
"tcp",
"udp",
"tcp,udp"
];
public static readonly List<string> destOverrideProtocols =
[
"http",
"tls",
"quic",
"fakedns",
"fakedns+others"
"tls",
"quic",
"fakedns",
"fakedns+others"
];
public static readonly List<int> TunMtus =
@@ -505,83 +508,83 @@ public class Global
public static readonly List<string> TunStacks =
[
"gvisor",
"system",
"mixed"
"system",
"mixed"
];
public static readonly List<string> PresetMsgFilters =
[
"proxy",
"direct",
"block",
""
"direct",
"block",
""
];
public static readonly List<string> SingboxMuxs =
[
"h2mux",
"smux",
"yamux",
""
"smux",
"yamux",
""
];
public static readonly List<string> TuicCongestionControls =
[
"cubic",
"new_reno",
"bbr"
"new_reno",
"bbr"
];
public static readonly List<string> allowSelectType =
[
"selector",
"urltest",
"loadbalance",
"fallback"
"urltest",
"loadbalance",
"fallback"
];
public static readonly List<string> notAllowTestType =
[
"selector",
"urltest",
"direct",
"reject",
"compatible",
"pass",
"loadbalance",
"fallback"
"urltest",
"direct",
"reject",
"compatible",
"pass",
"loadbalance",
"fallback"
];
public static readonly List<string> proxyVehicleType =
[
"file",
"http"
"http"
];
public static readonly Dictionary<ECoreType, string> CoreUrls = new()
{
{ ECoreType.v2fly, "v2fly/v2ray-core" },
{ ECoreType.v2fly_v5, "v2fly/v2ray-core" },
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
{ ECoreType.mieru, "enfein/mieru" },
{ ECoreType.v2rayN, "2dust/v2rayN" },
};
{
{ ECoreType.v2fly, "v2fly/v2ray-core" },
{ ECoreType.v2fly_v5, "v2fly/v2ray-core" },
{ ECoreType.Xray, "XTLS/Xray-core" },
{ ECoreType.sing_box, "SagerNet/sing-box" },
{ ECoreType.mihomo, "MetaCubeX/mihomo" },
{ ECoreType.hysteria, "apernet/hysteria" },
{ ECoreType.hysteria2, "apernet/hysteria" },
{ ECoreType.naiveproxy, "klzgrad/naiveproxy" },
{ ECoreType.tuic, "EAimTY/tuic" },
{ ECoreType.juicity, "juicity/juicity" },
{ ECoreType.brook, "txthinking/brook" },
{ ECoreType.overtls, "ShadowsocksR-Live/overtls" },
{ ECoreType.shadowquic, "spongebob888/shadowquic" },
{ ECoreType.mieru, "enfein/mieru" },
{ ECoreType.v2rayN, "2dust/v2rayN" },
};
public static readonly List<string> OtherGeoUrls =
[
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat",
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb",
@"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb"
@"https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb",
@"https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb"
];
public static readonly List<string> IPAPIUrls =
@@ -601,36 +604,37 @@ public class Global
];
public static readonly Dictionary<string, List<string>> PredefinedHosts = new()
{
{ "dns.google", new List<string> { "8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844" } },
{ "dns.alidns.com", new List<string> { "223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1" } },
{ "one.one.one.one", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "1dot1dot1dot1.cloudflare-dns.com", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "cloudflare-dns.com", new List<string> { "104.16.249.249", "104.16.248.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9" } },
{ "dns.cloudflare.com", new List<string> { "104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5" } },
{ "dot.pub", new List<string> { "1.12.12.12", "120.53.53.53" } },
{ "doh.pub", new List<string> { "1.12.12.12", "120.53.53.53" } },
{ "dns.quad9.net", new List<string> { "9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9" } },
{ "dns.yandex.net", new List<string> { "77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff" } },
{ "dns.sb", new List<string> { "185.222.222.222", "2a09::" } },
{ "dns.umbrella.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "dns.sse.cisco.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "engage.cloudflareclient.com", new List<string> { "162.159.192.1", "2606:4700:d0::a29f:c001" } }
};
{
{ "dns.google", new List<string> { "8.8.8.8", "8.8.4.4", "2001:4860:4860::8888", "2001:4860:4860::8844" } },
{ "dns.alidns.com", new List<string> { "223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1" } },
{ "one.one.one.one", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "1dot1dot1dot1.cloudflare-dns.com", new List<string> { "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" } },
{ "cloudflare-dns.com", new List<string> { "104.16.249.249", "104.16.248.249", "2606:4700::6810:f8f9", "2606:4700::6810:f9f9" } },
{ "dns.cloudflare.com", new List<string> { "104.16.132.229", "104.16.133.229", "2606:4700::6810:84e5", "2606:4700::6810:85e5" } },
{ "dot.pub", new List<string> { "1.12.12.12", "120.53.53.53" } },
{ "doh.pub", new List<string> { "1.12.12.12", "120.53.53.53" } },
{ "dns.quad9.net", new List<string> { "9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9" } },
{ "dns.yandex.net", new List<string> { "77.88.8.8", "77.88.8.1", "2a02:6b8::feed:0ff", "2a02:6b8:0:1::feed:0ff" } },
{ "dns.sb", new List<string> { "185.222.222.222", "2a09::" } },
{ "dns.umbrella.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "dns.sse.cisco.com", new List<string> { "208.67.220.220", "208.67.222.222", "2620:119:35::35", "2620:119:53::53" } },
{ "engage.cloudflareclient.com", new List<string> { "162.159.192.1", "2606:4700:d0::a29f:c001" } }
};
public static readonly List<string> ExpectedIPs =
[
"geoip:cn",
"geoip:ir",
"geoip:ru",
""
"geoip:ir",
"geoip:ru",
""
];
public static readonly List<string> EchForceQuerys =
[
"none",
"half",
"full",
"none",
"half",
"full",
""
];
#endregion const
@@ -316,5 +316,72 @@ public class ProfileGroupItemManager
return childAddresses;
}
public static async Task<HashSet<string>> GetAllChildEchQuerySni(string indexId)
{
// include grand children
var childAddresses = new HashSet<string>();
if (!Instance.TryGet(indexId, out var groupItem) || groupItem == null)
{
return childAddresses;
}
if (groupItem.SubChildItems.IsNotEmpty())
{
var subItems = await GetSubChildProfileItems(groupItem);
foreach (var childNode in subItems)
{
if (childNode.EchConfigList.IsNullOrEmpty())
{
continue;
}
if (childNode.StreamSecurity == Global.StreamSecurity
&& childNode.EchConfigList?.Contains("://") == true)
{
var idx = childNode.EchConfigList.IndexOf('+');
childAddresses.Add(idx > 0 ? childNode.EchConfigList[..idx] : childNode.Sni);
}
else
{
childAddresses.Add(childNode.Sni);
}
}
}
var childIds = Utils.String2List(groupItem.ChildItems) ?? [];
foreach (var childId in childIds)
{
var childNode = await AppManager.Instance.GetProfileItem(childId);
if (childNode == null)
{
continue;
}
if (!childNode.IsComplex() && !childNode.EchConfigList.IsNullOrEmpty())
{
if (childNode.StreamSecurity == Global.StreamSecurity
&& childNode.EchConfigList?.Contains("://") == true)
{
var idx = childNode.EchConfigList.IndexOf('+');
childAddresses.Add(idx > 0 ? childNode.EchConfigList[..idx] : childNode.Sni);
}
else
{
childAddresses.Add(childNode.Sni);
}
}
else if (childNode.ConfigType.IsGroupType())
{
var subAddresses = await GetAllChildDomainAddresses(childNode.IndexId);
foreach (var addr in subAddresses)
{
childAddresses.Add(addr);
}
}
}
return childAddresses;
}
#endregion Helper
}
@@ -182,6 +182,14 @@ public class Tls4Sbox
public string? fragment_fallback_delay { get; set; }
public bool? record_fragment { get; set; }
public List<string>? certificate { get; set; }
public Ech4Sbox? ech { get; set; }
}
public class Ech4Sbox
{
public bool enabled { get; set; }
public List<string>? config { get; set; }
public string? query_server_name { get; set; }
}
public class Multiplex4Sbox
@@ -371,7 +371,7 @@ public partial class CoreConfigSingboxService(Config config)
await GenRouting(singboxConfig);
await GenExperimental(singboxConfig);
await GenDns(null, singboxConfig);
await GenDns(parentNode, singboxConfig);
await ConvertGeo2Ruleset(singboxConfig);
ret.Success = true;
@@ -428,7 +428,7 @@ public partial class CoreConfigSingboxService(Config config)
await GenRouting(singboxConfig);
await GenExperimental(singboxConfig);
await GenDns(null, singboxConfig);
await GenDns(parentNode, singboxConfig);
await ConvertGeo2Ruleset(singboxConfig);
ret.Success = true;
@@ -13,8 +13,8 @@ public partial class CoreConfigSingboxService
}
var simpleDNSItem = _config.SimpleDNSItem;
await GenDnsServers(singboxConfig, simpleDNSItem);
await GenDnsRules(singboxConfig, simpleDNSItem);
await GenDnsServers(node, singboxConfig, simpleDNSItem);
await GenDnsRules(node, singboxConfig, simpleDNSItem);
singboxConfig.dns ??= new Dns4Sbox();
singboxConfig.dns.independent_cache = true;
@@ -52,7 +52,7 @@ public partial class CoreConfigSingboxService
return 0;
}
private async Task<int> GenDnsServers(SingboxConfig singboxConfig, SimpleDNSItem simpleDNSItem)
private async Task<int> GenDnsServers(ProfileItem? node, SingboxConfig singboxConfig, SimpleDNSItem simpleDNSItem)
{
var finalDns = await GenDnsDomains(singboxConfig, simpleDNSItem);
@@ -133,6 +133,29 @@ public partial class CoreConfigSingboxService
singboxConfig.dns.servers.Add(fakeip);
}
// ech
var (_, dnsServer) = ParseEchParam(node?.EchConfigList);
if (dnsServer is not null)
{
dnsServer.tag = Global.SingboxEchDNSTag;
if (dnsServer.server is not null
&& hostsDns.predefined.ContainsKey(dnsServer.server))
{
dnsServer.domain_resolver = Global.SingboxHostsDNSTag;
}
else
{
dnsServer.domain_resolver = Global.SingboxLocalDNSTag;
}
singboxConfig.dns.servers.Add(dnsServer);
}
else if (node?.ConfigType.IsGroupType() == true)
{
var echDnsObject = JsonUtils.DeepCopy(directDns);
echDnsObject.tag = Global.SingboxEchDNSTag;
singboxConfig.dns.servers.Add(echDnsObject);
}
return await Task.FromResult(0);
}
@@ -146,7 +169,7 @@ public partial class CoreConfigSingboxService
return await Task.FromResult(finalDns);
}
private async Task<int> GenDnsRules(SingboxConfig singboxConfig, SimpleDNSItem simpleDNSItem)
private async Task<int> GenDnsRules(ProfileItem? node, SingboxConfig singboxConfig, SimpleDNSItem simpleDNSItem)
{
singboxConfig.dns ??= new Dns4Sbox();
singboxConfig.dns.rules ??= new List<Rule4Sbox>();
@@ -168,6 +191,31 @@ public partial class CoreConfigSingboxService
}
});
var (ech, _) = ParseEchParam(node?.EchConfigList);
if (ech is not null)
{
var echDomain = ech.query_server_name ?? node?.Sni;
singboxConfig.dns.rules.Add(new()
{
query_type = new List<int> { 64, 65 },
server = Global.SingboxEchDNSTag,
domain = echDomain is not null ? new List<string> { echDomain } : null,
});
}
else if (node?.ConfigType.IsGroupType() == true)
{
var queryServerNames = (await ProfileGroupItemManager.GetAllChildEchQuerySni(node.IndexId)).ToList();
if (queryServerNames.Count > 0)
{
singboxConfig.dns.rules.Add(new()
{
query_type = new List<int> { 64, 65 },
server = Global.SingboxEchDNSTag,
domain = queryServerNames,
});
}
}
if (simpleDNSItem.BlockBindingQuery == true)
{
singboxConfig.dns.rules.Add(new()
@@ -334,6 +334,11 @@ public partial class CoreConfigSingboxService
};
tls.insecure = false;
}
var (ech, _) = ParseEchParam(node.EchConfigList);
if (ech is not null)
{
tls.ech = ech;
}
outbound.tls = tls;
}
catch (Exception ex)
@@ -904,4 +909,31 @@ public partial class CoreConfigSingboxService
}
return await Task.FromResult(0);
}
private (Ech4Sbox? ech, Server4Sbox? dnsServer) ParseEchParam(string? echConfig)
{
if (echConfig.IsNullOrEmpty())
{
return (null, null);
}
if (!echConfig.Contains("://"))
{
return (new Ech4Sbox()
{
enabled = true,
config = [$"-----BEGIN ECH CONFIGS-----\n" +
$"{echConfig}\n" +
$"-----END ECH CONFIGS-----"],
}, null);
}
var idx = echConfig.IndexOf('+');
// NOTE: query_server_name, since sing-box 1.13.0
//var queryServerName = idx > 0 ? echConfig[..idx] : null;
var echDnsServer = idx > 0 ? echConfig[(idx + 1)..] : echConfig;
return (new Ech4Sbox()
{
enabled = true,
query_server_name = null,
}, ParseDnsAddress(echDnsServer));
}
}
+2
View File
@@ -148,6 +148,8 @@ dependencies {
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.fragment)
// UI Libraries
implementation(libs.material)
@@ -148,6 +148,7 @@ object AppConfig {
const val MSG_MEASURE_CONFIG = 7
const val MSG_MEASURE_CONFIG_SUCCESS = 71
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_FINISH = 73
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -0,0 +1,6 @@
package com.v2ray.ang.dto
data class GroupMapItem(
var id: String,
var remarks: String,
)
@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
object SettingsChangeManager {
private val _restartService = MutableStateFlow(false)
private val _reinitGroupTab = MutableStateFlow(false)
private val _setupGroupTab = MutableStateFlow(false)
// Mark restartService as requiring a restart
fun makeRestartService() {
@@ -19,14 +19,14 @@ object SettingsChangeManager {
}
// Mark reinitGroupTab as requiring tab reinitialization
fun makeReinitGroupTab() {
_reinitGroupTab.value = true
fun makeSetupGroupTab() {
_setupGroupTab.value = true
}
// Read and clear the reinitGroupTab flag
fun consumeReinitGroupTab(): Boolean {
val v = _reinitGroupTab.value
_reinitGroupTab.value = false
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
}
@@ -34,12 +34,12 @@ import kotlin.math.sign
*
* @author Paul Burke (ipaulpro)
*/
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter) : ItemTouchHelper.Callback() {
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
private var mReturnAnimator: ValueAnimator? = null
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
override fun getMovementFlags(
recyclerView: RecyclerView,
@@ -49,10 +49,10 @@ class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter
val swipeFlags: Int
if (recyclerView.layoutManager is GridLayoutManager) {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
} else {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = ItemTouchHelper.START or ItemTouchHelper.END
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
}
return makeMovementFlags(dragFlags, swipeFlags)
}
@@ -3,6 +3,7 @@ package com.v2ray.ang.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_SUCCESS
@@ -15,16 +16,26 @@ import com.v2ray.ang.handler.V2rayConfigManager
import com.v2ray.ang.util.MessageUtil
import com.v2ray.ang.util.Utils
import go.Seq
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import libv2ray.Libv2ray
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
class V2RayTestService : Service() {
private val realTestScope by lazy { CoroutineScope(Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()).asCoroutineDispatcher()) }
private val realTestJob = SupervisorJob()
private val realDispatcher = Dispatchers.IO.limitedParallelism(
Runtime.getRuntime().availableProcessors() * 3
)
private val realTestScope = CoroutineScope(
realTestJob + realDispatcher + CoroutineName("RealTest")
)
// simple counter for currently running tasks
private val realTestRunningCount = AtomicInteger(0)
/**
* Initializes the V2Ray environment.
@@ -47,13 +58,19 @@ class V2RayTestService : Service() {
MSG_MEASURE_CONFIG -> {
val guid = intent.serializable<String>("content") ?: ""
realTestScope.launch {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
realTestRunningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(this@V2RayTestService, MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} finally {
val left = realTestRunningCount.decrementAndGet()
MessageUtil.sendMsg2UI(this@V2RayTestService, AppConfig.MSG_MEASURE_CONFIG_FINISH, left.toString())
}
}
}
MSG_MEASURE_CONFIG_CANCEL -> {
realTestScope.coroutineContext[Job]?.cancelChildren()
realTestJob.cancelChildren()
}
}
return super.onStartCommand(intent, flags, startId)
@@ -68,6 +85,14 @@ class V2RayTestService : Service() {
return null
}
/**
* Cleans up resources when the service is destroyed.
*/
override fun onDestroy() {
super.onDestroy()
realTestJob.cancel()
}
/**
* Starts the real ping test.
* @param guid The GUID of the configuration.
@@ -170,7 +170,7 @@ class BackupActivity : BaseActivity() {
}
val count = MMKV.restoreAllFromDirectory(backupDir)
SettingsChangeManager.makeReinitGroupTab()
SettingsChangeManager.makeSetupGroupTab()
SettingsChangeManager.makeRestartService()
return count > 0
}
@@ -0,0 +1,53 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.v2ray.ang.helper.CustomDividerItemDecoration
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding: VB
get() = _binding!!
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = inflateBinding(inflater, container)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Adds a custom divider to a RecyclerView.
*
* @param recyclerView The target RecyclerView to which the divider will be added.
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
}
@@ -0,0 +1,20 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.v2ray.ang.dto.GroupMapItem
/**
* Pager adapter for subscription groups.
*/
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
@SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) {
this.groups = groups
notifyDataSetChanged()
}
}
@@ -0,0 +1,68 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.viewmodel.MainViewModel
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
companion object {
private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply {
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
}
}
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
FragmentGroupServerBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(requireActivity() as MainActivity)
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
}
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning
}
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
super.onResume()
mainViewModel.subscriptionIdChanged(subId)
}
}
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
@@ -23,10 +22,8 @@ import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
@@ -37,7 +34,6 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel
@@ -51,7 +47,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
ActivityMainBinding.inflate(layoutInflater)
}
private val adapter by lazy { MainRecyclerAdapter(this) }
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
private var tabMediator: TabLayoutMediator? = null
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
startV2Ray()
@@ -61,26 +60,10 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
restartV2Ray()
}
if (SettingsChangeManager.consumeReinitGroupTab()) {
initGroupTab()
if (SettingsChangeManager.consumeSetupGroupTab()) {
setupGroupTab()
}
}
private val tabGroupListener = object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val selectId = tab?.tag.toString()
if (selectId != mainViewModel.subscriptionId) {
mainViewModel.subscriptionIdChanged(selectId)
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
}
private var mItemTouchHelper: ItemTouchHelper? = null
val mainViewModel: MainViewModel by viewModels()
// register activity result for requesting permission
private val requestPermissionLauncher =
@@ -158,17 +141,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(this, 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(this, 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter
binding.viewPager.isUserInputEnabled = true
val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close
@@ -177,7 +152,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
initGroupTab()
setupGroupTab()
setupViewModel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -198,20 +173,12 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
})
mainViewModel.reloadServerList()
}
@SuppressLint("NotifyDataSetChanged")
private fun setupViewModel() {
mainViewModel.updateListAction.observe(this) { index ->
if (index >= 0) {
adapter.notifyItemChanged(index)
} else {
adapter.notifyDataSetChanged()
}
}
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isRunning.observe(this) { isRunning ->
adapter.isRunning = isRunning
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
@@ -230,27 +197,22 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
mainViewModel.initAssets(assets)
}
private fun initGroupTab() {
binding.tabGroup.removeOnTabSelectedListener(tabGroupListener)
binding.tabGroup.removeAllTabs()
binding.tabGroup.isVisible = false
private fun setupGroupTab() {
val groups = mainViewModel.getSubscriptions(this)
groupPagerAdapter.update(groups)
val (listId, listRemarks) = mainViewModel.getSubscriptions(this)
if (listId == null || listRemarks == null) {
return
}
tabMediator?.detach()
tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position ->
groupPagerAdapter.groups.getOrNull(position)?.let {
tab.text = it.remarks
tab.tag = it.id
}
}.also { it.attach() }
for (it in listRemarks.indices) {
val tab = binding.tabGroup.newTab()
tab.text = listRemarks[it]
tab.tag = listId[it]
binding.tabGroup.addTab(tab)
}
val selectIndex =
listId.indexOf(mainViewModel.subscriptionId).takeIf { it >= 0 } ?: (listId.count() - 1)
binding.tabGroup.selectTab(binding.tabGroup.getTabAt(selectIndex))
binding.tabGroup.addOnTabSelectedListener(tabGroupListener)
binding.tabGroup.isVisible = true
val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1)
binding.viewPager.setCurrentItem(targetIndex, false)
binding.tabGroup.isVisible = groups.size > 1
}
private fun startV2Ray() {
@@ -261,7 +223,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
V2RayServiceManager.startVService(this)
}
public fun restartV2Ray() {
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
}
@@ -271,12 +233,11 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
}
}
public override fun onResume() {
override fun onResume() {
super.onResume()
mainViewModel.reloadServerList()
}
public override fun onPause() {
override fun onPause() {
super.onPause()
}
@@ -475,7 +436,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
mainViewModel.reloadServerList()
}
countSub > 0 -> initGroupTab()
countSub > 0 -> setupGroupTab()
else -> toastError(R.string.toast_failure)
}
binding.pbWaiting.hide()
@@ -693,4 +654,9 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
}
override fun onDestroy() {
tabMediator?.detach()
super.onDestroy()
}
}
@@ -1,5 +1,6 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.graphics.Color
import android.text.TextUtils
@@ -19,6 +20,7 @@ import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -26,9 +28,7 @@ import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.handler.V2RayServiceManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
@@ -46,17 +46,24 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
}
var isRunning = false
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: List<ServersCache> = emptyList()
/**
* Gets the total number of items in the adapter (servers count + footer view)
* @return The total item count
*/
override fun getItemCount() = mActivity.mainViewModel.serversCache.size + 1
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: List<ServersCache>?, position: Int = -1) {
data = newData ?: emptyList()
if (position >= 0) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
}
override fun getItemCount() = data.size + 1
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) {
val guid = mActivity.mainViewModel.serversCache[position].guid
val profile = mActivity.mainViewModel.serversCache[position].profile
val guid = data[position].guid
val profile = data[position].profile
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
@@ -294,7 +301,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
private fun removeServerSub(guid: String, position: Int) {
mActivity.mainViewModel.removeServer(guid)
notifyItemRemoved(position)
notifyItemRangeChanged(position, mActivity.mainViewModel.serversCache.size)
notifyItemRangeChanged(position, data.size)
}
/**
@@ -327,7 +334,7 @@ class MainRecyclerAdapter(val activity: MainActivity) : RecyclerView.Adapter<Mai
}
override fun getItemViewType(position: Int): Int {
return if (position == mActivity.mainViewModel.serversCache.size) {
return if (position == data.size) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ITEM
@@ -31,7 +31,7 @@ class SubEditActivity : BaseActivity() {
setContentView(binding.root)
title = getString(R.string.title_sub_setting)
SettingsChangeManager.makeReinitGroupTab()
SettingsChangeManager.makeSetupGroupTab()
val subItem = MmkvManager.decodeSubscription(editSubId)
if (subItem != null) {
bindingServer(subItem)
@@ -16,6 +16,7 @@ import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
@@ -121,6 +122,7 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
notifyItemRemoved(position)
notifyItemRangeChanged(position, mActivity.subscriptions.size)
mActivity.refreshData()
SettingsChangeManager.makeSetupGroupTab()
}
}
}
@@ -156,6 +158,7 @@ class SubSettingRecyclerAdapter(val activity: SubSettingActivity) : RecyclerView
override fun onItemMoveCompleted() {
mActivity.refreshData()
SettingsChangeManager.makeSetupGroupTab()
}
override fun onItemDismiss(position: Int) {
@@ -16,6 +16,7 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.ProfileItem
import com.v2ray.ang.dto.ServersCache
import com.v2ray.ang.dto.GroupMapItem
import com.v2ray.ang.extension.serializable
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -144,7 +145,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun updateCache() {
serversCache.clear()
for (guid in serverList) {
var profile = MmkvManager.decodeServerConfig(guid) ?: continue
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// var profile = MmkvManager.decodeProfileConfig(guid)
// if (profile == null) {
// val config = MmkvManager.decodeServerConfig(guid) ?: continue
@@ -266,22 +267,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @param context The context.
* @return A pair of lists containing the subscription IDs and remarks.
*/
fun getSubscriptions(context: Context): Pair<MutableList<String>?, MutableList<String>?> {
fun getSubscriptions(context: Context): List<GroupMapItem> {
val subscriptions = MmkvManager.decodeSubscriptions()
if (subscriptionId.isNotEmpty()
&& !subscriptions.map { it.first }.contains(subscriptionId)
) {
subscriptionIdChanged("")
}
if (subscriptions.isEmpty()) {
return null to null
}
val listId = subscriptions.map { it.first }.toMutableList()
listId.add(0, "")
val listRemarks = subscriptions.map { it.second.remarks }.toMutableList()
listRemarks.add(0, context.getString(R.string.filter_config_all))
return listId to listRemarks
val groups = mutableListOf<GroupMapItem>()
groups.add(
GroupMapItem(
id = "",
remarks = context.getString(R.string.filter_config_all)
)
)
subscriptions.forEach { (id, item) ->
groups.add(GroupMapItem(id = id, remarks = item.remarks))
}
return groups
}
/**
@@ -441,6 +445,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
updateListAction.value = getPosition(resultPair.first)
}
AppConfig.MSG_MEASURE_CONFIG_FINISH -> {
val content = intent.getStringExtra("content")
updateTestResultAction.value =
getApplication<AngApplication>().getString(R.string.connection_runing_task_left, content)
}
}
}
}
@@ -48,18 +48,17 @@
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/padding_spacing_dp8"
app:tabIndicatorFullWidth="false"
app:tabMode="scrollable"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:nextFocusRight="@+id/fab"
android:scrollbars="vertical" />
android:scrollbars="vertical"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/layout_test"
@@ -67,7 +66,7 @@
android:layout_height="@dimen/view_height_dp64"
android:clickable="true"
android:focusable="true"
android:nextFocusLeft="@+id/recycler_view"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:orientation="vertical">
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</FrameLayout>
@@ -316,6 +316,7 @@
<string name="connection_test_error_status_code">رمز الخطأ: #%d</string>
<string name="connection_connected">متصل، انقر للتحقق من الاتصال</string>
<string name="connection_not_connected">غير متصل</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">تم استيراد الاشتراك بنجاح</string>
<string name="import_subscription_failure">فشل استيراد الاشتراك</string>
@@ -315,6 +315,7 @@
<string name="connection_test_error_status_code">ত্রুটি কোড: #%d</string>
<string name="connection_connected">সংযুক্ত, সংযোগ পরীক্ষা করতে ট্যাপ করুন</string>
<string name="connection_not_connected">সংযুক্ত নয়</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">সাবস্ক্রিপশন সফলভাবে আমদানি করা হয়েছে</string>
<string name="import_subscription_failure">সাবস্ক্রিপশন আমদানি ব্যর্থ</string>
@@ -29,7 +29,7 @@
<string name="menu_item_import_config_qrcode">و من ٱووردن کانفیگ ز QRcode</string>
<string name="menu_item_import_config_clipboard">و من ٱووردن کانفیگ ز کلیپ بورد</string>
<string name="menu_item_import_config_local">و من ٱووردن کانفیگ ز مهلی</string>
<string name="menu_item_import_config_policy_group">Add [Policy group]</string>
<string name="menu_item_import_config_policy_group">ٱووردن [بونکۊ سیاست]</string>
<string name="menu_item_import_config_manually_vmess">هؽل دستی[VMess]</string>
<string name="menu_item_import_config_manually_vless">هؽل دستی[VLESS]</string>
<string name="menu_item_import_config_manually_ss">هؽل دستی[Shadowsocks]</string>
@@ -113,8 +113,8 @@
<string name="server_lab_bandwidth_up">وا روء رئڌن پئنا باند (واهڌ)</string>
<string name="server_lab_xhttp_mode">هالت XHTTP</string>
<string name="server_lab_xhttp_extra">XHTTP Extra خام JSON، قالوو: { XHTTPObject }</string>
<string name="server_lab_ech_config_list">EchConfigList</string>
<string name="server_lab_ech_force_query">EchForceQuery</string>
<string name="server_lab_ech_config_list">نومگه کانفیگ Ech</string>
<string name="server_lab_ech_force_query">پورس وو جۊ اجباری Ech</string>
<!-- UserAssetActivity -->
<string name="toast_asset_copy_failed">لف گیری فایل ٱنجوم نوابی، ز ی برنومه دؽوۉداری فایل هیاری بگرین</string>
@@ -200,8 +200,8 @@
<string name="title_pref_delay_test_url">نشۊوی اینترنتی آزمایش تئخیر واقعی </string>
<string name="summary_pref_delay_test_url">نشۊوی اینترنتی</string>
<string name="title_pref_ip_api_url">Current connection info test url</string>
<string name="summary_pref_ip_api_url">Url</string>
<string name="title_pref_ip_api_url">نشۊوی اینترنتی آزمایش دووسمندیا منپیز هیم سکویی</string>
<string name="summary_pref_ip_api_url">نشۊوی اینترنتی</string>
<string name="title_pref_proxy_sharing_enabled">هشتن منپیزا ز شبکه مهلی</string>
<string name="summary_pref_proxy_sharing_enabled">پوی دسگایل ترن وا نشۊوی IP ایسا، ز ر socks/http و پروکسی منپیز بۊن، تینا من شبکه قابل اعتماد فعال بۊ تا ز منپیز غیر موجاز جلو گری بۊ.</string>
@@ -324,6 +324,7 @@
<string name="connection_test_error_status_code">کود ختا: #%d</string>
<string name="connection_connected">منپیز هڌ، سی واجۊری کیلیک کوݩ</string>
<string name="connection_not_connected">منپیز نؽڌ</string>
<string name="connection_runing_task_left">تعداد وزیفه یل آزمایشی ک ر اۊفتانه: %s</string>
<string name="import_subscription_success">اشتراک وا مووفقیت زفت زابی</string>
<string name="import_subscription_failure">اشتراک زفت نوابی</string>
@@ -340,21 +341,21 @@
<string name="update_check_pre_release">واجۊری نوسخه یل پؽش ز تیجنیڌن</string>
<string name="update_checking_for_update">ورۊ رسۊوی ن هونی واجۊری اکونه...</string>
<string name="title_policy_group_type">Policy group type</string>
<string name="title_policy_group_subscription_id">From subscription group</string>
<string name="title_policy_group_subscription_filter">Remarks regular filter</string>
<string name="title_policy_group_type">نوع بونکۊ سیاست</string>
<string name="title_policy_group_subscription_id">ز بونکۊ اشتراک</string>
<string name="title_policy_group_subscription_filter">توزیهات فیلتر معمۊلی</string>
<!-- BackupActivity -->
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup_restore">لادراری گرؽڌن &amp; وورگندن</string>
<string name="title_configuration_backup">لادراری گرؽڌن ز کانفیگ</string>
<string name="title_configuration_restore">وورگندن کانفیگ</string>
<string name="title_configuration_share">یک رسۊوی کانفیگ</string>
<string name="title_webdav_config_setting">WebDAV Settings</string>
<string name="title_webdav_config_setting_unknown">Please configure WebDAV first.</string>
<string name="title_webdav_url">WebDAV server URL</string>
<string name="title_webdav_user">Username</string>
<string name="title_webdav_pass">Password</string>
<string name="title_webdav_remote_path">Remote directory (optional)</string>
<string name="title_webdav_config_setting">WebDAV سامووا</string>
<string name="title_webdav_config_setting_unknown">ٱول WebDAV ن کانفیگ کۊنین</string>
<string name="title_webdav_url">WebDAV نشۊوی اینترنتی سرور</string>
<string name="title_webdav_user">نوم منتوری</string>
<string name="title_webdav_pass">رزم</string>
<string name="title_webdav_remote_path">دایرکتوری ر دیر (اختیاری)</string>
<string-array name="share_method">
<item>QRcode</item>
@@ -410,7 +411,7 @@
</string-array>
<string-array name="config_backup_options">
<item>Local</item>
<item>مهلی</item>
<item>WebDAV</item>
</string-array>
@@ -321,6 +321,7 @@
<string name="connection_test_error_status_code">کد خطا: #%d</string>
<string name="connection_connected">متصل است، برای بررسی اتصال ضربه بزنید</string>
<string name="connection_not_connected">متصل نیست</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">اشتراک با موفقیت ذخیره شد</string>
<string name="import_subscription_failure">ذخیره اشتراک ناموفق بود</string>
@@ -323,6 +323,7 @@
<string name="connection_test_error_status_code">Код ошибки: #%d</string>
<string name="connection_connected">Подключено, нажмите для проверки</string>
<string name="connection_not_connected">Не подключено</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">Подписка импортирована</string>
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
@@ -316,6 +316,7 @@
<string name="connection_test_error_status_code">Mã lỗi: #%d</string>
<string name="connection_connected">Đã kết nối, nhấn để kiểm tra kết nối mạng!</string>
<string name="connection_not_connected">Chưa kết nối</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">Nhập gói đăng ký thành công!</string>
<string name="import_subscription_failure">Nhập gói đăng ký không thành công!</string>
@@ -313,6 +313,7 @@
<string name="connection_test_error_status_code">"状态码无效(#%d"</string>
<string name="connection_connected">"已连接,点击测试连接"</string>
<string name="connection_not_connected">"未连接"</string>
<string name="connection_runing_task_left">运行中的测试任务数:%s</string>
<string name="import_subscription_success">订阅导入成功</string>
<string name="import_subscription_failure">导入订阅失败</string>
@@ -313,6 +313,7 @@
<string name="connection_test_error_status_code">"錯誤碼:(#%d)"</string>
<string name="connection_connected">"已連線,輕觸以檢查連線能力"</string>
<string name="connection_not_connected">"未連線"</string>
<string name="connection_runing_task_left">運行中的測試任務數:%s</string>
<string name="import_subscription_success">匯入訂閱成功</string>
<string name="import_subscription_failure">匯入訂閱失敗</string>
@@ -327,6 +327,7 @@
<string name="connection_test_error_status_code">Error code: #%d</string>
<string name="connection_connected">Connected, tap to check connection</string>
<string name="connection_not_connected">Not connected</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="import_subscription_success">Subscription imported Successfully</string>
<string name="import_subscription_failure">Import subscription failed</string>
@@ -0,0 +1,382 @@
package com.v2ray.ang.fmt
import android.util.Base64
import android.util.Log
import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.ProfileItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.After
import org.junit.Test
import org.mockito.MockedStatic
import org.mockito.Mockito
import org.mockito.Mockito.mockStatic
import java.net.URLDecoder
import java.util.Base64 as JavaBase64
/**
* Unit tests for ShadowsocksFmt class.
*
* Tests cover:
* - Parsing SIP002 format URLs (modern format)
* - Parsing legacy format URLs
* - Converting ProfileItem to URI
* - Various encryption methods
*/
class ShadowsocksFmtTest {
companion object {
private const val SS_SCHEME = "ss://"
}
private lateinit var mockBase64: MockedStatic<Base64>
private lateinit var mockLog: MockedStatic<Log>
/**
* Helper function to create a SIP002 format Shadowsocks URL.
*/
private fun createSip002Url(
method: String,
password: String,
host: String,
port: Int,
remarks: String
): String {
val methodPassword = "$method:$password"
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
.encodeToString(methodPassword.toByteArray())
return "$SS_SCHEME${base64UserInfo}@$host:$port#${remarks.replace(" ", "%20")}"
}
/**
* Helper function to create a legacy format Shadowsocks URL.
*/
private fun createLegacyUrl(
method: String,
password: String,
host: String,
port: Int,
remarks: String
): String {
val legacyContent = "$method:$password@$host:$port"
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
return "$SS_SCHEME${base64Encoded}#${remarks.replace(" ", "%20")}"
}
@Before
fun setUp() {
mockLog = mockStatic(Log::class.java, Mockito.RETURNS_DEFAULTS)
mockBase64 = mockStatic(Base64::class.java)
// Mock decode with proper flag handling and exception propagation
mockBase64.`when`<ByteArray> {
Base64.decode(Mockito.anyString(), Mockito.anyInt())
}.thenAnswer { invocation ->
val input = invocation.arguments[0] as String
val flags = invocation.arguments[1] as Int
val isUrlSafe = (flags and Base64.URL_SAFE) != 0
val decoder = if (isUrlSafe) {
JavaBase64.getUrlDecoder()
} else {
JavaBase64.getDecoder()
}
// Propagate exception on invalid input (matches Android behavior)
decoder.decode(input)
}
// Mock encode with proper flag handling
mockBase64.`when`<String> {
Base64.encodeToString(Mockito.any(ByteArray::class.java), Mockito.anyInt())
}.thenAnswer { invocation ->
val input = invocation.arguments[0] as ByteArray
val flags = invocation.arguments[1] as Int
val isUrlSafe = (flags and Base64.URL_SAFE) != 0
val noPadding = (flags and Base64.NO_PADDING) != 0
var encoder = if (isUrlSafe) {
JavaBase64.getUrlEncoder()
} else {
JavaBase64.getEncoder()
}
if (noPadding) {
encoder = encoder.withoutPadding()
}
encoder.encodeToString(input)
}
}
@After
fun tearDown() {
mockLog.close()
mockBase64.close()
}
// ==================== SIP002 Format Tests ====================
@Test
fun test_parseSip002_validUrlWithBase64EncodedUserinfo() {
val ssUrl = createSip002Url(
method = "aes-256-gcm",
password = "my-secret-password",
host = "example.com",
port = 8388,
remarks = "Test Server"
)
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNotNull(result)
assertEquals("Test Server", result?.remarks)
assertEquals("example.com", result?.server)
assertEquals("8388", result?.serverPort)
assertEquals("aes-256-gcm", result?.method)
assertEquals("my-secret-password", result?.password)
}
@Test
fun test_parseSip002_validUrlWithPlainTextUserinfo() {
val ssUrl = "ss://aes-256-gcm:mypassword@example.com:8388#Plain%20Server"
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNotNull(result)
assertEquals("Plain Server", result?.remarks)
assertEquals("aes-256-gcm", result?.method)
assertEquals("mypassword", result?.password)
}
@Test
fun test_parseSip002_withChacha20Encryption() {
val ssUrl = createSip002Url(
method = "chacha20-ietf-poly1305",
password = "secret123",
host = "ss.example.com",
port = 443,
remarks = "ChaCha20"
)
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNotNull(result)
assertEquals("chacha20-ietf-poly1305", result?.method)
}
@Test
fun test_parseSip002_returnsNullForEmptyHost() {
// Manually construct URL with empty host (can't use helper)
val methodPassword = "aes-256-gcm:password"
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
.encodeToString(methodPassword.toByteArray())
val ssUrl = "${SS_SCHEME}${base64UserInfo}@:8388#No%20Host"
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNull(result)
}
@Test
fun test_parseSip002_returnsNullForInvalidPort() {
// Manually construct URL with invalid port (can't use helper)
val methodPassword = "aes-256-gcm:password"
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
.encodeToString(methodPassword.toByteArray())
val ssUrl = "${SS_SCHEME}${base64UserInfo}@example.com:-1#Invalid%20Port"
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNull(result)
}
// ==================== Legacy Format Tests ====================
@Test
fun test_parseLegacy_validUrl() {
val ssUrl = createLegacyUrl(
method = "aes-256-gcm",
password = "password123",
host = "legacy.example.com",
port = 8388,
remarks = "Legacy Server"
)
val result = ShadowsocksFmt.parseLegacy(ssUrl)
assertNotNull(result)
assertEquals("Legacy Server", result?.remarks)
assertEquals("legacy.example.com", result?.server)
assertEquals("8388", result?.serverPort)
assertEquals("aes-256-gcm", result?.method)
assertEquals("password123", result?.password)
}
@Test
fun test_parseLegacy_withPartiallyEncodedUrl() {
// Partially encoded legacy format (method:password encoded, host:port not)
val methodPassword = "chacha20-ietf-poly1305:my-pass"
val base64Part = JavaBase64.getEncoder().encodeToString(methodPassword.toByteArray())
val ssUrl = "${SS_SCHEME}${base64Part}@partial.example.com:443#Partial%20Encoded"
val result = ShadowsocksFmt.parseLegacy(ssUrl)
assertNotNull(result)
assertEquals("Partial Encoded", result?.remarks)
assertEquals("partial.example.com", result?.server)
assertEquals("chacha20-ietf-poly1305", result?.method)
}
@Test
fun test_parseLegacy_returnsNullForInvalidFormat() {
val invalidContent = "not-a-valid-format"
val base64Encoded = JavaBase64.getEncoder().encodeToString(invalidContent.toByteArray())
val ssUrl = "${SS_SCHEME}${base64Encoded}#Invalid"
val result = ShadowsocksFmt.parseLegacy(ssUrl)
assertNull(result)
}
@Test
fun test_parseLegacy_handlesPasswordWithColon() {
// Special case: password contains colons (can't use helper)
val legacyContent = "aes-256-gcm:pass:word:with:colons@example.com:8388"
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
val ssUrl = "${SS_SCHEME}${base64Encoded}#Colon%20Password"
val result = ShadowsocksFmt.parseLegacy(ssUrl)
assertNotNull(result)
assertEquals("pass:word:with:colons", result?.password)
}
// ==================== toUri Tests ====================
@Test
fun test_toUri_createsValidSip002Url() {
val config = ProfileItem.create(EConfigType.SHADOWSOCKS).apply {
remarks = "Test Server"
server = "example.com"
serverPort = "8388"
method = "aes-256-gcm"
password = "my-secret-password"
}
val uri = ShadowsocksFmt.toUri(config)
// Verify URI does not include scheme (toUri returns without ss:// prefix)
assertFalse("toUri should not include scheme prefix", uri.startsWith(SS_SCHEME))
assertTrue(uri.contains("@example.com:8388"))
assertTrue(uri.contains("#Test%20Server"))
// Verify the base64 part (URL decode first, then Base64 decode)
val base64Part = uri.substringBefore("@")
val urlDecoded = URLDecoder.decode(base64Part, Charsets.UTF_8.name())
val decoded = String(JavaBase64.getDecoder().decode(urlDecoded))
assertEquals("aes-256-gcm:my-secret-password", decoded)
}
@Test
fun test_toUri_handlesIpv6Address() {
val config = ProfileItem.create(EConfigType.SHADOWSOCKS).apply {
remarks = "IPv6 Server"
server = "2001:db8::1"
serverPort = "8388"
method = "aes-256-gcm"
password = "password"
}
val uri = ShadowsocksFmt.toUri(config)
assertTrue(uri.contains("[2001:db8::1]:8388"))
}
// ==================== Round-trip Tests ====================
@Test
fun test_parseAndToUri_roundTripPreservesData() {
val originalUrl = createSip002Url(
method = "chacha20-ietf-poly1305",
password = "round-trip-password",
host = "roundtrip.example.com",
port = 443,
remarks = "Round Trip Test"
)
val parsed = ShadowsocksFmt.parse(originalUrl)
assertNotNull(parsed)
val regeneratedUri = ShadowsocksFmt.toUri(parsed!!)
// Verify toUri returns without scheme, so we need to prepend it
assertFalse(
"toUri should return URI without scheme",
regeneratedUri.startsWith(SS_SCHEME)
)
val reparsed = ShadowsocksFmt.parse("$SS_SCHEME$regeneratedUri")
assertNotNull(reparsed)
assertEquals(parsed.remarks, reparsed?.remarks)
assertEquals(parsed.server, reparsed?.server)
assertEquals(parsed.serverPort, reparsed?.serverPort)
assertEquals(parsed.method, reparsed?.method)
assertEquals(parsed.password, reparsed?.password)
}
// ==================== Edge Cases ====================
@Test
fun test_parse_handlesEmptyRemarksGracefully() {
// Empty remarks edge case (can't use helper)
val methodPassword = "aes-256-gcm:password"
val base64UserInfo = JavaBase64.getUrlEncoder().withoutPadding()
.encodeToString(methodPassword.toByteArray())
val ssUrl = "${SS_SCHEME}${base64UserInfo}@example.com:8388#"
val result = ShadowsocksFmt.parse(ssUrl)
assertNotNull(result)
assertEquals("none", result?.remarks)
}
@Test
fun test_parseSip002_handlesDifferentEncryptionMethods() {
val methods = listOf("aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305")
for (method in methods) {
val ssUrl = createSip002Url(
method = method,
password = "testpass",
host = "example.com",
port = 8388,
remarks = method
)
val result = ShadowsocksFmt.parseSip002(ssUrl)
assertNotNull("Failed for method: $method", result)
assertEquals(method, result?.method)
}
}
@Test
fun test_parseLegacy_convertsMethodToLowercase() {
// Uppercase method to test lowercase conversion (can't use helper)
val legacyContent = "AES-256-GCM:password@example.com:8388"
val base64Encoded = JavaBase64.getEncoder().encodeToString(legacyContent.toByteArray())
val ssUrl = "${SS_SCHEME}${base64Encoded}#Uppercase%20Method"
val result = ShadowsocksFmt.parseLegacy(ssUrl)
assertNotNull(result)
assertEquals("aes-256-gcm", result?.method)
}
}
+5 -1
View File
@@ -24,10 +24,12 @@ core = "3.5.4"
workRuntimeKtx = "2.11.0"
lifecycleViewmodelKtx = "2.10.0"
multidex = "2.0.1"
mockitoMockitoInline = "6.1.0"
mockitoMockitoInline = "5.2.0"
flexbox = "3.0.0"
preferenceKtx = "1.2.1"
recyclerview = "1.4.0"
viewpager2 = "1.1.0"
fragment = "1.8.9"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" }
@@ -62,6 +64,8 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "
flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "flexbox" }
recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+27 -56
View File
@@ -1,40 +1,17 @@
package router_test
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/xtls/xray-core/app/router"
"github.com/xtls/xray-core/common"
"github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/platform"
"github.com/xtls/xray-core/common/platform/filesystem"
"google.golang.org/protobuf/proto"
"github.com/xtls/xray-core/infra/conf"
)
func getAssetPath(file string) (string, error) {
path := platform.GetAssetLocation(file)
_, err := os.Stat(path)
if os.IsNotExist(err) {
path := filepath.Join("..", "..", "resources", file)
_, err := os.Stat(path)
if os.IsNotExist(err) {
return "", fmt.Errorf("can't find %s in standard asset locations or {project_root}/resources", file)
}
if err != nil {
return "", fmt.Errorf("can't stat %s: %v", path, err)
}
return path, nil
}
if err != nil {
return "", fmt.Errorf("can't stat %s: %v", path, err)
}
return path, nil
}
func TestGeoIPMatcher(t *testing.T) {
cidrList := []*router.CIDR{
{Ip: []byte{0, 0, 0, 0}, Prefix: 8},
@@ -182,12 +159,11 @@ func TestGeoIPReverseMatcher(t *testing.T) {
}
func TestGeoIPMatcher4CN(t *testing.T) {
ips, err := loadGeoIP("CN")
geo := "geoip:cn"
geoip, err := loadGeoIP(geo)
common.Must(err)
matcher, err := router.BuildOptimizedGeoIPMatcher(&router.GeoIP{
Cidr: ips,
})
matcher, err := router.BuildOptimizedGeoIPMatcher(geoip)
common.Must(err)
if matcher.Match([]byte{8, 8, 8, 8}) {
@@ -196,12 +172,11 @@ func TestGeoIPMatcher4CN(t *testing.T) {
}
func TestGeoIPMatcher6US(t *testing.T) {
ips, err := loadGeoIP("US")
geo := "geoip:us"
geoip, err := loadGeoIP(geo)
common.Must(err)
matcher, err := router.BuildOptimizedGeoIPMatcher(&router.GeoIP{
Cidr: ips,
})
matcher, err := router.BuildOptimizedGeoIPMatcher(geoip)
common.Must(err)
if !matcher.Match(net.ParseAddress("2001:4860:4860::8888").IP()) {
@@ -209,37 +184,34 @@ func TestGeoIPMatcher6US(t *testing.T) {
}
}
func loadGeoIP(country string) ([]*router.CIDR, error) {
path, err := getAssetPath("geoip.dat")
if err != nil {
return nil, err
}
geoipBytes, err := filesystem.ReadFile(path)
func loadGeoIP(geo string) (*router.GeoIP, error) {
os.Setenv("XRAY_LOCATION_ASSET", filepath.Join("..", "..", "resources"))
geoip, err := conf.ToCidrList([]string{geo})
if err != nil {
return nil, err
}
var geoipList router.GeoIPList
if err := proto.Unmarshal(geoipBytes, &geoipList); err != nil {
return nil, err
}
for _, geoip := range geoipList.Entry {
if geoip.CountryCode == country {
return geoip.Cidr, nil
if runtime.GOOS != "windows" && runtime.GOOS != "wasm" {
geoip, err = router.GetGeoIPList(geoip)
if err != nil {
return nil, err
}
}
panic("country not found: " + country)
if len(geoip) == 0 {
panic("country not found: " + geo)
}
return geoip[0], nil
}
func BenchmarkGeoIPMatcher4CN(b *testing.B) {
ips, err := loadGeoIP("CN")
geo := "geoip:cn"
geoip, err := loadGeoIP(geo)
common.Must(err)
matcher, err := router.BuildOptimizedGeoIPMatcher(&router.GeoIP{
Cidr: ips,
})
matcher, err := router.BuildOptimizedGeoIPMatcher(geoip)
common.Must(err)
b.ResetTimer()
@@ -250,12 +222,11 @@ func BenchmarkGeoIPMatcher4CN(b *testing.B) {
}
func BenchmarkGeoIPMatcher6US(b *testing.B) {
ips, err := loadGeoIP("US")
geo := "geoip:us"
geoip, err := loadGeoIP(geo)
common.Must(err)
matcher, err := router.BuildOptimizedGeoIPMatcher(&router.GeoIP{
Cidr: ips,
})
matcher, err := router.BuildOptimizedGeoIPMatcher(geoip)
common.Must(err)
b.ResetTimer()
+65 -28
View File
@@ -1,20 +1,22 @@
package router_test
import (
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
"github.com/xtls/xray-core/app/router"
. "github.com/xtls/xray-core/app/router"
"github.com/xtls/xray-core/common"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/platform/filesystem"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/protocol/http"
"github.com/xtls/xray-core/common/session"
"github.com/xtls/xray-core/features/routing"
routing_session "github.com/xtls/xray-core/features/routing/session"
"google.golang.org/protobuf/proto"
"github.com/xtls/xray-core/infra/conf"
)
func withBackground() routing.Context {
@@ -300,32 +302,25 @@ func TestRoutingRule(t *testing.T) {
}
}
func loadGeoSite(country string) ([]*Domain, error) {
path, err := getAssetPath("geosite.dat")
if err != nil {
return nil, err
}
geositeBytes, err := filesystem.ReadFile(path)
func loadGeoSiteDomains(geo string) ([]*Domain, error) {
os.Setenv("XRAY_LOCATION_ASSET", filepath.Join("..", "..", "resources"))
domains, err := conf.ParseDomainRule(geo)
if err != nil {
return nil, err
}
var geositeList GeoSiteList
if err := proto.Unmarshal(geositeBytes, &geositeList); err != nil {
return nil, err
}
for _, site := range geositeList.Entry {
if site.CountryCode == country {
return site.Domain, nil
if runtime.GOOS != "windows" && runtime.GOOS != "wasm" {
domains, err = router.GetDomainList(domains)
if err != nil {
return nil, err
}
}
return nil, errors.New("country not found: " + country)
return domains, nil
}
func TestChinaSites(t *testing.T) {
domains, err := loadGeoSite("CN")
domains, err := loadGeoSiteDomains("geosite:cn")
common.Must(err)
acMatcher, err := NewMphMatcherGroup(domains)
@@ -366,8 +361,50 @@ func TestChinaSites(t *testing.T) {
}
}
func TestChinaSitesWithAttrs(t *testing.T) {
domains, err := loadGeoSiteDomains("geosite:google@cn")
common.Must(err)
acMatcher, err := NewMphMatcherGroup(domains)
common.Must(err)
type TestCase struct {
Domain string
Output bool
}
testCases := []TestCase{
{
Domain: "google.cn",
Output: true,
},
{
Domain: "recaptcha.net",
Output: true,
},
{
Domain: "164.com",
Output: false,
},
{
Domain: "164.com",
Output: false,
},
}
for i := 0; i < 1024; i++ {
testCases = append(testCases, TestCase{Domain: strconv.Itoa(i) + ".not-exists.com", Output: false})
}
for _, testCase := range testCases {
r := acMatcher.ApplyDomain(testCase.Domain)
if r != testCase.Output {
t.Error("ACDomainMatcher expected output ", testCase.Output, " for domain ", testCase.Domain, " but got ", r)
}
}
}
func BenchmarkMphDomainMatcher(b *testing.B) {
domains, err := loadGeoSite("CN")
domains, err := loadGeoSiteDomains("geosite:cn")
common.Must(err)
matcher, err := NewMphMatcherGroup(domains)
@@ -412,11 +449,11 @@ func BenchmarkMultiGeoIPMatcher(b *testing.B) {
var geoips []*GeoIP
{
ips, err := loadGeoIP("CN")
ips, err := loadGeoIP("geoip:cn")
common.Must(err)
geoips = append(geoips, &GeoIP{
CountryCode: "CN",
Cidr: ips,
Cidr: ips.Cidr,
})
}
@@ -425,25 +462,25 @@ func BenchmarkMultiGeoIPMatcher(b *testing.B) {
common.Must(err)
geoips = append(geoips, &GeoIP{
CountryCode: "JP",
Cidr: ips,
Cidr: ips.Cidr,
})
}
{
ips, err := loadGeoIP("CA")
ips, err := loadGeoIP("geoip:ca")
common.Must(err)
geoips = append(geoips, &GeoIP{
CountryCode: "CA",
Cidr: ips,
Cidr: ips.Cidr,
})
}
{
ips, err := loadGeoIP("US")
ips, err := loadGeoIP("geoip:us")
common.Must(err)
geoips = append(geoips, &GeoIP{
CountryCode: "US",
Cidr: ips,
Cidr: ips.Cidr,
})
}
+3 -3
View File
@@ -112,7 +112,7 @@ func (rr *RoutingRule) BuildCondition() (Condition, error) {
domains := rr.Domain
if runtime.GOOS != "windows" && runtime.GOOS != "wasm" {
var err error
domains, err = getDomainList(rr.Domain)
domains, err = GetDomainList(rr.Domain)
if err != nil {
return nil, errors.New("failed to build domains from mmap").Base(err)
}
@@ -122,7 +122,7 @@ func (rr *RoutingRule) BuildCondition() (Condition, error) {
if err != nil {
return nil, errors.New("failed to build domain condition with MphDomainMatcher").Base(err)
}
errors.LogDebug(context.Background(), "MphDomainMatcher is enabled for ", len(rr.Domain), " domain rule(s)")
errors.LogDebug(context.Background(), "MphDomainMatcher is enabled for ", len(domains), " domain rule(s)")
conds.Add(matcher)
}
@@ -214,7 +214,7 @@ func GetGeoIPList(ips []*GeoIP) ([]*GeoIP, error) {
}
func getDomainList(domains []*Domain) ([]*Domain, error) {
func GetDomainList(domains []*Domain) ([]*Domain, error) {
domainList := []*Domain{}
for _, domain := range domains {
val := strings.Split(domain.Value, "_")
+2
View File
@@ -22,6 +22,8 @@ const (
BrowserDialerAddress = "xray.browser.dialer"
XUDPLog = "xray.xudp.show"
XUDPBaseKey = "xray.xudp.basekey"
TunFdKey = "xray.tun.fd"
)
type EnvFlag struct {
+4 -1
View File
@@ -3,7 +3,9 @@
package platform
import "path/filepath"
import (
"path/filepath"
)
func LineSeparator() string {
return "\r\n"
@@ -12,6 +14,7 @@ func LineSeparator() string {
// GetAssetLocation searches for `file` in the env dir and the executable dir
func GetAssetLocation(file string) string {
assetPath := NewEnvFlag(AssetLocation).GetValue(getExecutableDir)
return filepath.Join(assetPath, file)
}
+1 -1
View File
@@ -89,7 +89,7 @@ func (c *NameServerConfig) Build() (*dns.NameServer, error) {
var originalRules []*dns.NameServer_OriginalRule
for _, rule := range c.Domains {
parsedDomain, err := parseDomainRule(rule)
parsedDomain, err := ParseDomainRule(rule)
if err != nil {
return nil, errors.New("invalid domain rule: ", rule).Base(err)
}
+3 -3
View File
@@ -291,7 +291,7 @@ func loadGeositeWithAttr(file string, siteWithAttr string) ([]*router.Domain, er
return filteredDomains, nil
}
func parseDomainRule(domain string) ([]*router.Domain, error) {
func ParseDomainRule(domain string) ([]*router.Domain, error) {
if strings.HasPrefix(domain, "geosite:") {
country := strings.ToUpper(domain[8:])
domains, err := loadGeositeWithAttr("geosite.dat", country)
@@ -489,7 +489,7 @@ func parseFieldRule(msg json.RawMessage) (*router.RoutingRule, error) {
if rawFieldRule.Domain != nil {
for _, domain := range *rawFieldRule.Domain {
rules, err := parseDomainRule(domain)
rules, err := ParseDomainRule(domain)
if err != nil {
return nil, errors.New("failed to parse domain rule: ", domain).Base(err)
}
@@ -499,7 +499,7 @@ func parseFieldRule(msg json.RawMessage) (*router.RoutingRule, error) {
if rawFieldRule.Domains != nil {
for _, domain := range *rawFieldRule.Domains {
rules, err := parseDomainRule(domain)
rules, err := ParseDomainRule(domain)
if err != nil {
return nil, errors.New("failed to parse domain rule: ", domain).Base(err)
}
+30
View File
@@ -0,0 +1,30 @@
package conf
import (
"github.com/xtls/xray-core/proxy/tun"
"google.golang.org/protobuf/proto"
)
type TunConfig struct {
Name string `json:"name"`
MTU uint32 `json:"MTU"`
UserLevel uint32 `json:"userLevel"`
}
func (v *TunConfig) Build() (proto.Message, error) {
config := &tun.Config{
Name: v.Name,
MTU: v.MTU,
UserLevel: v.UserLevel,
}
if v.Name == "" {
config.Name = "xray0"
}
if v.MTU == 0 {
config.MTU = 1500
}
return config, nil
}
+1
View File
@@ -28,6 +28,7 @@ var (
"vmess": func() interface{} { return new(VMessInboundConfig) },
"trojan": func() interface{} { return new(TrojanServerConfig) },
"wireguard": func() interface{} { return &WireGuardConfig{IsClient: false} },
"tun": func() interface{} { return new(TunConfig) },
}, "protocol", "settings")
outboundConfigLoader = NewJSONConfigLoader(ConfigCreatorCache{
+174
View File
@@ -0,0 +1,174 @@
# TUN network layer 3 input support
TUN interface support bridges the gap between network layer 3 and layer 7, introducing raw network input.
This functionality is targeted to assist applications/end devices that don't have proxy support, or can't run external applications (like Smart TV's). Making it possible to run Xray proxy right on network edge devices (routers) with support to route raw network traffic. \
Primary targets are Linux based router devices. Like most popular OpenWRT option. \
Although support for Windows is also implemented (see below).
## PLEASE READ FOLLOWING CAREFULLY
If you are not sure what this is and do you need it or not - you don't. \
This functionality is intended to be configured by network professionals, who understand the deployment case and scenarios. \
Plainly enabling it in the config probably will result nothing, or lock your router up in infinite network loop.
## DETAILS
Current implementation does not contain options to configure network level addresses, routing or rules.
Enabling the feature will result only tun interface up, and that's it. \
This is explicit decision, significantly simplifying implementation, and allowing any number of custom configurations, consumers could come up with. Network interface is OS level entity, and OS is what should manage it. \
Working configuration, is tun enabled in Xray config with specific name (e.g. xray0), and OS level configuration to manage "xray0" interface, applying routing and rules on interface up.
This way consistency of system level routing and rules is ensured from single place of responsibility - the OS itself. \
Examples of how to achieve this on a simple Linux system (Ubuntu with systemd-networkd) can be found at the end of this README.
Due to this inbound not actually being a proxy, the configuration ignore required listen and port options, and never listen on any port. \
Here is simple Xray config snippet to enable the inbound:
```
{
"inbounds": [
{
"port": 0,
"protocol": "tun",
"settings": {
"name": "xray0",
"MTU": 1492
}
}
],
```
## SUPPORTED FEATURES
- IPv4 and IPv6
- TCP and UDP
## LIMITATION
- No ICMP support
- Connections are established to any host, as connection success is only a mark of successful accepting packet for proxying. Hosts that are not accepting connections or don't even exists, will look like they opened a connection (SYN-ACK), and never send back a single byte, closing connection (RST) after some time. This is the side effect of the whole process actually being a proxy, and not real network layer 3 vpn
## CONSIDERATIONS
This feature being network level interface that require raw routing, bring some ambiguities that need to be taken in account. \
Xray-core itself is connecting to its uplinks on a network level, therefore, it's really simple to lock network up in an infinite loop, when trying to pass "everything through Xray". \
You can't just route 0.0.0.0/0 through xray0 interface, as that will result Xray-core itself try to reach its uplink through xray0 interface, resulting infinite network loop.
There are several ways to address this:
- Approach 1: \
Add precise static route to Xray upstreams, having them always routed through static internet gateway.
E.g. when 123.123.123.123 is the Xray VLESS uplink, this network configuration will work just fine:
```
ip route add 123.123.123.123/32 via <provider internet gateway ip>
ip route add 0.0.0.0/0 dev xray0
```
This has disadvantages, - a lot of conditions must be kept static: internet gateway address, xray uplink ip address, and so on.
- Approach 1-b: \
Route only specific networks through Xray, keeping the default gateway unchanged.
This can be done in many different ways, using ip sets, routing daemons like BGP peers, etc... All you need to do is to route the paths through xray0 dev.
The disadvantage in this case is smaller, - you need to make sure the uplink will not become part of those sets and that's it. Can easily be done with route metric priorities.
- Approach 2: \
Separate main route table and Xray route table with default gateways pointing to different destinations.
This way you can achieve full protection of hosts behind the router, keeping router configuration as flexible as desired. \
There are two ways to do that: \
Either configure xray0 interface to appear and operate as default gateway in a separate route table, e.g. 1001. Then mark and route protected traffic by ip rules to that table. \
It's a simplest way to make a "non-damaging" configuration, when the only thing you need to do to enable/disable proxying is to flip the ip rules off. Which is also a disadvantage of itself - if by accident ip rules will get disabled, the traffic will spill out of the internet interface unprotected. \
Or, other way around, move default routing to a separate route table, so that all usual routing information is set in e.g. route table 1000,
and Xray interface operate in the main route table. This will allow proper flexibility, but you need to ensure traffic from the Xray process, running on the router, is marked to get routed through table 1000. This again can be achieved in same ways, using ip rules and iptable rules combinations. \
Big advantage of that, is that traffic of the route itself is going to be wrapped into the proxy, including DNS queries, without any additional effort. Although, the disadvantage of that is, that in any case proxying stops (uplink dies, Xray hangs, encryption start to flap), it will result complete internet inaccessibility. \
Any approach is applicable, and if you know what you are doing (which is expected, if you read until here) you do understand which one you want and can manage. \
### Important:
TUN is network level entity, therefore communication through it, is always ip to ip, there are no host names. \
Therefore, DNS resolution will always happen before traffic even enter the interface (it will be separate ip-to-ip packets/connections to resolve hostnames). \
You always need to consider that DNS queries in any configuration you chose, most likely, will originate from the router itself (hosts behind the router access router DNS, router DNS fire queries to the outside).
Without proper addressing that, DNS queries will expose actual destinations/websites accessed through the router. \
To address that you can as ignore (not use) DNS of the router (just delegate some public DNS in DHCP configuration to your devices), or make sure routing rules are configured the way, DNS resolution of the router itself runs through Xray interface/routing table.
You also need to remember that local traffic of the router (e.g. DNS, firmware updates, etc.), is subject of firewall rules as outcoming/incoming traffic (not forward).
If you have restrictive firewall, you need to allow input/output traffic through xray0 interface, for it to properly dispatch and reach the OS back.
Additionally, working with two route tables is not taken lightly by Linux, and sometimes make it panic about "martian packets", which it calls the packets arriving through interfaces it does not expect they could arrive from. \
It was just a warning until recent kernel versions, but now traffic is often dropped. \
In simple case this can be just disabled with
```
/usr/sbin/sysctl -w net.ipv4.conf.all.rp_filter=0
```
But proper approach should be defining your route tables fully and consistently, adding all routes corresponding to traffic that flow through them.
## EXAMPLES
systemd-networkd \
configuration file you can place in /etc/systemd/networkd as 90-xray0.network
which will configure xray0 interface and routing using route table 1001, when the interface will appear in the system (Xray starts). And deconfigure when disappears.
```
[Match]
Name = xray0
[Network]
KeepConfiguration = yes
[Link]
ActivationPolicy = manual
RequiredForOnline = no
[Route]
Table = 1001
Destination = 0.0.0.0/0
[RoutingPolicyRule]
From = 192.168.0.0/24
Table = 1001
```
RoutingPolicyRule will add the record into ip rules, that will funnel all traffic from 192.168.0.0/24 through the table 1001 \
Please note that for ideal configuration of the routing you will also need to add the route to 192.168.0.0/24 to the route table 1001.
You can do that e.g. in the file, describing your adapter serving local network (e.g. 10-br0.network), additionally to its native properties like:
```
[Match]
Name = br0
Type = bridge
[Network]
...skip...
Address = 192.168.0.1/24
...skip...
[Route]
Table = 1001
Destination = 192.168.0.0/24
PreferredSource = 192.168.0.1
Scope = link
```
All in all systemd-networkd and its derivatives (like netplan or NetworkManager) provide all means to configure your networking, according to your wish, that will ensure network consistency of xray0 interface coming up and down, relative to other network configuration like internet interfaces, nat rules and so on.
## WINDOWS SUPPORT
Windows version of the same functionality is implemented through Wintun library. \
To make it start, wintun.dll specific for your Windows/arch must be present next to Xray.exe binary.
After the start network adapter with the name you chose in the config will be created in the system, and exist while Xray is running.
You can give the adapter ip address manually, you can live Windows to give it autogenerated ip address (which take few seconds), it doesn't matter, the traffic going _through_ the interface will be forwarded into the app for proxying. \
Minimal configuration that will work for local machine is routing passing the traffic on-link through the interface.
You will need the interface id for that, unfortunately it is going to change with every Xray start due to implementation ambiguity between Xray and wintun driver.
You can find the interface id with the command
```
route print
```
it will be in the list of interfaces on the top of the output
```
===========================================================================
Interface List
8...cc cc cc cc cc cc ......Realtek PCIe GbE Family Controller
47...........................Xray Tunnel
1...........................Software Loopback Interface 1
===========================================================================
```
In this case the interface id is "47". \
Then you can add on-link route through the adapter with (example) command
```
route add 1.1.1.1 mask 255.0.0.0 0.0.0.0 if 47
```
Note on ipv6 support. \
Despite Windows also giving the adapter autoconfigured ipv6 address, the ipv6 is not possible until the interface has any _routable_ ipv6 address (given link-local address will not accept traffic from external addresses). \
So everything applicable for ipv4 above also works for ipv6, you only need to give the interface some address manually, e.g. anything private like fc00::a:b:c:d/64 will do just fine
+1
View File
@@ -0,0 +1 @@
package tun
+149
View File
@@ -0,0 +1,149 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v6.30.2
// source: proxy/tun/config.proto
package tun
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Config struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
MTU uint32 `protobuf:"varint,2,opt,name=MTU,proto3" json:"MTU,omitempty"`
UserLevel uint32 `protobuf:"varint,3,opt,name=user_level,json=userLevel,proto3" json:"user_level,omitempty"`
}
func (x *Config) Reset() {
*x = Config{}
mi := &file_config_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Config) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Config) ProtoMessage() {}
func (x *Config) ProtoReflect() protoreflect.Message {
mi := &file_config_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Config.ProtoReflect.Descriptor instead.
func (*Config) Descriptor() ([]byte, []int) {
return file_config_proto_rawDescGZIP(), []int{0}
}
func (x *Config) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *Config) GetMTU() uint32 {
if x != nil {
return x.MTU
}
return 0
}
func (x *Config) GetUserLevel() uint32 {
if x != nil {
return x.UserLevel
}
return 0
}
var File_config_proto protoreflect.FileDescriptor
var file_config_proto_rawDesc = []byte{
0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e,
0x78, 0x72, 0x61, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2e, 0x74, 0x75, 0x6e, 0x22, 0x4d,
0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x10, 0x0a, 0x03,
0x4d, 0x54, 0x55, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x4d, 0x54, 0x55, 0x12, 0x1d,
0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x0d, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x42, 0x4c, 0x0a,
0x12, 0x63, 0x6f, 0x6d, 0x2e, 0x78, 0x72, 0x61, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2e,
0x74, 0x75, 0x6e, 0x50, 0x01, 0x5a, 0x23, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,
0x6d, 0x2f, 0x78, 0x74, 0x6c, 0x73, 0x2f, 0x78, 0x72, 0x61, 0x79, 0x2d, 0x63, 0x6f, 0x72, 0x65,
0x2f, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x2f, 0x74, 0x75, 0x6e, 0xaa, 0x02, 0x0e, 0x58, 0x72, 0x61,
0x79, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x2e, 0x54, 0x75, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
}
var (
file_config_proto_rawDescOnce sync.Once
file_config_proto_rawDescData = file_config_proto_rawDesc
)
func file_config_proto_rawDescGZIP() []byte {
file_config_proto_rawDescOnce.Do(func() {
file_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_config_proto_rawDescData)
})
return file_config_proto_rawDescData
}
var file_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
var file_config_proto_goTypes = []any{
(*Config)(nil), // 0: xray.proxy.tun.Config
}
var file_config_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_config_proto_init() }
func file_config_proto_init() {
if File_config_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_config_proto_rawDesc,
NumEnums: 0,
NumMessages: 1,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_config_proto_goTypes,
DependencyIndexes: file_config_proto_depIdxs,
MessageInfos: file_config_proto_msgTypes,
}.Build()
File_config_proto = out.File
file_config_proto_rawDesc = nil
file_config_proto_goTypes = nil
file_config_proto_depIdxs = nil
}
+13
View File
@@ -0,0 +1,13 @@
syntax = "proto3";
package xray.proxy.tun;
option csharp_namespace = "Xray.Proxy.Tun";
option go_package = "github.com/xtls/xray-core/proxy/tun";
option java_package = "com.xray.proxy.tun";
option java_multiple_files = true;
message Config {
string name = 1;
uint32 MTU = 2;
uint32 user_level = 3;
}
+142
View File
@@ -0,0 +1,142 @@
package tun
import (
"context"
"github.com/xtls/xray-core/common"
"github.com/xtls/xray-core/common/buf"
c "github.com/xtls/xray-core/common/ctx"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/net"
"github.com/xtls/xray-core/common/protocol"
"github.com/xtls/xray-core/common/session"
"github.com/xtls/xray-core/core"
"github.com/xtls/xray-core/features/policy"
"github.com/xtls/xray-core/features/routing"
"github.com/xtls/xray-core/transport"
"github.com/xtls/xray-core/transport/internet/stat"
)
// Handler is managing object that tie together tun interface, ip stack and dispatch connections to the routing
type Handler struct {
ctx context.Context
config *Config
stack Stack
policyManager policy.Manager
dispatcher routing.Dispatcher
}
// ConnectionHandler interface with the only method that stack is going to push new connections to
type ConnectionHandler interface {
HandleConnection(conn net.Conn, destination net.Destination)
}
// Handler implements ConnectionHandler
var _ ConnectionHandler = (*Handler)(nil)
func (t *Handler) policy() policy.Session {
p := t.policyManager.ForLevel(t.config.UserLevel)
return p
}
// Init the Handler instance with necessary parameters
func (t *Handler) Init(ctx context.Context, pm policy.Manager, dispatcher routing.Dispatcher) error {
var err error
t.ctx = core.ToBackgroundDetachedContext(ctx)
t.policyManager = pm
t.dispatcher = dispatcher
tunName := t.config.Name
tunOptions := TunOptions{
Name: tunName,
MTU: t.config.MTU,
}
tunInterface, err := NewTun(tunOptions)
if err != nil {
return err
}
errors.LogInfo(t.ctx, tunName, " created")
tunStackOptions := StackOptions{
Tun: tunInterface,
IdleTimeout: pm.ForLevel(t.config.UserLevel).Timeouts.ConnectionIdle,
}
tunStack, err := NewStack(t.ctx, tunStackOptions, t)
if err != nil {
_ = tunInterface.Close()
return err
}
err = tunStack.Start()
if err != nil {
_ = tunStack.Close()
_ = tunInterface.Close()
return err
}
err = tunInterface.Start()
if err != nil {
_ = tunStack.Close()
_ = tunInterface.Close()
return err
}
t.stack = tunStack
errors.LogInfo(t.ctx, tunName, " up")
return nil
}
// HandleConnection pass the connection coming from the ip stack to the routing dispatcher
func (t *Handler) HandleConnection(conn net.Conn, destination net.Destination) {
sid := session.NewID()
ctx := c.ContextWithID(t.ctx, sid)
errors.LogInfo(ctx, "processing connection from: ", conn.RemoteAddr())
inbound := session.Inbound{}
inbound.Name = "tun"
inbound.CanSpliceCopy = 1
inbound.Source = net.DestinationFromAddr(conn.RemoteAddr())
inbound.User = &protocol.MemoryUser{
Level: t.config.UserLevel,
}
ctx = session.ContextWithInbound(ctx, &inbound)
ctx = session.SubContextFromMuxInbound(ctx)
link := &transport.Link{
Reader: &buf.TimeoutWrapperReader{Reader: buf.NewReader(conn)},
Writer: buf.NewWriter(conn),
}
if err := t.dispatcher.DispatchLink(ctx, destination, link); err != nil {
errors.LogError(ctx, errors.New("connection closed").Base(err))
return
}
errors.LogInfo(ctx, "connection completed")
}
// Network implements proxy.Inbound
// and exists only to comply to proxy interface, declaring it doesn't listen on any network,
// making the process not open any port for this inbound (input will be network interface)
func (t *Handler) Network() []net.Network {
return []net.Network{}
}
// Process implements proxy.Inbound
// and exists only to comply to proxy interface, which should never get any inputs due to no listening ports
func (t *Handler) Process(ctx context.Context, network net.Network, conn stat.Connection, dispatcher routing.Dispatcher) error {
return nil
}
func init() {
common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) {
t := &Handler{config: config.(*Config)}
err := core.RequireFeatures(ctx, func(pm policy.Manager, dispatcher routing.Dispatcher) error {
return t.Init(ctx, pm, dispatcher)
})
return t, err
}))
}
+17
View File
@@ -0,0 +1,17 @@
package tun
import (
"time"
)
// Stack interface implement ip protocol stack, bridging raw network packets and data streams
type Stack interface {
Start() error
Close() error
}
// StackOptions for the stack implementation
type StackOptions struct {
Tun Tun
IdleTimeout time.Duration
}
+206
View File
@@ -0,0 +1,206 @@
package tun
import (
"context"
"time"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/net"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv4"
"gvisor.dev/gvisor/pkg/tcpip/network/ipv6"
"gvisor.dev/gvisor/pkg/tcpip/stack"
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
"gvisor.dev/gvisor/pkg/tcpip/transport/udp"
"gvisor.dev/gvisor/pkg/waiter"
)
const (
defaultNIC tcpip.NICID = 1
tcpRXBufMinSize = tcp.MinBufferSize
tcpRXBufDefSize = tcp.DefaultSendBufferSize
tcpRXBufMaxSize = 8 << 20 // 8MiB
tcpTXBufMinSize = tcp.MinBufferSize
tcpTXBufDefSize = tcp.DefaultReceiveBufferSize
tcpTXBufMaxSize = 6 << 20 // 6MiB
)
// stackGVisor is ip stack implemented by gVisor package
type stackGVisor struct {
ctx context.Context
tun GVisorTun
idleTimeout time.Duration
handler *Handler
stack *stack.Stack
endpoint stack.LinkEndpoint
}
// GVisorTun implements a bridge to connect gVisor ip stack to tun interface
type GVisorTun interface {
newEndpoint() (stack.LinkEndpoint, error)
}
// NewStack builds new ip stack (using gVisor)
func NewStack(ctx context.Context, options StackOptions, handler *Handler) (Stack, error) {
gStack := &stackGVisor{
ctx: ctx,
tun: options.Tun.(GVisorTun),
idleTimeout: options.IdleTimeout,
handler: handler,
}
return gStack, nil
}
// Start is called by Handler to bring stack to life
func (t *stackGVisor) Start() error {
linkEndpoint, err := t.tun.newEndpoint()
if err != nil {
return err
}
ipStack, err := createStack(linkEndpoint)
if err != nil {
return err
}
tcpForwarder := tcp.NewForwarder(ipStack, 0, 65535, func(r *tcp.ForwarderRequest) {
go func(r *tcp.ForwarderRequest) {
var wq waiter.Queue
var id = r.ID()
// Perform a TCP three-way handshake.
ep, err := r.CreateEndpoint(&wq)
if err != nil {
errors.LogError(t.ctx, err.String())
r.Complete(true)
return
}
options := ep.SocketOptions()
options.SetKeepAlive(false)
options.SetReuseAddress(true)
options.SetReusePort(true)
t.handler.HandleConnection(
gonet.NewTCPConn(&wq, ep),
// local address on the gVisor side is connection destination
net.TCPDestination(net.IPAddress(id.LocalAddress.AsSlice()), net.Port(id.LocalPort)),
)
// close the socket
ep.Close()
// send connection complete upstream
r.Complete(false)
}(r)
})
ipStack.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpForwarder.HandlePacket)
udpForwarder := udp.NewForwarder(ipStack, func(r *udp.ForwarderRequest) {
go func(r *udp.ForwarderRequest) {
var wq waiter.Queue
var id = r.ID()
ep, err := r.CreateEndpoint(&wq)
if err != nil {
errors.LogError(t.ctx, err.String())
return
}
options := ep.SocketOptions()
options.SetReuseAddress(true)
options.SetReusePort(true)
t.handler.HandleConnection(
gonet.NewUDPConn(&wq, ep),
// local address on the gVisor side is connection destination
net.UDPDestination(net.IPAddress(id.LocalAddress.AsSlice()), net.Port(id.LocalPort)),
)
// close the socket
ep.Close()
}(r)
})
ipStack.SetTransportProtocolHandler(udp.ProtocolNumber, udpForwarder.HandlePacket)
t.stack = ipStack
t.endpoint = linkEndpoint
return nil
}
// Close is called by Handler to shut down the stack
func (t *stackGVisor) Close() error {
if t.stack == nil {
return nil
}
t.endpoint.Attach(nil)
t.stack.Close()
for _, endpoint := range t.stack.CleanupEndpoints() {
endpoint.Abort()
}
return nil
}
// createStack configure gVisor ip stack
func createStack(ep stack.LinkEndpoint) (*stack.Stack, error) {
opts := stack.Options{
NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol, ipv6.NewProtocol},
TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol, udp.NewProtocol},
HandleLocal: false,
}
gStack := stack.New(opts)
err := gStack.CreateNIC(defaultNIC, ep)
if err != nil {
return nil, errors.New(err.String())
}
gStack.SetRouteTable([]tcpip.Route{
{Destination: header.IPv4EmptySubnet, NIC: defaultNIC},
{Destination: header.IPv6EmptySubnet, NIC: defaultNIC},
})
err = gStack.SetSpoofing(defaultNIC, true)
if err != nil {
return nil, errors.New(err.String())
}
err = gStack.SetPromiscuousMode(defaultNIC, true)
if err != nil {
return nil, errors.New(err.String())
}
cOpt := tcpip.CongestionControlOption("cubic")
gStack.SetTransportProtocolOption(tcp.ProtocolNumber, &cOpt)
sOpt := tcpip.TCPSACKEnabled(true)
gStack.SetTransportProtocolOption(tcp.ProtocolNumber, &sOpt)
mOpt := tcpip.TCPModerateReceiveBufferOption(true)
gStack.SetTransportProtocolOption(tcp.ProtocolNumber, &mOpt)
tcpRXBufOpt := tcpip.TCPReceiveBufferSizeRangeOption{
Min: tcpRXBufMinSize,
Default: tcpRXBufDefSize,
Max: tcpRXBufMaxSize,
}
err = gStack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpRXBufOpt)
if err != nil {
return nil, errors.New(err.String())
}
tcpTXBufOpt := tcpip.TCPSendBufferSizeRangeOption{
Min: tcpTXBufMinSize,
Default: tcpTXBufDefSize,
Max: tcpTXBufMaxSize,
}
err = gStack.SetTransportProtocolOption(tcp.ProtocolNumber, &tcpTXBufOpt)
if err != nil {
return nil, errors.New(err.String())
}
return gStack, nil
}
+13
View File
@@ -0,0 +1,13 @@
package tun
// Tun interface implements tun interface interaction
type Tun interface {
Start() error
Close() error
}
// TunOptions for tun interface implementation
type TunOptions struct {
Name string
MTU uint32
}
+58
View File
@@ -0,0 +1,58 @@
//go:build android
package tun
import (
"context"
"strconv"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/platform"
"golang.org/x/sys/unix"
"gvisor.dev/gvisor/pkg/tcpip/link/fdbased"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
type AndroidTun struct {
tunFd int
options TunOptions
}
// DefaultTun implements Tun
var _ Tun = (*AndroidTun)(nil)
// DefaultTun implements GVisorTun
var _ GVisorTun = (*AndroidTun)(nil)
// NewTun builds new tun interface handler
func NewTun(options TunOptions) (Tun, error) {
fd, err := strconv.Atoi(platform.NewEnvFlag(platform.TunFdKey).GetValue(func() string { return "0" }))
errors.LogInfo(context.Background(), "read Android Tun Fd ", fd, err)
err = unix.SetNonblock(fd, true)
if err != nil {
_ = unix.Close(fd)
return nil, err
}
return &AndroidTun{
tunFd: fd,
options: options,
}, nil
}
func (t *AndroidTun) Start() error {
return nil
}
func (t *AndroidTun) Close() error {
return nil
}
func (t *AndroidTun) newEndpoint() (stack.LinkEndpoint, error) {
return fdbased.New(&fdbased.Options{
FDs: []int{t.tunFd},
MTU: t.options.MTU,
RXChecksumOffload: true,
})
}
+34
View File
@@ -0,0 +1,34 @@
//go:build !linux && !windows && !android
package tun
import (
"github.com/xtls/xray-core/common/errors"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
type DefaultTun struct {
}
// DefaultTun implements Tun
var _ Tun = (*DefaultTun)(nil)
// DefaultTun implements GVisorTun
var _ GVisorTun = (*DefaultTun)(nil)
// NewTun builds new tun interface handler
func NewTun(options TunOptions) (Tun, error) {
return nil, errors.New("Tun is not supported on your platform")
}
func (t *DefaultTun) Start() error {
return errors.New("Tun is not supported on your platform")
}
func (t *DefaultTun) Close() error {
return errors.New("Tun is not supported on your platform")
}
func (t *DefaultTun) newEndpoint() (stack.LinkEndpoint, error) {
return nil, errors.New("Tun is not supported on your platform")
}
+120
View File
@@ -0,0 +1,120 @@
//go:build linux && !android
package tun
import (
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"gvisor.dev/gvisor/pkg/tcpip/link/fdbased"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// LinuxTun is an object that handles tun network interface on linux
// current version is heavily stripped to do nothing more,
// then create a network interface, to be provided as file descriptor to gVisor ip stack
type LinuxTun struct {
tunFd int
tunLink netlink.Link
options TunOptions
}
// LinuxTun implements Tun
var _ Tun = (*LinuxTun)(nil)
// LinuxTun implements GVisorTun
var _ GVisorTun = (*LinuxTun)(nil)
// NewTun builds new tun interface handler (linux specific)
func NewTun(options TunOptions) (Tun, error) {
tunFd, err := open(options.Name)
if err != nil {
return nil, err
}
tunLink, err := setup(options.Name, int(options.MTU))
if err != nil {
_ = unix.Close(tunFd)
return nil, err
}
linuxTun := &LinuxTun{
tunFd: tunFd,
tunLink: tunLink,
options: options,
}
return linuxTun, nil
}
// open the file that implements tun interface in the OS
func open(name string) (int, error) {
fd, err := unix.Open("/dev/net/tun", unix.O_RDWR, 0)
if err != nil {
return -1, err
}
ifr, err := unix.NewIfreq(name)
if err != nil {
_ = unix.Close(fd)
return 0, err
}
flags := unix.IFF_TUN | unix.IFF_NO_PI
ifr.SetUint16(uint16(flags))
err = unix.IoctlIfreq(fd, unix.TUNSETIFF, ifr)
if err != nil {
_ = unix.Close(fd)
return 0, err
}
err = unix.SetNonblock(fd, true)
if err != nil {
_ = unix.Close(fd)
return 0, err
}
return fd, nil
}
// setup the interface through netlink socket
func setup(name string, MTU int) (netlink.Link, error) {
tunLink, err := netlink.LinkByName(name)
if err != nil {
return nil, err
}
err = netlink.LinkSetMTU(tunLink, MTU)
if err != nil {
_ = netlink.LinkSetDown(tunLink)
return nil, err
}
return tunLink, nil
}
// Start is called by handler to bring tun interface to life
func (t *LinuxTun) Start() error {
err := netlink.LinkSetUp(t.tunLink)
if err != nil {
return err
}
return nil
}
// Close is called to shut down the tun interface
func (t *LinuxTun) Close() error {
_ = netlink.LinkSetDown(t.tunLink)
_ = unix.Close(t.tunFd)
return nil
}
// newEndpoint builds new gVisor stack.LinkEndpoint from the tun interface file descriptor
func (t *LinuxTun) newEndpoint() (stack.LinkEndpoint, error) {
return fdbased.New(&fdbased.Options{
FDs: []int{t.tunFd},
MTU: t.options.MTU,
RXChecksumOffload: true,
})
}
+84
View File
@@ -0,0 +1,84 @@
//go:build windows
package tun
import (
"golang.org/x/sys/windows"
"golang.zx2c4.com/wintun"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// WindowsTun is an object that handles tun network interface on Windows
// current version is heavily stripped to do nothing more,
// then create a network interface, to be provided as endpoint to gVisor ip stack
type WindowsTun struct {
options TunOptions
adapter *wintun.Adapter
session wintun.Session
MTU uint32
}
// WindowsTun implements Tun
var _ Tun = (*WindowsTun)(nil)
// WindowsTun implements GVisorTun
var _ GVisorTun = (*WindowsTun)(nil)
// NewTun creates a Wintun interface with the given name. Should a Wintun
// interface with the same name exist, it tried to be reused.
func NewTun(options TunOptions) (Tun, error) {
// instantiate wintun adapter
adapter, err := open(options.Name)
if err != nil {
return nil, err
}
// start the interface with ring buffer capacity of 8 MiB
session, err := adapter.StartSession(0x800000)
if err != nil {
_ = adapter.Close()
return nil, err
}
tun := &WindowsTun{
options: options,
adapter: adapter,
session: session,
// there is currently no iphndl.dll support, which is the netlink library for windows
// so there is nowhere to change MTU for the Wintun interface, and we take its default value
MTU: wintun.PacketSizeMax,
}
return tun, nil
}
func open(name string) (*wintun.Adapter, error) {
var guid *windows.GUID
// try to open existing adapter by name
adapter, err := wintun.OpenAdapter(name)
if err == nil {
return adapter, nil
}
// try to create adapter anew
adapter, err = wintun.CreateAdapter(name, "Xray", guid)
if err == nil {
return adapter, nil
}
return nil, err
}
func (t *WindowsTun) Start() error {
return nil
}
func (t *WindowsTun) Close() error {
t.session.End()
_ = t.adapter.Close()
return nil
}
// newEndpoint builds new gVisor stack.LinkEndpoint (WintunEndpoint) on top of WindowsTun
func (t *WindowsTun) newEndpoint() (stack.LinkEndpoint, error) {
return &WintunEndpoint{tun: t}, nil
}
+180
View File
@@ -0,0 +1,180 @@
//go:build windows
package tun
import (
"context"
"errors"
_ "unsafe"
"golang.org/x/sys/windows"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
// WintunEndpoint implements GVisor stack.LinkEndpoint
var _ stack.LinkEndpoint = (*WintunEndpoint)(nil)
type WintunEndpoint struct {
tun *WindowsTun
dispatcherCancel context.CancelFunc
}
var ErrUnsupportedNetworkProtocol = errors.New("unsupported ip version")
//go:linkname procyield runtime.procyield
func procyield(cycles uint32)
func (e *WintunEndpoint) MTU() uint32 {
return e.tun.MTU
}
func (e *WintunEndpoint) SetMTU(mtu uint32) {
// not Implemented, as it is not expected GVisor will be asking tun device to be modified
}
func (e *WintunEndpoint) MaxHeaderLength() uint16 {
return 0
}
func (e *WintunEndpoint) LinkAddress() tcpip.LinkAddress {
return ""
}
func (e *WintunEndpoint) SetLinkAddress(addr tcpip.LinkAddress) {
// not Implemented, as it is not expected GVisor will be asking tun device to be modified
}
func (e *WintunEndpoint) Capabilities() stack.LinkEndpointCapabilities {
return stack.CapabilityRXChecksumOffload
}
func (e *WintunEndpoint) Attach(dispatcher stack.NetworkDispatcher) {
if e.dispatcherCancel != nil {
e.dispatcherCancel()
e.dispatcherCancel = nil
}
if dispatcher != nil {
ctx, cancel := context.WithCancel(context.Background())
go e.dispatchLoop(ctx, dispatcher)
e.dispatcherCancel = cancel
}
}
func (e *WintunEndpoint) IsAttached() bool {
return e.dispatcherCancel != nil
}
func (e *WintunEndpoint) Wait() {
}
func (e *WintunEndpoint) ARPHardwareType() header.ARPHardwareType {
return header.ARPHardwareNone
}
func (e *WintunEndpoint) AddHeader(buffer *stack.PacketBuffer) {
// tun interface doesn't have link layer header, it will be added by the OS
}
func (e *WintunEndpoint) ParseHeader(ptr *stack.PacketBuffer) bool {
return true
}
func (e *WintunEndpoint) Close() {
if e.dispatcherCancel != nil {
e.dispatcherCancel()
e.dispatcherCancel = nil
}
}
func (e *WintunEndpoint) SetOnCloseAction(f func()) {
}
func (e *WintunEndpoint) WritePackets(packetBufferList stack.PacketBufferList) (int, tcpip.Error) {
var n int
// for all packets in the list to send
for _, packetBuffer := range packetBufferList.AsSlice() {
// request buffer from Wintun
packet, err := e.tun.session.AllocateSendPacket(packetBuffer.Size())
if err != nil {
return n, &tcpip.ErrAborted{}
}
// copy the bytes of slices that compose the packet into the allocated buffer
var index int
for _, packetElement := range packetBuffer.AsSlices() {
index += copy(packet[index:], packetElement)
}
// signal Wintun to send that buffer as the packet
e.tun.session.SendPacket(packet)
n++
}
return n, nil
}
func (e *WintunEndpoint) readPacket() (tcpip.NetworkProtocolNumber, *stack.PacketBuffer, error) {
packet, err := e.tun.session.ReceivePacket()
if err != nil {
return 0, nil, err
}
var networkProtocol tcpip.NetworkProtocolNumber
switch header.IPVersion(packet) {
case header.IPv4Version:
networkProtocol = header.IPv4ProtocolNumber
case header.IPv6Version:
networkProtocol = header.IPv6ProtocolNumber
default:
e.tun.session.ReleaseReceivePacket(packet)
return 0, nil, ErrUnsupportedNetworkProtocol
}
packetBuffer := buffer.MakeWithView(buffer.NewViewWithData(packet))
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: packetBuffer,
IsForwardedPacket: true,
OnRelease: func() {
e.tun.session.ReleaseReceivePacket(packet)
},
})
return networkProtocol, pkt, nil
}
func (e *WintunEndpoint) dispatchLoop(ctx context.Context, dispatcher stack.NetworkDispatcher) {
readWait := e.tun.session.ReadWaitEvent()
for {
select {
case <-ctx.Done():
return
default:
networkProtocolNumber, packet, err := e.readPacket()
// read queue empty, yield slightly, wait for the spinlock, retry
if errors.Is(err, windows.ERROR_NO_MORE_ITEMS) {
procyield(1)
_, _ = windows.WaitForSingleObject(readWait, windows.INFINITE)
continue
}
// discard unknown network protocol packet
if errors.Is(err, ErrUnsupportedNetworkProtocol) {
continue
}
// stop dispatcher loop on any other interface failure
if err != nil {
e.Attach(nil)
continue
}
// dispatch the buffer to the stack
dispatcher.DeliverNetworkPacket(networkProtocolNumber, packet)
// signal the buffer that it can be released
packet.DecRef()
}
}
}