Files
NexusGate/agent-go/resources.go

210 lines
5.6 KiB
Go

package main
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// readResources loads every *.json in the resource dir, filename-sorted,
// exactly like agent.js readResources(). Files hold the *materialized*
// resource objects written by applyResource.
func readResources() ([]map[string]interface{}, error) {
entries, err := os.ReadDir(cfg.ResourceDir)
if err != nil {
return nil, err
}
names := []string{}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
names = append(names, entry.Name())
}
}
sort.Strings(names)
resources := []map[string]interface{}{}
for _, name := range names {
data, err := os.ReadFile(filepath.Join(cfg.ResourceDir, name))
if err != nil {
return nil, err
}
decoded, err := decodeJSON(data)
if err != nil {
return nil, err
}
if m, ok := asMap(decoded); ok {
resources = append(resources, m)
continue
}
if decoded == nil {
// JS would throw a TypeError on item.inbounds for a null resource.
return nil, fmt.Errorf("resource %s is null", name)
}
// Non-object JSON: JS property access yields undefined everywhere,
// which behaves like an empty resource. Mirror with an empty map.
resources = append(resources, map[string]interface{}{})
}
return resources, nil
}
var resourceIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{8,100}$`)
func safeResourcePath(resourceID string) (string, error) {
if !resourceIDRe.MatchString(resourceID) {
return "", fmt.Errorf("Invalid resource id")
}
return filepath.Join(cfg.ResourceDir, resourceID+".json"), nil
}
func resMeta(res map[string]interface{}) map[string]interface{} {
meta, _ := asMap(res["meta"])
return meta
}
func isLocalAddress(text string) bool {
target := net.ParseIP(text)
if target == nil {
return false
}
ifaces, err := net.Interfaces()
if err != nil {
return false
}
for _, iface := range ifaces {
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch a := addr.(type) {
case *net.IPNet:
ip = a.IP
case *net.IPAddr:
ip = a.IP
}
if ip != nil && ip.Equal(target) {
return true
}
}
}
return false
}
// normalizeInboundListen mirrors agent.js: a public IP that is not assigned
// to a local interface (typical NAT cloud VM) cannot be bound, so fall back
// to the wildcard address of the same family.
func normalizeInboundListen(inbound interface{}) interface{} {
m, ok := asMap(inbound)
if !ok {
return inbound
}
raw, isStr := m["listen"].(string)
if !isStr {
return inbound
}
text := strings.TrimSpace(raw)
if text == "" || net.ParseIP(text) == nil || text == "0.0.0.0" || text == "::" {
return inbound
}
if isLocalAddress(text) {
return inbound
}
fallback := "0.0.0.0"
if strings.Contains(text, ":") {
fallback = "::"
}
logf("Listen %s 不是本机地址,回退为 %s", text, fallback)
out := make(map[string]interface{}, len(m))
for k, v := range m {
out[k] = v
}
out["listen"] = fallback
return out
}
// Ordered structs keep the generated sing-box config's top-level field order
// identical to the JS template literal in combinedSingBoxConfig (nested
// resource objects keep their own keys, reserialized alphabetically by Go —
// semantically irrelevant to sing-box).
type combinedLog struct {
Level string `json:"level"`
Output string `json:"output"`
Timestamp bool `json:"timestamp"`
}
type combinedRoute struct {
Rules []interface{} `json:"rules"`
Final string `json:"final"`
}
type v2rayAPIStats struct {
Enabled bool `json:"enabled"`
Inbounds []interface{} `json:"inbounds"`
Users []interface{} `json:"users"`
}
type v2rayAPI struct {
Listen string `json:"listen"`
Stats v2rayAPIStats `json:"stats"`
}
type combinedExperimental struct {
V2RayAPI v2rayAPI `json:"v2ray_api"`
}
type combinedConfig struct {
Log combinedLog `json:"log"`
Inbounds []interface{} `json:"inbounds"`
Outbounds []interface{} `json:"outbounds"`
Route combinedRoute `json:"route"`
Experimental combinedExperimental `json:"experimental"`
}
// combinedSingBoxConfig merges all resources into the single sing-box config
// — agent.js combinedSingBoxConfig().
func combinedSingBoxConfig(resources []map[string]interface{}) combinedConfig {
inbounds := []interface{}{}
statInbounds := []interface{}{}
statUsers := []interface{}{}
outbounds := []interface{}{map[string]interface{}{"type": "direct", "tag": "ng-fallback-direct"}}
rules := []interface{}{}
for _, res := range resources {
resInbounds := asSlice(res["inbounds"])
for _, inbound := range resInbounds {
inbounds = append(inbounds, normalizeInboundListen(inbound))
if im, ok := asMap(inbound); ok {
statInbounds = append(statInbounds, im["tag"])
for _, user := range asSlice(im["users"]) {
um, ok := asMap(user)
if !ok {
continue
}
name := um["name"]
if !jsTruthy(name) {
name = um["username"] // AnyTLS/SS use name, SOCKS5 username
}
if jsTruthy(name) {
statUsers = append(statUsers, name)
}
}
}
}
outbounds = append(outbounds, asSlice(res["outbounds"])...)
rules = append(rules, asSlice(res["routingRules"])...)
}
return combinedConfig{
Log: combinedLog{Level: "info", Output: cfg.SingBoxLog, Timestamp: true},
Inbounds: inbounds,
Outbounds: outbounds,
Route: combinedRoute{Rules: rules, Final: "ng-fallback-direct"},
Experimental: combinedExperimental{V2RayAPI: v2rayAPI{
Listen: "127.0.0.1:" + cfg.APIPort,
Stats: v2rayAPIStats{Enabled: true, Inbounds: statInbounds, Users: statUsers},
}},
}
}