4200f90159
Go 控制面(SQLite + REST API)嵌入 Mihomo 数据面,Vue 3 多语言 Web 管理台。 含订阅/节点/规则/出站/监控/日志、OpenAPI、API Key、Gitea Actions CI(runs-on: docker) 与开源文档;镜像发布至 git.rc707blog.top Container Registry。 Co-authored-by: Cursor <cursoragent@cursor.com>
368 lines
8.7 KiB
Go
368 lines
8.7 KiB
Go
package configbuilder
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/prism/proxy/internal/country"
|
|
"github.com/prism/proxy/internal/geodata"
|
|
"github.com/prism/proxy/internal/store"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Builder struct {
|
|
store *store.Store
|
|
dataDir string
|
|
}
|
|
|
|
func New(s *store.Store, dataDir string) *Builder {
|
|
return &Builder{store: s, dataDir: dataDir}
|
|
}
|
|
|
|
func (b *Builder) Build(ctx context.Context) ([]byte, error) {
|
|
return b.build(ctx, false)
|
|
}
|
|
|
|
// BuildMinimal loads nodes/groups with MATCH-only rules when full config fails.
|
|
func (b *Builder) BuildMinimal(ctx context.Context) ([]byte, error) {
|
|
return b.build(ctx, true)
|
|
}
|
|
|
|
func (b *Builder) build(ctx context.Context, minimalRules bool) ([]byte, error) {
|
|
settings, err := b.store.GetSettings(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpPort := settings["http_port"]
|
|
socksPort := settings["socks_port"]
|
|
logLevel := settings["log_level"]
|
|
if logLevel == "" {
|
|
logLevel = "info"
|
|
}
|
|
defaultPolicy := settings["default_policy"]
|
|
if defaultPolicy == "" {
|
|
defaultPolicy = "DIRECT"
|
|
}
|
|
|
|
nodes, err := b.store.ListProxyNodes(ctx, "", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
groups, err := b.store.ListProxyGroups(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
mergedRules, err := b.store.ListMergedRules(ctx, defaultPolicy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if minimalRules {
|
|
mergedRules = nil
|
|
}
|
|
|
|
cfg := map[string]any{
|
|
"port": atoi(httpPort, 7890),
|
|
"socks-port": atoi(socksPort, 7891),
|
|
"allow-lan": true,
|
|
"mode": "rule",
|
|
"log-level": logLevel,
|
|
"proxies": []any{},
|
|
"proxy-groups": []any{
|
|
map[string]any{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": []string{"DIRECT"},
|
|
},
|
|
},
|
|
"rules": []string{"MATCH," + defaultPolicy},
|
|
}
|
|
applyInboundAuth(cfg, settings)
|
|
|
|
var proxies []any
|
|
proxyNames := []string{}
|
|
for _, n := range nodes {
|
|
if !n.Enabled {
|
|
continue
|
|
}
|
|
var proxy map[string]any
|
|
if err := json.Unmarshal([]byte(n.RawConfig), &proxy); err != nil {
|
|
continue
|
|
}
|
|
proxy["name"] = n.RuntimeName
|
|
proxies = append(proxies, proxy)
|
|
proxyNames = append(proxyNames, n.RuntimeName)
|
|
}
|
|
cfg["proxies"] = proxies
|
|
|
|
var proxyGroups []any
|
|
hasGroups := false
|
|
for _, g := range groups {
|
|
if !g.Enabled {
|
|
continue
|
|
}
|
|
hasGroups = true
|
|
var members []string
|
|
_ = json.Unmarshal([]byte(g.Proxies), &members)
|
|
remapped := remapMembers(members, nodes, groups)
|
|
proxyGroups = append(proxyGroups, buildProxyGroup(g.RuntimeName, g.Type, remapped))
|
|
}
|
|
if !hasGroups && len(proxyNames) > 0 {
|
|
proxyGroups = []any{
|
|
map[string]any{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": append(proxyNames, "DIRECT"),
|
|
},
|
|
}
|
|
} else if len(proxyGroups) > 0 {
|
|
cfg["proxy-groups"] = proxyGroups
|
|
} else {
|
|
cfg["proxy-groups"] = []any{
|
|
map[string]any{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": []string{"DIRECT"},
|
|
},
|
|
}
|
|
}
|
|
if len(proxyGroups) > 0 {
|
|
cfg["proxy-groups"] = proxyGroups
|
|
}
|
|
proxyGroups = appendCountryProxyGroups(proxyGroups, nodes)
|
|
cfg["proxy-groups"] = proxyGroups
|
|
|
|
nameMap := buildNameMap(nodes, groups)
|
|
countryNameMap, countryValid := countryMaps(nodes)
|
|
for k, v := range countryNameMap {
|
|
nameMap[k] = v
|
|
}
|
|
valid := buildValidTargets(nodes, groups)
|
|
for k := range countryValid {
|
|
valid[k] = true
|
|
}
|
|
defaultPolicy = resolvePolicy(defaultPolicy, nameMap, valid, groups)
|
|
cfg["rules"] = sanitizeRules(mergedRules, nameMap, valid, defaultPolicy, geodata.Ready(b.dataDir))
|
|
|
|
out, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func buildNameMap(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]string {
|
|
nameMap := make(map[string]string)
|
|
for _, n := range nodes {
|
|
nameMap[n.Name] = n.RuntimeName
|
|
nameMap[n.RuntimeName] = n.RuntimeName
|
|
}
|
|
for _, g := range groups {
|
|
nameMap[g.Name] = g.RuntimeName
|
|
nameMap[g.RuntimeName] = g.RuntimeName
|
|
}
|
|
return nameMap
|
|
}
|
|
|
|
func remapMembers(members []string, nodes []store.ProxyNode, groups []store.ProxyGroup) []string {
|
|
nameMap := buildNameMap(nodes, groups)
|
|
out := make([]string, 0, len(members))
|
|
for _, name := range members {
|
|
if name == "DIRECT" || name == "REJECT" {
|
|
out = append(out, name)
|
|
continue
|
|
}
|
|
if rn, ok := nameMap[name]; ok {
|
|
out = append(out, rn)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func remapRuleTarget(rule string, nameMap map[string]string) string {
|
|
parts := strings.Split(rule, ",")
|
|
if len(parts) < 2 {
|
|
return rule
|
|
}
|
|
target := parts[len(parts)-1]
|
|
if code, ok := country.ParseTargetAlias(target); ok {
|
|
parts[len(parts)-1] = country.GroupRuntime(code)
|
|
return strings.Join(parts, ",")
|
|
}
|
|
if mapped, ok := nameMap[target]; ok {
|
|
parts[len(parts)-1] = mapped
|
|
return strings.Join(parts, ",")
|
|
}
|
|
return rule
|
|
}
|
|
|
|
func remapProxyNames(names []string, nodes []store.ProxyNode) []string {
|
|
return remapMembers(names, nodes, nil)
|
|
}
|
|
|
|
func buildProxyGroup(name, gtype string, members []string) map[string]any {
|
|
group := map[string]any{
|
|
"name": name,
|
|
"type": gtype,
|
|
"proxies": appendGroupMembers(gtype, members),
|
|
}
|
|
switch gtype {
|
|
case "url-test", "fallback":
|
|
group["url"] = "http://www.gstatic.com/generate_204"
|
|
group["interval"] = 300
|
|
}
|
|
return group
|
|
}
|
|
|
|
func appendGroupMembers(gtype string, members []string) []string {
|
|
switch strings.ToLower(gtype) {
|
|
case "url-test", "fallback":
|
|
return members
|
|
default:
|
|
return append(append([]string{}, members...), "DIRECT")
|
|
}
|
|
}
|
|
|
|
func buildValidTargets(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]bool {
|
|
valid := map[string]bool{
|
|
"DIRECT": true,
|
|
"REJECT": true,
|
|
}
|
|
for _, n := range nodes {
|
|
if n.Enabled {
|
|
valid[n.RuntimeName] = true
|
|
}
|
|
}
|
|
for _, g := range groups {
|
|
if g.Enabled {
|
|
valid[g.RuntimeName] = true
|
|
}
|
|
}
|
|
return valid
|
|
}
|
|
|
|
func resolvePolicy(policy string, nameMap map[string]string, valid map[string]bool, groups []store.ProxyGroup) string {
|
|
if code, ok := country.ParseTargetAlias(policy); ok {
|
|
rn := country.GroupRuntime(code)
|
|
if valid[rn] {
|
|
return rn
|
|
}
|
|
}
|
|
if mapped, ok := nameMap[policy]; ok && valid[mapped] {
|
|
return mapped
|
|
}
|
|
if valid[policy] {
|
|
return policy
|
|
}
|
|
if policy == "PROXY" || policy == "GLOBAL" {
|
|
for _, g := range groups {
|
|
if g.Enabled && valid[g.RuntimeName] {
|
|
return g.RuntimeName
|
|
}
|
|
}
|
|
}
|
|
return "DIRECT"
|
|
}
|
|
|
|
func sanitizeRules(merged []store.MergedRule, nameMap map[string]string, valid map[string]bool, defaultPolicy string, geoReady bool) []string {
|
|
var rules []string
|
|
hasMatch := false
|
|
for _, r := range merged {
|
|
line := remapRuleTarget(r.Rule, nameMap)
|
|
if !geoReady && needsGeoData(line) {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "MATCH,") {
|
|
line = "MATCH," + defaultPolicy
|
|
hasMatch = true
|
|
}
|
|
target := ruleTarget(line)
|
|
if target == "" || !valid[target] {
|
|
continue
|
|
}
|
|
rules = append(rules, line)
|
|
}
|
|
if !hasMatch {
|
|
rules = append(rules, "MATCH,"+defaultPolicy)
|
|
}
|
|
return rules
|
|
}
|
|
|
|
func needsGeoData(rule string) bool {
|
|
upper := strings.ToUpper(rule)
|
|
if strings.HasPrefix(upper, "GEOIP,") || strings.HasPrefix(upper, "GEOSITE,") {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(rule), "geoip:")
|
|
}
|
|
|
|
func ruleTarget(line string) string {
|
|
parts := strings.Split(line, ",")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
func applyInboundAuth(cfg map[string]any, settings map[string]string) {
|
|
user := strings.TrimSpace(settings["proxy_auth_user"])
|
|
pass := settings["proxy_auth_pass"]
|
|
if user == "" || pass == "" {
|
|
return
|
|
}
|
|
cfg["authentication"] = []string{user + ":" + pass}
|
|
cfg["skip-auth-prefixes"] = []string{"127.0.0.1/8", "::1/128"}
|
|
}
|
|
|
|
func atoi(s string, def int) int {
|
|
var v int
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%d", &v); err != nil || v <= 0 {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func appendCountryProxyGroups(groups []any, nodes []store.ProxyNode) []any {
|
|
byCountry := map[string][]string{}
|
|
for _, n := range nodes {
|
|
if !n.Enabled || n.Country == "" {
|
|
continue
|
|
}
|
|
byCountry[n.Country] = append(byCountry[n.Country], n.RuntimeName)
|
|
}
|
|
codes := make([]string, 0, len(byCountry))
|
|
for code := range byCountry {
|
|
codes = append(codes, code)
|
|
}
|
|
sort.Strings(codes)
|
|
for _, code := range codes {
|
|
members := byCountry[code]
|
|
if len(members) == 0 {
|
|
continue
|
|
}
|
|
groups = append(groups, buildProxyGroup(country.GroupRuntime(code), "url-test", members))
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func countryMaps(nodes []store.ProxyNode) (map[string]string, map[string]bool) {
|
|
nameMap := map[string]string{}
|
|
valid := map[string]bool{}
|
|
seen := map[string]bool{}
|
|
for _, n := range nodes {
|
|
if !n.Enabled || n.Country == "" || seen[n.Country] {
|
|
continue
|
|
}
|
|
seen[n.Country] = true
|
|
rn := country.GroupRuntime(n.Country)
|
|
nameMap[country.TargetAlias(n.Country)] = rn
|
|
nameMap[country.Name(n.Country)] = rn
|
|
nameMap[rn] = rn
|
|
valid[rn] = true
|
|
}
|
|
return nameMap, valid
|
|
}
|