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>
285 lines
7.4 KiB
Go
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
|
|
}
|