mirror of
https://github.com/bolucat/Archive.git
synced 2026-04-23 00:17:16 +08:00
Update On Thu Jan 8 19:40:22 CET 2026
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+151
-1
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 & Restore</string>
|
||||
<string name="title_configuration_backup_restore">لادراری گرؽڌن & وورگندن</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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "_")
|
||||
|
||||
@@ -22,6 +22,8 @@ const (
|
||||
BrowserDialerAddress = "xray.browser.dialer"
|
||||
XUDPLog = "xray.xudp.show"
|
||||
XUDPBaseKey = "xray.xudp.basekey"
|
||||
|
||||
TunFdKey = "xray.tun.fd"
|
||||
)
|
||||
|
||||
type EnvFlag struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
package tun
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user