mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-08 19:27:14 +00:00
c41f97cf86
Each is independently reproducible; fixed together since one review pass found all of them. - manager.go: the shared "ip rule add fwmark" policy route had no existence check, so it duplicated in "ip rule show" on every interface bounce (which hostRulesFingerprint forces on any client add/remove/ re-IP). Now checked via "ip rule list | grep -q ..." first. (Finding 2) - params.go: ExternalInterface, IPv6ExternalInterface, and subnetIp/ subnetCidr are interpolated unescaped into a shell-executed PostUp/ PostDown line, but only obfuscation and the IPv6 subnet were validated before save. Added ValidateInterfaceName (a strict charset+length pattern) and ValidateSubnetIPv4 (netip.ParsePrefix), wired into normalizeAmneziaWGSettings. (Finding 3) - amneziawg_job.go: IsAwgInstalled() existed but nothing ever called it, so a host without awg/awg-quick (the Docker image, RHEL, Arch, a failed install.sh PPA step) logged a reconcile failure every 10s forever. Now checked once an inbound actually needs it, warning once instead of spamming. (Finding 4) - client_inbound_apply.go: the WireGuard/AmneziaWG credential carry-forward (added so a metadata-only client edit doesn't rotate keys) never covered ForwardedPorts, so a partial edit -- an API call or Telegram-bot toggle that omits the field -- silently wiped a client's port-forwarding spec. Carried forward and written back the same way the key fields already are. (Finding 5) - manager.go: hostRulesFingerprint keyed each peer on its IPv4 address only, and structuralFingerprint omitted IPv6Enabled/IPv6ExternalInterface entirely, so an IPv6-only change could pick the syncconf reload path (which never re-runs PostUp, leaving a stale NDP-proxy entry) or be a complete no-op. Both fingerprints now cover the IPv6 fields. (Finding 6) - port_conflict.go: the AmneziaWG egress bridge (injectAmneziawgEgress) binds 127.0.0.1:63100+id with no collision check anywhere, since it isn't a database row the ordinary port-conflict query can see -- same blind spot the reserved Xray API port already has its own check for. Added the equivalent check for the AmneziaWG bridge port. (Finding 7) - install.sh: install_amneziawg ran unconditionally for every install/ update, building a DKMS kernel module and enabling host-wide IPv4/IPv6 forwarding whether or not the feature is ever used. Gated behind a new should_install_amneziawg (XUI_INSTALL_AMNEZIAWG=true/false, or an interactive y/N prompt defaulting to no). Also replaced the deprecated apt-key adv with a dedicated keyring + signed-by= on the Debian branch, and guarded its sources.list appends against duplication on a retried install. (Finding 8) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
package amneziawg
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGenerateObfuscation20DefaultRanges(t *testing.T) {
|
|
for i := 0; i < 200; i++ {
|
|
o := GenerateObfuscation20("default")
|
|
if o.Jc < 3 || o.Jc > 6 {
|
|
t.Fatalf("Jc = %d, want [3,6]", o.Jc)
|
|
}
|
|
if o.Jmin < 40 || o.Jmin > 89 {
|
|
t.Fatalf("Jmin = %d, want [40,89]", o.Jmin)
|
|
}
|
|
if o.Jmax < o.Jmin+50 || o.Jmax > o.Jmin+250 {
|
|
t.Fatalf("Jmax = %d, want [Jmin+50, Jmin+250] (Jmin=%d)", o.Jmax, o.Jmin)
|
|
}
|
|
if o.S1 < 15 || o.S1 > 150 {
|
|
t.Fatalf("S1 = %d, want [15,150]", o.S1)
|
|
}
|
|
if o.S2 < 15 || o.S2 > 150 {
|
|
t.Fatalf("S2 = %d, want [15,150]", o.S2)
|
|
}
|
|
if o.S1+56 == o.S2 {
|
|
t.Fatalf("S1+56 == S2 (%d+56 == %d): violates kernel constraint", o.S1, o.S2)
|
|
}
|
|
if o.S3 < 8 || o.S3 > 55 {
|
|
t.Fatalf("S3 = %d, want [8,55]", o.S3)
|
|
}
|
|
if o.S4 < 4 || o.S4 > 27 {
|
|
t.Fatalf("S4 = %d, want [4,27]", o.S4)
|
|
}
|
|
for name, h := range map[string]string{"H1": o.H1, "H2": o.H2, "H3": o.H3, "H4": o.H4} {
|
|
if err := validateHValue(h); err != nil {
|
|
t.Fatalf("%s = %q invalid: %v", name, h, err)
|
|
}
|
|
if h == "" {
|
|
t.Fatalf("%s is empty, want a generated range", name)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(o.I1, "<r ") || !strings.HasSuffix(o.I1, ">") {
|
|
t.Fatalf("I1 = %q, want \"<r N>\" form", o.I1)
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(o.I1, "<r "), ">"))
|
|
if err != nil || n < 32 || n > 256 {
|
|
t.Fatalf("I1 = %q, embedded N must be an integer in [32,256]", o.I1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateObfuscation20MobilePreset(t *testing.T) {
|
|
for i := 0; i < 100; i++ {
|
|
o := GenerateObfuscation20("mobile")
|
|
if o.Jc != 3 {
|
|
t.Fatalf("mobile preset: Jc = %d, want 3", o.Jc)
|
|
}
|
|
if o.Jmin < 30 || o.Jmin > 50 {
|
|
t.Fatalf("mobile preset: Jmin = %d, want [30,50]", o.Jmin)
|
|
}
|
|
if o.Jmax < o.Jmin+20 || o.Jmax > o.Jmin+80 {
|
|
t.Fatalf("mobile preset: Jmax = %d, want [Jmin+20, Jmin+80] (Jmin=%d)", o.Jmax, o.Jmin)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateHRangesNonOverlapping(t *testing.T) {
|
|
for i := 0; i < 50; i++ {
|
|
h := generateHRanges()
|
|
var prevHi int64
|
|
for i, r := range h {
|
|
lo, hi, ok := strings.Cut(r, "-")
|
|
if !ok {
|
|
t.Fatalf("H%d = %q is not a range", i+1, r)
|
|
}
|
|
loN, _ := strconv.ParseInt(lo, 10, 64)
|
|
hiN, _ := strconv.ParseInt(hi, 10, 64)
|
|
if loN <= prevHi {
|
|
t.Fatalf("H%d = %q overlaps or touches the previous range (prev high=%d)", i+1, r, prevHi)
|
|
}
|
|
if hiN-loN < hMinWidth {
|
|
t.Fatalf("H%d = %q is narrower than hMinWidth=%d", i+1, r, hMinWidth)
|
|
}
|
|
prevHi = hiN
|
|
}
|
|
}
|
|
}
|
|
|
|
func validObfuscation() Obfuscation20 {
|
|
return GenerateObfuscation20("default")
|
|
}
|
|
|
|
func TestValidateObfuscationAcceptsGenerated(t *testing.T) {
|
|
for i := 0; i < 50; i++ {
|
|
if err := ValidateObfuscation(validObfuscation()); err != nil {
|
|
t.Fatalf("generated obfuscation set rejected: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateObfuscationAcceptsBlankH(t *testing.T) {
|
|
o := validObfuscation()
|
|
o.H1, o.H2, o.H3, o.H4 = "", "", "", ""
|
|
if err := ValidateObfuscation(o); err != nil {
|
|
t.Fatalf("blank H values should be allowed (fall back to defaults): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateObfuscationRejectsBadJminJmax(t *testing.T) {
|
|
o := validObfuscation()
|
|
o.Jmin, o.Jmax = 50, 10
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatal("Jmin > Jmax must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateObfuscationRejectsBadS3S4(t *testing.T) {
|
|
o := validObfuscation()
|
|
o.S3 = 65
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatal("S3 > 64 must be rejected")
|
|
}
|
|
o = validObfuscation()
|
|
o.S4 = 33
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatal("S4 > 32 must be rejected")
|
|
}
|
|
o = validObfuscation()
|
|
o.S3, o.S4 = -1, -1
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatal("negative S3/S4 must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateObfuscationRejectsS1S2Collision(t *testing.T) {
|
|
o := validObfuscation()
|
|
o.S1 = 30
|
|
o.S2 = o.S1 + 56
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatal("S1+56 == S2 must be rejected (kernel constraint)")
|
|
}
|
|
}
|
|
|
|
func TestValidateObfuscationRejectsBadH(t *testing.T) {
|
|
cases := []string{"not-a-number", "10-", "-10", "5-4", "-1-10"}
|
|
for _, h := range cases {
|
|
o := validObfuscation()
|
|
o.H1 = h
|
|
if err := ValidateObfuscation(o); err == nil {
|
|
t.Fatalf("H1 = %q must be rejected", h)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateInterfaceNameAcceptsBlankAndPlausibleNames(t *testing.T) {
|
|
for _, name := range []string{"", "eth0", "wg0", "br-lan", "eno1.100", "veth1a2b3c", "eth0:0"} {
|
|
if err := ValidateInterfaceName(name); err != nil {
|
|
t.Errorf("ValidateInterfaceName(%q) rejected a plausible name: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateInterfaceNameRejectsShellMetacharactersAndOverlength(t *testing.T) {
|
|
cases := []string{
|
|
"eth0 -j ACCEPT; rm -rf /",
|
|
"eth0`whoami`",
|
|
"eth0$(id)",
|
|
"eth0|cat /etc/passwd",
|
|
"eth0\nMASQUERADE",
|
|
"aaaaaaaaaaaaaaaaaaaa", // 20 chars, over IFNAMSIZ-1
|
|
}
|
|
for _, name := range cases {
|
|
if err := ValidateInterfaceName(name); err == nil {
|
|
t.Errorf("ValidateInterfaceName(%q) must be rejected", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSubnetIPv4AcceptsValidBases(t *testing.T) {
|
|
cases := []struct {
|
|
ip string
|
|
cidr int
|
|
}{
|
|
{"10.8.1.0", 24},
|
|
{"10.8.1.0", 0}, // cidr <= 0 defaults to /24, mirroring serverAddress
|
|
{"192.168.5.10", 32},
|
|
}
|
|
for _, c := range cases {
|
|
if err := ValidateSubnetIPv4(c.ip, c.cidr); err != nil {
|
|
t.Errorf("ValidateSubnetIPv4(%q, %d) rejected a valid subnet: %v", c.ip, c.cidr, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateSubnetIPv4RejectsMalformedOrInjectedValues(t *testing.T) {
|
|
cases := []struct {
|
|
ip string
|
|
cidr int
|
|
}{
|
|
{"10.8.1.0 -j ACCEPT; rm -rf /", 24}, // shell injection attempt
|
|
{"not-an-ip", 24},
|
|
{"", 24},
|
|
{"fd86::1", 64}, // IPv6, not IPv4
|
|
{"10.8.1.0", 33}, // cidr out of range
|
|
}
|
|
for _, c := range cases {
|
|
if err := ValidateSubnetIPv4(c.ip, c.cidr); err == nil {
|
|
t.Errorf("ValidateSubnetIPv4(%q, %d) must be rejected", c.ip, c.cidr)
|
|
}
|
|
}
|
|
}
|