mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
f66e369e41
A node-sync token, or a master enrolled with the mTLS certificate, may push inbounds, and the node stored them as already validated. An inbound is effectively config for a core running as root, so a push could serve the host's files (hysteria file masquerade), relay to its own services or the core API (masquerade proxy, fallback dest, REALITY target), write files (masterKeyLog), use local or templated XDRIVE storage, fetch URLs (ECH, realm masks), or run tun/http/mixed inbounds. A push now refuses those fields unless the node already stores the same value, which keeps rows configured by the node's own admin syncing: relay targets must be public or another of this node's inbounds, and only node-eligible protocols are accepted. Every caller is refused env: references, which the core expands from the panel's environment, and the inbound listen address is JSON-encoded into the config instead of quoted by hand.
233 lines
7.4 KiB
Go
233 lines
7.4 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
)
|
|
|
|
// jsonLeaf is one scalar of a JSON document with the lowercased key path leading to it.
|
|
// Repeated keys each yield a leaf: xray-core binds keys case-folded, and the last wins.
|
|
type jsonLeaf struct {
|
|
path []string
|
|
value string
|
|
}
|
|
|
|
func (l jsonLeaf) endsWith(suffix ...string) bool {
|
|
return len(l.path) >= len(suffix) && slices.Equal(l.path[len(l.path)-len(suffix):], suffix)
|
|
}
|
|
|
|
// jsonLeaves walks raw token by token, so no repeated key hides behind another.
|
|
func jsonLeaves(raw string) []jsonLeaf {
|
|
dec := json.NewDecoder(strings.NewReader(raw))
|
|
dec.UseNumber()
|
|
var leaves []jsonLeaf
|
|
var walk func(path []string) bool
|
|
walk = func(path []string) bool {
|
|
tok, err := dec.Token()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
switch tok {
|
|
case json.Delim('{'):
|
|
for dec.More() {
|
|
key, err := dec.Token()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
name, _ := key.(string)
|
|
if !walk(append(slices.Clone(path), strings.ToLower(name))) {
|
|
return false
|
|
}
|
|
}
|
|
_, err = dec.Token()
|
|
return err == nil
|
|
case json.Delim('['):
|
|
for dec.More() {
|
|
if !walk(append(slices.Clone(path), "[]")) {
|
|
return false
|
|
}
|
|
}
|
|
_, err = dec.Token()
|
|
return err == nil
|
|
}
|
|
leaves = append(leaves, jsonLeaf{path: path, value: fmt.Sprint(tok)})
|
|
return true
|
|
}
|
|
walk(nil)
|
|
return leaves
|
|
}
|
|
|
|
// isEnvRef matches what xray-core expands from the panel's environment: a whole value, or
|
|
// one entry of a comma list such as a port list.
|
|
func isEnvRef(value string) bool {
|
|
for part := range strings.SplitSeq(value, ",") {
|
|
if strings.HasPrefix(strings.TrimSpace(part), "env:") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validateInboundEnvRefs refuses "env:NAME" anywhere the core would expand it: the panel's
|
|
// environment (database DSN, token key) must not reach a config or a load error quoting it.
|
|
func validateInboundEnvRefs(inbound *model.Inbound) error {
|
|
found := isEnvRef(inbound.Listen)
|
|
for _, raw := range []string{inbound.Settings, inbound.StreamSettings, inbound.Sniffing} {
|
|
for _, leaf := range jsonLeaves(raw) {
|
|
found = found || isEnvRef(leaf.value)
|
|
}
|
|
}
|
|
if found {
|
|
return common.NewError(`an inbound must not refer to environment variables ("env:")`)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pushReach is one field of a pushed inbound that names a resource on this host.
|
|
type pushReach struct {
|
|
field, value string
|
|
}
|
|
|
|
// validatePushedInbound refuses, on a master's push, the fields that reach this host's own
|
|
// files, sockets and services; a value the stored row already carries was set here and stays.
|
|
func validatePushedInbound(inbound, stored *model.Inbound) error {
|
|
if (stored == nil || stored.Protocol != inbound.Protocol) && !nodeEligibleProtocols[inbound.Protocol] {
|
|
return common.NewErrorf("%s inbounds cannot be pushed to a node", inbound.Protocol)
|
|
}
|
|
reaches, err := hostReaches(inbound)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var allowed []pushReach
|
|
if stored != nil {
|
|
if allowed, err = hostReaches(stored); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, r := range reaches {
|
|
if !slices.Contains(allowed, r) {
|
|
return common.NewErrorf("a pushed inbound may not set %s to %q on this node", r.field, r.value)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hostReaches(inbound *model.Inbound) ([]pushReach, error) {
|
|
local, err := localRelayTargets(inbound.Id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var reaches []pushReach
|
|
add := func(field string, leaf jsonLeaf) {
|
|
reaches = append(reaches, pushReach{field: field, value: leaf.value})
|
|
}
|
|
streamLeaves := jsonLeaves(inbound.StreamSettings)
|
|
// A whole object is one reach: a push keeping the type but moving the url or folder
|
|
// is a different reach than the one this node stores.
|
|
object := func(field string, container ...string) {
|
|
var parts []string
|
|
for _, l := range streamLeaves {
|
|
if i := slices.Index(l.path, container[len(container)-1]); i >= len(container)-1 && slices.Equal(l.path[i+1-len(container):i+1], container) {
|
|
parts = append(parts, strings.Join(l.path[i+1:], ".")+"="+l.value)
|
|
}
|
|
}
|
|
slices.Sort(parts)
|
|
reaches = append(reaches, pushReach{field: field, value: strings.Join(parts, ";")})
|
|
}
|
|
for _, leaf := range streamLeaves {
|
|
lower := strings.ToLower(strings.TrimSpace(leaf.value))
|
|
switch {
|
|
case leaf.endsWith("hysteriasettings", "masquerade", "type") && (lower == "file" || lower == "proxy"):
|
|
object("hysteria masquerade", "hysteriasettings", "masquerade")
|
|
case leaf.endsWith("masterkeylog") && lower != "" && lower != "none":
|
|
add("masterKeyLog", leaf)
|
|
case leaf.endsWith("xdrivesettings", "service") && (lower == "local" || lower == "template"):
|
|
object("xdrive service", "xdrivesettings")
|
|
case leaf.endsWith("echconfiglist") && strings.Contains(lower, "://"):
|
|
add("echConfigList", leaf)
|
|
case slices.Contains(leaf.path, "finalmask") && leaf.endsWith("type") && lower == "realm":
|
|
add("finalmask", leaf)
|
|
case leaf.endsWith("realitysettings", "target") || leaf.endsWith("realitysettings", "dest"):
|
|
if !isPublicRelayTarget(leaf.value) {
|
|
add("REALITY target", leaf)
|
|
}
|
|
}
|
|
}
|
|
for _, leaf := range jsonLeaves(inbound.Settings) {
|
|
switch {
|
|
case leaf.endsWith("fallbacks", "[]", "dest"):
|
|
if !isPublicRelayTarget(leaf.value) && !slices.Contains(local, normalizeRelayTarget(leaf.value)) {
|
|
add("fallback dest", leaf)
|
|
}
|
|
case leaf.endsWith("domainfronting", "ip"):
|
|
if ip, err := netip.ParseAddr(strings.TrimSpace(leaf.value)); err == nil && isBlockedIP(net.IP(ip.AsSlice())) {
|
|
add("domainFronting ip", leaf)
|
|
}
|
|
}
|
|
}
|
|
return reaches, nil
|
|
}
|
|
|
|
// isPublicRelayTarget reports whether a relay target dials a public host, not this one: a
|
|
// bare port, a unix socket and a loopback or private host all stay on the machine.
|
|
func isPublicRelayTarget(target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") {
|
|
return false
|
|
}
|
|
host, _, err := net.SplitHostPort(target)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !namesInternalHost(host)
|
|
}
|
|
|
|
// normalizeRelayTarget reduces a fallback dest to the form localRelayTargets lists.
|
|
func normalizeRelayTarget(target string) string {
|
|
target = strings.TrimSpace(target)
|
|
if strings.HasPrefix(target, "/") || strings.HasPrefix(target, "@") {
|
|
return target
|
|
}
|
|
if _, err := strconv.Atoi(target); err == nil {
|
|
return target
|
|
}
|
|
if _, port, err := net.SplitHostPort(target); err == nil {
|
|
return port
|
|
}
|
|
return target
|
|
}
|
|
|
|
// localRelayTargets lists what this node's other inbounds listen on, the only local
|
|
// fallback dests a master generates; the core's own listeners are never among them.
|
|
func localRelayTargets(selfID int) ([]string, error) {
|
|
var rows []model.Inbound
|
|
if err := database.GetDB().Select("id", "listen", "port").Where("node_id IS NULL AND id <> ?", selfID).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
reserved := map[string]bool{strconv.Itoa(reservedAPIPort()): true}
|
|
for _, port := range xrayOwnPorts() {
|
|
reserved[port] = true
|
|
}
|
|
var targets []string
|
|
for _, ib := range rows {
|
|
port := strconv.Itoa(ib.Port)
|
|
if reserved[port] {
|
|
continue
|
|
}
|
|
targets = append(targets, port)
|
|
if listen := strings.TrimSpace(ib.Listen); strings.HasPrefix(listen, "/") || strings.HasPrefix(listen, "@") {
|
|
targets = append(targets, strings.SplitN(listen, ",", 2)[0])
|
|
}
|
|
}
|
|
return targets, nil
|
|
}
|