feat: Go agent source (stdlib-only rewrite of agent.js) + parity/install docs; binaries ship as release assets
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
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},
|
||||
}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user