feat(inbounds): local SOCKS/HTTP + dokodemo inbounds [T1/T2]
build / aarch64_cortex-a53 (push) Successful in 3m11s
build / x86_64 (push) Successful in 3m4s

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>
This commit is contained in:
2026-07-09 15:33:08 +03:00
co-authored by Claude Opus 4.8
parent ad272af260
commit a08aac62e6
11 changed files with 374 additions and 52 deletions
+11 -1
View File
@@ -35,11 +35,21 @@ config globals 'globals'
config inbound
option name 'lan'
option enabled '1'
option network 'lan' # UCI-интерфейс(ы) LAN для перехвата
option type 'tproxy' # tproxy|socks|http|dokodemo (default tproxy)
option network 'lan' # tproxy: UCI-интерфейс(ы) LAN для перехвата
option tproxy_port '12345'
option tcp '1'
option udp '1'
option sniff '1' # recover SNI/Host/QUIC
# --- локальные socks/http/dokodemo (type != tproxy); НЕ участвуют в tproxy-nft ---
option listen '127.0.0.1' # socks/http/dokodemo: адрес прослушки
option port '1080' # socks/http/dokodemo: порт
option auth 'noauth' # socks/http: noauth|password
option user '' # auth=password
option pass ''
option target_addr '' # dokodemo: фикс. адрес назначения (awg-wrap)
option target_port '' # dokodemo: фикс. порт
option target_network 'udp' # dokodemo: tcp|udp|tcp,udp
```
### subscription (0..N)
+1
View File
@@ -163,6 +163,7 @@ egress-binding и DNS-рендер; далее T1 (цепочки в UI, per-cli
|---|---|
| **mux/XUDP + per-node sockopt** (T1) | mux-нода → `{enabled,concurrency:8,xudpConcurrency:16,xudpProxyUDP443:reject}`; loop-guard `mark==255` сохранён; `xray -test OK`. Vision→mux пропускается. |
| **WireGuard-outbound** (T2) | `wireguard://`-схема + импорт wg-quick `.conf`; xray 25.1.30 **принимает** wireguard-outbound (`xray -test OK`), endpoint/mark:255 верны; fingerprint по peer-pubkey. AmneziaWG — best-effort (`wgAWGSupported`, mainline не поддерживает). |
| **Локальные SOCKS/HTTP + dokodemo-inbounds** (T1/T2) | socks-inbound `{protocol:socks,port:1080,udp:true}` → `xray -test OK`; **nft НЕ содержит порт 1080** (isTproxyInbound-фильтр — локальные листенеры не диверсятся); listener поднялся `127.0.0.1:1080`; mgmt-bypass в nft цел; type пустой ⇒ tproxy (обратная совместимость). |
---
@@ -66,9 +66,10 @@ return view.extend({
o.default = '60s';
o.placeholder = '60s';
// ---------------- inbounds (multi-LAN) ----------------
s = m.section(form.GridSection, 'inbound', _('Inbounds (transparent proxy)'),
_('One or more LAN networks intercepted via TPROXY.'));
// ---------------- inbounds (tproxy + local socks/http/dokodemo) ----------------
s = m.section(form.GridSection, 'inbound', _('Inbounds'),
_('Transparent TPROXY LAN interception, plus optional local SOCKS/HTTP ' +
'proxies and a dokodemo wrap listener.'));
s.addremove = true;
s.anonymous = true;
s.nodescriptions = true;
@@ -82,13 +83,74 @@ return view.extend({
o.default = '1';
o.editable = true;
o = s.option(form.ListValue, 'type', _('Type'));
o.value('tproxy', _('Transparent (TPROXY)'));
o.value('socks', _('Local SOCKS'));
o.value('http', _('Local HTTP'));
o.value('dokodemo', _('Dokodemo (wrap)'));
o.default = 'tproxy';
// tproxy fields
o = s.option(form.Value, 'network', _('LAN network(s)'),
_('UCI interface name(s) of the LAN(s) to intercept.'));
o.placeholder = 'lan';
o.depends('type', 'tproxy');
o.depends('type', '');
o = s.option(form.Value, 'tproxy_port', _('TPROXY port'));
o.datatype = 'port';
o.default = '12345';
o.depends('type', 'tproxy');
o.depends('type', '');
// local socks/http/dokodemo listener
o = s.option(form.Value, 'listen', _('Listen address'),
_('127.0.0.1 = router/apps only (recommended). A LAN IP / 0.0.0.0 exposes it to LAN.'));
o.default = '127.0.0.1';
o.depends('type', 'socks');
o.depends('type', 'http');
o.depends('type', 'dokodemo');
o = s.option(form.Value, 'port', _('Listen port'));
o.datatype = 'port';
o.depends('type', 'socks');
o.depends('type', 'http');
o.depends('type', 'dokodemo');
// socks/http auth
o = s.option(form.ListValue, 'auth', _('Auth'));
o.value('noauth', _('None'));
o.value('password', _('Username/password'));
o.default = 'noauth';
o.depends('type', 'socks');
o.depends('type', 'http');
o = s.option(form.Value, 'user', _('Username'));
o.depends('auth', 'password');
o.modalonly = true;
o = s.option(form.Value, 'pass', _('Password'));
o.depends('auth', 'password');
o.password = true;
o.modalonly = true;
// dokodemo target
o = s.option(form.Value, 'target_addr', _('Wrap target address'));
o.depends('type', 'dokodemo');
o.modalonly = true;
o = s.option(form.Value, 'target_port', _('Wrap target port'));
o.datatype = 'port';
o.depends('type', 'dokodemo');
o.modalonly = true;
o = s.option(form.ListValue, 'target_network', _('Wrap network'));
o.value('udp');
o.value('tcp');
o.value('tcp,udp');
o.default = 'udp';
o.depends('type', 'dokodemo');
o.modalonly = true;
o = s.option(form.Flag, 'tcp', _('TCP'));
o.default = '1';
+1 -1
View File
@@ -14,7 +14,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=shater-core
PKG_VERSION:=0.1.0
PKG_RELEASE:=10
PKG_RELEASE:=11
PKG_MAINTAINER:=Shater <maqrota@icloud.com>
PKG_LICENSE:=GPL-2.0-or-later
+1 -1
View File
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=xrayctl
PKG_VERSION:=0.1.0
PKG_RELEASE:=10
PKG_RELEASE:=11
PKG_MAINTAINER:=shater
PKG_LICENSE:=MIT
+3 -1
View File
@@ -433,8 +433,10 @@ func RenderNft(m *Model) string {
}
// 2) Per-inbound catch-all divert (all remaining LAN-ingress traffic).
// Only tproxy inbounds divert; local socks/http/dokodemo listeners must not
// inject a bogus LAN tproxy rule pointing at their listener port.
for _, in := range m.Inbounds {
if !in.Enabled {
if !isTproxyInbound(in) {
continue
}
dev := ifaceDevice(in.Network)
+104 -29
View File
@@ -258,39 +258,114 @@ func (b *builder) buildInbounds() {
if !in.Enabled {
continue
}
nets := []string{}
if in.TCP {
nets = append(nets, "tcp")
switch in.inboundType() {
case "socks", "http":
b.emitLocalProxyInbound(in)
case "dokodemo":
b.emitDokodemoInbound(in)
default: // tproxy (and legacy no-Type)
b.emitTproxyInbound(in)
}
if in.UDP {
nets = append(nets, "udp")
}
if len(nets) == 0 {
nets = []string{"tcp", "udp"}
}
ib := map[string]any{
"tag": "tproxy-" + orDefault(in.Name, "lan"),
"protocol": "dokodemo-door",
"port": in.TproxyPort,
"settings": map[string]any{
"network": strings.Join(nets, ","),
"followRedirect": true,
},
"streamSettings": map[string]any{
"sockopt": map[string]any{"tproxy": "tproxy", "mark": loopMark},
},
}
if in.Sniff {
ib["sniffing"] = map[string]any{
"enabled": true,
"destOverride": []any{"http", "tls", "quic"},
"routeOnly": false,
}
}
b.inbounds = append(b.inbounds, ib)
}
}
// inNets joins an inbound's enabled networks, defaulting to tcp,udp.
func inNets(in Inbound) string {
nets := []string{}
if in.TCP {
nets = append(nets, "tcp")
}
if in.UDP {
nets = append(nets, "udp")
}
if len(nets) == 0 {
nets = []string{"tcp", "udp"}
}
return strings.Join(nets, ",")
}
func sniffBlock() map[string]any {
return map[string]any{
"enabled": true,
"destOverride": []any{"http", "tls", "quic"},
"routeOnly": false,
}
}
// emitTproxyInbound is the transparent TPROXY dokodemo-door inbound (the default).
func (b *builder) emitTproxyInbound(in Inbound) {
ib := map[string]any{
"tag": "tproxy-" + orDefault(in.Name, "lan"),
"protocol": "dokodemo-door",
"port": in.TproxyPort,
"settings": map[string]any{
"network": inNets(in),
"followRedirect": true,
},
"streamSettings": map[string]any{
"sockopt": map[string]any{"tproxy": "tproxy", "mark": loopMark},
},
}
if in.Sniff {
ib["sniffing"] = sniffBlock()
}
b.inbounds = append(b.inbounds, ib)
}
// emitLocalProxyInbound emits a local socks/http listener (router/apps). No
// sockopt/tproxy — it is not a diverted inbound; it is covered by the same
// routing rules (no dedicated inboundTag) so its traffic follows user policy.
func (b *builder) emitLocalProxyInbound(in Inbound) {
proto := in.inboundType() // socks | http
settings := map[string]any{}
if proto == "socks" {
settings["udp"] = in.UDP
}
if strings.EqualFold(in.Auth, "password") && in.User != "" {
settings["auth"] = "password"
settings["accounts"] = []any{map[string]any{"user": in.User, "pass": in.Pass}}
} else if proto == "socks" {
settings["auth"] = "noauth"
}
ib := map[string]any{
"tag": proto + "-in-" + orDefault(in.Name, proto),
"protocol": proto,
"listen": orDefault(in.Listen, "127.0.0.1"),
"port": in.Port,
"settings": settings,
}
if in.Sniff {
ib["sniffing"] = sniffBlock()
}
b.inbounds = append(b.inbounds, ib)
}
// emitDokodemoInbound emits a plain (non-tproxy) dokodemo-door listener for
// wrapping specific traffic (e.g. awg-wrap) to a fixed destination.
func (b *builder) emitDokodemoInbound(in Inbound) {
settings := map[string]any{
"network": orDefault(in.TargetNetwork, inNets(in)),
"followRedirect": false,
}
if in.TargetAddr != "" {
settings["address"] = in.TargetAddr
}
if in.TargetPort != 0 {
settings["port"] = in.TargetPort
}
ib := map[string]any{
"tag": "dokodemo-" + orDefault(in.Name, "wrap"),
"protocol": "dokodemo-door",
"listen": orDefault(in.Listen, "127.0.0.1"),
"port": in.Port,
"settings": settings,
}
if in.Sniff {
ib["sniffing"] = sniffBlock()
}
b.inbounds = append(b.inbounds, ib)
}
// --- groups (standalone balancers referenced by rules/explain) ---
func (b *builder) buildGroups() error {
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"strings"
"testing"
)
func inboundByTag(cfg map[string]any, sub string) map[string]any {
for _, o := range cfg["inbounds"].([]any) {
ib := o.(map[string]any)
if tag, _ := ib["tag"].(string); strings.Contains(tag, sub) {
return ib
}
}
return nil
}
func buildWithInbounds(t *testing.T, ins ...Inbound) map[string]any {
t.Helper()
m := &Model{Globals: defaultGlobals()}
m.Inbounds = ins
m.Nodes = []Node{{Name: "n1", Enabled: true, URI: vlessReality}}
m.Rules = []Rule{{Name: "r", Enabled: true, Order: 10, Target: "node:n1"}}
cfg, err := BuildConfig(m)
if err != nil {
t.Fatalf("BuildConfig: %v", err)
}
return cfg
}
func TestSocksInboundEmitted(t *testing.T) {
cfg := buildWithInbounds(t, Inbound{Type: "socks", Name: "local", Enabled: true, Listen: "127.0.0.1", Port: 1080, UDP: true, Sniff: true, Auth: "noauth"})
ib := inboundByTag(cfg, "socks-in-local")
if ib == nil || ib["protocol"] != "socks" {
t.Fatalf("socks inbound missing: %v", ib)
}
if ib["listen"] != "127.0.0.1" || ib["port"] != 1080 {
t.Fatalf("listen/port wrong: %v", ib)
}
st := ib["settings"].(map[string]any)
if st["udp"] != true || st["auth"] != "noauth" {
t.Fatalf("settings wrong: %v", st)
}
if _, ok := ib["streamSettings"]; ok {
t.Fatalf("local socks must have no streamSettings/sockopt: %v", ib["streamSettings"])
}
if ib["sniffing"] == nil {
t.Fatalf("sniffing expected")
}
}
func TestSocksInboundPasswordAuth(t *testing.T) {
cfg := buildWithInbounds(t, Inbound{Type: "socks", Name: "a", Enabled: true, Port: 1080, Auth: "password", User: "u", Pass: "p"})
st := inboundByTag(cfg, "socks-in-a")["settings"].(map[string]any)
if st["auth"] != "password" {
t.Fatalf("auth = %v", st["auth"])
}
acc := st["accounts"].([]any)[0].(map[string]any)
if acc["user"] != "u" || acc["pass"] != "p" {
t.Fatalf("accounts = %v", acc)
}
}
func TestHttpInboundEmitted(t *testing.T) {
cfg := buildWithInbounds(t, Inbound{Type: "http", Name: "h", Enabled: true, Port: 8123})
ib := inboundByTag(cfg, "http-in-h")
if ib == nil || ib["protocol"] != "http" || ib["port"] != 8123 {
t.Fatalf("http inbound wrong: %v", ib)
}
if _, ok := ib["streamSettings"]; ok {
t.Fatalf("http inbound must have no sockopt")
}
}
func TestDokodemoWrapInbound(t *testing.T) {
cfg := buildWithInbounds(t, Inbound{Type: "dokodemo", Name: "wrap", Enabled: true, Port: 5353, TargetAddr: "10.13.13.1", TargetPort: 51820, TargetNetwork: "udp", Sniff: false})
ib := inboundByTag(cfg, "dokodemo-wrap")
if ib == nil || ib["protocol"] != "dokodemo-door" {
t.Fatalf("dokodemo inbound wrong: %v", ib)
}
st := ib["settings"].(map[string]any)
if st["address"] != "10.13.13.1" || st["port"] != 51820 || st["network"] != "udp" || st["followRedirect"] != false {
t.Fatalf("dokodemo settings wrong: %v", st)
}
if ib["sniffing"] != nil {
t.Fatalf("sniffing should be off for dokodemo-wrap")
}
if _, ok := ib["streamSettings"]; ok {
t.Fatalf("dokodemo must have no sockopt")
}
}
func TestLegacyTproxyInboundStillEmitted(t *testing.T) {
// Type empty => tproxy; must keep the sockopt tproxy/mark.
cfg := buildWithInbounds(t, Inbound{Name: "lan", Enabled: true, TproxyPort: 12345, TCP: true, UDP: true})
ib := inboundByTag(cfg, "tproxy-lan")
if ib == nil || ib["protocol"] != "dokodemo-door" {
t.Fatalf("legacy tproxy inbound missing: %v", ib)
}
so := ib["streamSettings"].(map[string]any)["sockopt"].(map[string]any)
if so["tproxy"] != "tproxy" || so["mark"] != loopMark {
t.Fatalf("tproxy sockopt wrong: %v", so)
}
}
func TestNftIgnoresNonTproxyInbounds(t *testing.T) {
m := &Model{Globals: defaultGlobals()}
m.Inbounds = []Inbound{
{Name: "lan", Enabled: true, Network: "lan", TproxyPort: 12345, TCP: true, UDP: true},
{Type: "socks", Name: "local", Enabled: true, Port: 1080, UDP: true},
}
nft := RenderNft(m)
if !strings.Contains(nft, "12345") {
t.Fatalf("tproxy port 12345 must appear in nft")
}
if strings.Contains(nft, "1080") {
t.Fatalf("socks listener port 1080 must NOT appear as a tproxy divert:\n%s", nft)
}
}
+48 -6
View File
@@ -51,15 +51,57 @@ func defaultGlobals() Globals {
}
}
// Inbound is a `config inbound` (multi-LAN tproxy entry).
// Inbound is a `config inbound`. Type selects the shape:
//
// tproxy (default) — transparent dokodemo-door + TPROXY sockopt (multi-LAN)
// socks — local SOCKS5 listener (router/apps) [T1]
// http — local HTTP proxy listener [T1]
// dokodemo — plain redirect target for wrapping traffic (awg-wrap)[T2]
//
// Only tproxy inbounds are wired into the nft TPROXY plane (see isTproxyInbound);
// socks/http/dokodemo are plain listeners with no sockopt/mark. Absent Type =>
// tproxy, so existing configs are unchanged.
type Inbound struct {
Name string
Enabled bool
Name string
Enabled bool
Type string // tproxy|socks|http|dokodemo (default tproxy)
// tproxy
Network string
TproxyPort int
TCP bool
UDP bool
Sniff bool
// socks/http/dokodemo local listener
Listen string // bind addr; default 127.0.0.1
Port int
// socks/http auth
Auth string // noauth|password (default noauth)
User string
Pass string
// dokodemo target (fixed destination for wrapped traffic)
TargetAddr string
TargetPort int
TargetNetwork string // tcp|udp|tcp,udp (default udp)
// shared
TCP bool
UDP bool
Sniff bool
}
// inboundType returns the effective type, defaulting empty -> tproxy.
func (in Inbound) inboundType() string {
if in.Type == "" {
return "tproxy"
}
return strings.ToLower(in.Type)
}
// isTproxyInbound reports whether an inbound participates in the TPROXY nft plane
// (only tproxy inbounds divert LAN traffic; local socks/http/dokodemo do not).
func isTproxyInbound(in Inbound) bool {
return in.Enabled && in.inboundType() == "tproxy"
}
// Subscription is a `config subscription`.
+3 -3
View File
@@ -90,17 +90,17 @@ func nftIifExpr(devs []string) string {
func nftEnabledInboundDevs(m *Model) []string {
var out []string
for _, in := range m.Inbounds {
if in.Enabled {
if isTproxyInbound(in) {
out = append(out, ifaceDevice(in.Network))
}
}
return nftDedupStr(out)
}
// nftPrimaryInbound returns the first enabled inbound (port/mark source).
// nftPrimaryInbound returns the first enabled tproxy inbound (port/mark source).
func nftPrimaryInbound(m *Model) (Inbound, bool) {
for _, in := range m.Inbounds {
if in.Enabled {
if isTproxyInbound(in) {
return in, true
}
}
+18 -7
View File
@@ -40,14 +40,25 @@ func ParseUCIExport(text string) (*Model, error) {
case "globals":
applyGlobals(&m.Globals, s)
case "inbound":
typ := s.optOr("type", "tproxy")
m.Inbounds = append(m.Inbounds, Inbound{
Name: s.opt("name"),
Enabled: s.optBool("enabled", true),
Network: s.opt("network"),
TproxyPort: parseInt(s.opt("tproxy_port"), 12345),
TCP: s.optBool("tcp", true),
UDP: s.optBool("udp", true),
Sniff: s.optBool("sniff", true),
Name: s.opt("name"),
Enabled: s.optBool("enabled", true),
Type: typ,
Network: s.opt("network"),
TproxyPort: parseInt(s.opt("tproxy_port"), 12345),
Listen: s.optOr("listen", "127.0.0.1"),
Port: parseInt(s.opt("port"), 0),
Auth: s.optOr("auth", "noauth"),
User: s.opt("user"),
Pass: s.opt("pass"),
TargetAddr: s.opt("target_addr"),
TargetPort: parseInt(s.opt("target_port"), 0),
TargetNetwork: s.optOr("target_network", "udp"),
TCP: s.optBool("tcp", true),
UDP: s.optBool("udp", true),
// sniff defaults on for tproxy/socks/http, off for dokodemo-wrap.
Sniff: s.optBool("sniff", typ != "dokodemo"),
})
case "subscription":
m.Subscriptions = append(m.Subscriptions, Subscription{