feat: Prism HTTP/SOCKS5 代理网关及管理台
CI / docker (push) Successful in 2m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 15:58:28 +08:00
commit fe8ea784d0
170 changed files with 20056 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"context"
"fmt"
"sync"
"time"
"github.com/prism/proxy/internal/cache"
"github.com/prism/proxy/internal/configbuilder"
"github.com/prism/proxy/internal/core"
"github.com/prism/proxy/internal/geodata"
applog "github.com/prism/proxy/internal/log"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/store"
"github.com/prism/proxy/internal/subscription"
)
type App struct {
Store *store.Store
Cache *cache.Cache
Engine *core.Engine
Builder *configbuilder.Builder
Subscription *subscription.Service
Logs *applog.Collector
DataDir string
LastReloadError string
engineMu sync.RWMutex
engineOK bool
engineAt time.Time
}
func (a *App) ReloadEngine(ctx context.Context) error {
geodata.InitHomeDir(a.DataDir)
settings, err := a.Store.GetSettings(ctx)
if err != nil {
return err
}
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
yaml, err := a.Builder.Build(ctx)
if err != nil {
return err
}
apiPort := settings["mihomo_api_port"]
if apiPort == "" {
apiPort = "9090"
}
secret := settings["api_secret"]
a.Engine.Configure("127.0.0.1:"+apiPort, secret)
if err := a.applyConfig(ctx, yaml); err != nil {
a.Logs.Add("warn", "prism", "full config failed, trying minimal: "+err.Error())
minimal, mErr := a.Builder.BuildMinimal(ctx)
if mErr != nil {
return err
}
if mErr := a.applyConfig(ctx, minimal); mErr != nil {
a.LastReloadError = err.Error()
if last := a.Cache.GetLastYAML(); last != nil {
if fallbackErr := a.Engine.Reload(last); fallbackErr == nil {
a.Logs.Add("warn", "prism", "kept previous engine config after reload failure")
}
}
return err
}
a.LastReloadError = "rules simplified: " + err.Error()
a.Logs.Add("warn", "prism", a.LastReloadError)
return nil
}
return nil
}
func (a *App) applyConfig(ctx context.Context, yaml []byte) error {
if err := a.Engine.Reload(yaml); err != nil {
return err
}
a.LastReloadError = ""
a.Cache.SetLastYAML(yaml)
a.Logs.Add("info", "prism", "engine reloaded successfully")
a.engineMu.Lock()
a.engineOK = true
a.engineAt = time.Now()
a.engineMu.Unlock()
_ = a.Store.AddAuditLog(ctx, "reload", "engine", "config applied")
return nil
}
func (a *App) MihomoAPIBase(ctx context.Context) (string, string, error) {
settings, err := a.Store.GetSettings(ctx)
if err != nil {
return "", "", err
}
port := settings["mihomo_api_port"]
if port == "" {
port = "9090"
}
return fmt.Sprintf("http://127.0.0.1:%s", port), settings["api_secret"], nil
}
func (a *App) EngineReachable(ctx context.Context) bool {
if a.Engine.Running() {
return true
}
a.engineMu.RLock()
if time.Since(a.engineAt) < 15*time.Second {
ok := a.engineOK
a.engineMu.RUnlock()
return ok
}
a.engineMu.RUnlock()
ok := a.probeEngine(ctx)
a.engineMu.Lock()
a.engineOK = ok
a.engineAt = time.Now()
a.engineMu.Unlock()
return ok
}
// EngineStatusCached returns last-known engine reachability without blocking on Mihomo.
func (a *App) EngineStatusCached() bool {
if a.Engine.Running() {
return true
}
a.engineMu.RLock()
defer a.engineMu.RUnlock()
return a.engineOK
}
func (a *App) RefreshEngineStatusAsync() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok := a.probeEngine(ctx)
a.engineMu.Lock()
a.engineOK = ok
a.engineAt = time.Now()
a.engineMu.Unlock()
}()
}
func (a *App) probeEngine(ctx context.Context) bool {
base, secret, err := a.MihomoAPIBase(ctx)
if err != nil {
return false
}
return mihomoapi.New(base, secret).Reachable()
}
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"context"
"strings"
"github.com/prism/proxy/internal/country"
)
func (a *App) ProxyDisplayNames(ctx context.Context) (map[string]string, error) {
nodes, err := a.Store.ListProxyNodes(ctx, "", nil)
if err != nil {
return nil, err
}
groups, err := a.Store.ListProxyGroups(ctx)
if err != nil {
return nil, err
}
m := map[string]string{
"DIRECT": "DIRECT",
"REJECT": "REJECT",
}
for _, n := range nodes {
m[n.RuntimeName] = n.Name
}
for _, g := range groups {
m[g.RuntimeName] = g.Name
}
seen := map[string]bool{}
for _, n := range nodes {
if n.Country == "" || seen[n.Country] {
continue
}
seen[n.Country] = true
rn := country.GroupRuntime(n.Country)
m[rn] = country.Name(n.Country) + "(国家池)"
}
return m, nil
}
func (a *App) PolicyGroupRuntimes(ctx context.Context) (map[string]bool, error) {
groups, err := a.Store.ListProxyGroups(ctx)
if err != nil {
return nil, err
}
out := map[string]bool{
"GLOBAL": true,
"PROXY": true,
}
for _, g := range groups {
if g.Enabled {
out[g.RuntimeName] = true
}
}
return out, nil
}
func DisplayName(names map[string]string, key string) string {
if key == "" {
return ""
}
if name, ok := names[key]; ok && name != "" {
return name
}
if strings.HasPrefix(key, "country_") {
code := strings.TrimPrefix(key, "country_")
return country.Name(code) + "(国家池)"
}
return key
}
+166
View File
@@ -0,0 +1,166 @@
package service
import (
"context"
"fmt"
"strings"
"time"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/store"
)
type OutboundMember struct {
RuntimeName string `json:"runtime_name"`
Name string `json:"name"`
}
type OutboundGroup struct {
RuntimeName string `json:"runtime_name"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
Now string `json:"now"`
NowName string `json:"now_name"`
Selectable bool `json:"selectable"`
IsCountry bool `json:"is_country"`
Members []OutboundMember `json:"members"`
}
type OutboundOverview struct {
DefaultPolicy string `json:"default_policy"`
Groups []OutboundGroup `json:"groups"`
}
func (a *App) OutboundOverview(ctx context.Context) (OutboundOverview, error) {
settings, err := a.Store.GetSettings(ctx)
if err != nil {
return OutboundOverview{}, err
}
defaultPolicy := settings["default_policy"]
if defaultPolicy == "" {
defaultPolicy = "DIRECT"
}
base, secret, err := a.MihomoAPIBase(ctx)
if err != nil {
return OutboundOverview{}, err
}
live, err := mihomoapi.New(base, secret).ListProxiesCached(8 * time.Second)
if err != nil {
return OutboundOverview{}, fmt.Errorf("mihomo proxies: %w", err)
}
nodes, err := a.Store.ListProxyNodes(ctx, "", nil)
if err != nil {
return OutboundOverview{}, err
}
groups, err := a.Store.ListProxyGroups(ctx)
if err != nil {
return OutboundOverview{}, err
}
display := buildDisplayMap(nodes, groups)
var outGroups []OutboundGroup
seen := map[string]bool{}
for _, g := range groups {
if !g.Enabled {
continue
}
state, ok := live[g.RuntimeName]
if !ok || !mihomoapi.IsPolicyGroupType(state.Type) {
continue
}
seen[g.RuntimeName] = true
outGroups = append(outGroups, buildOutboundGroup(g.RuntimeName, g.Name, g.Source, state, display))
}
for name, state := range live {
if seen[name] || !mihomoapi.IsPolicyGroupType(state.Type) {
continue
}
outGroups = append(outGroups, buildOutboundGroup(name, display[name], "runtime", state, display))
}
return OutboundOverview{
DefaultPolicy: defaultPolicy,
Groups: outGroups,
}, nil
}
func (a *App) SelectOutboundGroup(ctx context.Context, groupRuntime, proxyRuntime string) error {
base, secret, err := a.MihomoAPIBase(ctx)
if err != nil {
return err
}
if err := mihomoapi.New(base, secret).SelectProxy(groupRuntime, proxyRuntime); err != nil {
return err
}
_ = a.Store.AddAuditLog(ctx, "select", "outbound", groupRuntime+" -> "+proxyRuntime)
return nil
}
func (a *App) SetDefaultPolicy(ctx context.Context, policy string) error {
if err := a.Store.SetSettings(ctx, map[string]string{"default_policy": policy}); err != nil {
return err
}
return a.ReloadEngine(ctx)
}
func buildOutboundGroup(runtimeName, name, source string, state mihomoapi.ProxyState, display map[string]string) OutboundGroup {
if name == "" {
name = runtimeName
}
autoOnly := state.Type == "URLTest" || state.Type == "Fallback"
members := make([]OutboundMember, 0, len(state.All))
for _, m := range state.All {
if autoOnly && (m == "DIRECT" || m == "REJECT" || m == "REJECT-DROP") {
continue
}
if m == "DIRECT" || m == "REJECT" || m == "REJECT-DROP" {
members = append(members, OutboundMember{RuntimeName: m, Name: m})
continue
}
members = append(members, OutboundMember{
RuntimeName: m,
Name: displayName(display, m),
})
}
return OutboundGroup{
RuntimeName: runtimeName,
Name: name,
Type: state.Type,
Source: source,
Now: state.Now,
NowName: displayName(display, state.Now),
Selectable: state.Type == "Selector",
IsCountry: strings.HasPrefix(runtimeName, "country_"),
Members: members,
}
}
func buildDisplayMap(nodes []store.ProxyNode, groups []store.ProxyGroup) map[string]string {
m := map[string]string{
"DIRECT": "DIRECT",
"REJECT": "REJECT",
}
seen := map[string]bool{}
for _, n := range nodes {
m[n.RuntimeName] = n.Name
if n.Country != "" && !seen[n.Country] {
seen[n.Country] = true
m[country.GroupRuntime(n.Country)] = country.Name(n.Country) + "(国家池)"
}
}
for _, g := range groups {
m[g.RuntimeName] = g.Name
}
return m
}
func displayName(display map[string]string, runtime string) string {
if name, ok := display[runtime]; ok && name != "" {
return name
}
return runtime
}