210 lines
5.6 KiB
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},
|
|
}},
|
|
}
|
|
}
|