Files
shater/xrayctl/nftstats.go
T
omarandClaude Opus 4.8 a08aac62e6
build / aarch64_cortex-a53 (push) Successful in 3m11s
build / x86_64 (push) Successful in 3m4s
feat(inbounds): local SOCKS/HTTP + dokodemo inbounds [T1/T2]
Adds a Type discriminator to `config inbound` (tproxy|socks|http|dokodemo);
absent Type => tproxy, so existing configs are byte-identical. Local socks/http
listeners and the dokodemo-wrap listener get no sockopt/tproxy and are covered by
the same routing rules (no dedicated inboundTag), so their traffic follows user
policy.

Critical fix: RenderNft/nftEnabledInboundDevs/nftPrimaryInbound now filter on
isTproxyInbound() — previously EVERY enabled inbound injected a LAN tproxy divert,
so a local socks listener would have wrongly diverted LAN traffic to its port.

- model.go: Inbound.{Type,Listen,Port,Auth,User,Pass,TargetAddr,TargetPort,
  TargetNetwork} + inboundType()/isTproxyInbound(); uci.go parses them.
- generate.go: buildInbounds dispatches by type (emitTproxyInbound/
  emitLocalProxyInbound/emitDokodemoInbound); sniff default off for dokodemo.
- apply.go + nftstats.go: only tproxy inbounds participate in the nft plane.
- settings.js: type selector + dependent listener/auth/target fields.
- tests: inbound_local_test.go (socks/http/dokodemo emit, password auth, legacy
  tproxy default, TestNftIgnoresNonTproxyInbounds).

Verified live on the 512M VM: socks inbound {protocol:socks,port:1080,udp:true}
→ xray -test OK; nft does NOT contain port 1080; listener up on 127.0.0.1:1080;
mgmt-bypass chain intact. r11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:33:08 +03:00

285 lines
7.4 KiB
Go

package main
// Helpers for the `inet shater` nft table: source classification (CIDR / host /
// MAC / iface / zone), identifier sanitising, and parsing of the per-client
// dynamic set + named per-rule counters back out of `nft -j`.
//
// Naming contract (consumed by status/stats and the LuCI dashboard):
// * set `clients` : type ipv4_addr, flags dynamic, per-element counter — LAN client bytes.
// * set `clients6` : type ipv6_addr, flags dynamic, per-element counter — IPv6 client bytes.
// * counter `c_rule_<ident>` : bytes/packets attributed to rule <name|order-N>.
// * counter `c_in_<ident>` : bytes/packets for an inbound's catch-all divert.
import (
"encoding/json"
"fmt"
"net"
"os/exec"
"regexp"
"strings"
)
const (
nftTable = "inet shater"
nftClient4 = "clients"
nftClient6 = "clients6"
nftLastGood = "/etc/xray/nft/last-good.nft"
)
var nftMACRe = regexp.MustCompile(`^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$`)
// nftCount is a packets/bytes pair.
type nftCount struct {
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
}
// nftFrag is one nft match line derived from a single rule.src entry. iif, when
// non-nil, overrides the inbound device set (iface:/zone: sources). match is an
// optional saddr expression ("ip saddr X" / "ip6 saddr X" / "ether saddr X").
// fam is 4, 6, or 0 (both).
type nftFrag struct {
iif []string
match string
fam int
}
// nftIdent maps an arbitrary name to a safe nft identifier ([A-Za-z0-9_]).
func nftIdent(s string) string {
if s == "" {
return "unnamed"
}
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
return r
}
return '_'
}, s)
}
// nftRuleKey is the stable key used for a rule's counter (name, else order-N).
func nftRuleKey(r Rule) string {
if strings.TrimSpace(r.Name) != "" {
return r.Name
}
return fmt.Sprintf("order-%d", r.Order)
}
func nftRuleCounter(r Rule) string { return "c_rule_" + nftIdent(nftRuleKey(r)) }
func nftInCounter(in Inbound) string {
return "c_in_" + nftIdent(orDefault(in.Name, "lan"))
}
// nftIifExpr renders an iifname match for one or more devices.
func nftIifExpr(devs []string) string {
devs = nftDedupStr(devs)
if len(devs) == 0 {
return ""
}
if len(devs) == 1 {
return fmt.Sprintf("iifname \"%s\"", devs[0])
}
q := make([]string, len(devs))
for i, d := range devs {
q[i] = "\"" + d + "\""
}
return "iifname { " + strings.Join(q, ", ") + " }"
}
// nftEnabledInboundDevs returns the L3 devices of all enabled inbounds.
func nftEnabledInboundDevs(m *Model) []string {
var out []string
for _, in := range m.Inbounds {
if isTproxyInbound(in) {
out = append(out, ifaceDevice(in.Network))
}
}
return nftDedupStr(out)
}
// nftPrimaryInbound returns the first enabled tproxy inbound (port/mark source).
func nftPrimaryInbound(m *Model) (Inbound, bool) {
for _, in := range m.Inbounds {
if isTproxyInbound(in) {
return in, true
}
}
return Inbound{}, false
}
// nftSrcFrags converts a rule's src list into independent match fragments,
// preserving first-match OR semantics (each src entry => its own line(s)).
// inboundDevs is the default iif for ip/mac sources.
func nftSrcFrags(r Rule, inboundDevs []string) []nftFrag {
var frags []nftFrag
for _, raw := range r.Src {
s := strings.TrimSpace(raw)
if s == "" {
continue
}
switch {
case strings.HasPrefix(s, "iface:"):
dev := ifaceDevice(strings.TrimPrefix(s, "iface:"))
frags = append(frags, nftFrag{iif: []string{dev}, fam: 0})
case strings.HasPrefix(s, "zone:"):
devs := nftZoneDevices(strings.TrimPrefix(s, "zone:"))
if len(devs) > 0 {
frags = append(frags, nftFrag{iif: devs, fam: 0})
}
case nftMACRe.MatchString(s):
frags = append(frags, nftFrag{iif: inboundDevs, match: "ether saddr " + s, fam: 0})
default:
// CIDR or bare host -> normalise to a prefix and split by family.
cidr, fam := nftNormalizeCIDR(s)
if cidr == "" {
continue
}
if fam == 6 {
frags = append(frags, nftFrag{iif: inboundDevs, match: "ip6 saddr " + cidr, fam: 6})
} else {
frags = append(frags, nftFrag{iif: inboundDevs, match: "ip saddr " + cidr, fam: 4})
}
}
}
return frags
}
// nftNormalizeCIDR returns a canonical CIDR string and family (4/6) for a
// CIDR or bare host; "" if unparseable.
func nftNormalizeCIDR(s string) (string, int) {
if strings.Contains(s, "/") {
if ip, _, err := net.ParseCIDR(s); err == nil {
if ip.To4() != nil {
return s, 4
}
return s, 6
}
return "", 0
}
ip := net.ParseIP(s)
if ip == nil {
return "", 0
}
if ip.To4() != nil {
return s + "/32", 4
}
return s + "/128", 6
}
// nftZoneDevices resolves an fw4 zone name to its member L3 devices by reading
// /etc/config/firewall (reusing the UCI export parser).
func nftZoneDevices(zone string) []string {
out, err := exec.Command("uci", "-q", "export", "firewall").Output()
if err != nil {
return nil
}
secs, err := parseSections(string(out))
if err != nil {
return nil
}
var devs []string
for _, s := range secs {
if s.Type != "zone" || s.Options["name"] != zone {
continue
}
for _, netName := range s.Lists["network"] {
devs = append(devs, ifaceDevice(netName))
}
devs = append(devs, s.Lists["device"]...)
if d := s.Options["device"]; d != "" {
devs = append(devs, d)
}
}
return nftDedupStr(devs)
}
func nftDedupStr(in []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
return out
}
// --- parsing nft -j output ---
// nftListClients parses `nft -j list set inet shater <set>` into ip -> counter.
func nftListClients(set string) map[string]nftCount {
res := map[string]nftCount{}
out, err := exec.Command("nft", "-j", "list", "set", "inet", "shater", set).Output()
if err != nil {
return res
}
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
if json.Unmarshal(out, &doc) != nil {
return res
}
for _, obj := range doc.Nftables {
raw, ok := obj["set"]
if !ok {
continue
}
var s struct {
Elem []struct {
Elem struct {
Val string `json:"val"`
Counter nftCount `json:"counter"`
} `json:"elem"`
} `json:"elem"`
}
if json.Unmarshal(raw, &s) != nil {
continue
}
for _, e := range s.Elem {
if e.Elem.Val != "" {
res[e.Elem.Val] = e.Elem.Counter
}
}
}
return res
}
// nftListCounters parses `nft -j list counters table inet shater` into name -> counter.
func nftListCounters() map[string]nftCount {
res := map[string]nftCount{}
out, err := exec.Command("nft", "-j", "list", "counters", "table", "inet", "shater").Output()
if err != nil {
return res
}
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
if json.Unmarshal(out, &doc) != nil {
return res
}
for _, obj := range doc.Nftables {
raw, ok := obj["counter"]
if !ok {
continue
}
var c struct {
Name string `json:"name"`
Packets int64 `json:"packets"`
Bytes int64 `json:"bytes"`
}
if json.Unmarshal(raw, &c) != nil || c.Name == "" {
continue
}
res[c.Name] = nftCount{Packets: c.Packets, Bytes: c.Bytes}
}
return res
}
// nftTableExists reports whether our `inet shater` table is currently loaded.
func nftTableExists() bool {
return exec.Command("nft", "list", "table", "inet", "shater").Run() == nil
}