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
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
func issueToken(secret string, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
}
func verifyToken(secret, token string) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
exp, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || time.Now().Unix() > exp {
return false
}
expected := signToken(secret, exp)
return hmac.Equal([]byte(parts[1]), []byte(expected))
}
func signToken(secret string, exp int64) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"fmt"
"testing"
"time"
)
func TestIssueAndVerifyToken(t *testing.T) {
secret := "test-secret"
token := issueToken(secret, time.Hour)
if !verifyToken(secret, token) {
t.Fatal("expected valid token")
}
}
func TestVerifyTokenWrongSecret(t *testing.T) {
token := issueToken("a", time.Hour)
if verifyToken("b", token) {
t.Fatal("expected invalid token for wrong secret")
}
}
func TestVerifyTokenExpired(t *testing.T) {
secret := "test-secret"
exp := time.Now().Add(-time.Hour).Unix()
token := fmtToken(secret, exp)
if verifyToken(secret, token) {
t.Fatal("expected expired token to be rejected")
}
}
func fmtToken(secret string, exp int64) string {
return fmt.Sprintf("%d.%s", exp, signToken(secret, exp))
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xri := strings.TrimSpace(c.GetHeader("X-Real-IP")); xri != "" {
return xri
}
host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
if err != nil {
return c.Request.RemoteAddr
}
return host
}
+498
View File
@@ -0,0 +1,498 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/health"
"github.com/prism/proxy/internal/metrics"
applog "github.com/prism/proxy/internal/log"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/service"
"github.com/prism/proxy/internal/store"
"github.com/prism/proxy/internal/subscription"
"github.com/prism/proxy/internal/trafficlog"
)
type Server struct {
app *service.App
health *health.Checker
metrics *metrics.Collector
subscription *subscription.Service
logs *applog.Collector
traffic *trafficlog.Collector
loginLimiter *auth.LoginLimiter
}
func NewServer(app *service.App, hc *health.Checker, mc *metrics.Collector, sub *subscription.Service, logs *applog.Collector, traffic *trafficlog.Collector) *Server {
return &Server{
app: app,
health: hc,
metrics: mc,
subscription: sub,
logs: logs,
traffic: traffic,
loginLimiter: auth.NewLoginLimiter(app.Store),
}
}
func (s *Server) syncMihomoClient(ctx context.Context) {
base, secret, err := s.app.MihomoAPIBase(ctx)
if err != nil {
return
}
s.health.UpdateAPIBase(base, secret)
s.metrics.UpdateAPIBase(base, secret)
if s.traffic != nil {
s.traffic.UpdateClient(mihomoapi.New(base, secret))
}
}
func (s *Server) Router() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery(), s.corsMiddleware())
r.GET("/api/v1/openapi.json", s.serveOpenAPIJSON)
r.GET("/api/v1/openapi.yaml", s.serveOpenAPIYAML)
r.GET("/api/docs", s.serveOpenAPIDocs)
api := r.Group("/api/v1")
api.POST("/auth/login", s.login)
api.GET("/auth/status", s.authStatus)
protected := api.Group("")
protected.Use(s.authMiddleware())
{
protected.GET("/dashboard", s.dashboard)
protected.GET("/connections", s.connections)
protected.GET("/subscriptions", s.listSubscriptions)
protected.POST("/subscriptions", s.createSubscription)
protected.PUT("/subscriptions/:id", s.updateSubscription)
protected.DELETE("/subscriptions/:id", s.deleteSubscription)
protected.POST("/subscriptions/:id/refresh", s.refreshSubscription)
protected.GET("/proxies", s.listProxies)
protected.POST("/proxies", s.createProxy)
protected.PUT("/proxies/:id", s.updateProxy)
protected.DELETE("/proxies/:id", s.deleteProxy)
protected.POST("/proxies/:id/test", s.testProxy)
protected.POST("/proxies/test-all", s.testAllProxies)
protected.POST("/proxies/rebuild-countries", s.rebuildCountries)
protected.GET("/proxies/export", s.exportProxies)
protected.POST("/proxies/import", s.importProxies)
protected.GET("/countries", s.listCountries)
protected.GET("/outbound", s.outboundOverview)
protected.PUT("/outbound/groups/:name", s.selectOutboundGroup)
protected.PUT("/outbound/default-policy", s.setDefaultPolicy)
protected.GET("/traffic-logs", s.listTrafficLogs)
protected.GET("/rules", s.listRules)
protected.POST("/rules", s.createRule)
protected.PUT("/rules/:id", s.updateRule)
protected.DELETE("/rules/:id", s.deleteRule)
protected.GET("/rules/merged", s.mergedRules)
protected.POST("/rules/test", s.testRule)
protected.GET("/rules/export", s.exportRules)
protected.POST("/rules/import", s.importRules)
protected.GET("/settings", s.getSettings)
protected.PUT("/settings", s.putSettings)
protected.GET("/api-keys", s.listAPIKeys)
protected.POST("/api-keys", s.createAPIKey)
protected.DELETE("/api-keys/:id", s.deleteAPIKey)
protected.GET("/logs", s.listLogs)
protected.GET("/audit-logs", s.listAuditLogs)
protected.GET("/system/status", s.systemStatus)
protected.POST("/system/reload", s.systemReload)
protected.GET("/system/geo", s.geoStatus)
protected.POST("/system/geo/update", s.geoUpdate)
}
r.NoRoute(s.serveSPA)
return r
}
func (s *Server) corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key")
c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func (s *Server) dashboard(c *gin.Context) {
ctx := c.Request.Context()
s.metrics.RefreshAsync(10 * time.Second)
s.app.RefreshEngineStatusAsync()
history, _ := s.app.Store.ListTrafficSnapshots(ctx, 60)
conns := s.metrics.CachedConnections()
policyGroups, _ := s.app.PolicyGroupRuntimes(ctx)
names, _ := s.app.ProxyDisplayNames(ctx)
liveDomainsAll, liveNodesAll := metrics.AggregateConnections(conns, policyGroups)
liveDomainsAll = metrics.ApplyDomainLabels(liveDomainsAll, names)
liveNodesAll = metrics.ApplyTrafficLabels(liveNodesAll, names)
liveDomainLimit, liveDomainOffset := parseLimitOffset(c.DefaultQuery("live_domain_limit", "50"), c.DefaultQuery("live_domain_offset", "0"))
liveNodeLimit, liveNodeOffset := parseLimitOffset(c.DefaultQuery("live_node_limit", "50"), c.DefaultQuery("live_node_offset", "0"))
periodDomainLimit, periodDomainOffset := parseLimitOffset(c.DefaultQuery("period_domain_limit", "50"), c.DefaultQuery("period_domain_offset", "0"))
periodNodeLimit, periodNodeOffset := parseLimitOffset(c.DefaultQuery("period_node_limit", "50"), c.DefaultQuery("period_node_offset", "0"))
liveDomains, liveDomainTotal := paginateSlice(liveDomainsAll, liveDomainLimit, liveDomainOffset)
liveNodes, liveNodeTotal := paginateSlice(liveNodesAll, liveNodeLimit, liveNodeOffset)
hostNodeTotals, _ := s.app.Store.AggregateTrafficByHostNode(ctx, 24, periodDomainLimit, periodDomainOffset)
nodeTotals, _ := s.app.Store.AggregateTrafficByNode(ctx, 24, periodNodeLimit, periodNodeOffset)
periodDomainTotal, _ := s.app.Store.CountAggregateTrafficByHostNode(ctx, 24)
periodNodeTotal, _ := s.app.Store.CountAggregateTrafficByNode(ctx, 24)
periodDomains := metrics.TrafficHostNodeAggToItems(hostNodeTotals, names)
periodNodes := metrics.TrafficAggToItems(nodeTotals, names)
c.JSON(http.StatusOK, gin.H{
"snapshot": s.metrics.Snapshot(ctx),
"history": history,
"connections": conns,
"engine_running": s.app.EngineStatusCached(),
"live_domains": liveDomains,
"live_nodes": liveNodes,
"live_domain_total": liveDomainTotal,
"live_node_total": liveNodeTotal,
"period_domains": periodDomains,
"period_nodes": periodNodes,
"period_domain_total": periodDomainTotal,
"period_node_total": periodNodeTotal,
"period_hours": 24,
})
}
func (s *Server) connections(c *gin.Context) {
conns, err := s.metrics.GetConnectionsCached(8 * time.Second)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"connections": conns})
}
func (s *Server) listSubscriptions(c *gin.Context) {
items, err := s.app.Store.ListSubscriptions(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createSubscription(c *gin.Context) {
var sub store.Subscription
if err := c.ShouldBindJSON(&sub); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if sub.Type == "" {
sub.Type = "auto"
}
if sub.IntervalSec <= 0 {
sub.IntervalSec = 3600
}
sub.Enabled = true
if err := s.app.Store.CreateSubscription(c.Request.Context(), &sub); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "subscription", sub.Name)
go func(id int64) {
ctx := context.Background()
_ = s.subscription.FetchAndStore(ctx, id)
_ = s.app.ReloadEngine(ctx)
}(sub.ID)
c.JSON(http.StatusCreated, sub)
}
func (s *Server) updateSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
sub, err := s.app.Store.GetSubscription(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(sub); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sub.ID = id
if err := s.app.Store.UpdateSubscription(c.Request.Context(), sub); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, sub)
}
func (s *Server) deleteSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteSubscription(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) refreshSubscription(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.subscription.FetchAndStore(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
c.JSON(http.StatusOK, gin.H{"ok": true, "warning": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listProxies(c *gin.Context) {
source := c.Query("source")
country := c.Query("country")
var subID *int64
if v := c.Query("subscription_id"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
subID = &id
}
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListProxyNodesPage(c.Request.Context(), source, subID, country, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountProxyNodes(c.Request.Context(), source, subID, country)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
type proxyReq struct {
Name string `json:"name"`
Type string `json:"type"`
RawConfig string `json:"raw_config"`
Enabled *bool `json:"enabled"`
}
func (s *Server) createProxy(c *gin.Context) {
var req proxyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n := store.ProxyNode{
Name: req.Name,
RuntimeName: store.RuntimeName("manual", 0, req.Name),
Type: req.Type,
RawConfig: req.RawConfig,
Source: "manual",
Country: country.Detect(req.Name),
Enabled: true,
}
if req.Enabled != nil {
n.Enabled = *req.Enabled
}
if err := s.app.Store.CreateProxyNode(c.Request.Context(), &n); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusCreated, n)
}
func (s *Server) updateProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
var req proxyReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
n.Name = req.Name
n.Country = country.Detect(req.Name)
}
if req.Type != "" {
n.Type = req.Type
}
if req.RawConfig != "" {
n.RawConfig = req.RawConfig
}
if req.Enabled != nil {
n.Enabled = *req.Enabled
}
if err := s.app.Store.UpdateProxyNode(c.Request.Context(), n); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, n)
}
func (s *Server) deleteProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
n, err := s.app.Store.GetProxyNode(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if n.Source != "manual" {
c.JSON(http.StatusBadRequest, gin.H{"error": "only manual proxies can be deleted"})
return
}
if err := s.app.Store.DeleteProxyNode(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) testProxy(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
latency, err := s.health.TestNode(c.Request.Context(), id, s.app.ReloadEngine)
if err != nil {
c.JSON(http.StatusOK, gin.H{"latency_ms": -1, "alive": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"latency_ms": latency, "alive": latency >= 0})
}
func (s *Server) testAllProxies(c *gin.Context) {
if err := s.health.TestAll(c.Request.Context(), s.app.ReloadEngine); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listRules(c *gin.Context) {
source := c.DefaultQuery("source", "manual")
items, err := s.app.Store.ListRules(c.Request.Context(), source)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createRule(c *gin.Context) {
var r store.Rule
if err := c.ShouldBindJSON(&r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r.Source = "manual"
r.Enabled = true
if r.Priority <= 0 {
p, _ := s.app.Store.NextManualRulePriority(c.Request.Context())
r.Priority = p
}
if err := s.app.Store.CreateRule(c.Request.Context(), &r); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusCreated, r)
}
func (s *Server) updateRule(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
r, err := s.app.Store.GetRule(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := c.ShouldBindJSON(r); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
r.ID = id
if err := s.app.Store.UpdateRule(c.Request.Context(), r); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, r)
}
func (s *Server) deleteRule(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteRule(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listLogs(c *gin.Context) {
level := c.Query("level")
keyword := c.Query("keyword")
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, total, err := s.app.Store.ListRequestLogs(c.Request.Context(), level, keyword, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) listAuditLogs(c *gin.Context) {
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListAuditLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountAuditLogs(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) systemStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"engine_running": s.app.EngineReachable(c.Request.Context()),
"has_config": len(s.app.Cache.GetLastYAML()) > 0,
"last_reload_error": s.app.LastReloadError,
})
}
func (s *Server) systemReload(c *gin.Context) {
if err := s.app.ReloadEngine(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
)
type createAPIKeyReq struct {
Name string `json:"name"`
}
func (s *Server) listAPIKeys(c *gin.Context) {
items, err := s.app.Store.ListAPIKeys(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createAPIKey(c *gin.Context) {
var req createAPIKeyReq
_ = c.ShouldBindJSON(&req)
name := strings.TrimSpace(req.Name)
if name == "" {
name = "default"
}
raw, err := auth.GenerateAPIKey()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
item, err := s.app.Store.CreateAPIKey(
c.Request.Context(),
name,
auth.APIKeyDisplayPrefix(raw),
auth.HashAPIKey(raw),
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "create", "api_key", name)
c.JSON(http.StatusCreated, gin.H{
"id": item.ID,
"name": item.Name,
"key": raw,
"key_prefix": item.KeyPrefix,
"created_at": item.CreatedAt,
})
}
func (s *Server) deleteAPIKey(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteAPIKey(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(c.Request.Context(), "delete", "api_key", strconv.FormatInt(id, 10))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) authorizeRequest(c *gin.Context, settings map[string]string) bool {
if settings["auth_enabled"] != "true" {
return true
}
ctx := c.Request.Context()
if key := strings.TrimSpace(c.GetHeader("X-API-Key")); key != "" {
if ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(key)); ok {
return true
}
}
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return false
}
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" {
return false
}
if auth.IsAPIKeyFormat(token) {
ok, _ := s.app.Store.VerifyAPIKey(ctx, auth.HashAPIKey(token))
return ok
}
return verifyToken(settings["admin_password"], token)
}
+66
View File
@@ -0,0 +1,66 @@
package api
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/auth"
"github.com/prism/proxy/internal/service"
"github.com/prism/proxy/internal/store"
)
func TestAuthorizeRequestWithAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
ctx := context.Background()
if err := st.SetSettings(ctx, map[string]string{
"auth_enabled": "true",
"admin_password": "secret",
}); err != nil {
t.Fatal(err)
}
raw, err := auth.GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateAPIKey(ctx, "test", auth.APIKeyDisplayPrefix(raw), auth.HashAPIKey(raw)); err != nil {
t.Fatal(err)
}
s := &Server{app: &service.App{Store: st}}
settings, _ := st.GetSettings(ctx)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c.Request.Header.Set("X-API-Key", raw)
if !s.authorizeRequest(c, settings) {
t.Fatal("expected api key to authorize")
}
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c2.Request.Header.Set("Authorization", "Bearer "+raw)
if !s.authorizeRequest(c2, settings) {
t.Fatal("expected bearer api key to authorize")
}
w3 := httptest.NewRecorder()
c3, _ := gin.CreateTestContext(w3)
c3.Request = httptest.NewRequest("GET", "/api/v1/dashboard", nil)
c3.Request.Header.Set("Authorization", "Bearer prism_invalid")
if s.authorizeRequest(c3, settings) {
t.Fatal("expected invalid key to fail")
}
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"math/rand"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/prism/proxy/internal/auth"
)
type loginReq struct {
Password string `json:"password"`
}
func (s *Server) authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
c.Abort()
return
}
if !s.authorizeRequest(c, settings) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
c.Abort()
return
}
c.Next()
}
}
func (s *Server) authStatus(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enabled": settings["auth_enabled"] == "true"})
}
func (s *Server) login(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ip := clientIP(c)
cfg := auth.ParseLoginConfig(settings)
check, err := s.loginLimiter.Check(ctx, ip, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !check.Allowed {
writeLoginLocked(c, check.RetryAfter)
return
}
hash := settings["admin_password"]
if !checkPassword(req.Password, hash) {
time.Sleep(loginFailureDelay())
result, err := s.loginLimiter.RecordFailure(ctx, ip, cfg)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if result.Locked {
writeLoginLocked(c, result.RetryAfter)
return
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid password",
"remaining": result.Remaining,
})
return
}
if err := s.loginLimiter.RecordSuccess(ctx, ip); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
token := issueToken(hash, 24*time.Hour)
c.JSON(http.StatusOK, gin.H{"token": token})
}
func writeLoginLocked(c *gin.Context, retryAfter time.Duration) {
secs := int(retryAfter.Seconds())
if secs < 1 {
secs = 1
}
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "too many login attempts",
"retry_after": secs,
})
}
func loginFailureDelay() time.Duration {
return 300*time.Millisecond + time.Duration(rand.Intn(200))*time.Millisecond
}
func checkPassword(password, stored string) bool {
if strings.HasPrefix(stored, "$2a$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
return password == stored
}
+26
View File
@@ -0,0 +1,26 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) listCountries(c *gin.Context) {
items, err := s.app.Store.ListCountryStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) rebuildCountries(c *gin.Context) {
n, err := s.app.Store.RebuildAllNodeCountries(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"updated": n})
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/geodata"
)
func (s *Server) geoStatus(c *gin.Context) {
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
cfg := geodata.ConfigFromSettings(settings)
c.JSON(http.StatusOK, gin.H{
"ready": geodata.Ready(s.app.DataDir),
"auto_download": settings["geo_auto_download"] != "false",
"files": geodata.Status(s.app.DataDir, cfg),
"default_urls": geodata.DefaultConfig(),
})
}
type geoUpdateReq struct {
Force bool `json:"force"`
}
func (s *Server) geoUpdate(c *gin.Context) {
var req geoUpdateReq
_ = c.ShouldBindJSON(&req)
ctx := c.Request.Context()
settings, err := s.app.Store.GetSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
cfg := geodata.ConfigFromSettings(settings)
geodata.ApplyMihomoURLs(cfg)
updated, err := geodata.Ensure(s.app.DataDir, cfg, req.Force)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.AddAuditLog(ctx, "update", "geo", "updated: "+joinNames(updated))
s.logs.Add("info", "prism", "geo data updated")
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"updated": updated,
"ready": geodata.Ready(s.app.DataDir),
"files": geodata.Status(s.app.DataDir, cfg),
})
}
func joinNames(names []string) string {
if len(names) == 0 {
return "none (already present)"
}
out := names[0]
for i := 1; i < len(names); i++ {
out += ", " + names[i]
}
return out
}
func geoDataReady(dataDir string) bool {
return geodata.Ready(dataDir)
}
func applyGeoSettings(dataDir string, settings map[string]string) {
geodata.InitHomeDir(dataDir)
geodata.ApplyMihomoURLs(geodata.ConfigFromSettings(settings))
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) outboundOverview(c *gin.Context) {
data, err := s.app.OutboundOverview(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
type selectOutboundReq struct {
Proxy string `json:"proxy"`
}
func (s *Server) selectOutboundGroup(c *gin.Context) {
name := c.Param("name")
var req selectOutboundReq
if err := c.ShouldBindJSON(&req); err != nil || req.Proxy == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "proxy required"})
return
}
if err := s.app.SelectOutboundGroup(c.Request.Context(), name, req.Proxy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type defaultPolicyReq struct {
Policy string `json:"policy"`
}
func (s *Server) setDefaultPolicy(c *gin.Context) {
var req defaultPolicyReq
if err := c.ShouldBindJSON(&req); err != nil || req.Policy == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "policy required"})
return
}
if err := s.app.SetDefaultPolicy(c.Request.Context(), req.Policy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
s.syncMihomoClient(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/proxyio"
"github.com/prism/proxy/internal/store"
)
func (s *Server) exportProxies(c *gin.Context) {
ctx := c.Request.Context()
items, err := s.app.Store.ListProxyNodes(ctx, "manual", nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
format := strings.ToLower(c.DefaultQuery("format", "json"))
filename := "prism-proxies"
switch format {
case "text", "txt", "links":
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
c.String(http.StatusOK, proxyio.ExportLinks(items))
default:
data, err := proxyio.ExportJSON(items)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
}
}
type importProxiesReq struct {
Mode string `json:"mode"`
Text string `json:"text"`
Nodes []proxyio.ExportNode `json:"nodes"`
}
func (s *Server) importProxies(c *gin.Context) {
ctx := c.Request.Context()
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
var nodes []store.ProxyNode
var parseErrors []string
contentType := c.GetHeader("Content-Type")
if strings.Contains(contentType, "application/json") {
var req importProxiesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(req.Mode))
}
if len(req.Nodes) > 0 {
var valErrors []string
nodes, valErrors = proxyio.ToStoreNodes(req.Nodes)
parseErrors = append(parseErrors, valErrors...)
} else if strings.TrimSpace(req.Text) != "" {
nodes, parseErrors = proxyio.ParseText(req.Text)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "nodes or text required"})
return
}
} else {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = "append"
}
nodes, parseErrors = proxyio.ParseText(string(body))
}
if mode == "" {
mode = "append"
}
if mode != "append" && mode != "replace" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
return
}
if len(nodes) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid proxies", "errors": parseErrors})
return
}
if mode == "append" {
existing, _ := s.app.Store.ListProxyNodes(ctx, "manual", nil)
proxyio.AssignUniqueNames(nodes, proxyio.ReservedNames(existing))
if err := s.app.Store.AppendManualProxies(ctx, nodes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
proxyio.AssignUniqueNames(nodes, nil)
if err := s.app.Store.ReplaceManualProxies(ctx, nodes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
_ = s.app.Store.AddAuditLog(ctx, "import", "proxies", mode+": "+strconv.Itoa(len(nodes)))
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"imported": len(nodes),
"skipped": len(parseErrors),
"errors": parseErrors,
"mode": mode,
})
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/rulematch"
)
type testRuleReq struct {
URL string `json:"url"`
}
func (s *Server) mergedRules(c *gin.Context) {
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, err := s.app.Store.ListMergedRulesPage(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
total, err := s.app.Store.CountMergedRules(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
settings, _ := s.app.Store.GetSettings(c.Request.Context())
policy := settings["default_policy"]
if policy == "" {
policy = "DIRECT"
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
"default_policy": policy,
})
}
func (s *Server) testRule(c *gin.Context) {
var req testRuleReq
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.URL) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "url required"})
return
}
ctx := c.Request.Context()
rules, err := s.app.Store.ListRules(ctx, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
settings, _ := s.app.Store.GetSettings(ctx)
policy := settings["default_policy"]
geoReady := geoDataReady(s.app.DataDir)
result := rulematch.Match(rules, policy, req.URL, geoReady)
if result.SkippedGeo > 0 {
result.Note = appendNote(result.Note, "已跳过 "+strconv.Itoa(result.SkippedGeo)+" 条 Geo 规则(本地测试不支持 GEOIP/GEOSITE")
}
c.JSON(http.StatusOK, result)
}
func appendNote(existing, extra string) string {
if existing == "" {
return extra
}
return existing + "" + extra
}
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/prism/proxy/internal/ruleio"
)
func (s *Server) exportRules(c *gin.Context) {
ctx := c.Request.Context()
items, err := s.app.Store.ListRules(ctx, "manual")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
format := strings.ToLower(c.DefaultQuery("format", "json"))
filename := "prism-rules"
switch format {
case "text", "txt", "clash":
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.txt"`)
c.String(http.StatusOK, ruleio.ExportText(items))
default:
data, err := ruleio.ExportJSON(items)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`.json"`)
c.Data(http.StatusOK, "application/json; charset=utf-8", data)
}
}
type importRulesReq struct {
Mode string `json:"mode"`
Text string `json:"text"`
Rules []ruleio.ExportRule `json:"rules"`
}
func (s *Server) importRules(c *gin.Context) {
ctx := c.Request.Context()
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "")))
var parsed []ruleio.ExportRule
var parseErrors []string
contentType := c.GetHeader("Content-Type")
if strings.Contains(contentType, "application/json") {
var req importRulesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(req.Mode))
}
if len(req.Rules) > 0 {
parsed = req.Rules
} else if strings.TrimSpace(req.Text) != "" {
parsed, parseErrors = parseImportText(req.Text)
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "rules or text required"})
return
}
} else {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if mode == "" {
mode = "append"
}
parsed, parseErrors = parseImportText(string(body))
}
if mode == "" {
mode = "append"
}
if mode != "append" && mode != "replace" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be append or replace"})
return
}
if len(parsed) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": parseErrors})
return
}
rules, valErrors := ruleio.ToStoreRules(parsed)
allErrors := append(parseErrors, valErrors...)
if len(rules) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no valid rules", "errors": allErrors})
return
}
if mode == "append" {
start, _ := s.app.Store.NextManualRulePriority(ctx)
ruleio.AssignPriorities(rules, start)
if err := s.app.Store.AppendManualRules(ctx, rules); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
} else {
ruleio.AssignPriorities(rules, 100)
if err := s.app.Store.ReplaceManualRules(ctx, rules); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
_ = s.app.Store.AddAuditLog(ctx, "import", "rules", mode+": "+strconv.Itoa(len(rules)))
_ = s.app.ReloadEngine(ctx)
c.JSON(http.StatusOK, gin.H{
"ok": true,
"imported": len(rules),
"skipped": len(allErrors),
"errors": allErrors,
"mode": mode,
})
}
func parseImportText(text string) ([]ruleio.ExportRule, []string) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
rules, err := ruleio.ParseJSON([]byte(text))
if err == nil && len(rules) > 0 {
return rules, nil
}
}
return ruleio.ParseText(text)
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
appsettings "github.com/prism/proxy/internal/settings"
"github.com/prism/proxy/internal/store"
)
func (s *Server) getSettings(c *gin.Context) {
settings, err := s.app.Store.GetSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
safe := make(map[string]string)
for k, v := range settings {
if k == appsettings.KeyAdminPassword || k == appsettings.KeyProxyAuthPass {
safe[k] = ""
continue
}
safe[k] = v
}
c.JSON(http.StatusOK, appsettings.Display(safe))
}
func (s *Server) putSettings(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if pw, ok := req[appsettings.KeyAdminPassword]; ok && pw != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
req[appsettings.KeyAdminPassword] = string(hash)
} else {
delete(req, appsettings.KeyAdminPassword)
}
if err := normalizeProxyAuthSettings(c.Request.Context(), s.app.Store, req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
appsettings.NormalizeSubmit(req)
if err := s.app.Store.SetSettings(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
allSettings, _ := s.app.Store.GetSettings(c.Request.Context())
applyGeoSettings(s.app.DataDir, allSettings)
base, secret, _ := s.app.MihomoAPIBase(c.Request.Context())
s.health.UpdateAPIBase(base, secret)
s.metrics.UpdateAPIBase(base, secret)
s.syncMihomoClient(c.Request.Context())
_ = s.app.ReloadEngine(c.Request.Context())
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func normalizeProxyAuthSettings(ctx context.Context, st *store.Store, req map[string]string) error {
user, hasUser := req["proxy_auth_user"]
pass, hasPass := req["proxy_auth_pass"]
if !hasUser && !hasPass {
return nil
}
user = strings.TrimSpace(user)
if user == "" {
req["proxy_auth_user"] = ""
req["proxy_auth_pass"] = ""
return nil
}
if strings.Contains(user, ":") {
return fmt.Errorf("代理用户名不能包含冒号")
}
req["proxy_auth_user"] = user
if !hasPass || pass == "" {
current, err := st.GetSettings(ctx)
if err != nil {
return err
}
if existing := current["proxy_auth_pass"]; existing != "" {
delete(req, "proxy_auth_pass")
return nil
}
return fmt.Errorf("请填写代理密码")
}
req["proxy_auth_pass"] = pass
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *Server) listTrafficLogs(c *gin.Context) {
keyword := c.Query("keyword")
limit, offset := parseLimitOffset(c.DefaultQuery("limit", "50"), c.DefaultQuery("offset", "0"))
items, total, err := s.app.Store.ListProxyRequestLogs(c.Request.Context(), keyword, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
_ "embed"
"encoding/json"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
//go:embed openapi/spec.yaml
var openAPISpecYAML []byte
//go:embed openapi/docs.html
var openAPIDocsHTML []byte
var (
openAPIJSONOnce sync.Once
openAPIJSON []byte
openAPIJSONErr error
)
func loadOpenAPIJSON() {
var doc any
if err := yaml.Unmarshal(openAPISpecYAML, &doc); err != nil {
openAPIJSONErr = err
return
}
openAPIJSON, openAPIJSONErr = json.Marshal(doc)
}
func (s *Server) serveOpenAPIJSON(c *gin.Context) {
openAPIJSONOnce.Do(loadOpenAPIJSON)
if openAPIJSONErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": openAPIJSONErr.Error()})
return
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", openAPIJSON)
}
func (s *Server) serveOpenAPIYAML(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpecYAML)
}
func (s *Server) serveOpenAPIDocs(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", openAPIDocsHTML)
}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Prism API 文档</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = function () {
SwaggerUIBundle({
url: '/api/v1/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
displayRequestDuration: true,
tryItOutEnabled: true,
})
}
</script>
</body>
</html>
+753
View File
@@ -0,0 +1,753 @@
openapi: 3.0.3
info:
title: Prism Management API
description: |
Prism 代理网关管理接口(默认端口 8080)。
除 `auth/login`、`auth/status` 与 OpenAPI 文档外,启用登录后需在请求头携带以下任一凭证:
- `Authorization: Bearer <token>`(登录接口返回的短期 token)
- `Authorization: Bearer <api_key>` 或 `X-API-Key: <api_key>`(在系统设置中申请的 API Key)
version: 1.0.0
contact:
name: Prism
servers:
- url: /api/v1
description: 管理 API v1
tags:
- name: Auth
- name: Dashboard
- name: Subscriptions
- name: Proxies
- name: Countries
- name: Outbound
- name: Rules
- name: TrafficLogs
- name: Settings
- name: System
- name: Logs
paths:
/auth/login:
post:
tags: [Auth]
summary: 登录
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [password]
properties:
password:
type: string
responses:
'200':
description: 登录成功
content:
application/json:
schema:
type: object
properties:
token:
type: string
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: 登录尝试过多
/auth/status:
get:
tags: [Auth]
summary: 是否启用登录认证
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
/api-keys:
get:
tags: [Auth]
summary: API Key 列表
security:
- bearerAuth: []
responses:
'200':
description: OK
post:
tags: [Auth]
summary: 申请 API Key
description: 完整 Key 仅在创建响应中返回一次,请妥善保存
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
responses:
'201':
description: Created
/api-keys/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
delete:
tags: [Auth]
summary: 吊销 API Key
security:
- bearerAuth: []
responses:
'200':
description: OK
/dashboard:
get:
tags: [Dashboard]
summary: 监控大盘
security:
- bearerAuth: []
parameters:
- name: live_domain_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: live_domain_offset
in: query
schema:
type: integer
default: 0
- name: live_node_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: live_node_offset
in: query
schema:
type: integer
default: 0
- name: period_domain_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: period_domain_offset
in: query
schema:
type: integer
default: 0
- name: period_node_limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: period_node_offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
/connections:
get:
tags: [Dashboard]
summary: 当前活跃连接
security:
- bearerAuth: []
responses:
'200':
description: OK
/subscriptions:
get:
tags: [Subscriptions]
summary: 订阅列表
security:
- bearerAuth: []
responses:
'200':
description: OK
post:
tags: [Subscriptions]
summary: 创建订阅
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
responses:
'201':
description: Created
/subscriptions/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Subscriptions]
summary: 更新订阅
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Subscription'
responses:
'200':
description: OK
delete:
tags: [Subscriptions]
summary: 删除订阅
security:
- bearerAuth: []
responses:
'200':
description: OK
/subscriptions/{id}/refresh:
parameters:
- $ref: '#/components/parameters/IdPath'
post:
tags: [Subscriptions]
summary: 刷新订阅
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies:
get:
tags: [Proxies]
summary: 节点列表
security:
- bearerAuth: []
parameters:
- name: source
in: query
schema:
type: string
enum: [manual, subscription]
- name: subscription_id
in: query
schema:
type: integer
- name: country
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
maximum: 500
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
post:
tags: [Proxies]
summary: 添加手动节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
type:
type: string
raw_config:
type: string
responses:
'201':
description: Created
/proxies/export:
get:
tags: [Proxies]
summary: 导出手动节点
security:
- bearerAuth: []
parameters:
- name: format
in: query
schema:
type: string
enum: [json, links, text, txt]
default: json
responses:
'200':
description: 文件下载
/proxies/import:
post:
tags: [Proxies]
summary: 导入手动节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [append, replace]
text:
type: string
nodes:
type: array
items:
type: object
responses:
'200':
description: OK
/proxies/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Proxies]
summary: 更新节点
security:
- bearerAuth: []
responses:
'200':
description: OK
delete:
tags: [Proxies]
summary: 删除手动节点
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/{id}/test:
parameters:
- $ref: '#/components/parameters/IdPath'
post:
tags: [Proxies]
summary: 单节点测速
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/test-all:
post:
tags: [Proxies]
summary: 全部节点测速
security:
- bearerAuth: []
responses:
'200':
description: OK
/proxies/rebuild-countries:
post:
tags: [Proxies]
summary: 重新识别节点国家
security:
- bearerAuth: []
responses:
'200':
description: OK
/countries:
get:
tags: [Countries]
summary: 国家统计
security:
- bearerAuth: []
responses:
'200':
description: OK
/outbound:
get:
tags: [Outbound]
summary: 出站概览
security:
- bearerAuth: []
responses:
'200':
description: OK
/outbound/groups/{name}:
parameters:
- name: name
in: path
required: true
schema:
type: string
put:
tags: [Outbound]
summary: 切换策略组节点
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [proxy]
properties:
proxy:
type: string
responses:
'200':
description: OK
/outbound/default-policy:
put:
tags: [Outbound]
summary: 设置默认出站
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [policy]
properties:
policy:
type: string
responses:
'200':
description: OK
/traffic-logs:
get:
tags: [TrafficLogs]
summary: 请求日志
security:
- bearerAuth: []
parameters:
- name: keyword
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 50
- name: offset
in: query
schema:
type: integer
default: 0
responses:
'200':
description: OK
/rules:
get:
tags: [Rules]
summary: 规则列表
security:
- bearerAuth: []
parameters:
- name: source
in: query
schema:
type: string
responses:
'200':
description: OK
post:
tags: [Rules]
summary: 添加手动规则
security:
- bearerAuth: []
responses:
'201':
description: Created
/rules/export:
get:
tags: [Rules]
summary: 导出手动规则
security:
- bearerAuth: []
parameters:
- name: format
in: query
schema:
type: string
enum: [json, text, txt, clash]
default: json
responses:
'200':
description: 文件下载
/rules/import:
post:
tags: [Rules]
summary: 导入手动规则
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [append, replace]
text:
type: string
rules:
type: array
items:
type: object
responses:
'200':
description: OK
/rules/merged:
get:
tags: [Rules]
summary: 合成规则预览
security:
- bearerAuth: []
parameters:
- name: limit
in: query
schema:
type: integer
- name: offset
in: query
schema:
type: integer
responses:
'200':
description: OK
/rules/test:
post:
tags: [Rules]
summary: 规则命中测试
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
responses:
'200':
description: OK
/rules/{id}:
parameters:
- $ref: '#/components/parameters/IdPath'
put:
tags: [Rules]
summary: 更新规则
security:
- bearerAuth: []
responses:
'200':
description: OK
delete:
tags: [Rules]
summary: 删除手动规则
security:
- bearerAuth: []
responses:
'200':
description: OK
/settings:
get:
tags: [Settings]
summary: 获取系统设置
security:
- bearerAuth: []
responses:
'200':
description: OK
put:
tags: [Settings]
summary: 更新系统设置
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
additionalProperties:
type: string
responses:
'200':
description: OK
/logs:
get:
tags: [Logs]
summary: 运行日志
security:
- bearerAuth: []
parameters:
- name: level
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
responses:
'200':
description: OK
/audit-logs:
get:
tags: [Logs]
summary: 审计日志
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/status:
get:
tags: [System]
summary: 系统状态
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/reload:
post:
tags: [System]
summary: 重载内核
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/geo:
get:
tags: [System]
summary: Geo 数据状态
security:
- bearerAuth: []
responses:
'200':
description: OK
/system/geo/update:
post:
tags: [System]
summary: 更新 Geo 数据
security:
- bearerAuth: []
requestBody:
content:
application/json:
schema:
type: object
properties:
force:
type: boolean
responses:
'200':
description: OK
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: 登录 token 或 API Keyprism_ 开头)
apiKeyAuth:
type: apiKey
in: header
name: X-API-Key
description: 系统设置中申请的 API Key
parameters:
IdPath:
name: id
in: path
required: true
schema:
type: integer
format: int64
schemas:
Subscription:
type: object
properties:
name:
type: string
url:
type: string
type:
type: string
description: auto / clash / links
interval_sec:
type: integer
enabled:
type: boolean
Error:
type: object
properties:
error:
type: string
responses:
Unauthorized:
description: 未授权
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestServeOpenAPIJSON(t *testing.T) {
s := &Server{}
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("content-type: %s", ct)
}
var doc map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if doc["openapi"] != "3.0.3" {
t.Fatalf("unexpected openapi version: %v", doc["openapi"])
}
}
func TestServeOpenAPIDocs(t *testing.T) {
s := &Server{}
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/api/docs", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if !strings.Contains(w.Body.String(), "swagger-ui") {
t.Fatal("expected swagger ui html")
}
}
+35
View File
@@ -0,0 +1,35 @@
package api
import "strconv"
const (
defaultPageLimit = 50
maxPageLimit = 500
)
func parseLimitOffset(limitStr, offsetStr string) (limit, offset int) {
limit, _ = strconv.Atoi(limitStr)
offset, _ = strconv.Atoi(offsetStr)
if limit <= 0 {
limit = defaultPageLimit
}
if limit > maxPageLimit {
limit = maxPageLimit
}
if offset < 0 {
offset = 0
}
return limit, offset
}
func paginateSlice[T any](items []T, limit, offset int) ([]T, int) {
total := len(items)
if offset >= total {
return nil, total
}
end := offset + limit
if end > total {
end = total
}
return items[offset:end], total
}
+47
View File
@@ -0,0 +1,47 @@
package api
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed static/*
var staticFS embed.FS
func (s *Server) serveSPA(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
sub, err := fs.Sub(staticFS, "static")
if err != nil {
c.String(http.StatusInternalServerError, "static fs error")
return
}
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(sub, path)
if err != nil {
data, err = fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusNotFound, "not found")
return
}
}
contentType := "text/html; charset=utf-8"
switch {
case strings.HasSuffix(path, ".js"):
contentType = "application/javascript"
case strings.HasSuffix(path, ".css"):
contentType = "text/css"
case strings.HasSuffix(path, ".svg"):
contentType = "image/svg+xml"
}
c.Data(http.StatusOK, contentType, data)
}
@@ -0,0 +1 @@
import{Kt as e,R as t,Ut as n,V as r,b as i,d as a,m as o,p as s}from"./client-CiT_nDm8.js";var c={class:`page-header`},l={class:`page-title`},u={key:0,class:`page-desc`},d={key:0,class:`page-actions`},f=i({__name:`PageHeader`,props:{title:{},description:{}},setup(n){return(i,f)=>(t(),o(`div`,c,[a(`div`,null,[a(`h1`,l,e(n.title),1),n.description?(t(),o(`p`,u,e(n.description),1)):s(``,!0)]),i.$slots.actions?(t(),o(`div`,d,[r(i.$slots,`actions`)])):s(``,!0)]))}}),p={class:`app-card`},m={key:0,class:`app-card__header`},h={class:`app-card__title`},g=i({__name:`AppCard`,props:{title:{},padded:{type:Boolean}},setup(i){return(c,l)=>(t(),o(`div`,p,[i.title||c.$slots.header?(t(),o(`div`,m,[r(c.$slots,`header`,{},()=>[a(`h3`,h,e(i.title),1)])])):s(``,!0),a(`div`,{class:n(i.padded?`app-card__body--padded`:``)},[r(c.$slots,`default`)],2)]))}});export{f as n,g as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{H as e,R as t,_ as n,b as r,d as i,et as a,m as o,ut as s,v as c,y as l}from"./client-CiT_nDm8.js";import{i as u,n as d,o as f,t as p}from"./index-B4K9RppJ.js";var m={class:`login-page`},h={class:`login-wrap`},g={class:`login-card`},_=r({__name:`LoginView`,setup(r){let _=d(),v=p(),y=s(``),b=s(!1);async function x(){b.value=!0;try{await v.login(y.value),_.push(`/`)}catch{u.error(`密码错误`)}finally{b.value=!1}}return(r,s)=>{let u=e(`el-input`),d=e(`el-form-item`),p=e(`el-button`),_=e(`el-form`);return t(),o(`div`,m,[i(`div`,h,[s[4]||=n(`<div class="login-brand-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path><path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"></path></svg></div><h1 class="page-title">Prism</h1><p class="page-desc">HTTP / SOCKS5 代理网关管理台</p>`,3),i(`div`,g,[s[2]||=i(`h3`,{class:`app-card__title`,style:{"margin-bottom":`24px`}},`登录`,-1),l(_,{size:`large`,onSubmit:f(x,[`prevent`])},{default:a(()=>[l(d,{label:`密码`},{default:a(()=>[l(u,{modelValue:y.value,"onUpdate:modelValue":s[0]||=e=>y.value=e,type:`password`,"show-password":``,placeholder:`默认 admin`},null,8,[`modelValue`])]),_:1}),l(p,{type:`primary`,"native-type":`submit`,loading:b.value,style:{width:`100%`}},{default:a(()=>[...s[1]||=[c(` 登录 `,-1)]]),_:1},8,[`loading`])]),_:1}),s[3]||=i(`p`,{class:`form-hint`,style:{"margin-top":`16px`,"text-align":`center`}},` 首次使用默认密码为 admin,请在设置中修改 `,-1)])])])}}});export{_ as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,p as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{a as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`tabs-bar`},b={key:0,class:`card-toolbar`},x={key:2,class:`card-pager`},S=50,C=i({__name:`LogsView`,setup(i){let C=f([]),w=f([]),T=f(0),E=f(!1),D=f(``),O=f(``),k=f(1),A=f(`runtime`);async function j(){E.value=!0;try{if(A.value===`audit`){let{data:e}=await u.get(`/audit-logs`);w.value=e.items||[];return}let{data:e}=await u.get(`/logs`,{params:{level:D.value,keyword:O.value,limit:S,offset:(k.value-1)*S}});C.value=e.items||[],T.value=e.total||0}finally{E.value=!1}}return e(j),(e,i)=>{let u=t(`el-tab-pane`),f=t(`el-tabs`),M=t(`el-option`),N=t(`el-select`),P=t(`el-input`),F=t(`el-button`),I=t(`el-table-column`),L=t(`el-table`),R=t(`el-pagination`),z=r(`loading`);return n(),c(`div`,v,[m(g,{title:`运行日志`,description:`Prism 与代理内核运行日志`}),m(_,null,{default:o(()=>[a(`div`,y,[m(f,{modelValue:A.value,"onUpdate:modelValue":i[0]||=e=>A.value=e,onTabChange:j},{default:o(()=>[m(u,{label:`运行日志`,name:`runtime`}),m(u,{label:`审计日志`,name:`audit`})]),_:1},8,[`modelValue`])]),A.value===`runtime`?(n(),c(`div`,b,[m(N,{modelValue:D.value,"onUpdate:modelValue":i[1]||=e=>D.value=e,placeholder:`级别`,clearable:``,style:{width:`120px`},onChange:j},{default:o(()=>[m(M,{label:`info`,value:`info`}),m(M,{label:`warn`,value:`warn`}),m(M,{label:`error`,value:`error`})]),_:1},8,[`modelValue`]),m(P,{modelValue:O.value,"onUpdate:modelValue":i[2]||=e=>O.value=e,placeholder:`搜索关键词`,clearable:``,style:{width:`200px`},onKeyup:h(j,[`enter`])},null,8,[`modelValue`]),m(F,{onClick:j},{default:o(()=>[...i[4]||=[p(`刷新`,-1)]]),_:1})])):l(``,!0),A.value===`runtime`?d((n(),s(L,{key:1,data:C.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`level`,label:`级别`,width:`80`}),m(I,{prop:`source`,label:`来源`,width:`100`}),m(I,{prop:`message`,label:`消息`,"min-width":`300`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]]):l(``,!0),A.value===`runtime`?(n(),c(`div`,x,[m(R,{"current-page":k.value,"onUpdate:currentPage":i[3]||=e=>k.value=e,"page-size":S,total:T.value,layout:`total, prev, pager, next`,onCurrentChange:j},null,8,[`current-page`,`total`])])):d((n(),s(L,{key:3,data:w.value,size:`small`},{default:o(()=>[m(I,{prop:`created_at`,label:`时间`,width:`170`}),m(I,{prop:`action`,label:`操作`,width:`100`}),m(I,{prop:`resource`,label:`资源`,width:`120`}),m(I,{prop:`detail`,label:`详情`,"min-width":`200`,"show-overflow-tooltip":``})]),_:1},8,[`data`])),[[z,E.value]])]),_:1})])}}});export{C as default};
@@ -0,0 +1 @@
import{F as e,H as t,Kt as n,R as r,U as i,Ut as a,b as o,d as s,et as c,f as l,m as u,t as d,tt as f,u as p,ut as m,v as h,y as g}from"./client-CiT_nDm8.js";import{i as _}from"./index-B4K9RppJ.js";import{n as v,t as y}from"./AppCard-ClnBgZ-s.js";var b={class:`page`},x={class:`tabs-bar`},S=o({__name:`ProxiesView`,setup(o){let S=m([]),C=m(!1),w=m(`all`),T=m(!1),E=m(!1),D=m({name:``,type:`socks5`,server:``,port:1080,username:``,password:``}),O=p(()=>w.value===`manual`?S.value.filter(e=>e.source===`manual`):w.value===`subscription`?S.value.filter(e=>e.source===`subscription`):S.value);function k(e){return e<0?`latency-dead`:e<200?`latency-good`:e<500?`latency-warn`:`latency-bad`}async function A(){C.value=!0;try{let{data:e}=await d.get(`/proxies`);S.value=e.items||[]}finally{C.value=!1}}async function j(e){let{data:t}=await d.post(`/proxies/${e.id}/test`);e.latency_ms=t.latency_ms,e.alive=t.alive}async function M(){T.value=!0;try{await d.post(`/proxies/test-all`),_.success(`测速完成`),A()}finally{T.value=!1}}function N(){D.value={name:``,type:`socks5`,server:``,port:1080,username:``,password:``},E.value=!0}async function P(){let e={type:D.value.type,server:D.value.server,port:D.value.port,...D.value.username?{username:D.value.username,password:D.value.password}:{}};await d.post(`/proxies`,{name:D.value.name,type:D.value.type,raw_config:JSON.stringify(e)}),_.success(`节点已添加`),E.value=!1,A()}return e(A),(e,o)=>{let d=t(`el-button`),p=t(`el-tab-pane`),m=t(`el-tabs`),_=t(`el-table-column`),S=t(`el-tag`),A=t(`el-table`),F=t(`el-input`),I=t(`el-form-item`),L=t(`el-option`),R=t(`el-select`),z=t(`el-input-number`),B=t(`el-form`),V=t(`el-dialog`),H=i(`loading`);return r(),u(`div`,b,[g(v,{title:`代理节点`,description:`查看节点状态与延迟,支持手动添加节点`},{actions:c(()=>[g(d,{onClick:N},{default:c(()=>[...o[9]||=[h(`添加节点`,-1)]]),_:1}),g(d,{loading:T.value,onClick:M},{default:c(()=>[...o[10]||=[h(`全部测速`,-1)]]),_:1},8,[`loading`])]),_:1}),g(y,null,{default:c(()=>[s(`div`,x,[g(m,{modelValue:w.value,"onUpdate:modelValue":o[0]||=e=>w.value=e},{default:c(()=>[g(p,{label:`全部`,name:`all`}),g(p,{label:`订阅`,name:`subscription`}),g(p,{label:`手动`,name:`manual`})]),_:1},8,[`modelValue`])]),f((r(),l(A,{data:O.value,size:`small`},{default:c(()=>[g(_,{prop:`name`,label:`名称`,"min-width":`140`}),g(_,{prop:`type`,label:`类型`,width:`90`}),g(_,{prop:`source`,label:`来源`,width:`90`}),g(_,{label:`延迟`,width:`100`},{default:c(({row:e})=>[s(`span`,{class:a(k(e.latency_ms))},n(e.latency_ms>=0?`${e.latency_ms} ms`:`未测`),3)]),_:1}),g(_,{label:`状态`,width:`80`},{default:c(({row:e})=>[g(S,{type:e.alive?`success`:`info`,size:`small`},{default:c(()=>[h(n(e.alive?`可用`:`未知`),1)]),_:2},1032,[`type`])]),_:1}),g(_,{label:`操作`,width:`80`,fixed:`right`},{default:c(({row:e})=>[g(d,{size:`small`,link:``,onClick:t=>j(e)},{default:c(()=>[...o[11]||=[h(`测速`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[H,C.value]])]),_:1}),g(V,{modelValue:E.value,"onUpdate:modelValue":o[8]||=e=>E.value=e,title:`添加手动节点`,width:`520px`,"destroy-on-close":``},{footer:c(()=>[g(d,{onClick:o[7]||=e=>E.value=!1},{default:c(()=>[...o[12]||=[h(`取消`,-1)]]),_:1}),g(d,{type:`primary`,onClick:P},{default:c(()=>[...o[13]||=[h(`确认`,-1)]]),_:1})]),default:c(()=>[g(B,{"label-width":`100px`},{default:c(()=>[g(I,{label:`名称`},{default:c(()=>[g(F,{modelValue:D.value.name,"onUpdate:modelValue":o[1]||=e=>D.value.name=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`类型`},{default:c(()=>[g(R,{modelValue:D.value.type,"onUpdate:modelValue":o[2]||=e=>D.value.type=e,style:{width:`100%`}},{default:c(()=>[g(L,{label:`SOCKS5`,value:`socks5`}),g(L,{label:`HTTP`,value:`http`})]),_:1},8,[`modelValue`])]),_:1}),g(I,{label:`服务器`},{default:c(()=>[g(F,{modelValue:D.value.server,"onUpdate:modelValue":o[3]||=e=>D.value.server=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`端口`},{default:c(()=>[g(z,{modelValue:D.value.port,"onUpdate:modelValue":o[4]||=e=>D.value.port=e,min:1,max:65535},null,8,[`modelValue`])]),_:1}),g(I,{label:`用户名`},{default:c(()=>[g(F,{modelValue:D.value.username,"onUpdate:modelValue":o[5]||=e=>D.value.username=e},null,8,[`modelValue`])]),_:1}),g(I,{label:`密码`},{default:c(()=>[g(F,{modelValue:D.value.password,"onUpdate:modelValue":o[6]||=e=>D.value.password=e,type:`password`,"show-password":``},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{S as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m,r as h}from"./index-B4K9RppJ.js";import{n as g,t as _}from"./AppCard-ClnBgZ-s.js";var v={class:`page`},y={class:`content-grid content-grid--2`},b=i({__name:`RulesView`,setup(i){let b=d([]),x=d([]),S=d(!1),C=d(!1),w=d({rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100});async function T(){S.value=!0;try{let[e,t]=await Promise.all([l.get(`/rules?source=manual`),l.get(`/rules/merged`)]);b.value=e.data.items||[],x.value=t.data.items||[]}finally{S.value=!1}}function E(){w.value={rule_type:`DOMAIN-SUFFIX`,payload:``,target:`PROXY`,priority:100},C.value=!0}async function D(){await l.post(`/rules`,w.value),m.success(`规则已添加`),C.value=!1,T()}async function O(e){await h.confirm(`确定删除该规则?`,`确认`),await l.delete(`/rules/${e.id}`),T()}return e(T),(e,i)=>{let l=t(`el-button`),d=t(`el-table-column`),m=t(`el-table`),h=t(`el-option`),T=t(`el-select`),k=t(`el-form-item`),A=t(`el-input`),j=t(`el-input-number`),M=t(`el-form`),N=t(`el-dialog`),P=r(`loading`);return n(),c(`div`,v,[p(g,{title:`路由规则`,description:`手动规则优先级高于订阅规则,自上而下首条命中`},{actions:o(()=>[p(l,{type:`primary`,onClick:E},{default:o(()=>[...i[6]||=[f(`添加规则`,-1)]]),_:1})]),_:1}),a(`div`,y,[p(_,{title:`手动规则(高优先级)`},{default:o(()=>[u((n(),s(m,{data:b.value,size:`small`},{default:o(()=>[p(d,{prop:`priority`,label:`优先级`,width:`80`}),p(d,{prop:`rule_type`,label:`类型`,width:`120`}),p(d,{prop:`payload`,label:`匹配`,"min-width":`120`}),p(d,{prop:`target`,label:`目标`,width:`100`}),p(d,{label:`操作`,width:`80`,fixed:`right`},{default:o(({row:e})=>[p(l,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:o(()=>[...i[7]||=[f(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[P,S.value]])]),_:1}),p(_,{title:`合成后规则预览`},{default:o(()=>[p(m,{data:x.value,size:`small`,"max-height":`400`},{default:o(()=>[p(d,{prop:`priority`,label:``,width:`70`}),p(d,{prop:`rule`,label:`规则`,"min-width":`200`}),p(d,{prop:`source`,label:`来源`,width:`90`})]),_:1},8,[`data`])]),_:1})]),p(N,{modelValue:C.value,"onUpdate:modelValue":i[5]||=e=>C.value=e,title:`添加规则`,width:`520px`,"destroy-on-close":``},{footer:o(()=>[p(l,{onClick:i[4]||=e=>C.value=!1},{default:o(()=>[...i[9]||=[f(`取消`,-1)]]),_:1}),p(l,{type:`primary`,onClick:D},{default:o(()=>[...i[10]||=[f(`确认`,-1)]]),_:1})]),default:o(()=>[p(M,{"label-width":`100px`},{default:o(()=>[p(k,{label:`类型`},{default:o(()=>[p(T,{modelValue:w.value.rule_type,"onUpdate:modelValue":i[0]||=e=>w.value.rule_type=e,style:{width:`100%`}},{default:o(()=>[p(h,{label:`DOMAIN`,value:`DOMAIN`}),p(h,{label:`DOMAIN-SUFFIX`,value:`DOMAIN-SUFFIX`}),p(h,{label:`DOMAIN-KEYWORD`,value:`DOMAIN-KEYWORD`}),p(h,{label:`IP-CIDR`,value:`IP-CIDR`}),p(h,{label:`GEOIP`,value:`GEOIP`})]),_:1},8,[`modelValue`])]),_:1}),p(k,{label:`匹配值`},{default:o(()=>[p(A,{modelValue:w.value.payload,"onUpdate:modelValue":i[1]||=e=>w.value.payload=e},null,8,[`modelValue`])]),_:1}),p(k,{label:`目标`},{default:o(()=>[p(A,{modelValue:w.value.target,"onUpdate:modelValue":i[2]||=e=>w.value.target=e,placeholder:`PROXY / DIRECT / REJECT`},null,8,[`modelValue`])]),_:1}),p(k,{label:`优先级`},{default:o(()=>[p(j,{modelValue:w.value.priority,"onUpdate:modelValue":i[3]||=e=>w.value.priority=e,min:1,max:9999},null,8,[`modelValue`]),i[8]||=a(`p`,{class:`form-hint`},`数值越小优先级越高`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
@@ -0,0 +1 @@
import{F as e,H as t,R as n,U as r,b as i,d as a,et as o,f as s,m as c,t as l,tt as u,ut as d,v as f,y as p}from"./client-CiT_nDm8.js";import{i as m}from"./index-B4K9RppJ.js";import{n as h,t as g}from"./AppCard-ClnBgZ-s.js";var _={class:`page`},v=i({__name:`SettingsView`,setup(i){let v=d({}),y=d(!1),b=d(!1);async function x(){y.value=!0;try{let{data:e}=await l.get(`/settings`);v.value=e}finally{y.value=!1}}async function S(){b.value=!0;try{await l.put(`/settings`,v.value),m.success(`设置已保存`)}finally{b.value=!1}}async function C(){await l.post(`/system/reload`),m.success(`内核已重载`)}return e(x),(e,i)=>{let l=t(`el-button`),d=t(`el-input`),m=t(`el-form-item`),x=t(`el-option`),w=t(`el-select`),T=t(`el-switch`),E=t(`el-form`),D=r(`loading`);return n(),c(`div`,_,[p(h,{title:`系统设置`,description:`代理端口、调度间隔与安全配置`},{actions:o(()=>[p(l,{onClick:C},{default:o(()=>[...i[10]||=[f(`重载内核`,-1)]]),_:1}),p(l,{type:`primary`,loading:b.value,onClick:S},{default:o(()=>[...i[11]||=[f(`保存`,-1)]]),_:1},8,[`loading`])]),_:1}),u((n(),s(g,{title:`入站与 API`,padded:``},{default:o(()=>[p(E,{"label-width":`120px`},{default:o(()=>[i[14]||=a(`div`,{class:`section-title`},`代理入站`,-1),p(m,{label:`HTTP 端口`},{default:o(()=>[p(d,{modelValue:v.value.http_port,"onUpdate:modelValue":i[0]||=e=>v.value.http_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`SOCKS5 端口`},{default:o(()=>[p(d,{modelValue:v.value.socks_port,"onUpdate:modelValue":i[1]||=e=>v.value.socks_port=e},null,8,[`modelValue`])]),_:1}),i[15]||=a(`div`,{class:`section-title`},`管理 API`,-1),p(m,{label:`API 端口`},{default:o(()=>[p(d,{modelValue:v.value.api_port,"onUpdate:modelValue":i[2]||=e=>v.value.api_port=e},null,8,[`modelValue`]),i[12]||=a(`p`,{class:`form-hint`},`修改后需重启进程生效`,-1)]),_:1}),p(m,{label:`Mihomo API`},{default:o(()=>[p(d,{modelValue:v.value.mihomo_api_port,"onUpdate:modelValue":i[3]||=e=>v.value.mihomo_api_port=e},null,8,[`modelValue`])]),_:1}),p(m,{label:`API Secret`},{default:o(()=>[p(d,{modelValue:v.value.api_secret,"onUpdate:modelValue":i[4]||=e=>v.value.api_secret=e,placeholder:`Clash API 密钥`},null,8,[`modelValue`])]),_:1}),i[16]||=a(`div`,{class:`section-title`},`调度`,-1),p(m,{label:`健康检查间隔`},{default:o(()=>[p(d,{modelValue:v.value.health_interval_sec,"onUpdate:modelValue":i[5]||=e=>v.value.health_interval_sec=e},null,8,[`modelValue`]),i[13]||=a(`p`,{class:`form-hint`},``,-1)]),_:1}),p(m,{label:`默认策略`},{default:o(()=>[p(w,{modelValue:v.value.default_policy,"onUpdate:modelValue":i[6]||=e=>v.value.default_policy=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`DIRECT`,value:`DIRECT`}),p(x,{label:`PROXY`,value:`PROXY`}),p(x,{label:`REJECT`,value:`REJECT`})]),_:1},8,[`modelValue`])]),_:1}),p(m,{label:`日志级别`},{default:o(()=>[p(w,{modelValue:v.value.log_level,"onUpdate:modelValue":i[7]||=e=>v.value.log_level=e,style:{width:`100%`}},{default:o(()=>[p(x,{label:`info`,value:`info`}),p(x,{label:`warning`,value:`warning`}),p(x,{label:`error`,value:`error`}),p(x,{label:`debug`,value:`debug`})]),_:1},8,[`modelValue`])]),_:1}),i[17]||=a(`div`,{class:`section-title`},`安全`,-1),p(m,{label:`启用登录`},{default:o(()=>[p(T,{modelValue:v.value.auth_enabled,"onUpdate:modelValue":i[8]||=e=>v.value.auth_enabled=e,"active-value":`true`,"inactive-value":`false`},null,8,[`modelValue`])]),_:1}),p(m,{label:`新密码`},{default:o(()=>[p(d,{modelValue:v.value.admin_password,"onUpdate:modelValue":i[9]||=e=>v.value.admin_password=e,type:`password`,"show-password":``,placeholder:`留空则不修改`},null,8,[`modelValue`])]),_:1})]),_:1})]),_:1})),[[D,y.value]])])}}});export{v as default};
@@ -0,0 +1 @@
import{F as e,H as t,Kt as n,R as r,U as i,b as a,d as o,et as s,f as c,m as l,t as u,tt as d,ut as f,v as p,y as m}from"./client-CiT_nDm8.js";import{i as h,r as g}from"./index-B4K9RppJ.js";import{n as _,t as v}from"./AppCard-ClnBgZ-s.js";var y={class:`page`},b=a({__name:`SubscriptionsView`,setup(a){let b=f([]),x=f(!1),S=f(!1),C=f({name:``,url:``,type:`clash`,interval_sec:3600});async function w(){x.value=!0;try{let{data:e}=await u.get(`/subscriptions`);b.value=e.items||[]}finally{x.value=!1}}function T(){C.value={name:``,url:``,type:`clash`,interval_sec:3600},S.value=!0}async function E(){await u.post(`/subscriptions`,C.value),h.success(`已添加订阅`),S.value=!1,w()}async function D(e){await u.post(`/subscriptions/${e.id}/refresh`),h.success(`刷新成功`),w()}async function O(e){await g.confirm(`确定删除订阅「${e.name}」?`,`确认`),await u.delete(`/subscriptions/${e.id}`),h.success(`已删除`),w()}return e(w),(e,a)=>{let u=t(`el-button`),f=t(`el-table-column`),h=t(`el-tag`),g=t(`el-table`),w=t(`el-input`),k=t(`el-form-item`),A=t(`el-option`),j=t(`el-select`),M=t(`el-input-number`),N=t(`el-form`),P=t(`el-dialog`),F=i(`loading`);return r(),l(`div`,y,[m(_,{title:`订阅管理`,description:`添加并定时更新 Clash 或节点链接订阅`},{actions:s(()=>[m(u,{type:`primary`,onClick:T},{default:s(()=>[...a[6]||=[p(`添加订阅`,-1)]]),_:1})]),_:1}),m(v,null,{default:s(()=>[d((r(),c(g,{data:b.value,size:`small`},{default:s(()=>[m(f,{prop:`name`,label:`名称`,"min-width":`120`}),m(f,{prop:`type`,label:`类型`,width:`80`}),m(f,{prop:`url`,label:`URL`,"min-width":`200`,"show-overflow-tooltip":``}),m(f,{prop:`node_count`,label:`节点数`,width:`80`}),m(f,{label:`状态`,width:`100`},{default:s(({row:e})=>[m(h,{type:e.last_error?`danger`:`success`,size:`small`},{default:s(()=>[p(n(e.last_error?`异常`:`正常`),1)]),_:2},1032,[`type`])]),_:1}),m(f,{prop:`last_fetch_at`,label:`上次更新`,width:`160`}),m(f,{label:`操作`,width:`160`,fixed:`right`},{default:s(({row:e})=>[m(u,{size:`small`,link:``,onClick:t=>D(e)},{default:s(()=>[...a[7]||=[p(`刷新`,-1)]]),_:1},8,[`onClick`]),m(u,{size:`small`,type:`danger`,link:``,onClick:t=>O(e)},{default:s(()=>[...a[8]||=[p(`删除`,-1)]]),_:1},8,[`onClick`])]),_:1})]),_:1},8,[`data`])),[[F,x.value]])]),_:1}),m(P,{modelValue:S.value,"onUpdate:modelValue":a[5]||=e=>S.value=e,title:`添加订阅`,width:`520px`,"destroy-on-close":``},{footer:s(()=>[m(u,{onClick:a[4]||=e=>S.value=!1},{default:s(()=>[...a[10]||=[p(`取消`,-1)]]),_:1}),m(u,{type:`primary`,onClick:E},{default:s(()=>[...a[11]||=[p(`确认`,-1)]]),_:1})]),default:s(()=>[m(N,{"label-width":`100px`},{default:s(()=>[m(k,{label:`名称`},{default:s(()=>[m(w,{modelValue:C.value.name,"onUpdate:modelValue":a[0]||=e=>C.value.name=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`URL`},{default:s(()=>[m(w,{modelValue:C.value.url,"onUpdate:modelValue":a[1]||=e=>C.value.url=e},null,8,[`modelValue`])]),_:1}),m(k,{label:`类型`},{default:s(()=>[m(j,{modelValue:C.value.type,"onUpdate:modelValue":a[2]||=e=>C.value.type=e,style:{width:`100%`}},{default:s(()=>[m(A,{label:`Clash YAML`,value:`clash`}),m(A,{label:`节点链接`,value:`links`})]),_:1},8,[`modelValue`])]),_:1}),m(k,{label:`更新间隔`},{default:s(()=>[m(M,{modelValue:C.value.interval_sec,"onUpdate:modelValue":a[3]||=e=>C.value.interval_sec=e,min:300,step:300},null,8,[`modelValue`]),a[9]||=o(`p`,{class:`form-hint`},`秒,最小 300`,-1)]),_:1})]),_:1})]),_:1},8,[`modelValue`])])}}});export{b as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
<script type="module" crossorigin src="/assets/index-B4K9RppJ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/client-CiT_nDm8.js">
<link rel="stylesheet" crossorigin href="/assets/index-CxbpOrcM.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
)
const APIKeyPrefix = "prism_"
func GenerateAPIKey() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return APIKeyPrefix + hex.EncodeToString(b[:]), nil
}
func HashAPIKey(key string) string {
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func APIKeyDisplayPrefix(key string) string {
key = strings.TrimSpace(key)
if len(key) <= 12 {
return key
}
return key[:12] + "…"
}
func IsAPIKeyFormat(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), APIKeyPrefix)
}
func ValidateAPIKeyFormat(key string) error {
key = strings.TrimSpace(key)
if !strings.HasPrefix(key, APIKeyPrefix) {
return fmt.Errorf("invalid api key format")
}
raw := strings.TrimPrefix(key, APIKeyPrefix)
if len(raw) != 32 {
return fmt.Errorf("invalid api key length")
}
if _, err := hex.DecodeString(raw); err != nil {
return fmt.Errorf("invalid api key encoding")
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package auth
import "testing"
func TestGenerateAndHashAPIKey(t *testing.T) {
key, err := GenerateAPIKey()
if err != nil {
t.Fatal(err)
}
if err := ValidateAPIKeyFormat(key); err != nil {
t.Fatal(err)
}
if HashAPIKey(key) == "" {
t.Fatal("empty hash")
}
if HashAPIKey(key) != HashAPIKey(key) {
t.Fatal("hash not stable")
}
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"context"
"fmt"
"strconv"
"time"
"github.com/prism/proxy/internal/store"
)
const (
defaultMaxAttempts = 5
defaultLockMinutes = 15
)
type LoginConfig struct {
MaxAttempts int
Window time.Duration
LockDuration time.Duration
}
type LoginResult struct {
Allowed bool
Locked bool
Remaining int
RetryAfter time.Duration
}
type LoginLimiter struct {
store *store.Store
}
func NewLoginLimiter(st *store.Store) *LoginLimiter {
return &LoginLimiter{store: st}
}
func ParseLoginConfig(settings map[string]string) LoginConfig {
maxAttempts := parsePositiveInt(settings["login_max_attempts"], defaultMaxAttempts)
lockMin := parsePositiveInt(settings["login_lock_minutes"], defaultLockMinutes)
lock := time.Duration(lockMin) * time.Minute
return LoginConfig{
MaxAttempts: maxAttempts,
Window: lock,
LockDuration: lock,
}
}
func (l *LoginLimiter) Check(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
attempt, err := l.store.GetLoginAttempt(ctx, ip)
if err != nil {
return LoginResult{}, err
}
if attempt == nil {
return LoginResult{Allowed: true, Remaining: cfg.MaxAttempts}, nil
}
now := time.Now()
if attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
return LoginResult{
Allowed: false,
Locked: true,
RetryAfter: attempt.LockedUntil.Sub(now),
}, nil
}
remaining := cfg.MaxAttempts - attempt.FailCount
if remaining < 0 {
remaining = 0
}
if attempt.FailCount > 0 && now.Sub(attempt.WindowStart) > cfg.Window {
remaining = cfg.MaxAttempts
}
return LoginResult{Allowed: true, Remaining: remaining}, nil
}
func (l *LoginLimiter) RecordFailure(ctx context.Context, ip string, cfg LoginConfig) (LoginResult, error) {
now := time.Now()
attempt, err := l.store.GetLoginAttempt(ctx, ip)
if err != nil {
return LoginResult{}, err
}
if attempt != nil && attempt.LockedUntil != nil && now.Before(*attempt.LockedUntil) {
return LoginResult{
Allowed: false,
Locked: true,
RetryAfter: attempt.LockedUntil.Sub(now),
}, nil
}
failCount := 1
windowStart := now
if attempt != nil {
if attempt.LockedUntil != nil || now.Sub(attempt.WindowStart) <= cfg.Window {
if now.Sub(attempt.WindowStart) <= cfg.Window {
failCount = attempt.FailCount + 1
windowStart = attempt.WindowStart
}
}
}
var lockedUntil *time.Time
locked := false
if failCount >= cfg.MaxAttempts {
t := now.Add(cfg.LockDuration)
lockedUntil = &t
locked = true
_ = l.store.AddAuditLog(ctx, "login_lock", "auth", fmt.Sprintf("ip=%s locked %v", ip, cfg.LockDuration))
}
if err := l.store.SetLoginAttempt(ctx, ip, store.LoginAttempt{
FailCount: failCount,
WindowStart: windowStart,
LockedUntil: lockedUntil,
}); err != nil {
return LoginResult{}, err
}
remaining := cfg.MaxAttempts - failCount
if remaining < 0 {
remaining = 0
}
res := LoginResult{Allowed: !locked, Remaining: remaining, Locked: locked}
if locked && lockedUntil != nil {
res.RetryAfter = lockedUntil.Sub(now)
}
return res, nil
}
func (l *LoginLimiter) RecordSuccess(ctx context.Context, ip string) error {
return l.store.ClearLoginAttempt(ctx, ip)
}
func parsePositiveInt(raw string, fallback int) int {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return fallback
}
return n
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/prism/proxy/internal/store"
)
func TestLoginLimiterLockAndClear(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "data"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
limiter := NewLoginLimiter(st)
cfg := LoginConfig{MaxAttempts: 3, Window: time.Minute, LockDuration: 2 * time.Minute}
ip := "203.0.113.1"
for i := 0; i < 2; i++ {
res, err := limiter.RecordFailure(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if res.Locked {
t.Fatalf("unexpected lock at attempt %d", i+1)
}
if res.Remaining != cfg.MaxAttempts-i-1 {
t.Fatalf("remaining=%d want %d", res.Remaining, cfg.MaxAttempts-i-1)
}
}
res, err := limiter.RecordFailure(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if !res.Locked || res.RetryAfter <= 0 {
t.Fatalf("expected lock, got %+v", res)
}
check, err := limiter.Check(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if check.Allowed {
t.Fatal("expected blocked while locked")
}
if err := limiter.RecordSuccess(ctx, ip); err != nil {
t.Fatal(err)
}
check, err = limiter.Check(ctx, ip, cfg)
if err != nil {
t.Fatal(err)
}
if !check.Allowed || check.Remaining != cfg.MaxAttempts {
t.Fatalf("expected reset, got %+v", check)
}
}
+68
View File
@@ -0,0 +1,68 @@
package cache
import (
"sync"
"time"
)
type DashboardSnapshot struct {
UploadRate int64 `json:"upload_rate"`
DownloadRate int64 `json:"download_rate"`
Connections int `json:"connections"`
TotalNodes int `json:"total_nodes"`
AliveNodes int `json:"alive_nodes"`
UpdatedAt time.Time `json:"updated_at"`
}
type Cache struct {
mu sync.RWMutex
lastYAML []byte
dashboard DashboardSnapshot
proxyLatency map[string]int
}
func New() *Cache {
return &Cache{
proxyLatency: make(map[string]int),
}
}
func (c *Cache) SetLastYAML(yaml []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.lastYAML = append([]byte(nil), yaml...)
}
func (c *Cache) GetLastYAML() []byte {
c.mu.RLock()
defer c.mu.RUnlock()
if c.lastYAML == nil {
return nil
}
return append([]byte(nil), c.lastYAML...)
}
func (c *Cache) SetDashboard(s DashboardSnapshot) {
c.mu.Lock()
defer c.mu.Unlock()
c.dashboard = s
}
func (c *Cache) GetDashboard() DashboardSnapshot {
c.mu.RLock()
defer c.mu.RUnlock()
return c.dashboard
}
func (c *Cache) SetProxyLatency(name string, ms int) {
c.mu.Lock()
defer c.mu.Unlock()
c.proxyLatency[name] = ms
}
func (c *Cache) GetProxyLatency(name string) (int, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.proxyLatency[name]
return v, ok
}
+367
View File
@@ -0,0 +1,367 @@
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
}
+89
View File
@@ -0,0 +1,89 @@
package configbuilder
import (
"testing"
"github.com/metacubex/mihomo/hub/executor"
"github.com/prism/proxy/internal/store"
)
func TestSanitizeRulesSkipsUnknownTarget(t *testing.T) {
nameMap := map[string]string{
"节点选择": "grp1_auto",
"grp1_auto": "grp1_auto",
}
valid := map[string]bool{
"DIRECT": true,
"REJECT": true,
"grp1_auto": true,
}
merged := []store.MergedRule{
{Rule: "DOMAIN,google.com,不存在组"},
{Rule: "DOMAIN,example.com,节点选择"},
{Rule: "MATCH,DIRECT", Source: "system"},
}
rules := sanitizeRules(merged, nameMap, valid, "DIRECT", true)
if len(rules) != 2 {
t.Fatalf("expected 2 rules, got %d: %v", len(rules), rules)
}
if rules[0] != "DOMAIN,example.com,grp1_auto" {
t.Fatalf("unexpected rule: %s", rules[0])
}
if rules[1] != "MATCH,DIRECT" {
t.Fatalf("unexpected match: %s", rules[1])
}
}
func TestRemapCountryTarget(t *testing.T) {
nameMap := map[string]string{"country:HK": "country_HK"}
line := remapRuleTarget("DOMAIN-SUFFIX,google.com,country:HK", nameMap)
if line != "DOMAIN-SUFFIX,google.com,country_HK" {
t.Fatalf("unexpected: %s", line)
}
}
func TestApplyInboundAuth(t *testing.T) {
cfg := map[string]any{}
applyInboundAuth(cfg, map[string]string{})
if _, ok := cfg["authentication"]; ok {
t.Fatal("expected no authentication when empty")
}
applyInboundAuth(cfg, map[string]string{
"proxy_auth_user": "proxy",
"proxy_auth_pass": "secret",
})
auth, ok := cfg["authentication"].([]string)
if !ok || len(auth) != 1 || auth[0] != "proxy:secret" {
t.Fatalf("unexpected authentication: %#v", cfg["authentication"])
}
prefixes, ok := cfg["skip-auth-prefixes"].([]string)
if !ok || len(prefixes) != 2 {
t.Fatalf("unexpected skip-auth-prefixes: %#v", cfg["skip-auth-prefixes"])
}
}
func TestBuiltConfigValidatesWithSanitizedRules(t *testing.T) {
yaml := []byte(`port: 7890
socks-port: 7891
mode: rule
log-level: info
proxies:
- name: sub1_node
type: ss
server: 1.2.3.4
port: 8388
cipher: aes-256-gcm
password: test
proxy-groups:
- name: grp1_auto
type: select
proxies: [sub1_node, DIRECT]
rules:
- DOMAIN,example.com,grp1_auto
- MATCH,grp1_auto
`)
if _, err := executor.ParseWithBytes(yaml); err != nil {
t.Fatalf("config should validate: %v", err)
}
}
+10
View File
@@ -0,0 +1,10 @@
package core
import (
"github.com/prism/proxy/internal/geodata"
)
// InitDataDir points Mihomo geodata/cache paths at dataDir/geo.
func InitDataDir(dataDir string) string {
return geodata.InitHomeDir(dataDir)
}
+74
View File
@@ -0,0 +1,74 @@
package core
import (
"fmt"
"sync"
"github.com/metacubex/mihomo/hub"
"github.com/metacubex/mihomo/hub/executor"
)
type Engine struct {
mu sync.Mutex
apiAddr string
secret string
lastGood []byte
running bool
}
func NewEngine() *Engine {
return &Engine{}
}
func (e *Engine) Configure(apiAddr, secret string) {
e.mu.Lock()
defer e.mu.Unlock()
e.apiAddr = apiAddr
e.secret = secret
}
func (e *Engine) Reload(yaml []byte) error {
e.mu.Lock()
defer e.mu.Unlock()
if _, err := executor.ParseWithBytes(yaml); err != nil {
return fmt.Errorf("config validation failed: %w", err)
}
opts := []hub.Option{
hub.WithExternalController(e.apiAddr),
}
if e.secret != "" {
opts = append(opts, hub.WithSecret(e.secret))
}
if err := hub.Parse(yaml, opts...); err != nil {
return fmt.Errorf("mihomo reload failed: %w", err)
}
e.lastGood = append([]byte(nil), yaml...)
e.running = true
return nil
}
func (e *Engine) Shutdown() {
e.mu.Lock()
defer e.mu.Unlock()
executor.Shutdown()
e.running = false
}
func (e *Engine) LastGoodConfig() []byte {
e.mu.Lock()
defer e.mu.Unlock()
if e.lastGood == nil {
return nil
}
return append([]byte(nil), e.lastGood...)
}
func (e *Engine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
+261
View File
@@ -0,0 +1,261 @@
package country
// catalog lists ISO 3166-1 alpha-2 codes with Chinese names and common proxy-node keywords.
// Keywords are matched longest-first; 2-letter codes require word boundaries.
var catalog = []entry{
// Greater China
{Code: "HK", Name: "香港", Keywords: []string{"香港", "hong kong", "hongkong", "hk", "hkg"}},
{Code: "TW", Name: "台湾", Keywords: []string{"台湾", "台北", "台中", "高雄", "taiwan", "taipei", "tw", "twn"}},
{Code: "MO", Name: "澳门", Keywords: []string{"澳门", "macau", "macao", "mo", "mac"}},
{Code: "CN", Name: "中国", Keywords: []string{"中国", "内地", "china", "beijing", "shanghai", "cn", "chn"}},
// East & Southeast Asia
{Code: "JP", Name: "日本", Keywords: []string{"日本", "东京", "大阪", "东京都", "japan", "tokyo", "osaka", "jp", "jpn"}},
{Code: "KR", Name: "韩国", Keywords: []string{"韩国", "首尔", "south korea", "korea", "seoul", "kr", "kor"}},
{Code: "KP", Name: "朝鲜", Keywords: []string{"朝鲜", "north korea", "pyongyang", "kp", "prk"}},
{Code: "SG", Name: "新加坡", Keywords: []string{"新加坡", "singapore", "sg", "sgp"}},
{Code: "MY", Name: "马来西亚", Keywords: []string{"马来西亚", "吉隆坡", "malaysia", "kuala lumpur", "my", "mys"}},
{Code: "TH", Name: "泰国", Keywords: []string{"泰国", "曼谷", "thailand", "bangkok", "th", "tha"}},
{Code: "VN", Name: "越南", Keywords: []string{"越南", "胡志明", "河内", "vietnam", "hanoi", "saigon", "vn", "vnm"}},
{Code: "PH", Name: "菲律宾", Keywords: []string{"菲律宾", "马尼拉", "philippines", "manila", "ph", "phl"}},
{Code: "ID", Name: "印尼", Keywords: []string{"印尼", "印度尼西亚", "indonesia", "jakarta", "id", "idn"}},
{Code: "MM", Name: "缅甸", Keywords: []string{"缅甸", "myanmar", "burma", "yangon", "mm", "mmr"}},
{Code: "KH", Name: "柬埔寨", Keywords: []string{"柬埔寨", "cambodia", "phnom penh", "kh", "khm"}},
{Code: "LA", Name: "老挝", Keywords: []string{"老挝", "laos", "vientiane", "la", "lao"}},
{Code: "BN", Name: "文莱", Keywords: []string{"文莱", "brunei", "bn", "brn"}},
{Code: "TL", Name: "东帝汶", Keywords: []string{"东帝汶", "timor", "dili", "tl", "tls"}},
// South Asia
{Code: "IN", Name: "印度", Keywords: []string{"印度", "india", "mumbai", "delhi", "bangalore", "in", "ind"}},
{Code: "PK", Name: "巴基斯坦", Keywords: []string{"巴基斯坦", "pakistan", "karachi", "lahore", "pk", "pak"}},
{Code: "BD", Name: "孟加拉国", Keywords: []string{"孟加拉", "bangladesh", "dhaka", "bd", "bgd"}},
{Code: "LK", Name: "斯里兰卡", Keywords: []string{"斯里兰卡", "sri lanka", "colombo", "lk", "lka"}},
{Code: "NP", Name: "尼泊尔", Keywords: []string{"尼泊尔", "nepal", "kathmandu", "np", "npl"}},
{Code: "BT", Name: "不丹", Keywords: []string{"不丹", "bhutan", "thimphu", "bt", "btn"}},
{Code: "MV", Name: "马尔代夫", Keywords: []string{"马尔代夫", "maldives", "male", "mv", "mdv"}},
{Code: "AF", Name: "阿富汗", Keywords: []string{"阿富汗", "afghanistan", "kabul", "af", "afg"}},
// Central & West Asia
{Code: "KZ", Name: "哈萨克斯坦", Keywords: []string{"哈萨克斯坦", "kazakhstan", "almaty", "astana", "kz", "kaz"}},
{Code: "UZ", Name: "乌兹别克斯坦", Keywords: []string{"乌兹别克斯坦", "uzbekistan", "tashkent", "uz", "uzb"}},
{Code: "TM", Name: "土库曼斯坦", Keywords: []string{"土库曼斯坦", "turkmenistan", "ashgabat", "tm", "tkm"}},
{Code: "KG", Name: "吉尔吉斯斯坦", Keywords: []string{"吉尔吉斯斯坦", "kyrgyzstan", "bishkek", "kg", "kgz"}},
{Code: "TJ", Name: "塔吉克斯坦", Keywords: []string{"塔吉克斯坦", "tajikistan", "dushanbe", "tj", "tjk"}},
{Code: "MN", Name: "蒙古", Keywords: []string{"蒙古", "mongolia", "ulaanbaatar", "mn", "mng"}},
{Code: "IR", Name: "伊朗", Keywords: []string{"伊朗", "iran", "tehran", "ir", "irn"}},
{Code: "IQ", Name: "伊拉克", Keywords: []string{"伊拉克", "iraq", "baghdad", "iq", "irq"}},
{Code: "SA", Name: "沙特阿拉伯", Keywords: []string{"沙特", "沙特阿拉伯", "saudi arabia", "riyadh", "jeddah", "sa", "sau"}},
{Code: "AE", Name: "阿联酋", Keywords: []string{"阿联酋", "迪拜", "uae", "dubai", "abu dhabi", "ae", "are"}},
{Code: "QA", Name: "卡塔尔", Keywords: []string{"卡塔尔", "qatar", "doha", "qa", "qat"}},
{Code: "KW", Name: "科威特", Keywords: []string{"科威特", "kuwait", "kw", "kwt"}},
{Code: "BH", Name: "巴林", Keywords: []string{"巴林", "bahrain", "manama", "bh", "bhr"}},
{Code: "OM", Name: "阿曼", Keywords: []string{"阿曼", "oman", "muscat", "om", "omn"}},
{Code: "YE", Name: "也门", Keywords: []string{"也门", "yemen", "sanaa", "ye", "yem"}},
{Code: "JO", Name: "约旦", Keywords: []string{"约旦", "jordan", "amman", "jo", "jor"}},
{Code: "LB", Name: "黎巴嫩", Keywords: []string{"黎巴嫩", "lebanon", "beirut", "lb", "lbn"}},
{Code: "SY", Name: "叙利亚", Keywords: []string{"叙利亚", "syria", "damascus", "sy", "syr"}},
{Code: "IL", Name: "以色列", Keywords: []string{"以色列", "israel", "tel aviv", "jerusalem", "il", "isr"}},
{Code: "PS", Name: "巴勒斯坦", Keywords: []string{"巴勒斯坦", "palestine", "gaza", "ps", "pse"}},
{Code: "TR", Name: "土耳其", Keywords: []string{"土耳其", "turkey", "istanbul", "ankara", "tr", "tur"}},
{Code: "CY", Name: "塞浦路斯", Keywords: []string{"塞浦路斯", "cyprus", "nicosia", "cy", "cyp"}},
{Code: "AM", Name: "亚美尼亚", Keywords: []string{"亚美尼亚", "armenia", "yerevan", "am", "arm"}},
{Code: "AZ", Name: "阿塞拜疆", Keywords: []string{"阿塞拜疆", "azerbaijan", "baku", "az", "aze"}},
{Code: "GE", Name: "格鲁吉亚", Keywords: []string{"格鲁吉亚", "georgia", "tbilisi", "ge", "geo"}},
// Europe — Western
{Code: "GB", Name: "英国", Keywords: []string{"英国", "伦敦", "united kingdom", "britain", "london", "uk", "gb", "gbr"}},
{Code: "IE", Name: "爱尔兰", Keywords: []string{"爱尔兰", "ireland", "dublin", "ie", "irl"}},
{Code: "FR", Name: "法国", Keywords: []string{"法国", "巴黎", "france", "paris", "fr", "fra"}},
{Code: "DE", Name: "德国", Keywords: []string{"德国", "法兰克福", "germany", "frankfurt", "berlin", "munich", "de", "deu"}},
{Code: "NL", Name: "荷兰", Keywords: []string{"荷兰", "阿姆斯特丹", "netherlands", "amsterdam", "nl", "nld"}},
{Code: "BE", Name: "比利时", Keywords: []string{"比利时", "belgium", "brussels", "be", "bel"}},
{Code: "LU", Name: "卢森堡", Keywords: []string{"卢森堡", "luxembourg", "lu", "lux"}},
{Code: "CH", Name: "瑞士", Keywords: []string{"瑞士", "switzerland", "zurich", "ch", "che"}},
{Code: "AT", Name: "奥地利", Keywords: []string{"奥地利", "austria", "vienna", "at", "aut"}},
{Code: "LI", Name: "列支敦士登", Keywords: []string{"列支敦士登", "liechtenstein", "li", "lie"}},
{Code: "MC", Name: "摩纳哥", Keywords: []string{"摩纳哥", "monaco", "mc", "mco"}},
{Code: "AD", Name: "安道尔", Keywords: []string{"安道尔", "andorra", "ad", "and"}},
{Code: "ES", Name: "西班牙", Keywords: []string{"西班牙", "spain", "madrid", "barcelona", "es", "esp"}},
{Code: "PT", Name: "葡萄牙", Keywords: []string{"葡萄牙", "portugal", "lisbon", "pt", "prt"}},
{Code: "IT", Name: "意大利", Keywords: []string{"意大利", "italy", "milan", "rome", "it", "ita"}},
{Code: "MT", Name: "马耳他", Keywords: []string{"马耳他", "malta", "valletta", "mt", "mlt"}},
{Code: "SM", Name: "圣马力诺", Keywords: []string{"圣马力诺", "san marino", "sm", "smr"}},
{Code: "VA", Name: "梵蒂冈", Keywords: []string{"梵蒂冈", "vatican", "va", "vat"}},
// Europe — Northern
{Code: "IS", Name: "冰岛", Keywords: []string{"冰岛", "iceland", "reykjavik", "is", "isl"}},
{Code: "NO", Name: "挪威", Keywords: []string{"挪威", "norway", "oslo", "no", "nor"}},
{Code: "SE", Name: "瑞典", Keywords: []string{"瑞典", "sweden", "stockholm", "se", "swe"}},
{Code: "FI", Name: "芬兰", Keywords: []string{"芬兰", "finland", "helsinki", "fi", "fin"}},
{Code: "DK", Name: "丹麦", Keywords: []string{"丹麦", "denmark", "copenhagen", "dk", "dnk"}},
{Code: "EE", Name: "爱沙尼亚", Keywords: []string{"爱沙尼亚", "estonia", "tallinn", "ee", "est"}},
{Code: "LV", Name: "拉脱维亚", Keywords: []string{"拉脱维亚", "latvia", "riga", "lv", "lva"}},
{Code: "LT", Name: "立陶宛", Keywords: []string{"立陶宛", "lithuania", "vilnius", "lt", "ltu"}},
// Europe — Eastern
{Code: "RU", Name: "俄罗斯", Keywords: []string{"俄罗斯", "russia", "moscow", "saint petersburg", "ru", "rus"}},
{Code: "UA", Name: "乌克兰", Keywords: []string{"乌克兰", "ukraine", "kyiv", "kiev", "ua", "ukr"}},
{Code: "BY", Name: "白俄罗斯", Keywords: []string{"白俄罗斯", "belarus", "minsk", "by", "blr"}},
{Code: "PL", Name: "波兰", Keywords: []string{"波兰", "poland", "warsaw", "pl", "pol"}},
{Code: "CZ", Name: "捷克", Keywords: []string{"捷克", "czech", "prague", "cz", "cze"}},
{Code: "SK", Name: "斯洛伐克", Keywords: []string{"斯洛伐克", "slovakia", "bratislava", "sk", "svk"}},
{Code: "HU", Name: "匈牙利", Keywords: []string{"匈牙利", "hungary", "budapest", "hu", "hun"}},
{Code: "RO", Name: "罗马尼亚", Keywords: []string{"罗马尼亚", "romania", "bucharest", "ro", "rou"}},
{Code: "BG", Name: "保加利亚", Keywords: []string{"保加利亚", "bulgaria", "sofia", "bg", "bgr"}},
{Code: "MD", Name: "摩尔多瓦", Keywords: []string{"摩尔多瓦", "moldova", "chisinau", "md", "mda"}},
{Code: "RS", Name: "塞尔维亚", Keywords: []string{"塞尔维亚", "serbia", "belgrade", "rs", "srb"}},
{Code: "ME", Name: "黑山", Keywords: []string{"黑山", "montenegro", "podgorica", "me", "mne"}},
{Code: "BA", Name: "波黑", Keywords: []string{"波黑", "bosnia", "sarajevo", "ba", "bih"}},
{Code: "HR", Name: "克罗地亚", Keywords: []string{"克罗地亚", "croatia", "zagreb", "hr", "hrv"}},
{Code: "SI", Name: "斯洛文尼亚", Keywords: []string{"斯洛文尼亚", "slovenia", "ljubljana", "si", "svn"}},
{Code: "MK", Name: "北马其顿", Keywords: []string{"北马其顿", "macedonia", "skopje", "mk", "mkd"}},
{Code: "AL", Name: "阿尔巴尼亚", Keywords: []string{"阿尔巴尼亚", "albania", "tirana", "al", "alb"}},
{Code: "XK", Name: "科索沃", Keywords: []string{"科索沃", "kosovo", "pristina", "xk", "xxk"}},
// Europe — Southern (Greece etc.)
{Code: "GR", Name: "希腊", Keywords: []string{"希腊", "greece", "athens", "gr", "grc"}},
// North America
{Code: "US", Name: "美国", Keywords: []string{"美国", "洛杉矶", "纽约", "西雅图", "硅谷", "芝加哥", "达拉斯", "迈阿密", "united states", "america", "usa", "us", "lax", "nyc", "sfo", "dfw", "mia"}},
{Code: "CA", Name: "加拿大", Keywords: []string{"加拿大", "canada", "toronto", "vancouver", "montreal", "ca", "can"}},
{Code: "MX", Name: "墨西哥", Keywords: []string{"墨西哥", "mexico", "mexico city", "guadalajara", "mx", "mex"}},
{Code: "GT", Name: "危地马拉", Keywords: []string{"危地马拉", "guatemala", "gt", "gtm"}},
{Code: "BZ", Name: "伯利兹", Keywords: []string{"伯利兹", "belize", "bz", "blz"}},
{Code: "HN", Name: "洪都拉斯", Keywords: []string{"洪都拉斯", "honduras", "hn", "hnd"}},
{Code: "SV", Name: "萨尔瓦多", Keywords: []string{"萨尔瓦多", "el salvador", "sv", "slv"}},
{Code: "NI", Name: "尼加拉瓜", Keywords: []string{"尼加拉瓜", "nicaragua", "ni", "nic"}},
{Code: "CR", Name: "哥斯达黎加", Keywords: []string{"哥斯达黎加", "costa rica", "cr", "cri"}},
{Code: "PA", Name: "巴拿马", Keywords: []string{"巴拿马", "panama", "pa", "pan"}},
{Code: "CU", Name: "古巴", Keywords: []string{"古巴", "cuba", "havana", "cu", "cub"}},
{Code: "JM", Name: "牙买加", Keywords: []string{"牙买加", "jamaica", "kingston", "jm", "jam"}},
{Code: "HT", Name: "海地", Keywords: []string{"海地", "haiti", "ht", "hti"}},
{Code: "DO", Name: "多米尼加", Keywords: []string{"多米尼加", "dominican republic", "santo domingo", "do", "dom"}},
{Code: "TT", Name: "特立尼达和多巴哥", Keywords: []string{"特立尼达", "trinidad", "tobago", "tt", "tto"}},
{Code: "BS", Name: "巴哈马", Keywords: []string{"巴哈马", "bahamas", "nassau", "bs", "bhs"}},
{Code: "BB", Name: "巴巴多斯", Keywords: []string{"巴巴多斯", "barbados", "bb", "brb"}},
{Code: "PR", Name: "波多黎各", Keywords: []string{"波多黎各", "puerto rico", "pr", "pri"}},
// South America
{Code: "BR", Name: "巴西", Keywords: []string{"巴西", "brazil", "sao paulo", "rio", "br", "bra"}},
{Code: "AR", Name: "阿根廷", Keywords: []string{"阿根廷", "argentina", "buenos aires", "ar", "arg"}},
{Code: "CL", Name: "智利", Keywords: []string{"智利", "chile", "santiago", "cl", "chl"}},
{Code: "CO", Name: "哥伦比亚", Keywords: []string{"哥伦比亚", "colombia", "bogota", "co", "col"}},
{Code: "PE", Name: "秘鲁", Keywords: []string{"秘鲁", "peru", "lima", "pe", "per"}},
{Code: "VE", Name: "委内瑞拉", Keywords: []string{"委内瑞拉", "venezuela", "caracas", "ve", "ven"}},
{Code: "EC", Name: "厄瓜多尔", Keywords: []string{"厄瓜多尔", "ecuador", "quito", "ec", "ecu"}},
{Code: "BO", Name: "玻利维亚", Keywords: []string{"玻利维亚", "bolivia", "bo", "bol"}},
{Code: "PY", Name: "巴拉圭", Keywords: []string{"巴拉圭", "paraguay", "asuncion", "py", "pry"}},
{Code: "UY", Name: "乌拉圭", Keywords: []string{"乌拉圭", "uruguay", "montevideo", "uy", "ury"}},
{Code: "GY", Name: "圭亚那", Keywords: []string{"圭亚那", "guyana", "georgetown", "gy", "guy"}},
{Code: "SR", Name: "苏里南", Keywords: []string{"苏里南", "suriname", "sr", "sur"}},
{Code: "GF", Name: "法属圭亚那", Keywords: []string{"法属圭亚那", "french guiana", "gf", "guf"}},
// Oceania
{Code: "AU", Name: "澳大利亚", Keywords: []string{"澳大利亚", "澳洲", "悉尼", "墨尔本", "australia", "sydney", "melbourne", "au", "aus"}},
{Code: "NZ", Name: "新西兰", Keywords: []string{"新西兰", "new zealand", "auckland", "wellington", "nz", "nzl"}},
{Code: "FJ", Name: "斐济", Keywords: []string{"斐济", "fiji", "suva", "fj", "fji"}},
{Code: "PG", Name: "巴布亚新几内亚", Keywords: []string{"巴布亚新几内亚", "papua new guinea", "port moresby", "pg", "png"}},
{Code: "NC", Name: "新喀里多尼亚", Keywords: []string{"新喀里多尼亚", "new caledonia", "nc", "ncl"}},
{Code: "PF", Name: "法属波利尼西亚", Keywords: []string{"法属波利尼西亚", "french polynesia", "tahiti", "pf", "pyf"}},
{Code: "GU", Name: "关岛", Keywords: []string{"关岛", "guam", "gu", "gum"}},
{Code: "AS", Name: "美属萨摩亚", Keywords: []string{"美属萨摩亚", "american samoa", "as", "asm"}},
{Code: "WS", Name: "萨摩亚", Keywords: []string{"萨摩亚", "samoa", "apia", "ws", "wsm"}},
{Code: "TO", Name: "汤加", Keywords: []string{"汤加", "tonga", "to", "ton"}},
{Code: "VU", Name: "瓦努阿图", Keywords: []string{"瓦努阿图", "vanuatu", "vu", "vut"}},
{Code: "SB", Name: "所罗门群岛", Keywords: []string{"所罗门群岛", "solomon islands", "sb", "slb"}},
{Code: "KI", Name: "基里巴斯", Keywords: []string{"基里巴斯", "kiribati", "ki", "kir"}},
{Code: "MH", Name: "马绍尔群岛", Keywords: []string{"马绍尔群岛", "marshall islands", "mh", "mhl"}},
{Code: "FM", Name: "密克罗尼西亚", Keywords: []string{"密克罗尼西亚", "micronesia", "fm", "fsm"}},
{Code: "PW", Name: "帕劳", Keywords: []string{"帕劳", "palau", "pw", "plw"}},
{Code: "NR", Name: "瑙鲁", Keywords: []string{"瑙鲁", "nauru", "nr", "nru"}},
{Code: "TV", Name: "图瓦卢", Keywords: []string{"图瓦卢", "tuvalu", "tv", "tuv"}},
// North Africa
{Code: "EG", Name: "埃及", Keywords: []string{"埃及", "egypt", "cairo", "eg", "egy"}},
{Code: "LY", Name: "利比亚", Keywords: []string{"利比亚", "libya", "tripoli", "ly", "lby"}},
{Code: "TN", Name: "突尼斯", Keywords: []string{"突尼斯", "tunisia", "tunis", "tn", "tun"}},
{Code: "DZ", Name: "阿尔及利亚", Keywords: []string{"阿尔及利亚", "algeria", "algiers", "dz", "dza"}},
{Code: "MA", Name: "摩洛哥", Keywords: []string{"摩洛哥", "morocco", "casablanca", "rabat", "ma", "mar"}},
{Code: "SD", Name: "苏丹", Keywords: []string{"苏丹", "sudan", "khartoum", "sd", "sdn"}},
// West Africa
{Code: "NG", Name: "尼日利亚", Keywords: []string{"尼日利亚", "nigeria", "lagos", "abuja", "ng", "nga"}},
{Code: "GH", Name: "加纳", Keywords: []string{"加纳", "ghana", "accra", "gh", "gha"}},
{Code: "CI", Name: "科特迪瓦", Keywords: []string{"科特迪瓦", "ivory coast", "cote d'ivoire", "abidjan", "ci", "civ"}},
{Code: "SN", Name: "塞内加尔", Keywords: []string{"塞内加尔", "senegal", "dakar", "sn", "sen"}},
{Code: "ML", Name: "马里", Keywords: []string{"马里", "mali", "bamako", "ml", "mli"}},
{Code: "BF", Name: "布基纳法索", Keywords: []string{"布基纳法索", "burkina faso", "bf", "bfa"}},
{Code: "NE", Name: "尼日尔", Keywords: []string{"尼日尔", "niger", "niamey", "ne", "ner"}},
{Code: "GN", Name: "几内亚", Keywords: []string{"几内亚", "guinea", "conakry", "gn", "gin"}},
{Code: "GW", Name: "几内亚比绍", Keywords: []string{"几内亚比绍", "guinea bissau", "gw", "gnb"}},
{Code: "SL", Name: "塞拉利昂", Keywords: []string{"塞拉利昂", "sierra leone", "freetown", "sl", "sle"}},
{Code: "LR", Name: "利比里亚", Keywords: []string{"利比里亚", "liberia", "monrovia", "lr", "lbr"}},
{Code: "TG", Name: "多哥", Keywords: []string{"多哥", "togo", "lome", "tg", "tgo"}},
{Code: "BJ", Name: "贝宁", Keywords: []string{"贝宁", "benin", "porto novo", "bj", "ben"}},
{Code: "GM", Name: "冈比亚", Keywords: []string{"冈比亚", "gambia", "banjul", "gm", "gmb"}},
{Code: "CV", Name: "佛得角", Keywords: []string{"佛得角", "cape verde", "cv", "cpv"}},
{Code: "MR", Name: "毛里塔尼亚", Keywords: []string{"毛里塔尼亚", "mauritania", "mr", "mrt"}},
// Central Africa
{Code: "CM", Name: "喀麦隆", Keywords: []string{"喀麦隆", "cameroon", "yaounde", "cm", "cmr"}},
{Code: "CF", Name: "中非", Keywords: []string{"中非", "central african", "bangui", "cf", "caf"}},
{Code: "TD", Name: "乍得", Keywords: []string{"乍得", "chad", "ndjamena", "td", "tcd"}},
{Code: "CG", Name: "刚果布", Keywords: []string{"刚果布", "republic of congo", "brazzaville", "cg", "cog"}},
{Code: "CD", Name: "刚果金", Keywords: []string{"刚果金", "democratic republic of congo", "kinshasa", "cd", "cod"}},
{Code: "GA", Name: "加蓬", Keywords: []string{"加蓬", "gabon", "libreville", "ga", "gab"}},
{Code: "GQ", Name: "赤道几内亚", Keywords: []string{"赤道几内亚", "equatorial guinea", "gq", "gnq"}},
{Code: "ST", Name: "圣多美和普林西比", Keywords: []string{"圣多美", "sao tome", "st", "stp"}},
// East Africa
{Code: "ET", Name: "埃塞俄比亚", Keywords: []string{"埃塞俄比亚", "ethiopia", "addis ababa", "et", "eth"}},
{Code: "KE", Name: "肯尼亚", Keywords: []string{"肯尼亚", "kenya", "nairobi", "ke", "ken"}},
{Code: "TZ", Name: "坦桑尼亚", Keywords: []string{"坦桑尼亚", "tanzania", "dar es salaam", "tz", "tza"}},
{Code: "UG", Name: "乌干达", Keywords: []string{"乌干达", "uganda", "kampala", "ug", "uga"}},
{Code: "RW", Name: "卢旺达", Keywords: []string{"卢旺达", "rwanda", "kigali", "rw", "rwa"}},
{Code: "BI", Name: "布隆迪", Keywords: []string{"布隆迪", "burundi", "bujumbura", "bi", "bdi"}},
{Code: "SO", Name: "索马里", Keywords: []string{"索马里", "somalia", "mogadishu", "so", "som"}},
{Code: "DJ", Name: "吉布提", Keywords: []string{"吉布提", "djibouti", "dj", "dji"}},
{Code: "ER", Name: "厄立特里亚", Keywords: []string{"厄立特里亚", "eritrea", "asmara", "er", "eri"}},
{Code: "SS", Name: "南苏丹", Keywords: []string{"南苏丹", "south sudan", "juba", "ss", "ssd"}},
{Code: "SC", Name: "塞舌尔", Keywords: []string{"塞舌尔", "seychelles", "sc", "syc"}},
{Code: "MU", Name: "毛里求斯", Keywords: []string{"毛里求斯", "mauritius", "mu", "mus"}},
{Code: "KM", Name: "科摩罗", Keywords: []string{"科摩罗", "comoros", "km", "com"}},
{Code: "MG", Name: "马达加斯加", Keywords: []string{"马达加斯加", "madagascar", "antananarivo", "mg", "mdg"}},
{Code: "MW", Name: "马拉维", Keywords: []string{"马拉维", "malawi", "lilongwe", "mw", "mwi"}},
{Code: "ZM", Name: "赞比亚", Keywords: []string{"赞比亚", "zambia", "lusaka", "zm", "zmb"}},
{Code: "ZW", Name: "津巴布韦", Keywords: []string{"津巴布韦", "zimbabwe", "harare", "zw", "zwe"}},
{Code: "MZ", Name: "莫桑比克", Keywords: []string{"莫桑比克", "mozambique", "maputo", "mz", "moz"}},
{Code: "AO", Name: "安哥拉", Keywords: []string{"安哥拉", "angola", "luanda", "ao", "ago"}},
{Code: "NA", Name: "纳米比亚", Keywords: []string{"纳米比亚", "namibia", "windhoek", "na", "nam"}},
{Code: "BW", Name: "博茨瓦纳", Keywords: []string{"博茨瓦纳", "botswana", "gaborone", "bw", "bwa"}},
{Code: "LS", Name: "莱索托", Keywords: []string{"莱索托", "lesotho", "ls", "lso"}},
{Code: "SZ", Name: "斯威士兰", Keywords: []string{"斯威士兰", "eswatini", "swaziland", "sz", "swz"}},
// Southern Africa
{Code: "ZA", Name: "南非", Keywords: []string{"南非", "south africa", "johannesburg", "cape town", "za", "zaf"}},
// Caribbean & Atlantic territories
{Code: "BM", Name: "百慕大", Keywords: []string{"百慕大", "bermuda", "bm", "bmu"}},
{Code: "KY", Name: "开曼群岛", Keywords: []string{"开曼群岛", "cayman", "ky", "cym"}},
{Code: "VG", Name: "英属维尔京群岛", Keywords: []string{"英属维尔京", "british virgin", "vg", "vgb"}},
{Code: "VI", Name: "美属维尔京群岛", Keywords: []string{"美属维尔京", "us virgin", "vi", "vir"}},
{Code: "AW", Name: "阿鲁巴", Keywords: []string{"阿鲁巴", "aruba", "aw", "abw"}},
{Code: "CW", Name: "库拉索", Keywords: []string{"库拉索", "curacao", "cw", "cuw"}},
{Code: "AG", Name: "安提瓜和巴布达", Keywords: []string{"安提瓜", "antigua", "ag", "atg"}},
{Code: "DM", Name: "多米尼克", Keywords: []string{"多米尼克", "dominica", "dm", "dma"}},
{Code: "GD", Name: "格林纳达", Keywords: []string{"格林纳达", "grenada", "gd", "grd"}},
{Code: "KN", Name: "圣基茨和尼维斯", Keywords: []string{"圣基茨", "saint kitts", "kn", "kna"}},
{Code: "LC", Name: "圣卢西亚", Keywords: []string{"圣卢西亚", "saint lucia", "lc", "lca"}},
{Code: "VC", Name: "圣文森特", Keywords: []string{"圣文森特", "saint vincent", "vc", "vct"}},
// European territories & microstates
{Code: "GI", Name: "直布罗陀", Keywords: []string{"直布罗陀", "gibraltar", "gi", "gib"}},
{Code: "FO", Name: "法罗群岛", Keywords: []string{"法罗群岛", "faroe", "fo", "fro"}},
{Code: "GL", Name: "格陵兰", Keywords: []string{"格陵兰", "greenland", "gl", "grl"}},
{Code: "JE", Name: "泽西岛", Keywords: []string{"泽西岛", "jersey", "je", "jey"}},
{Code: "GG", Name: "根西岛", Keywords: []string{"根西岛", "guernsey", "gg", "ggy"}},
{Code: "IM", Name: "马恩岛", Keywords: []string{"马恩岛", "isle of man", "im", "imn"}},
{Code: "RE", Name: "留尼汪", Keywords: []string{"留尼汪", "reunion", "re", "reu"}},
{Code: "GP", Name: "瓜德罗普", Keywords: []string{"瓜德罗普", "guadeloupe", "gp", "glp"}},
{Code: "MQ", Name: "马提尼克", Keywords: []string{"马提尼克", "martinique", "mq", "mtq"}},
}
+166
View File
@@ -0,0 +1,166 @@
package country
import (
"sort"
"strings"
)
type entry struct {
Code string
Name string
Keywords []string
}
var (
sortedKeywords []keywordMatch
nameByCode map[string]string
)
type keywordMatch struct {
keyword string
code string
}
func init() {
nameByCode = make(map[string]string, len(catalog))
for _, e := range catalog {
nameByCode[e.Code] = e.Name
for _, kw := range e.Keywords {
sortedKeywords = append(sortedKeywords, keywordMatch{keyword: strings.ToLower(kw), code: e.Code})
}
}
sort.Slice(sortedKeywords, func(i, j int) bool {
return len(sortedKeywords[i].keyword) > len(sortedKeywords[j].keyword)
})
}
// Detect returns ISO-like country code from node name, or empty string.
func Detect(name string) string {
lower := strings.ToLower(strings.TrimSpace(name))
if lower == "" {
return ""
}
normalized := strings.NewReplacer(
"_", " ", "-", " ", "|", " ", "/", " ", "[", " ", "]", " ",
"(", " ", ")", " ", "【", " ", "】", " ", "", " ", "", " ",
).Replace(lower)
padded := " " + normalized + " "
for _, m := range sortedKeywords {
kw := m.keyword
if len(kw) <= 2 {
continue
}
if keywordMatches(padded, kw) {
return m.code
}
}
for _, tok := range strings.Fields(normalized) {
tok = strings.TrimSpace(tok)
if tok == "" {
continue
}
if len(tok) == 2 {
if _, ok := nameByCode[strings.ToUpper(tok)]; ok {
return strings.ToUpper(tok)
}
continue
}
if len(tok) > 2 {
prefix := strings.ToUpper(tok[:2])
if _, ok := nameByCode[prefix]; !ok {
continue
}
suffix := tok[2:]
if suffix == "" {
continue
}
allDigits := true
for _, r := range suffix {
if r < '0' || r > '9' {
allDigits = false
break
}
}
if allDigits {
return prefix
}
}
}
return ""
}
// Name returns Chinese display name for a country code.
func Name(code string) string {
if n, ok := nameByCode[strings.ToUpper(code)]; ok {
return n
}
return code
}
// GroupRuntime returns Mihomo proxy-group name for a country pool.
func GroupRuntime(code string) string {
return "country_" + strings.ToUpper(strings.TrimSpace(code))
}
// TargetAlias returns rule target alias stored in DB, e.g. country:HK.
func TargetAlias(code string) string {
return "country:" + strings.ToUpper(strings.TrimSpace(code))
}
// ParseTargetAlias parses country:XX to code, ok false if not a country target.
func ParseTargetAlias(target string) (string, bool) {
if !strings.HasPrefix(strings.ToLower(target), "country:") {
return "", false
}
code := strings.TrimSpace(target[len("country:"):])
if code == "" {
return "", false
}
return strings.ToUpper(code), true
}
// All returns catalog entries for UI.
func All() []struct{ Code, Name string } {
out := make([]struct{ Code, Name string }, 0, len(catalog))
for _, e := range catalog {
out = append(out, struct{ Code, Name string }{Code: e.Code, Name: e.Name})
}
return out
}
func keywordMatches(text, kw string) bool {
if !strings.Contains(text, kw) {
return false
}
if containsHan(kw) {
return true
}
start := 0
for {
idx := strings.Index(text[start:], kw)
if idx < 0 {
return false
}
pos := start + idx
before := pos == 0 || !isASCIIAlphaNum(text[pos-1])
afterEnd := pos + len(kw)
after := afterEnd >= len(text) || !isASCIIAlphaNum(text[afterEnd])
if before && after {
return true
}
start = pos + 1
}
}
func isASCIIAlphaNum(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')
}
func containsHan(s string) bool {
for _, r := range s {
if r >= 0x4e00 && r <= 0x9fff {
return true
}
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
package country
import "testing"
func TestDetectCountry(t *testing.T) {
cases := map[string]string{
"香港 01 | IEPL": "HK",
"🇺🇸美国洛杉矶": "US",
"日本东京专线": "JP",
"Singapore-01": "SG",
"KR首尔": "KR",
"台湾节点": "TW",
"DE法兰克福": "DE",
"HK 01": "HK",
"墨西哥城 01": "MX",
"Mexico City": "MX",
"南非 约翰内斯堡": "ZA",
"South Africa JNB": "ZA",
"新西兰 奥克兰": "NZ",
"New Zealand Auckland": "NZ",
"尼日利亚 拉各斯": "NG",
"Nigeria Lagos": "NG",
"冰岛 雷克雅未克": "IS",
"Iceland Reykjavik": "IS",
"random node": "",
}
for name, want := range cases {
if got := Detect(name); got != want {
t.Fatalf("%q: want %q got %q", name, want, got)
}
}
}
func TestTargetAlias(t *testing.T) {
code, ok := ParseTargetAlias("country:hk")
if !ok || code != "HK" {
t.Fatalf("parse failed: %v %q", ok, code)
}
if GroupRuntime("HK") != "country_HK" {
t.Fatal("group runtime name")
}
}
func TestCatalogCoverage(t *testing.T) {
if len(catalog) < 150 {
t.Fatalf("expected comprehensive catalog, got %d entries", len(catalog))
}
for _, e := range catalog {
if e.Code == "" || e.Name == "" || len(e.Keywords) == 0 {
t.Fatalf("invalid entry: %+v", e)
}
if Name(e.Code) != e.Name {
t.Fatalf("name mismatch for %s", e.Code)
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package geodata
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/metacubex/mihomo/component/geodata"
C "github.com/metacubex/mihomo/constant"
)
const DefaultBase = "https://ghfast.top/https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest"
type Config struct {
CountryMmdbURL string
GeoIPURL string
GeoSiteURL string
}
type FileSpec struct {
Name string
URL func(Config) string
}
var fileSpecs = []FileSpec{
{Name: "Country.mmdb", URL: func(c Config) string { return c.CountryMmdbURL }},
{Name: "geoip.dat", URL: func(c Config) string { return c.GeoIPURL }},
{Name: "geosite.dat", URL: func(c Config) string { return c.GeoSiteURL }},
}
func DefaultConfig() Config {
return Config{
CountryMmdbURL: DefaultBase + "/Country.mmdb",
GeoIPURL: DefaultBase + "/geoip.dat",
GeoSiteURL: DefaultBase + "/geosite.dat",
}
}
func ConfigFromSettings(settings map[string]string) Config {
def := DefaultConfig()
return Config{
CountryMmdbURL: pickURL(settings["geo_country_mmdb_url"], def.CountryMmdbURL),
GeoIPURL: pickURL(settings["geo_geoip_url"], def.GeoIPURL),
GeoSiteURL: pickURL(settings["geo_geosite_url"], def.GeoSiteURL),
}
}
func pickURL(value, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
func InitHomeDir(dataDir string) string {
geoDir := filepath.Join(dataDir, "geo")
_ = os.MkdirAll(geoDir, 0o755)
C.SetHomeDir(geoDir)
return geoDir
}
func ApplyMihomoURLs(cfg Config) {
geodata.SetMmdbUrl(cfg.CountryMmdbURL)
geodata.SetGeoIpUrl(cfg.GeoIPURL)
geodata.SetGeoSiteUrl(cfg.GeoSiteURL)
geodata.SetASNUrl(DefaultBase + "/GeoLite2-ASN.mmdb")
}
type FileStatus struct {
Name string `json:"name"`
Exists bool `json:"exists"`
Size int64 `json:"size"`
URL string `json:"url"`
}
func Status(dataDir string, cfg Config) []FileStatus {
geoDir := filepath.Join(dataDir, "geo")
out := make([]FileStatus, 0, len(fileSpecs))
for _, spec := range fileSpecs {
dest := filepath.Join(geoDir, spec.Name)
item := FileStatus{Name: spec.Name, URL: spec.URL(cfg)}
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
item.Exists = true
item.Size = st.Size()
}
out = append(out, item)
}
return out
}
func Ready(dataDir string) bool {
geoDir := filepath.Join(dataDir, "geo")
for _, spec := range fileSpecs {
st, err := os.Stat(filepath.Join(geoDir, spec.Name))
if err != nil || st.Size() == 0 {
return false
}
}
return true
}
// Ensure downloads missing geo files. When force is true, all files are re-downloaded.
func Ensure(dataDir string, cfg Config, force bool) ([]string, error) {
geoDir := InitHomeDir(dataDir)
client := &http.Client{Timeout: 120 * time.Second}
var updated []string
for _, spec := range fileSpecs {
dest := filepath.Join(geoDir, spec.Name)
if !force {
if st, err := os.Stat(dest); err == nil && st.Size() > 0 {
continue
}
}
url := spec.URL(cfg)
if err := downloadFile(client, url, dest); err != nil {
return updated, fmt.Errorf("download %s: %w (place files manually in %s)", spec.Name, err, geoDir)
}
updated = append(updated, spec.Name)
}
return updated, nil
}
func downloadFile(client *http.Client, url, dest string) error {
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
tmp := dest + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
f.Close()
return os.Rename(tmp, dest)
}
+23
View File
@@ -0,0 +1,23 @@
package geodata
import "testing"
func TestConfigFromSettingsUsesDefaults(t *testing.T) {
cfg := ConfigFromSettings(map[string]string{})
def := DefaultConfig()
if cfg.CountryMmdbURL != def.CountryMmdbURL || cfg.GeoIPURL != def.GeoIPURL || cfg.GeoSiteURL != def.GeoSiteURL {
t.Fatalf("expected defaults, got %+v", cfg)
}
}
func TestConfigFromSettingsOverrides(t *testing.T) {
cfg := ConfigFromSettings(map[string]string{
"geo_geoip_url": "https://example.com/geoip.dat",
})
if cfg.GeoIPURL != "https://example.com/geoip.dat" {
t.Fatalf("got %q", cfg.GeoIPURL)
}
if cfg.GeoSiteURL != DefaultConfig().GeoSiteURL {
t.Fatalf("expected default geosite url")
}
}
+170
View File
@@ -0,0 +1,170 @@
package health
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/prism/proxy/internal/cache"
"github.com/prism/proxy/internal/store"
)
type ReloadFunc func(ctx context.Context) error
type Checker struct {
store *store.Store
cache *cache.Cache
apiBase string
secret string
client *http.Client
testing atomic.Bool
}
func NewChecker(s *store.Store, c *cache.Cache, apiBase, secret string) *Checker {
return &Checker{
store: s,
cache: c,
apiBase: stringsTrimRight(apiBase, "/"),
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func stringsTrimRight(s, cut string) string {
for len(s) > 0 && stringsHasSuffix(s, cut) {
s = s[:len(s)-len(cut)]
}
return s
}
func stringsHasSuffix(s, suffix string) bool {
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}
func (h *Checker) TestNode(ctx context.Context, nodeID int64, reload ReloadFunc) (int, error) {
node, err := h.store.GetProxyNode(ctx, nodeID)
if err != nil {
return -1, err
}
latency, err := h.testRuntimeName(node.RuntimeName)
if isNotFound(err) && reload != nil {
if reloadErr := reload(ctx); reloadErr == nil {
latency, err = h.testRuntimeName(node.RuntimeName)
}
}
if isNotFound(err) {
err = fmt.Errorf("proxy %q not loaded in engine; reload engine in settings", node.RuntimeName)
}
alive := err == nil && latency >= 0
lat := latency
if !alive {
lat = -1
}
_ = h.store.UpdateProxyNodeHealth(ctx, nodeID, lat, alive)
h.cache.SetProxyLatency(node.RuntimeName, lat)
return lat, err
}
func (h *Checker) TestAll(ctx context.Context, _ ReloadFunc) error {
if !h.testing.CompareAndSwap(false, true) {
return nil
}
go func() {
defer h.testing.Store(false)
nodes, err := h.store.ListProxyNodes(context.Background(), "", nil)
if err != nil {
return
}
for _, n := range nodes {
if !n.Enabled {
continue
}
_, _ = h.TestNode(context.Background(), n.ID, nil)
}
}()
return nil
}
func (h *Checker) ProxyLoaded(name string) bool {
data, err := h.getJSON("/proxies")
if err != nil {
return false
}
proxies, ok := data["proxies"].(map[string]any)
if !ok {
return false
}
_, ok = proxies[name]
return ok
}
func (h *Checker) testRuntimeName(name string) (int, error) {
testURL := fmt.Sprintf("%s/proxies/%s/delay?timeout=5000&url=%s", h.apiBase, url.PathEscape(name), url.QueryEscape("http://www.gstatic.com/generate_204"))
req, err := http.NewRequest(http.MethodGet, testURL, nil)
if err != nil {
return -1, err
}
if h.secret != "" {
req.Header.Set("Authorization", "Bearer "+h.secret)
}
resp, err := h.client.Do(req)
if err != nil {
return -1, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return -1, fmt.Errorf("delay test failed: %s", string(body))
}
var result struct {
Delay int `json:"delay"`
}
if err := json.Unmarshal(body, &result); err != nil {
return -1, err
}
return result.Delay, nil
}
func (h *Checker) getJSON(path string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, h.apiBase+path, nil)
if err != nil {
return nil, err
}
if h.secret != "" {
req.Header.Set("Authorization", "Bearer "+h.secret)
}
resp, err := h.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body))
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return out, nil
}
func isNotFound(err error) bool {
return err != nil && strings.Contains(err.Error(), "Resource not found")
}
func (h *Checker) UpdateAPIBase(apiBase, secret string) {
h.apiBase = stringsTrimRight(apiBase, "/")
h.secret = secret
}
+67
View File
@@ -0,0 +1,67 @@
package applog
import (
"context"
"sync"
"time"
"github.com/prism/proxy/internal/store"
)
type Entry struct {
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
}
type Collector struct {
mu sync.RWMutex
ring []Entry
cap int
store *store.Store
persist bool
}
func NewCollector(s *store.Store, capacity int, persist bool) *Collector {
return &Collector{
ring: make([]Entry, 0, capacity),
cap: capacity,
store: s,
persist: persist,
}
}
func (c *Collector) Add(level, source, message string) {
e := Entry{
Level: level,
Source: source,
Message: message,
CreatedAt: time.Now(),
}
c.mu.Lock()
if len(c.ring) >= c.cap {
c.ring = c.ring[1:]
}
c.ring = append(c.ring, e)
c.mu.Unlock()
if c.persist && c.store != nil {
_ = c.store.AddRequestLog(context.Background(), level, source, message)
}
}
func (c *Collector) List(limit int) []Entry {
c.mu.RLock()
defer c.mu.RUnlock()
if limit <= 0 || limit > len(c.ring) {
limit = len(c.ring)
}
start := len(c.ring) - limit
if start < 0 {
start = 0
}
out := make([]Entry, limit)
copy(out, c.ring[start:])
return out
}
+173
View File
@@ -0,0 +1,173 @@
package metrics
import (
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/store"
)
type TrafficItem struct {
Key string `json:"key"`
Label string `json:"label"`
Node string `json:"node,omitempty"`
NodeKey string `json:"node_key,omitempty"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Total int64 `json:"total"`
Connections int `json:"connections"`
}
func AggregateConnections(conns []map[string]any, policyGroups map[string]bool) (domains, nodes []TrafficItem) {
domainMap := map[string]*TrafficItem{}
nodeMap := map[string]*TrafficItem{}
for _, conn := range conns {
host := connHost(conn)
nodeKey := connLeafNode(conn, policyGroups)
up := toInt64(conn["upload"])
down := toInt64(conn["download"])
if host != "" {
key := host + "\x00" + nodeKey
acc(domainMap, key, up, down)
item := domainMap[key]
item.Label = host
item.NodeKey = nodeKey
}
if nodeKey != "" {
acc(nodeMap, nodeKey, up, down)
}
}
return sortTrafficItems(domainMap), sortTrafficItems(nodeMap)
}
func acc(m map[string]*TrafficItem, key string, up, down int64) {
item, ok := m[key]
if !ok {
item = &TrafficItem{Key: key, Label: key}
m[key] = item
}
item.Upload += up
item.Download += down
item.Total += up + down
item.Connections++
}
func sortTrafficItems(m map[string]*TrafficItem) []TrafficItem {
out := make([]TrafficItem, 0, len(m))
for _, v := range m {
out = append(out, *v)
}
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Total > out[i].Total {
out[i], out[j] = out[j], out[i]
}
}
}
return out
}
func connHost(m map[string]any) string {
meta, _ := m["metadata"].(map[string]any)
if meta == nil {
return ""
}
if h, ok := meta["host"].(string); ok && h != "" {
return h
}
if h, ok := meta["sniffHost"].(string); ok && h != "" {
return h
}
if h, ok := meta["remoteDestination"].(string); ok && h != "" {
return h
}
if ip, ok := meta["destinationIP"].(string); ok && ip != "" {
port, _ := meta["destinationPort"].(string)
if port != "" {
return ip + ":" + port
}
return ip
}
return ""
}
func connLeafNode(m map[string]any, policyGroups map[string]bool) string {
return mihomoapi.ResolveLeafOutbound(mihomoapi.ChainStringsFromAny(m["chains"]), policyGroups)
}
func toInt64(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
default:
return 0
}
}
func ApplyTrafficLabels(items []TrafficItem, names map[string]string) []TrafficItem {
out := make([]TrafficItem, len(items))
for i, item := range items {
out[i] = item
if label, ok := names[item.Key]; ok && label != "" {
out[i].Label = label
}
}
return out
}
func ApplyDomainLabels(items []TrafficItem, names map[string]string) []TrafficItem {
out := make([]TrafficItem, len(items))
for i, item := range items {
out[i] = item
if label, ok := names[item.NodeKey]; ok && label != "" {
out[i].Node = label
} else if item.NodeKey != "" {
out[i].Node = item.NodeKey
}
}
return out
}
func TrafficAggToItems(aggs []store.TrafficAgg, names map[string]string) []TrafficItem {
out := make([]TrafficItem, 0, len(aggs))
for _, a := range aggs {
label := a.Key
if n, ok := names[a.Key]; ok && n != "" {
label = n
}
out = append(out, TrafficItem{
Key: a.Key,
Label: label,
Upload: a.Upload,
Download: a.Download,
Total: a.Upload + a.Download,
Connections: a.Connections,
})
}
return out
}
func TrafficHostNodeAggToItems(aggs []store.TrafficHostNodeAgg, names map[string]string) []TrafficItem {
out := make([]TrafficItem, 0, len(aggs))
for _, a := range aggs {
nodeLabel := a.NodeKey
if n, ok := names[a.NodeKey]; ok && n != "" {
nodeLabel = n
}
out = append(out, TrafficItem{
Key: a.Host + "\x00" + a.NodeKey,
Label: a.Host,
Node: nodeLabel,
NodeKey: a.NodeKey,
Upload: a.Upload,
Download: a.Download,
Total: a.Upload + a.Download,
Connections: a.Connections,
})
}
return out
}
+46
View File
@@ -0,0 +1,46 @@
package metrics
import "testing"
func TestAggregateConnections(t *testing.T) {
groups := map[string]bool{
"PROXY": true,
"country_HK": true,
}
conns := []map[string]any{
{
"upload": float64(100),
"download": float64(200),
"metadata": map[string]any{"host": "www.google.com"},
"chains": []any{"PROXY", "node_us_1"},
},
{
"upload": float64(50),
"download": float64(150),
"metadata": map[string]any{"host": "www.google.com"},
"chains": []any{"country_HK", "node_us_1"},
},
{
"upload": float64(10),
"download": float64(20),
"metadata": map[string]any{"host": "api.github.com"},
"chains": []any{"DIRECT"},
},
}
domains, nodes := AggregateConnections(conns, groups)
if len(domains) != 2 {
t.Fatalf("domains: want 2 got %d", len(domains))
}
if domains[0].Label != "www.google.com" || domains[0].NodeKey != "node_us_1" || domains[0].Total != 500 {
t.Fatalf("google row: %+v", domains[0])
}
if domains[1].Label != "api.github.com" || domains[1].NodeKey != "DIRECT" {
t.Fatalf("github row: %+v", domains[1])
}
if len(nodes) != 2 {
t.Fatalf("nodes: want 2 got %d: %+v", len(nodes), nodes)
}
if nodes[0].Key != "node_us_1" {
t.Fatalf("top node: %+v", nodes[0])
}
}
+237
View File
@@ -0,0 +1,237 @@
package metrics
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/prism/proxy/internal/cache"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/store"
)
type Collector struct {
store *store.Store
cache *cache.Cache
apiBase string
secret string
client *http.Client
mu sync.Mutex
connCache []map[string]any
connCacheAt time.Time
refreshing bool
}
func NewCollector(s *store.Store, c *cache.Cache, apiBase, secret string) *Collector {
return &Collector{
store: s,
cache: c,
apiBase: trimRight(apiBase, "/"),
secret: secret,
client: &http.Client{
Timeout: 3 * time.Second,
},
}
}
func trimRight(s, cut string) string {
for len(s) > 0 && len(s) >= len(cut) && s[len(s)-len(cut):] == cut {
s = s[:len(s)-len(cut)]
}
return s
}
// Snapshot returns cached dashboard data and never blocks on Mihomo.
func (m *Collector) Snapshot(ctx context.Context) cache.DashboardSnapshot {
snap := m.cache.GetDashboard()
total, alive, _ := m.store.CountEnabledProxies(ctx)
snap.TotalNodes = total
snap.AliveNodes = alive
return snap
}
func (m *Collector) RefreshIfStale(ctx context.Context, maxAge time.Duration) cache.DashboardSnapshot {
snap := m.cache.GetDashboard()
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
return snap
}
refreshed, err := m.Refresh(ctx)
if err != nil {
return snap
}
return refreshed
}
func (m *Collector) RefreshAsync(maxAge time.Duration) {
snap := m.cache.GetDashboard()
if !snap.UpdatedAt.IsZero() && time.Since(snap.UpdatedAt) < maxAge {
return
}
m.mu.Lock()
if m.refreshing {
m.mu.Unlock()
return
}
m.refreshing = true
m.mu.Unlock()
go func() {
defer func() {
m.mu.Lock()
m.refreshing = false
m.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
_, _ = m.Refresh(ctx)
}()
}
func (m *Collector) Refresh(ctx context.Context) (cache.DashboardSnapshot, error) {
var snap cache.DashboardSnapshot
snap.UpdatedAt = time.Now()
var (
traffic TrafficRatesResult
conns map[string]any
wg sync.WaitGroup
)
wg.Add(2)
go func() {
defer wg.Done()
rates, err := mihomoapi.New(m.apiBase, m.secret).TrafficRates(ctx)
if err == nil {
traffic = TrafficRatesResult{Up: rates.Up, Down: rates.Down, OK: true}
}
}()
go func() {
defer wg.Done()
conns, _ = m.getJSON("/connections")
}()
wg.Wait()
if traffic.OK {
snap.UploadRate = traffic.Up
snap.DownloadRate = traffic.Down
}
if conns != nil {
if arr, ok := conns["connections"].([]any); ok {
snap.Connections = len(arr)
m.setConnCache(arr)
}
}
total, alive, _ := m.store.CountEnabledProxies(ctx)
snap.TotalNodes = total
snap.AliveNodes = alive
m.cache.SetDashboard(snap)
_ = m.store.AddTrafficSnapshot(ctx, snap.UploadRate, snap.DownloadRate, int64(snap.Connections))
return snap, nil
}
func (m *Collector) setConnCache(arr []any) {
out := make([]map[string]any, 0, len(arr))
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
m.mu.Lock()
m.connCache = out
m.connCacheAt = time.Now()
m.mu.Unlock()
}
func (m *Collector) CachedConnections() []map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
return copyConnSlice(m.connCache)
}
func (m *Collector) GetConnections() ([]map[string]any, error) {
return m.GetConnectionsCached(0)
}
func (m *Collector) GetConnectionsCached(maxAge time.Duration) ([]map[string]any, error) {
m.mu.Lock()
if maxAge > 0 && m.connCache != nil && time.Since(m.connCacheAt) < maxAge {
out := copyConnSlice(m.connCache)
m.mu.Unlock()
return out, nil
}
m.mu.Unlock()
m.RefreshAsync(maxAge)
if cached := m.CachedConnections(); len(cached) > 0 {
return cached, nil
}
data, err := m.getJSON("/connections")
if err != nil {
m.mu.Lock()
defer m.mu.Unlock()
if m.connCache != nil {
return copyConnSlice(m.connCache), nil
}
return nil, err
}
arr, ok := data["connections"].([]any)
if !ok {
return nil, nil
}
m.setConnCache(arr)
return m.CachedConnections(), nil
}
func copyConnSlice(in []map[string]any) []map[string]any {
if len(in) == 0 {
return nil
}
out := make([]map[string]any, len(in))
copy(out, in)
return out
}
type TrafficRatesResult struct {
Up int64
Down int64
OK bool
}
func (m *Collector) UpdateAPIBase(apiBase, secret string) {
m.apiBase = trimRight(apiBase, "/")
m.secret = secret
}
func (m *Collector) getJSON(path string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, m.apiBase+path, nil)
if err != nil {
return nil, err
}
if m.secret != "" {
req.Header.Set("Authorization", "Bearer "+m.secret)
}
resp, err := m.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(body))
}
var out map[string]any
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return out, nil
}
+40
View File
@@ -0,0 +1,40 @@
package mihomoapi
var leafOutbounds = map[string]bool{
"DIRECT": true,
"REJECT": true,
"REJECT-DROP": true,
}
// ResolveLeafOutbound returns the actual proxy node from a Mihomo chains path.
// Policy groups are appended after the leaf dial, so the last chain entry is often a group name.
func ResolveLeafOutbound(chains []string, policyGroups map[string]bool) string {
if len(chains) == 0 {
return ""
}
for i := len(chains) - 1; i >= 0; i-- {
name := chains[i]
if leafOutbounds[name] {
return name
}
if policyGroups != nil && policyGroups[name] {
continue
}
return name
}
return chains[len(chains)-1]
}
func ChainStringsFromAny(raw any) []string {
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(arr))
for _, item := range arr {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
+27
View File
@@ -0,0 +1,27 @@
package mihomoapi
import "testing"
func TestResolveLeafOutbound(t *testing.T) {
groups := map[string]bool{
"PROXY": true,
"country_HK": true,
"自动选择": true,
}
cases := []struct {
chains []string
want string
}{
{[]string{"PROXY", "sub1_hk_node"}, "sub1_hk_node"},
{[]string{"country_HK", "sub1_us_node"}, "sub1_us_node"},
{[]string{"PROXY", "自动选择", "sub1_jp_node"}, "sub1_jp_node"},
{[]string{"DIRECT"}, "DIRECT"},
{[]string{"country_HK"}, "country_HK"},
}
for _, tc := range cases {
got := ResolveLeafOutbound(tc.chains, groups)
if got != tc.want {
t.Fatalf("chains=%v want %q got %q", tc.chains, tc.want, got)
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package mihomoapi
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type Client struct {
base string
secret string
http *http.Client
mu sync.Mutex
proxyCache map[string]ProxyState
proxyCacheAt time.Time
}
type ProxyState struct {
Type string `json:"type"`
Now string `json:"now"`
All []string `json:"all"`
}
func New(base, secret string) *Client {
return &Client{
base: stringsTrimRight(base, "/"),
secret: secret,
http: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) ListProxies() (map[string]ProxyState, error) {
return c.ListProxiesCached(0)
}
func (c *Client) ListProxiesCached(ttl time.Duration) (map[string]ProxyState, error) {
c.mu.Lock()
if ttl > 0 && c.proxyCache != nil && time.Since(c.proxyCacheAt) < ttl {
out := copyProxyStates(c.proxyCache)
c.mu.Unlock()
return out, nil
}
c.mu.Unlock()
var out struct {
Proxies map[string]ProxyState `json:"proxies"`
}
if err := c.getJSON("/proxies", &out); err != nil {
c.mu.Lock()
defer c.mu.Unlock()
if c.proxyCache != nil {
return copyProxyStates(c.proxyCache), nil
}
return nil, err
}
if out.Proxies == nil {
out.Proxies = map[string]ProxyState{}
}
c.mu.Lock()
c.proxyCache = out.Proxies
c.proxyCacheAt = time.Now()
c.mu.Unlock()
return copyProxyStates(out.Proxies), nil
}
func (c *Client) Reachable() bool {
req, err := http.NewRequest(http.MethodGet, c.base+"/version", nil)
if err != nil {
return false
}
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode < 400
}
func copyProxyStates(in map[string]ProxyState) map[string]ProxyState {
out := make(map[string]ProxyState, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func (c *Client) SelectProxy(groupName, proxyName string) error {
body, _ := json.Marshal(map[string]string{"name": proxyName})
return c.putJSON("/proxies/"+url.PathEscape(groupName), body)
}
func (c *Client) getJSON(path string, dest any) error {
req, err := http.NewRequest(http.MethodGet, c.base+path, nil)
if err != nil {
return err
}
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("mihomo api %d: %s", resp.StatusCode, string(data))
}
return json.Unmarshal(data, dest)
}
func (c *Client) putJSON(path string, body []byte) error {
req, err := http.NewRequest(http.MethodPut, c.base+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("mihomo api %d: %s", resp.StatusCode, string(data))
}
return nil
}
func stringsTrimRight(s, cut string) string {
for len(s) > 0 && strings.HasSuffix(s, cut) {
s = s[:len(s)-len(cut)]
}
return s
}
func IsPolicyGroupType(t string) bool {
switch t {
case "Selector", "URLTest", "Fallback", "LoadBalance", "Relay":
return true
default:
return false
}
}
+99
View File
@@ -0,0 +1,99 @@
package mihomoapi
import (
"encoding/json"
"fmt"
"time"
)
type ConnectionSnapshot struct {
Connections []Connection `json:"connections"`
}
type Connection struct {
ID string `json:"id"`
Metadata ConnectionMeta `json:"metadata"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Start time.Time `json:"start"`
Chains []string `json:"chains"`
Rule string `json:"rule"`
RulePayload string `json:"rulePayload"`
}
type ConnectionMeta struct {
Network string `json:"network"`
Host string `json:"host"`
RemoteDestination string `json:"remoteDestination"`
DestinationIP string `json:"destinationIP"`
DestinationPort string `json:"destinationPort"`
SniffHost string `json:"sniffHost"`
}
func (c *Client) ConnectionSnapshot() (*ConnectionSnapshot, error) {
var snap ConnectionSnapshot
if err := c.getJSON("/connections", &snap); err != nil {
return nil, err
}
return &snap, nil
}
// ParseConnectionSnapshot decodes Mihomo /connections JSON.
func ParseConnectionSnapshot(data []byte) (*ConnectionSnapshot, error) {
var snap ConnectionSnapshot
if err := json.Unmarshal(data, &snap); err != nil {
return nil, err
}
return &snap, nil
}
func (conn *Connection) RequestHost() string {
if conn.Metadata.Host != "" {
return conn.Metadata.Host
}
if conn.Metadata.SniffHost != "" {
return conn.Metadata.SniffHost
}
if conn.Metadata.RemoteDestination != "" {
return conn.Metadata.RemoteDestination
}
if conn.Metadata.DestinationIP != "" {
return fmt.Sprintf("%s:%s", conn.Metadata.DestinationIP, conn.Metadata.DestinationPort)
}
return ""
}
func (conn *Connection) RuleLine() string {
if conn.Rule == "" {
return ""
}
if conn.RulePayload == "" {
return conn.Rule
}
return conn.Rule + "," + conn.RulePayload
}
func (conn *Connection) OutboundNode() string {
return ResolveLeafOutbound(conn.Chains, nil)
}
func (conn *Connection) LeafOutbound(policyGroups map[string]bool) string {
return ResolveLeafOutbound(conn.Chains, policyGroups)
}
func (conn *Connection) ChainString() string {
if len(conn.Chains) == 0 {
return ""
}
b, _ := json.Marshal(conn.Chains)
return string(b)
}
func (conn *Connection) IsSuccess() bool {
for _, ch := range conn.Chains {
if ch == "REJECT" || ch == "REJECT-DROP" {
return false
}
}
return conn.Upload+conn.Download > 0
}
+58
View File
@@ -0,0 +1,58 @@
package mihomoapi
import (
"testing"
"time"
)
func TestParseConnectionSnapshot(t *testing.T) {
raw := []byte(`{
"downloadTotal": 200,
"uploadTotal": 100,
"connections": [{
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"metadata": {
"network": "tcp",
"type": "HTTP",
"sourceIP": "127.0.0.1",
"destinationIP": "142.250.185.78",
"destinationPort": "443",
"host": "www.google.com"
},
"upload": 100,
"download": 200,
"start": "2026-06-16T10:00:00Z",
"chains": ["DIRECT"],
"rule": "DOMAIN",
"rulePayload": "google.com"
}],
"memory": 0
}`)
snap, err := ParseConnectionSnapshot(raw)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(snap.Connections) != 1 {
t.Fatalf("expected 1 connection, got %d", len(snap.Connections))
}
conn := snap.Connections[0]
if conn.ID == "" {
t.Fatal("expected connection id")
}
if conn.Metadata.DestinationPort != "443" {
t.Fatalf("destination port: %q", conn.Metadata.DestinationPort)
}
if conn.RequestHost() != "www.google.com" {
t.Fatalf("host: %q", conn.RequestHost())
}
if conn.OutboundNode() != "DIRECT" {
t.Fatalf("outbound: %q", conn.OutboundNode())
}
if conn.Start.IsZero() {
t.Fatal("expected start time")
}
if conn.Start.UTC() != time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) {
t.Fatalf("unexpected start: %v", conn.Start)
}
}
+42
View File
@@ -0,0 +1,42 @@
package mihomoapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type TrafficRates struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
UpTotal int64 `json:"upTotal"`
DownTotal int64 `json:"downTotal"`
}
// TrafficRates reads the first frame from Mihomo's streaming /traffic endpoint.
func (c *Client) TrafficRates(ctx context.Context) (TrafficRates, error) {
var zero TrafficRates
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/traffic", nil)
if err != nil {
return zero, err
}
if c.secret != "" {
req.Header.Set("Authorization", "Bearer "+c.secret)
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return zero, fmt.Errorf("mihomo api %d", resp.StatusCode)
}
var rates TrafficRates
if err := json.NewDecoder(resp.Body).Decode(&rates); err != nil {
return zero, err
}
return rates, nil
}
+35
View File
@@ -0,0 +1,35 @@
package mihomoapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTrafficRatesReadsFirstFrame(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
flusher, _ := w.(http.Flusher)
_ = json.NewEncoder(w).Encode(TrafficRates{Up: 100, Down: 200})
flusher.Flush()
time.Sleep(2 * time.Second)
_ = json.NewEncoder(w).Encode(TrafficRates{Up: 300, Down: 400})
flusher.Flush()
}))
defer srv.Close()
client := New(srv.URL, "")
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
rates, err := client.TrafficRates(ctx)
if err != nil {
t.Fatalf("TrafficRates: %v", err)
}
if rates.Up != 100 || rates.Down != 200 {
t.Fatalf("got %+v", rates)
}
}
+269
View File
@@ -0,0 +1,269 @@
package proxyio
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/store"
"github.com/prism/proxy/internal/subscription"
)
const ExportVersion = 1
type ExportDoc struct {
Version int `json:"version"`
Nodes []ExportNode `json:"nodes"`
}
type ExportNode struct {
Name string `json:"name"`
Type string `json:"type"`
RawConfig json.RawMessage `json:"raw_config"`
Country string `json:"country,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func nodeEnabled(n ExportNode) bool {
if n.Enabled == nil {
return true
}
return *n.Enabled
}
func ToExportNodes(nodes []store.ProxyNode) []ExportNode {
out := make([]ExportNode, 0, len(nodes))
for _, n := range nodes {
enabled := n.Enabled
raw := json.RawMessage(n.RawConfig)
if !json.Valid(raw) {
raw = json.RawMessage("{}")
}
out = append(out, ExportNode{
Name: n.Name,
Type: n.Type,
RawConfig: raw,
Country: n.Country,
Enabled: &enabled,
})
}
return out
}
func ExportJSON(nodes []store.ProxyNode) ([]byte, error) {
doc := ExportDoc{Version: ExportVersion, Nodes: ToExportNodes(nodes)}
return json.MarshalIndent(doc, "", " ")
}
func ExportLinks(nodes []store.ProxyNode) string {
var b strings.Builder
for _, n := range nodes {
if !n.Enabled {
continue
}
link, err := EncodeLink(n)
if err != nil {
b.WriteString("# ")
b.WriteString(n.Name)
b.WriteString(": ")
b.WriteString(err.Error())
b.WriteByte('\n')
continue
}
b.WriteString(link)
b.WriteByte('\n')
}
return b.String()
}
func ParseJSON(data []byte) ([]ExportNode, error) {
var doc ExportDoc
if err := json.Unmarshal(data, &doc); err == nil && (len(doc.Nodes) > 0 || doc.Version > 0) {
return doc.Nodes, nil
}
var nodes []ExportNode
if err := json.Unmarshal(data, &nodes); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return nodes, nil
}
func ParseText(text string) ([]store.ProxyNode, []string) {
text = strings.TrimSpace(text)
if text == "" {
return nil, nil
}
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
items, err := ParseJSON([]byte(text))
if err == nil && len(items) > 0 {
nodes, errs := ToStoreNodes(items)
return nodes, errs
}
}
return subscription.ParseLinkLines(text)
}
func ToStoreNodes(items []ExportNode) ([]store.ProxyNode, []string) {
out := make([]store.ProxyNode, 0, len(items))
var errs []string
for i, item := range items {
name := strings.TrimSpace(item.Name)
typ := strings.TrimSpace(strings.ToLower(item.Type))
raw := normalizeRawConfig(item.RawConfig)
if name == "" {
errs = append(errs, fmt.Sprintf("节点 %d: 缺少名称", i+1))
continue
}
if typ == "" {
errs = append(errs, fmt.Sprintf("节点 %d: 缺少类型", i+1))
continue
}
if raw == "" || raw == "{}" {
errs = append(errs, fmt.Sprintf("节点 %d: 缺少配置", i+1))
continue
}
code := strings.TrimSpace(item.Country)
if code == "" {
code = country.Detect(name)
}
out = append(out, store.ProxyNode{
Name: name,
RuntimeName: store.RuntimeName("manual", 0, name),
Type: typ,
RawConfig: raw,
Source: "manual",
Country: code,
Enabled: nodeEnabled(item),
})
}
return out, errs
}
func normalizeRawConfig(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
if raw[0] == '{' || raw[0] == '[' {
if !json.Valid(raw) {
return ""
}
return string(raw)
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return strings.TrimSpace(s)
}
return strings.TrimSpace(string(raw))
}
func AssignUniqueNames(nodes []store.ProxyNode, reserved map[string]bool) {
if reserved == nil {
reserved = map[string]bool{}
}
for i := range nodes {
name := strings.TrimSpace(nodes[i].Name)
if name == "" {
name = "node"
}
base := name
candidate := base
for n := 2; reserved[candidate]; n++ {
candidate = fmt.Sprintf("%s_%d", base, n)
}
reserved[candidate] = true
nodes[i].Name = candidate
nodes[i].RuntimeName = store.RuntimeName("manual", 0, candidate)
if nodes[i].Country == "" {
nodes[i].Country = country.Detect(candidate)
}
}
}
func ReservedNames(nodes []store.ProxyNode) map[string]bool {
m := make(map[string]bool, len(nodes))
for _, n := range nodes {
m[n.Name] = true
}
return m
}
func EncodeLink(n store.ProxyNode) (string, error) {
var raw map[string]any
if err := json.Unmarshal([]byte(n.RawConfig), &raw); err != nil {
return "", err
}
fragment := url.QueryEscape(n.Name)
typ := strings.ToLower(n.Type)
switch typ {
case "socks5":
return encodeSocks5(raw, fragment)
case "http", "https":
return encodeHTTP(raw, fragment, typ == "https")
case "trojan":
return encodeTrojan(raw, fragment)
default:
return "", fmt.Errorf("类型 %s 暂不支持导出为分享链接", n.Type)
}
}
func encodeSocks5(raw map[string]any, fragment string) (string, error) {
server, _ := raw["server"].(string)
port := intFromAny(raw["port"])
if server == "" || port <= 0 {
return "", fmt.Errorf("socks5 配置不完整")
}
host := fmt.Sprintf("%s:%d", server, port)
user, _ := raw["username"].(string)
pass, _ := raw["password"].(string)
if user != "" {
return fmt.Sprintf("socks5://%s:%s@%s#%s", url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
}
return fmt.Sprintf("socks5://%s#%s", host, fragment), nil
}
func encodeHTTP(raw map[string]any, fragment string, tls bool) (string, error) {
server, _ := raw["server"].(string)
port := intFromAny(raw["port"])
if server == "" || port <= 0 {
return "", fmt.Errorf("http 配置不完整")
}
scheme := "http"
if tls {
scheme = "https"
}
host := fmt.Sprintf("%s:%d", server, port)
user, _ := raw["username"].(string)
pass, _ := raw["password"].(string)
if user != "" {
return fmt.Sprintf("%s://%s:%s@%s#%s", scheme, url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
}
return fmt.Sprintf("%s://%s#%s", scheme, host, fragment), nil
}
func encodeTrojan(raw map[string]any, fragment string) (string, error) {
server, _ := raw["server"].(string)
port := intFromAny(raw["port"])
pass, _ := raw["password"].(string)
if server == "" || port <= 0 || pass == "" {
return "", fmt.Errorf("trojan 配置不完整")
}
return fmt.Sprintf("trojan://%s@%s:%d#%s", url.QueryEscape(pass), server, port, fragment), nil
}
func intFromAny(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
case json.Number:
i, _ := n.Int64()
return int(i)
default:
return 0
}
}
+40
View File
@@ -0,0 +1,40 @@
package proxyio
import (
"strings"
"testing"
"github.com/prism/proxy/internal/store"
)
func TestExportLinksSocks5(t *testing.T) {
text := ExportLinks([]store.ProxyNode{{
Name: "HK-1",
Type: "socks5",
RawConfig: `{"type":"socks5","server":"1.2.3.4","port":1080,"username":"u","password":"p"}`,
Enabled: true,
}})
if !strings.Contains(text, "socks5://u:p@1.2.3.4:1080#HK-1") {
t.Fatalf("got %q", text)
}
}
func TestParseJSONDoc(t *testing.T) {
nodes, err := ParseJSON([]byte(`{"version":1,"nodes":[{"name":"n1","type":"socks5","raw_config":{"server":"1.1.1.1","port":1080}}]}`))
if err != nil || len(nodes) != 1 {
t.Fatalf("nodes=%+v err=%v", nodes, err)
}
stored, errs := ToStoreNodes(nodes)
if len(errs) != 0 || len(stored) != 1 || stored[0].Name != "n1" {
t.Fatalf("stored=%+v errs=%v", stored, errs)
}
}
func TestAssignUniqueNames(t *testing.T) {
nodes := []store.ProxyNode{{Name: "a"}, {Name: "a"}}
reserved := map[string]bool{"a": true}
AssignUniqueNames(nodes, reserved)
if nodes[0].Name != "a_2" || nodes[1].Name != "a_3" {
t.Fatalf("names: %s, %s", nodes[0].Name, nodes[1].Name)
}
}
+189
View File
@@ -0,0 +1,189 @@
package ruleio
import (
"encoding/json"
"fmt"
"strings"
"github.com/prism/proxy/internal/store"
)
const ExportVersion = 1
type ExportDoc struct {
Version int `json:"version"`
Rules []ExportRule `json:"rules"`
}
type ExportRule struct {
Priority int `json:"priority"`
RuleType string `json:"rule_type"`
Payload string `json:"payload"`
Target string `json:"target"`
Enabled *bool `json:"enabled,omitempty"`
}
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors []string `json:"errors,omitempty"`
}
func ruleEnabled(r ExportRule) bool {
if r.Enabled == nil {
return true
}
return *r.Enabled
}
func ToExportRules(rules []store.Rule) []ExportRule {
out := make([]ExportRule, 0, len(rules))
for _, r := range rules {
enabled := r.Enabled
out = append(out, ExportRule{
Priority: r.Priority,
RuleType: r.RuleType,
Payload: r.Payload,
Target: r.Target,
Enabled: &enabled,
})
}
return out
}
func ExportJSON(rules []store.Rule) ([]byte, error) {
doc := ExportDoc{Version: ExportVersion, Rules: ToExportRules(rules)}
return json.MarshalIndent(doc, "", " ")
}
func ExportText(rules []store.Rule) string {
var b strings.Builder
for _, r := range rules {
if !r.Enabled {
continue
}
b.WriteString(formatRuleLine(r))
b.WriteByte('\n')
}
return b.String()
}
func formatRuleLine(r store.Rule) string {
if r.Payload == "" {
return fmt.Sprintf("%s,%s", r.RuleType, r.Target)
}
return fmt.Sprintf("%s,%s,%s", r.RuleType, r.Payload, r.Target)
}
func ParseJSON(data []byte) ([]ExportRule, error) {
var doc ExportDoc
if err := json.Unmarshal(data, &doc); err == nil && (len(doc.Rules) > 0 || doc.Version > 0) {
return doc.Rules, nil
}
var rules []ExportRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
return rules, nil
}
func ParseText(text string) ([]ExportRule, []string) {
lines := strings.Split(text, "\n")
var out []ExportRule
var errs []string
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
r, err := ParseLine(line)
if err != nil {
errs = append(errs, fmt.Sprintf("第 %d 行: %v", i+1, err))
continue
}
out = append(out, r)
}
return out, errs
}
func ParseLine(line string) (ExportRule, error) {
parts := strings.Split(line, ",")
if len(parts) < 2 {
return ExportRule{}, fmt.Errorf("格式无效 %q", line)
}
ruleType := strings.TrimSpace(strings.ToUpper(parts[0]))
if ruleType == "MATCH" {
return ExportRule{}, fmt.Errorf("跳过 MATCH 规则")
}
target := strings.TrimSpace(parts[len(parts)-1])
if target == "" {
return ExportRule{}, fmt.Errorf("缺少出站目标")
}
payload := ""
if len(parts) > 2 {
payload = strings.Join(parts[1:len(parts)-1], ",")
}
if needsPayload(ruleType) && payload == "" {
return ExportRule{}, fmt.Errorf("%s 需要匹配值", ruleType)
}
enabled := true
return ExportRule{
RuleType: ruleType,
Payload: payload,
Target: target,
Enabled: &enabled,
}, nil
}
func needsPayload(ruleType string) bool {
switch ruleType {
case "DIRECT", "REJECT", "REJECT-DROP", "MATCH":
return false
default:
return true
}
}
func ToStoreRules(rules []ExportRule) ([]store.Rule, []string) {
out := make([]store.Rule, 0, len(rules))
var errs []string
for i, r := range rules {
ruleType := strings.TrimSpace(strings.ToUpper(r.RuleType))
target := strings.TrimSpace(r.Target)
if ruleType == "" || ruleType == "MATCH" {
errs = append(errs, fmt.Sprintf("规则 %d: 类型无效", i+1))
continue
}
if target == "" {
errs = append(errs, fmt.Sprintf("规则 %d: 缺少出站目标", i+1))
continue
}
payload := strings.TrimSpace(r.Payload)
if needsPayload(ruleType) && payload == "" {
errs = append(errs, fmt.Sprintf("规则 %d: %s 需要匹配值", i+1, ruleType))
continue
}
out = append(out, store.Rule{
Priority: r.Priority,
RuleType: ruleType,
Payload: payload,
Target: target,
Source: "manual",
Enabled: ruleEnabled(r),
})
}
return out, errs
}
func AssignPriorities(rules []store.Rule, start int) {
if start <= 0 {
start = 100
}
step := 10
for i := range rules {
if rules[i].Priority <= 0 {
rules[i].Priority = start
start += step
}
}
}
+64
View File
@@ -0,0 +1,64 @@
package ruleio
import (
"strings"
"testing"
"github.com/prism/proxy/internal/store"
)
func TestParseLine(t *testing.T) {
r, err := ParseLine("DOMAIN-SUFFIX,google.com,country:HK")
if err != nil {
t.Fatal(err)
}
if r.RuleType != "DOMAIN-SUFFIX" || r.Payload != "google.com" || r.Target != "country:HK" {
t.Fatalf("unexpected %+v", r)
}
}
func TestParseLineSkipsMatch(t *testing.T) {
_, err := ParseLine("MATCH,DIRECT")
if err == nil {
t.Fatal("expected error")
}
}
func TestParseText(t *testing.T) {
rules, errs := ParseText("# comment\nDOMAIN,google.com,DIRECT\n\nDOMAIN-SUFFIX,foo.com,PROXY")
if len(errs) != 0 {
t.Fatal(errs)
}
if len(rules) != 2 {
t.Fatalf("got %d rules", len(rules))
}
}
func TestParseJSONDoc(t *testing.T) {
rules, err := ParseJSON([]byte(`{"version":1,"rules":[{"rule_type":"DOMAIN","payload":"a.com","target":"DIRECT"}]}`))
if err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].Target != "DIRECT" {
t.Fatalf("unexpected %+v", rules)
}
}
func TestExportText(t *testing.T) {
text := ExportText([]store.Rule{{
RuleType: "DOMAIN-SUFFIX",
Payload: "x.com",
Target: "PROXY",
Enabled: true,
}})
if !strings.Contains(text, "DOMAIN-SUFFIX,x.com,PROXY") {
t.Fatalf("got %q", text)
}
}
func TestToStoreRulesDefaultsEnabled(t *testing.T) {
rules, errs := ToStoreRules([]ExportRule{{RuleType: "DOMAIN", Payload: "a.com", Target: "DIRECT"}})
if len(errs) != 0 || len(rules) != 1 || !rules[0].Enabled {
t.Fatalf("rules=%+v errs=%v", rules, errs)
}
}
+168
View File
@@ -0,0 +1,168 @@
package rulematch
import (
"net"
"net/url"
"strings"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/store"
)
type Result struct {
Matched bool `json:"matched"`
Host string `json:"host"`
Port string `json:"port,omitempty"`
RuleLine string `json:"rule_line"`
RuleType string `json:"rule_type"`
Payload string `json:"payload"`
Target string `json:"target"`
TargetLabel string `json:"target_label"`
Source string `json:"source"`
Priority int `json:"priority"`
SkippedGeo int `json:"skipped_geo"`
Note string `json:"note,omitempty"`
}
func ParseHost(raw string) (host, port string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return strings.TrimSpace(raw), ""
}
host = u.Hostname()
port = u.Port()
if host == "" {
host = strings.TrimSpace(raw)
}
return strings.ToLower(host), port
}
func Match(rules []store.Rule, defaultPolicy, rawURL string, geoReady bool) Result {
host, port := ParseHost(rawURL)
if host == "" {
return Result{Note: "无法解析 URL 或域名"}
}
skippedGeo := 0
for _, r := range rules {
if !r.Enabled {
continue
}
ruleType := strings.ToUpper(strings.TrimSpace(r.RuleType))
if ruleType == "MATCH" {
continue
}
if isGeoRule(ruleType) {
if !geoReady {
skippedGeo++
continue
}
// Geo rules need Mihomo geodata engine; skip in offline matcher.
skippedGeo++
continue
}
if matchesRule(ruleType, r.Payload, host, port) {
line := formatLine(r)
return Result{
Matched: true,
Host: host,
Port: port,
RuleLine: line,
RuleType: ruleType,
Payload: r.Payload,
Target: r.Target,
TargetLabel: targetLabel(r.Target),
Source: r.Source,
Priority: r.Priority,
SkippedGeo: skippedGeo,
}
}
}
policy := defaultPolicy
if policy == "" {
policy = "DIRECT"
}
return Result{
Matched: true,
Host: host,
Port: port,
RuleLine: "MATCH," + policy,
RuleType: "MATCH",
Payload: "",
Target: policy,
TargetLabel: targetLabel(policy),
Source: "system",
Priority: 999999,
SkippedGeo: skippedGeo,
Note: "未命中任何前置规则,使用默认出站",
}
}
func isGeoRule(ruleType string) bool {
switch ruleType {
case "GEOIP", "GEOSITE":
return true
default:
return false
}
}
func matchesRule(ruleType, payload, host, port string) bool {
payload = strings.TrimSpace(payload)
switch ruleType {
case "DOMAIN":
return host == strings.ToLower(payload)
case "DOMAIN-SUFFIX":
suffix := strings.ToLower(payload)
return host == suffix || strings.HasSuffix(host, "."+suffix)
case "DOMAIN-KEYWORD":
return strings.Contains(host, strings.ToLower(payload))
case "IP-CIDR", "IP-CIDR6":
return matchCIDR(host, payload)
case "DST-PORT":
return port != "" && port == strings.TrimSpace(payload)
case "SRC-PORT":
return false
default:
return false
}
}
func matchCIDR(host, cidr string) bool {
ip := net.ParseIP(host)
if ip == nil {
return false
}
_, network, err := net.ParseCIDR(strings.TrimSpace(cidr))
if err != nil {
return false
}
return network.Contains(ip)
}
func formatLine(r store.Rule) string {
if r.Payload == "" {
return r.RuleType + "," + r.Target
}
return r.RuleType + "," + r.Payload + "," + r.Target
}
func targetLabel(target string) string {
target = strings.TrimSpace(target)
if code, ok := country.ParseTargetAlias(target); ok {
return country.Name(code) + "(国家池)"
}
if strings.HasPrefix(target, "country_") {
code := strings.TrimPrefix(target, "country_")
return country.Name(code) + "(国家池)"
}
return target
}
+35
View File
@@ -0,0 +1,35 @@
package rulematch
import (
"testing"
"github.com/prism/proxy/internal/store"
)
func TestMatchDomainSuffix(t *testing.T) {
rules := []store.Rule{
{Priority: 10, RuleType: "DOMAIN-SUFFIX", Payload: "google.com", Target: "PROXY", Source: "manual", Enabled: true},
{Priority: 20, RuleType: "DOMAIN", Payload: "exact.com", Target: "DIRECT", Source: "manual", Enabled: true},
}
res := Match(rules, "DIRECT", "https://www.google.com/search", true)
if !res.Matched || res.RuleType != "DOMAIN-SUFFIX" || res.Target != "PROXY" {
t.Fatalf("unexpected: %+v", res)
}
}
func TestMatchFallback(t *testing.T) {
rules := []store.Rule{
{Priority: 10, RuleType: "DOMAIN", Payload: "other.com", Target: "PROXY", Source: "manual", Enabled: true},
}
res := Match(rules, "country:HK", "https://example.com", true)
if res.RuleType != "MATCH" || res.Target != "country:HK" {
t.Fatalf("unexpected: %+v", res)
}
}
func TestParseHost(t *testing.T) {
host, port := ParseHost("https://foo.com:8443/path")
if host != "foo.com" || port != "8443" {
t.Fatalf("got %q %q", host, port)
}
}
+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
}
+61
View File
@@ -0,0 +1,61 @@
package settings
import (
"strconv"
"strings"
"github.com/prism/proxy/internal/geodata"
)
// Display merges stored settings with effective defaults for the settings UI.
func Display(raw map[string]string) map[string]string {
out := make(map[string]string, len(raw)+8)
for k, v := range raw {
out[k] = v
}
applyGeoDisplayDefaults(out)
applyScalarDisplayDefaults(out)
applyLocaleDisplayDefaults(out)
return out
}
// NormalizeSubmit clears values that match built-in defaults so empty means default.
func NormalizeSubmit(req map[string]string) {
normalizeGeoSubmit(req)
normalizeLocaleSubmit(req)
}
func applyGeoDisplayDefaults(out map[string]string) {
def := geodata.DefaultConfig()
if strings.TrimSpace(out["geo_country_mmdb_url"]) == "" {
out["geo_country_mmdb_url"] = def.CountryMmdbURL
}
if strings.TrimSpace(out["geo_geoip_url"]) == "" {
out["geo_geoip_url"] = def.GeoIPURL
}
if strings.TrimSpace(out["geo_geosite_url"]) == "" {
out["geo_geosite_url"] = def.GeoSiteURL
}
}
func normalizeGeoSubmit(req map[string]string) {
def := geodata.DefaultConfig()
if strings.TrimSpace(req["geo_country_mmdb_url"]) == def.CountryMmdbURL {
req["geo_country_mmdb_url"] = ""
}
if strings.TrimSpace(req["geo_geoip_url"]) == def.GeoIPURL {
req["geo_geoip_url"] = ""
}
if strings.TrimSpace(req["geo_geosite_url"]) == def.GeoSiteURL {
req["geo_geosite_url"] = ""
}
}
func applyScalarDisplayDefaults(out map[string]string) {
if strings.TrimSpace(out["login_max_attempts"]) == "" {
out["login_max_attempts"] = strconv.Itoa(5)
}
if strings.TrimSpace(out["login_lock_minutes"]) == "" {
out["login_lock_minutes"] = strconv.Itoa(15)
}
}
+27
View File
@@ -0,0 +1,27 @@
package settings
import "testing"
func TestDisplayFillsGeoDefaults(t *testing.T) {
out := Display(map[string]string{
"geo_country_mmdb_url": "",
"geo_geoip_url": "",
"geo_geosite_url": "",
})
if out["geo_country_mmdb_url"] == "" || out["geo_geoip_url"] == "" || out["geo_geosite_url"] == "" {
t.Fatalf("expected geo defaults, got %+v", out)
}
}
func TestNormalizeSubmitClearsDefaultGeoURLs(t *testing.T) {
out := Display(map[string]string{})
req := map[string]string{
"geo_country_mmdb_url": out["geo_country_mmdb_url"],
"geo_geoip_url": out["geo_geoip_url"],
"geo_geosite_url": out["geo_geosite_url"],
}
NormalizeSubmit(req)
if req["geo_country_mmdb_url"] != "" || req["geo_geoip_url"] != "" || req["geo_geosite_url"] != "" {
t.Fatalf("expected empty submit, got %+v", req)
}
}
+25
View File
@@ -0,0 +1,25 @@
package settings
// Well-known settings keys stored in SQLite.
const (
KeyHTTPPort = "http_port"
KeySocksPort = "socks_port"
KeyAPIPort = "api_port"
KeyMihomoAPIPort = "mihomo_api_port"
KeyAPISecret = "api_secret"
KeyAdminPassword = "admin_password"
KeyAuthEnabled = "auth_enabled"
KeyLogLevel = "log_level"
KeyHealthIntervalSec = "health_interval_sec"
KeySubSyncEnabled = "sub_sync_enabled"
KeyDefaultPolicy = "default_policy"
KeyGeoAutoDownload = "geo_auto_download"
KeyLoginMaxAttempts = "login_max_attempts"
KeyLoginLockMinutes = "login_lock_minutes"
KeyProxyAuthUser = "proxy_auth_user"
KeyProxyAuthPass = "proxy_auth_pass"
KeyGeoCountryMmdbURL = "geo_country_mmdb_url"
KeyGeoGeoIPURL = "geo_geoip_url"
KeyGeoGeoSiteURL = "geo_geosite_url"
KeyUILocale = "ui_locale"
)
+29
View File
@@ -0,0 +1,29 @@
package settings
import "strings"
var validUILocales = map[string]bool{
"zh-CN": true,
"zh-TW": true,
"en": true,
}
func NormalizeUILocale(v string) string {
v = strings.TrimSpace(v)
if validUILocales[v] {
return v
}
return "zh-CN"
}
func applyLocaleDisplayDefaults(out map[string]string) {
if strings.TrimSpace(out[KeyUILocale]) == "" {
out[KeyUILocale] = "zh-CN"
}
}
func normalizeLocaleSubmit(req map[string]string) {
if v, ok := req[KeyUILocale]; ok {
req[KeyUILocale] = NormalizeUILocale(v)
}
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"context"
"database/sql"
"time"
)
func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKey, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, name, key_prefix, enabled, last_used_at, created_at
FROM api_keys
ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanAPIKeys(rows)
}
func (s *Store) CreateAPIKey(ctx context.Context, name, keyPrefix, keyHash string) (*APIKey, error) {
res, err := s.db.ExecContext(ctx, `
INSERT INTO api_keys(name, key_prefix, key_hash, enabled)
VALUES(?,?,?,1)`, name, keyPrefix, keyHash)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return s.GetAPIKey(ctx, id)
}
func (s *Store) GetAPIKey(ctx context.Context, id int64) (*APIKey, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, name, key_prefix, enabled, last_used_at, created_at
FROM api_keys WHERE id=?`, id)
return scanAPIKey(row)
}
func (s *Store) DeleteAPIKey(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM api_keys WHERE id=?`, id)
return err
}
func (s *Store) VerifyAPIKey(ctx context.Context, keyHash string) (bool, error) {
var id int64
err := s.db.QueryRowContext(ctx, `
SELECT id FROM api_keys WHERE key_hash=? AND enabled=1`, keyHash).Scan(&id)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at=datetime('now') WHERE id=?`, id)
return true, nil
}
func scanAPIKeys(rows *sql.Rows) ([]APIKey, error) {
var out []APIKey
for rows.Next() {
item, err := scanAPIKeyFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, *item)
}
return out, rows.Err()
}
func scanAPIKey(row *sql.Row) (*APIKey, error) {
var item APIKey
var enabled int
var lastUsed, created sql.NullString
if err := row.Scan(&item.ID, &item.Name, &item.KeyPrefix, &enabled, &lastUsed, &created); err != nil {
return nil, err
}
item.Enabled = enabled == 1
if lastUsed.Valid {
if t, err := time.Parse("2006-01-02 15:04:05", lastUsed.String); err == nil {
item.LastUsedAt = &t
}
}
if created.Valid {
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
item.CreatedAt = t
}
}
return &item, nil
}
func scanAPIKeyFromRows(rows *sql.Rows) (*APIKey, error) {
var item APIKey
var enabled int
var lastUsed, created sql.NullString
if err := rows.Scan(&item.ID, &item.Name, &item.KeyPrefix, &enabled, &lastUsed, &created); err != nil {
return nil, err
}
item.Enabled = enabled == 1
if lastUsed.Valid {
if t, err := time.Parse("2006-01-02 15:04:05", lastUsed.String); err == nil {
item.LastUsedAt = &t
}
}
if created.Valid {
if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil {
item.CreatedAt = t
}
}
return &item, nil
}
+80
View File
@@ -0,0 +1,80 @@
package store
import (
"context"
"github.com/prism/proxy/internal/country"
)
type CountryStat struct {
Code string `json:"code"`
Name string `json:"name"`
Count int `json:"count"`
}
func (s *Store) ListCountryStats(ctx context.Context) ([]CountryStat, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT country, COUNT(*) FROM proxy_nodes
WHERE enabled=1 AND country != ''
GROUP BY country ORDER BY COUNT(*) DESC, country`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CountryStat
for rows.Next() {
var st CountryStat
if err := rows.Scan(&st.Code, &st.Count); err != nil {
return nil, err
}
st.Name = country.Name(st.Code)
out = append(out, st)
}
return out, rows.Err()
}
func (s *Store) BackfillNodeCountries(ctx context.Context) (int, error) {
nodes, err := s.ListProxyNodes(ctx, "", nil)
if err != nil {
return 0, err
}
updated := 0
for _, n := range nodes {
code := n.Country
if code == "" {
code = country.Detect(n.Name)
}
if code == "" || code == n.Country {
continue
}
if err := s.UpdateProxyNodeCountry(ctx, n.ID, code); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
func (s *Store) UpdateProxyNodeCountry(ctx context.Context, id int64, code string) error {
_, err := s.db.ExecContext(ctx, `UPDATE proxy_nodes SET country=?, updated_at=datetime('now') WHERE id=?`, code, id)
return err
}
func (s *Store) RebuildAllNodeCountries(ctx context.Context) (int, error) {
nodes, err := s.ListProxyNodes(ctx, "", nil)
if err != nil {
return 0, err
}
updated := 0
for _, n := range nodes {
code := country.Detect(n.Name)
if code == n.Country {
continue
}
if err := s.UpdateProxyNodeCountry(ctx, n.ID, code); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
+65
View File
@@ -0,0 +1,65 @@
package store
import (
"context"
"database/sql"
"time"
)
type LoginAttempt struct {
FailCount int
WindowStart time.Time
LockedUntil *time.Time
}
func (s *Store) GetLoginAttempt(ctx context.Context, ip string) (*LoginAttempt, error) {
var failCount int
var windowStart string
var lockedUntil sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT fail_count, window_start, locked_until FROM login_attempts WHERE ip = ?`, ip,
).Scan(&failCount, &windowStart, &lockedUntil)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
out := &LoginAttempt{
FailCount: failCount,
WindowStart: parseTime(windowStart),
}
if lockedUntil.Valid && lockedUntil.String != "" {
t := parseTime(lockedUntil.String)
out.LockedUntil = &t
}
return out, nil
}
func (s *Store) SetLoginAttempt(ctx context.Context, ip string, attempt LoginAttempt) error {
var locked string
if attempt.LockedUntil != nil {
locked = attempt.LockedUntil.Format("2006-01-02 15:04:05")
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO login_attempts(ip, fail_count, window_start, locked_until)
VALUES(?,?,?,?)
ON CONFLICT(ip) DO UPDATE SET
fail_count = excluded.fail_count,
window_start = excluded.window_start,
locked_until = excluded.locked_until
`, ip, attempt.FailCount, attempt.WindowStart.Format("2006-01-02 15:04:05"), nullIfEmpty(locked))
return err
}
func (s *Store) ClearLoginAttempt(ctx context.Context, ip string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM login_attempts WHERE ip = ?`, ip)
return err
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
+112
View File
@@ -0,0 +1,112 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'clash',
interval_sec INTEGER NOT NULL DEFAULT 3600,
enabled INTEGER NOT NULL DEFAULT 1,
last_fetch_at TEXT,
last_error TEXT,
node_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS proxy_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER,
name TEXT NOT NULL,
runtime_name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
raw_config TEXT NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'subscription',
enabled INTEGER NOT NULL DEFAULT 1,
latency_ms INTEGER NOT NULL DEFAULT -1,
alive INTEGER NOT NULL DEFAULT 0,
checked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
priority INTEGER NOT NULL DEFAULT 100,
rule_type TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '',
target TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
subscription_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS proxy_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
runtime_name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'select',
proxies TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT 'subscription',
subscription_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
resource TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL DEFAULT 'info',
source TEXT NOT NULL DEFAULT 'prism',
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS traffic_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_rate INTEGER NOT NULL DEFAULT 0,
download_rate INTEGER NOT NULL DEFAULT 0,
connections INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('http_port', '7890'),
('socks_port', '7891'),
('api_port', '8080'),
('mihomo_api_port', '9090'),
('api_secret', ''),
('admin_password', 'admin'),
('auth_enabled', 'true'),
('log_level', 'info'),
('health_interval_sec', '300'),
('sub_sync_enabled', 'true'),
('default_policy', 'DIRECT'),
('geo_auto_download', 'true');
-- +goose Down
DROP TABLE IF EXISTS traffic_snapshots;
DROP TABLE IF EXISTS request_logs;
DROP TABLE IF EXISTS audit_logs;
DROP TABLE IF EXISTS settings;
DROP TABLE IF EXISTS proxy_groups;
DROP TABLE IF EXISTS rules;
DROP TABLE IF EXISTS proxy_nodes;
DROP TABLE IF EXISTS subscriptions;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE proxy_nodes ADD COLUMN country TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE proxy_nodes DROP COLUMN country;
@@ -0,0 +1,23 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS proxy_request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL DEFAULT '',
network TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL DEFAULT '',
rule_payload TEXT NOT NULL DEFAULT '',
rule_line TEXT NOT NULL DEFAULT '',
outbound_node TEXT NOT NULL DEFAULT '',
outbound_chain TEXT NOT NULL DEFAULT '',
upload INTEGER NOT NULL DEFAULT 0,
download INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_created ON proxy_request_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_host ON proxy_request_logs(host);
-- +goose Down
DROP TABLE IF EXISTS proxy_request_logs;
@@ -0,0 +1,5 @@
-- +goose Up
CREATE INDEX IF NOT EXISTS idx_proxy_request_logs_node ON proxy_request_logs(outbound_node);
-- +goose Down
DROP INDEX IF EXISTS idx_proxy_request_logs_node;
@@ -0,0 +1,15 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
fail_count INTEGER NOT NULL DEFAULT 0,
window_start TEXT NOT NULL,
locked_until TEXT
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('login_max_attempts', '5'),
('login_lock_minutes', '15');
-- +goose Down
DROP TABLE IF EXISTS login_attempts;
DELETE FROM settings WHERE key IN ('login_max_attempts', 'login_lock_minutes');
@@ -0,0 +1,7 @@
-- +goose Up
INSERT OR IGNORE INTO settings (key, value) VALUES
('proxy_auth_user', ''),
('proxy_auth_pass', '');
-- +goose Down
DELETE FROM settings WHERE key IN ('proxy_auth_user', 'proxy_auth_pass');
@@ -0,0 +1,8 @@
-- +goose Up
INSERT OR IGNORE INTO settings (key, value) VALUES
('geo_country_mmdb_url', ''),
('geo_geoip_url', ''),
('geo_geosite_url', '');
-- +goose Down
DELETE FROM settings WHERE key IN ('geo_country_mmdb_url', 'geo_geoip_url', 'geo_geosite_url');
@@ -0,0 +1,13 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- +goose Down
DROP TABLE IF EXISTS api_keys;
@@ -0,0 +1,5 @@
-- +goose Up
INSERT OR IGNORE INTO settings(key, value) VALUES('ui_locale', 'zh-CN');
-- +goose Down
DELETE FROM settings WHERE key = 'ui_locale';
+108
View File
@@ -0,0 +1,108 @@
package store
import "time"
type Subscription struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
IntervalSec int `json:"interval_sec"`
Enabled bool `json:"enabled"`
LastFetchAt *time.Time `json:"last_fetch_at,omitempty"`
LastError string `json:"last_error,omitempty"`
NodeCount int `json:"node_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type APIKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
Enabled bool `json:"enabled"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type ProxyNode struct {
ID int64 `json:"id"`
SubscriptionID *int64 `json:"subscription_id,omitempty"`
Name string `json:"name"`
RuntimeName string `json:"runtime_name"`
Type string `json:"type"`
RawConfig string `json:"raw_config"`
Source string `json:"source"`
Enabled bool `json:"enabled"`
Country string `json:"country"`
LatencyMs int `json:"latency_ms"`
Alive bool `json:"alive"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Rule struct {
ID int64 `json:"id"`
Priority int `json:"priority"`
RuleType string `json:"rule_type"`
Payload string `json:"payload"`
Target string `json:"target"`
Source string `json:"source"`
SubscriptionID *int64 `json:"subscription_id,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProxyGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
RuntimeName string `json:"runtime_name"`
Type string `json:"type"`
Proxies string `json:"proxies"`
Source string `json:"source"`
SubscriptionID *int64 `json:"subscription_id,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AuditLog struct {
ID int64 `json:"id"`
Action string `json:"action"`
Resource string `json:"resource"`
Detail string `json:"detail"`
CreatedAt time.Time `json:"created_at"`
}
type RequestLog struct {
ID int64 `json:"id"`
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
CreatedAt time.Time `json:"created_at"`
}
type MergedRule struct {
Priority int `json:"priority"`
Rule string `json:"rule"`
Source string `json:"source"`
}
type ProxyRequestLog struct {
ID int64 `json:"id"`
Host string `json:"host"`
Network string `json:"network"`
RuleType string `json:"rule_type"`
RulePayload string `json:"rule_payload"`
RuleLine string `json:"rule_line"`
OutboundNode string `json:"outbound_node"`
OutboundChain string `json:"outbound_chain"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Success bool `json:"success"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
CreatedAt time.Time `json:"created_at"`
}
+508
View File
@@ -0,0 +1,508 @@
package store
import (
"context"
"database/sql"
"fmt"
)
func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,url,type,interval_sec,enabled,last_fetch_at,last_error,node_count,created_at,updated_at FROM subscriptions ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Subscription
for rows.Next() {
var sub Subscription
var enabled, nodeCount int
var lastFetch, lastErr sql.NullString
var created, updated string
if err := rows.Scan(&sub.ID, &sub.Name, &sub.URL, &sub.Type, &sub.IntervalSec, &enabled, &lastFetch, &lastErr, &nodeCount, &created, &updated); err != nil {
return nil, err
}
sub.Enabled = boolFromInt(enabled)
sub.NodeCount = nodeCount
sub.LastFetchAt = parseTimePtr(lastFetch)
sub.LastError = lastErr.String
sub.CreatedAt = parseTime(created)
sub.UpdatedAt = parseTime(updated)
out = append(out, sub)
}
return out, rows.Err()
}
func (s *Store) GetSubscription(ctx context.Context, id int64) (*Subscription, error) {
var sub Subscription
var enabled, nodeCount int
var lastFetch, lastErr sql.NullString
var created, updated string
err := s.db.QueryRowContext(ctx, `SELECT id,name,url,type,interval_sec,enabled,last_fetch_at,last_error,node_count,created_at,updated_at FROM subscriptions WHERE id=?`, id).
Scan(&sub.ID, &sub.Name, &sub.URL, &sub.Type, &sub.IntervalSec, &enabled, &lastFetch, &lastErr, &nodeCount, &created, &updated)
if err != nil {
return nil, err
}
sub.Enabled = boolFromInt(enabled)
sub.NodeCount = nodeCount
sub.LastFetchAt = parseTimePtr(lastFetch)
sub.LastError = lastErr.String
sub.CreatedAt = parseTime(created)
sub.UpdatedAt = parseTime(updated)
return &sub, nil
}
func (s *Store) CreateSubscription(ctx context.Context, sub *Subscription) error {
res, err := s.db.ExecContext(ctx, `INSERT INTO subscriptions(name,url,type,interval_sec,enabled) VALUES(?,?,?,?,?)`,
sub.Name, sub.URL, sub.Type, sub.IntervalSec, intFromBool(sub.Enabled))
if err != nil {
return err
}
id, _ := res.LastInsertId()
sub.ID = id
return nil
}
func (s *Store) UpdateSubscription(ctx context.Context, sub *Subscription) error {
_, err := s.db.ExecContext(ctx, `UPDATE subscriptions SET name=?,url=?,type=?,interval_sec=?,enabled=?,updated_at=datetime('now') WHERE id=?`,
sub.Name, sub.URL, sub.Type, sub.IntervalSec, intFromBool(sub.Enabled), sub.ID)
return err
}
func (s *Store) DeleteSubscription(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM subscriptions WHERE id=?`, id)
return err
}
func (s *Store) UpdateSubscriptionFetch(ctx context.Context, id int64, nodeCount int, lastError string) error {
_, err := s.db.ExecContext(ctx, `UPDATE subscriptions SET last_fetch_at=datetime('now'), last_error=?, node_count=?, updated_at=datetime('now') WHERE id=?`,
lastError, nodeCount, id)
return err
}
func (s *Store) proxyNodesWhere(source string, subscriptionID *int64, country string) (string, []any) {
query := ` FROM proxy_nodes WHERE 1=1`
args := []any{}
if source != "" {
query += ` AND source = ?`
args = append(args, source)
}
if subscriptionID != nil {
query += ` AND subscription_id = ?`
args = append(args, *subscriptionID)
}
if country != "" {
query += ` AND country = ?`
args = append(args, country)
}
return query, args
}
func (s *Store) CountProxyNodes(ctx context.Context, source string, subscriptionID *int64, country string) (int, error) {
where, args := s.proxyNodesWhere(source, subscriptionID, country)
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*)`+where, args...).Scan(&n)
return n, err
}
func (s *Store) ListProxyNodes(ctx context.Context, source string, subscriptionID *int64) ([]ProxyNode, error) {
return s.ListProxyNodesPage(ctx, source, subscriptionID, "", 0, 0)
}
func (s *Store) ListProxyNodesPage(ctx context.Context, source string, subscriptionID *int64, country string, limit, offset int) ([]ProxyNode, error) {
where, args := s.proxyNodesWhere(source, subscriptionID, country)
query := `SELECT id,subscription_id,name,runtime_name,type,raw_config,source,enabled,country,latency_ms,alive,checked_at,created_at,updated_at` + where + ` ORDER BY id`
if limit > 0 {
query += ` LIMIT ? OFFSET ?`
args = append(args, limit, offset)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProxyNodes(rows)
}
func (s *Store) GetProxyNode(ctx context.Context, id int64) (*ProxyNode, error) {
row := s.db.QueryRowContext(ctx, `SELECT id,subscription_id,name,runtime_name,type,raw_config,source,enabled,country,latency_ms,alive,checked_at,created_at,updated_at FROM proxy_nodes WHERE id=?`, id)
return scanProxyNode(row)
}
func (s *Store) CreateProxyNode(ctx context.Context, n *ProxyNode) error {
res, err := s.db.ExecContext(ctx, `INSERT INTO proxy_nodes(subscription_id,name,runtime_name,type,raw_config,source,enabled,country) VALUES(?,?,?,?,?,?,?,?)`,
n.SubscriptionID, n.Name, n.RuntimeName, n.Type, n.RawConfig, n.Source, intFromBool(n.Enabled), n.Country)
if err != nil {
return err
}
id, _ := res.LastInsertId()
n.ID = id
return nil
}
func (s *Store) UpdateProxyNode(ctx context.Context, n *ProxyNode) error {
_, err := s.db.ExecContext(ctx, `UPDATE proxy_nodes SET name=?,runtime_name=?,type=?,raw_config=?,enabled=?,country=?,updated_at=datetime('now') WHERE id=?`,
n.Name, n.RuntimeName, n.Type, n.RawConfig, intFromBool(n.Enabled), n.Country, n.ID)
return err
}
func (s *Store) DeleteProxyNode(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM proxy_nodes WHERE id=?`, id)
return err
}
func (s *Store) ReplaceManualProxies(ctx context.Context, nodes []ProxyNode) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM proxy_nodes WHERE source='manual'`); err != nil {
return err
}
for _, n := range nodes {
n.Source = "manual"
if _, err := tx.ExecContext(ctx,
`INSERT INTO proxy_nodes(subscription_id,name,runtime_name,type,raw_config,source,enabled,country) VALUES(?,?,?,?,?,?,?,?)`,
n.SubscriptionID, n.Name, n.RuntimeName, n.Type, n.RawConfig, "manual", intFromBool(n.Enabled), n.Country); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) AppendManualProxies(ctx context.Context, nodes []ProxyNode) error {
for i := range nodes {
nodes[i].Source = "manual"
if err := s.CreateProxyNode(ctx, &nodes[i]); err != nil {
return err
}
}
return nil
}
func (s *Store) UpdateProxyNodeHealth(ctx context.Context, id int64, latency int, alive bool) error {
_, err := s.db.ExecContext(ctx, `UPDATE proxy_nodes SET latency_ms=?, alive=?, checked_at=datetime('now'), updated_at=datetime('now') WHERE id=?`,
latency, intFromBool(alive), id)
return err
}
func (s *Store) ReplaceSubscriptionData(ctx context.Context, subID int64, nodes []ProxyNode, groups []ProxyGroup, rules []Rule) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM proxy_nodes WHERE subscription_id=?`, subID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM proxy_groups WHERE subscription_id=?`, subID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM rules WHERE subscription_id=?`, subID); err != nil {
return err
}
for _, n := range nodes {
n.SubscriptionID = &subID
if _, err := tx.ExecContext(ctx, `INSERT INTO proxy_nodes(subscription_id,name,runtime_name,type,raw_config,source,enabled,country) VALUES(?,?,?,?,?,?,?,?)`,
subID, n.Name, n.RuntimeName, n.Type, n.RawConfig, "subscription", intFromBool(n.Enabled), n.Country); err != nil {
return err
}
}
for _, g := range groups {
if _, err := tx.ExecContext(ctx, `INSERT INTO proxy_groups(name,runtime_name,type,proxies,source,subscription_id,enabled) VALUES(?,?,?,?,?,?,?)`,
g.Name, g.RuntimeName, g.Type, g.Proxies, "subscription", subID, intFromBool(g.Enabled)); err != nil {
return err
}
}
basePriority := 10000
for i, r := range rules {
r.Priority = basePriority + i
r.SubscriptionID = &subID
if _, err := tx.ExecContext(ctx, `INSERT INTO rules(priority,rule_type,payload,target,source,subscription_id,enabled) VALUES(?,?,?,?,?,?,?)`,
r.Priority, r.RuleType, r.Payload, r.Target, "subscription", subID, intFromBool(r.Enabled)); err != nil {
return err
}
}
return tx.Commit()
}
func scanProxyNodes(rows *sql.Rows) ([]ProxyNode, error) {
var out []ProxyNode
for rows.Next() {
n, err := scanProxyNodeFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, *n)
}
return out, rows.Err()
}
func scanProxyNode(row *sql.Row) (*ProxyNode, error) {
// reuse via a mini adapter - sql.Row doesn't share with Rows
var n ProxyNode
var subID sql.NullInt64
var enabled, alive int
var checked sql.NullString
var created, updated string
err := row.Scan(&n.ID, &subID, &n.Name, &n.RuntimeName, &n.Type, &n.RawConfig, &n.Source, &enabled, &n.Country, &n.LatencyMs, &alive, &checked, &created, &updated)
if err != nil {
return nil, err
}
if subID.Valid {
n.SubscriptionID = &subID.Int64
}
n.Enabled = boolFromInt(enabled)
n.Alive = boolFromInt(alive)
n.CheckedAt = parseTimePtr(checked)
n.CreatedAt = parseTime(created)
n.UpdatedAt = parseTime(updated)
return &n, nil
}
func scanProxyNodeFromRows(rows *sql.Rows) (*ProxyNode, error) {
var n ProxyNode
var subID sql.NullInt64
var enabled, alive int
var checked sql.NullString
var created, updated string
if err := rows.Scan(&n.ID, &subID, &n.Name, &n.RuntimeName, &n.Type, &n.RawConfig, &n.Source, &enabled, &n.Country, &n.LatencyMs, &alive, &checked, &created, &updated); err != nil {
return nil, err
}
if subID.Valid {
n.SubscriptionID = &subID.Int64
}
n.Enabled = boolFromInt(enabled)
n.Alive = boolFromInt(alive)
n.CheckedAt = parseTimePtr(checked)
n.CreatedAt = parseTime(created)
n.UpdatedAt = parseTime(updated)
return &n, nil
}
func (s *Store) ListRules(ctx context.Context, source string) ([]Rule, error) {
query := `SELECT id,priority,rule_type,payload,target,source,subscription_id,enabled,created_at,updated_at FROM rules WHERE 1=1`
args := []any{}
if source != "" {
query += ` AND source = ?`
args = append(args, source)
}
query += ` ORDER BY priority ASC, id ASC`
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Rule
for rows.Next() {
var r Rule
var subID sql.NullInt64
var enabled int
var created, updated string
if err := rows.Scan(&r.ID, &r.Priority, &r.RuleType, &r.Payload, &r.Target, &r.Source, &subID, &enabled, &created, &updated); err != nil {
return nil, err
}
if subID.Valid {
r.SubscriptionID = &subID.Int64
}
r.Enabled = boolFromInt(enabled)
r.CreatedAt = parseTime(created)
r.UpdatedAt = parseTime(updated)
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetRule(ctx context.Context, id int64) (*Rule, error) {
var r Rule
var subID sql.NullInt64
var enabled int
var created, updated string
err := s.db.QueryRowContext(ctx, `SELECT id,priority,rule_type,payload,target,source,subscription_id,enabled,created_at,updated_at FROM rules WHERE id=?`, id).
Scan(&r.ID, &r.Priority, &r.RuleType, &r.Payload, &r.Target, &r.Source, &subID, &enabled, &created, &updated)
if err != nil {
return nil, err
}
if subID.Valid {
r.SubscriptionID = &subID.Int64
}
r.Enabled = boolFromInt(enabled)
r.CreatedAt = parseTime(created)
r.UpdatedAt = parseTime(updated)
return &r, nil
}
func (s *Store) CreateRule(ctx context.Context, r *Rule) error {
res, err := s.db.ExecContext(ctx, `INSERT INTO rules(priority,rule_type,payload,target,source,subscription_id,enabled) VALUES(?,?,?,?,?,?,?)`,
r.Priority, r.RuleType, r.Payload, r.Target, r.Source, r.SubscriptionID, intFromBool(r.Enabled))
if err != nil {
return err
}
id, _ := res.LastInsertId()
r.ID = id
return nil
}
func (s *Store) UpdateRule(ctx context.Context, r *Rule) error {
_, err := s.db.ExecContext(ctx, `UPDATE rules SET priority=?,rule_type=?,payload=?,target=?,enabled=?,updated_at=datetime('now') WHERE id=?`,
r.Priority, r.RuleType, r.Payload, r.Target, intFromBool(r.Enabled), r.ID)
return err
}
func (s *Store) DeleteRule(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM rules WHERE id=? AND source='manual'`, id)
return err
}
func (s *Store) ReplaceManualRules(ctx context.Context, rules []Rule) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM rules WHERE source='manual'`); err != nil {
return err
}
for _, r := range rules {
r.Source = "manual"
if _, err := tx.ExecContext(ctx,
`INSERT INTO rules(priority,rule_type,payload,target,source,subscription_id,enabled) VALUES(?,?,?,?,?,?,?)`,
r.Priority, r.RuleType, r.Payload, r.Target, "manual", nil, intFromBool(r.Enabled)); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) AppendManualRules(ctx context.Context, rules []Rule) error {
for i := range rules {
rules[i].Source = "manual"
if err := s.CreateRule(ctx, &rules[i]); err != nil {
return err
}
}
return nil
}
func (s *Store) ListProxyGroups(ctx context.Context) ([]ProxyGroup, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,name,runtime_name,type,proxies,source,subscription_id,enabled,created_at,updated_at FROM proxy_groups ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProxyGroup
for rows.Next() {
var g ProxyGroup
var subID sql.NullInt64
var enabled int
var created, updated string
if err := rows.Scan(&g.ID, &g.Name, &g.RuntimeName, &g.Type, &g.Proxies, &g.Source, &subID, &enabled, &created, &updated); err != nil {
return nil, err
}
if subID.Valid {
g.SubscriptionID = &subID.Int64
}
g.Enabled = boolFromInt(enabled)
g.CreatedAt = parseTime(created)
g.UpdatedAt = parseTime(updated)
out = append(out, g)
}
return out, rows.Err()
}
func (s *Store) CountEnabledProxies(ctx context.Context) (total, alive int, err error) {
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_nodes WHERE enabled=1`).Scan(&total)
if err != nil {
return
}
err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_nodes WHERE enabled=1 AND alive=1`).Scan(&alive)
return
}
func (s *Store) NextManualRulePriority(ctx context.Context) (int, error) {
var max sql.NullInt64
err := s.db.QueryRowContext(ctx, `SELECT MAX(priority) FROM rules WHERE source='manual'`).Scan(&max)
if err != nil {
return 100, err
}
if !max.Valid || max.Int64 >= 9999 {
return 100, nil
}
return int(max.Int64) + 10, nil
}
func (s *Store) ListMergedRules(ctx context.Context, defaultPolicy string) ([]MergedRule, error) {
rules, err := s.ListRules(ctx, "")
if err != nil {
return nil, err
}
return buildMergedRules(rules, defaultPolicy), nil
}
func (s *Store) CountMergedRules(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM rules WHERE enabled=1`).Scan(&n)
return n, err
}
func (s *Store) ListMergedRulesPage(ctx context.Context, limit, offset int) ([]MergedRule, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(ctx, `
SELECT priority,rule_type,payload,target,source FROM rules
WHERE enabled=1
ORDER BY priority ASC, id ASC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MergedRule
for rows.Next() {
var r Rule
if err := rows.Scan(&r.Priority, &r.RuleType, &r.Payload, &r.Target, &r.Source); err != nil {
return nil, err
}
out = append(out, MergedRule{
Priority: r.Priority,
Rule: formatRuleLine(r),
Source: r.Source,
})
}
return out, rows.Err()
}
func buildMergedRules(rules []Rule, defaultPolicy string) []MergedRule {
var out []MergedRule
for _, r := range rules {
if !r.Enabled {
continue
}
out = append(out, MergedRule{
Priority: r.Priority,
Rule: formatRuleLine(r),
Source: r.Source,
})
}
if defaultPolicy == "" {
defaultPolicy = "DIRECT"
}
out = append(out, MergedRule{Priority: 999999, Rule: "MATCH," + defaultPolicy, Source: "system"})
return out
}
func formatRuleLine(r Rule) string {
if r.Payload == "" {
return fmt.Sprintf("%s,%s", r.RuleType, r.Target)
}
return fmt.Sprintf("%s,%s,%s", r.RuleType, r.Payload, r.Target)
}
+238
View File
@@ -0,0 +1,238 @@
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"os"
"path/filepath"
"time"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
db *sql.DB
}
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
dbPath := filepath.Join(dataDir, "prism.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) DB() *sql.DB { return s.db }
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
goose.SetBaseFS(migrationsFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.Up(s.db, "migrations")
}
func (s *Store) GetSetting(ctx context.Context, key string) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
return value, err
}
func (s *Store) GetSettings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func (s *Store) SetSettings(ctx context.Context, settings map[string]string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for k, v := range settings {
if _, err := tx.ExecContext(ctx, `INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, k, v); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) AddAuditLog(ctx context.Context, action, resource, detail string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO audit_logs(action,resource,detail) VALUES(?,?,?)`, action, resource, detail)
return err
}
func (s *Store) CountAuditLogs(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`).Scan(&n)
return n, err
}
func (s *Store) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditLog, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id,action,resource,detail,created_at FROM audit_logs ORDER BY id DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AuditLog
for rows.Next() {
var l AuditLog
var created string
if err := rows.Scan(&l.ID, &l.Action, &l.Resource, &l.Detail, &created); err != nil {
return nil, err
}
l.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) AddRequestLog(ctx context.Context, level, source, message string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO request_logs(level,source,message) VALUES(?,?,?)`, level, source, message)
return err
}
func (s *Store) ListRequestLogs(ctx context.Context, level, keyword string, limit, offset int) ([]RequestLog, int, error) {
query := `SELECT id,level,source,message,created_at FROM request_logs WHERE 1=1`
countQuery := `SELECT COUNT(*) FROM request_logs WHERE 1=1`
args := []any{}
if level != "" {
query += ` AND level = ?`
countQuery += ` AND level = ?`
args = append(args, level)
}
if keyword != "" {
query += ` AND message LIKE ?`
countQuery += ` AND message LIKE ?`
args = append(args, "%"+keyword+"%")
}
var total int
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
query += ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []RequestLog
for rows.Next() {
var l RequestLog
var created string
if err := rows.Scan(&l.ID, &l.Level, &l.Source, &l.Message, &created); err != nil {
return nil, 0, err
}
l.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
out = append(out, l)
}
return out, total, rows.Err()
}
func (s *Store) PruneRequestLogs(ctx context.Context, keep int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM request_logs WHERE id NOT IN (SELECT id FROM request_logs ORDER BY id DESC LIMIT ?)`, keep)
return err
}
func (s *Store) AddTrafficSnapshot(ctx context.Context, up, down, conns int64) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO traffic_snapshots(upload_rate,download_rate,connections) VALUES(?,?,?)`, up, down, conns)
return err
}
func (s *Store) ListTrafficSnapshots(ctx context.Context, limit int) ([]map[string]any, error) {
rows, err := s.db.QueryContext(ctx, `SELECT upload_rate,download_rate,connections,created_at FROM traffic_snapshots ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var up, down, conns int64
var created string
if err := rows.Scan(&up, &down, &conns, &created); err != nil {
return nil, err
}
out = append(out, map[string]any{
"upload_rate": up,
"download_rate": down,
"connections": conns,
"created_at": created,
})
}
return out, rows.Err()
}
func parseTimePtr(s sql.NullString) *time.Time {
if !s.Valid || s.String == "" {
return nil
}
t, err := time.Parse("2006-01-02 15:04:05", s.String)
if err != nil {
return nil
}
return &t
}
func parseTime(s string) time.Time {
t, _ := time.Parse("2006-01-02 15:04:05", s)
return t
}
func boolFromInt(v int) bool { return v != 0 }
func intFromBool(v bool) int {
if v {
return 1
}
return 0
}
func RuntimeName(prefix string, id int64, name string) string {
return fmt.Sprintf("%s%d_%s", prefix, id, sanitizeName(name))
}
func sanitizeName(name string) string {
out := make([]rune, 0, len(name))
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
out = append(out, r)
default:
out = append(out, '_')
}
}
if len(out) == 0 {
return "node"
}
return string(out)
}
+197
View File
@@ -0,0 +1,197 @@
package store
import (
"context"
"database/sql"
"fmt"
)
func (s *Store) InsertProxyRequestLog(ctx context.Context, l *ProxyRequestLog) error {
res, err := s.db.ExecContext(ctx, `
INSERT INTO proxy_request_logs(host,network,rule_type,rule_payload,rule_line,outbound_node,outbound_chain,upload,download,success,started_at,ended_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
l.Host, l.Network, l.RuleType, l.RulePayload, l.RuleLine, l.OutboundNode, l.OutboundChain,
l.Upload, l.Download, intFromBool(l.Success), l.StartedAt.Format("2006-01-02 15:04:05"), l.EndedAt.Format("2006-01-02 15:04:05"))
if err != nil {
return err
}
id, _ := res.LastInsertId()
l.ID = id
return nil
}
func (s *Store) ListProxyRequestLogs(ctx context.Context, keyword string, limit, offset int) ([]ProxyRequestLog, int, error) {
query := `SELECT id,host,network,rule_type,rule_payload,rule_line,outbound_node,outbound_chain,upload,download,success,started_at,ended_at,created_at FROM proxy_request_logs WHERE 1=1`
countQuery := `SELECT COUNT(*) FROM proxy_request_logs WHERE 1=1`
args := []any{}
if keyword != "" {
query += ` AND (host LIKE ? OR rule_line LIKE ? OR outbound_node LIKE ? OR outbound_chain LIKE ?)`
countQuery += ` AND (host LIKE ? OR rule_line LIKE ? OR outbound_node LIKE ? OR outbound_chain LIKE ?)`
kw := "%" + keyword + "%"
args = append(args, kw, kw, kw, kw)
}
var total int
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
query += ` ORDER BY id DESC LIMIT ? OFFSET ?`
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []ProxyRequestLog
for rows.Next() {
var l ProxyRequestLog
var success int
var started, ended, created string
if err := rows.Scan(&l.ID, &l.Host, &l.Network, &l.RuleType, &l.RulePayload, &l.RuleLine, &l.OutboundNode, &l.OutboundChain,
&l.Upload, &l.Download, &success, &started, &ended, &created); err != nil {
return nil, 0, err
}
l.Success = boolFromInt(success)
l.StartedAt = parseTime(started)
l.EndedAt = parseTime(ended)
l.CreatedAt = parseTime(created)
out = append(out, l)
}
return out, total, rows.Err()
}
func (s *Store) PruneProxyRequestLogs(ctx context.Context, keep int) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM proxy_request_logs WHERE id NOT IN (SELECT id FROM proxy_request_logs ORDER BY id DESC LIMIT ?)`, keep)
return err
}
type TrafficAgg struct {
Key string `json:"key"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Connections int `json:"connections"`
}
type TrafficHostNodeAgg struct {
Host string `json:"host"`
NodeKey string `json:"node_key"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Connections int `json:"connections"`
}
func (s *Store) AggregateTrafficByHost(ctx context.Context, hours, limit int) ([]TrafficAgg, error) {
if hours <= 0 {
hours = 24
}
if limit <= 0 {
limit = 30
}
rows, err := s.db.QueryContext(ctx, `
SELECT host, COALESCE(SUM(upload),0), COALESCE(SUM(download),0), COUNT(*)
FROM proxy_request_logs
WHERE host != '' AND created_at >= datetime('now', ?)
GROUP BY host
ORDER BY (SUM(upload)+SUM(download)) DESC
LIMIT ?`, fmt.Sprintf("-%d hours", hours), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTrafficAgg(rows)
}
func (s *Store) CountAggregateTrafficByNode(ctx context.Context, hours int) (int, error) {
if hours <= 0 {
hours = 24
}
var n int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM proxy_request_logs
WHERE outbound_node != '' AND created_at >= datetime('now', ?)
GROUP BY outbound_node
)`, fmt.Sprintf("-%d hours", hours)).Scan(&n)
return n, err
}
func (s *Store) AggregateTrafficByNode(ctx context.Context, hours, limit, offset int) ([]TrafficAgg, error) {
if hours <= 0 {
hours = 24
}
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(ctx, `
SELECT outbound_node, COALESCE(SUM(upload),0), COALESCE(SUM(download),0), COUNT(*)
FROM proxy_request_logs
WHERE outbound_node != '' AND created_at >= datetime('now', ?)
GROUP BY outbound_node
ORDER BY (SUM(upload)+SUM(download)) DESC
LIMIT ? OFFSET ?`, fmt.Sprintf("-%d hours", hours), limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
return scanTrafficAgg(rows)
}
func (s *Store) CountAggregateTrafficByHostNode(ctx context.Context, hours int) (int, error) {
if hours <= 0 {
hours = 24
}
var n int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM (
SELECT 1 FROM proxy_request_logs
WHERE host != '' AND outbound_node != '' AND created_at >= datetime('now', ?)
GROUP BY host, outbound_node
)`, fmt.Sprintf("-%d hours", hours)).Scan(&n)
return n, err
}
func (s *Store) AggregateTrafficByHostNode(ctx context.Context, hours, limit, offset int) ([]TrafficHostNodeAgg, error) {
if hours <= 0 {
hours = 24
}
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(ctx, `
SELECT host, outbound_node, COALESCE(SUM(upload),0), COALESCE(SUM(download),0), COUNT(*)
FROM proxy_request_logs
WHERE host != '' AND outbound_node != '' AND created_at >= datetime('now', ?)
GROUP BY host, outbound_node
ORDER BY (SUM(upload)+SUM(download)) DESC
LIMIT ? OFFSET ?`, fmt.Sprintf("-%d hours", hours), limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TrafficHostNodeAgg
for rows.Next() {
var item TrafficHostNodeAgg
if err := rows.Scan(&item.Host, &item.NodeKey, &item.Upload, &item.Download, &item.Connections); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func scanTrafficAgg(rows *sql.Rows) ([]TrafficAgg, error) {
var out []TrafficAgg
for rows.Next() {
var item TrafficAgg
if err := rows.Scan(&item.Key, &item.Upload, &item.Download, &item.Connections); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
+32
View File
@@ -0,0 +1,32 @@
package subscription
import (
"fmt"
"strings"
"github.com/prism/proxy/internal/store"
)
// ParseLinkLines parses share links (ss/socks5/http/trojan) into manual proxy nodes.
func ParseLinkLines(text string) ([]store.ProxyNode, []string) {
lines := strings.Split(text, "\n")
var out []store.ProxyNode
var errs []string
idx := 0
for i, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
n, err := parseLink(line, 0, idx)
if err != nil {
errs = append(errs, fmt.Sprintf("第 %d 行: %v", i+1, err))
continue
}
n.Source = "manual"
n.RuntimeName = store.RuntimeName("manual", 0, n.Name)
out = append(out, n)
idx++
}
return out, errs
}
+13
View File
@@ -0,0 +1,13 @@
package subscription
import "testing"
func TestParseLinkLines(t *testing.T) {
nodes, errs := ParseLinkLines("socks5://1.2.3.4:1080#test-node\n")
if len(errs) != 0 {
t.Fatal(errs)
}
if len(nodes) != 1 || nodes[0].Name != "test-node" || nodes[0].Source != "manual" {
t.Fatalf("unexpected %+v", nodes[0])
}
}
+464
View File
@@ -0,0 +1,464 @@
package subscription
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/store"
)
const subscriptionUserAgent = "clash.meta/1.19.0"
type Service struct {
store *store.Store
client *http.Client
}
func NewService(s *store.Store) *Service {
return &Service{
store: s,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
req.Header.Set("User-Agent", subscriptionUserAgent)
return nil
},
},
}
}
type ParsedData struct {
Nodes []store.ProxyNode
Groups []store.ProxyGroup
Rules []store.Rule
}
func (s *Service) FetchAndStore(ctx context.Context, subID int64) error {
sub, err := s.store.GetSubscription(ctx, subID)
if err != nil {
return err
}
body, err := s.fetchURL(sub.URL)
if err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
parsed, err := s.parseSubscription(body, subID, sub.Type)
if err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
if err := s.store.ReplaceSubscriptionData(ctx, subID, parsed.Nodes, parsed.Groups, parsed.Rules); err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
return s.store.UpdateSubscriptionFetch(ctx, subID, len(parsed.Nodes), "")
}
func (s *Service) ParseSubscription(body []byte, subID int64, subType string) (ParsedData, error) {
return s.parseSubscription(body, subID, subType)
}
func (s *Service) parseSubscription(body []byte, subID int64, subType string) (ParsedData, error) {
content := decodeMaybeBase64(body)
text := strings.TrimSpace(string(content))
format := subType
if format == "" || format == "auto" {
format = detectFormat(text)
}
switch format {
case "links":
return s.parseLinks(body, subID)
default:
parsed, err := s.parseClash(body, subID)
if err != nil && looksLikeLinks(text) {
return s.parseLinks(body, subID)
}
return parsed, err
}
}
func detectFormat(text string) string {
if strings.HasPrefix(text, "port:") || strings.HasPrefix(text, "mixed-port:") ||
strings.HasPrefix(text, "proxies:") || strings.Contains(text, "\nproxies:") {
return "clash"
}
if looksLikeLinks(text) {
return "links"
}
return "clash"
}
func looksLikeLinks(text string) bool {
lower := strings.ToLower(text)
for _, prefix := range []string{"ss://", "vmess://", "trojan://", "socks5://", "http://", "https://"} {
if strings.Contains(lower, prefix) {
return true
}
}
return false
}
func (s *Service) fetchURL(rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", subscriptionUserAgent)
req.Header.Set("Accept", "*/*")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch failed: HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func (s *Service) parseClash(body []byte, subID int64) (ParsedData, error) {
content := decodeMaybeBase64(body)
var raw map[string]any
if err := yaml.Unmarshal(content, &raw); err != nil {
return ParsedData{}, fmt.Errorf("invalid clash yaml: %w", err)
}
if _, ok := raw["proxies"]; !ok {
return ParsedData{}, fmt.Errorf("clash config missing proxies")
}
var out ParsedData
if proxies, ok := raw["proxies"].([]any); ok {
for i, p := range proxies {
pm, ok := p.(map[string]any)
if !ok {
continue
}
name, _ := pm["name"].(string)
if name == "" {
name = fmt.Sprintf("node%d", i+1)
}
rawJSON, _ := json.Marshal(pm)
out.Nodes = append(out.Nodes, store.ProxyNode{
Name: name,
RuntimeName: store.RuntimeName("sub", subID, fmt.Sprintf("%d_%s", i, name)),
Type: fmt.Sprint(pm["type"]),
RawConfig: string(rawJSON),
Country: country.Detect(name),
Enabled: true,
})
}
}
if groups, ok := raw["proxy-groups"].([]any); ok {
for i, g := range groups {
gm, ok := g.(map[string]any)
if !ok {
continue
}
name, _ := gm["name"].(string)
if name == "" {
continue
}
gtype, _ := gm["type"].(string)
var members []string
if arr, ok := gm["proxies"].([]any); ok {
for _, m := range arr {
members = append(members, fmt.Sprint(m))
}
}
membersJSON, _ := json.Marshal(members)
out.Groups = append(out.Groups, store.ProxyGroup{
Name: name,
RuntimeName: store.RuntimeName("grp", subID, fmt.Sprintf("%d_%s", i, name)),
Type: gtype,
Proxies: string(membersJSON),
Enabled: true,
})
}
}
if rules, ok := raw["rules"].([]any); ok {
for _, r := range rules {
line := fmt.Sprint(r)
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
rule := store.Rule{
RuleType: parts[0],
Target: parts[len(parts)-1],
Enabled: true,
}
if len(parts) > 2 {
rule.Payload = strings.Join(parts[1:len(parts)-1], ",")
}
out.Rules = append(out.Rules, rule)
}
}
return out, nil
}
func (s *Service) parseLinks(body []byte, subID int64) (ParsedData, error) {
text := string(decodeMaybeBase64(body))
lines := strings.Split(text, "\n")
var out ParsedData
idx := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
node, err := parseLink(line, subID, idx)
if err != nil {
continue
}
out.Nodes = append(out.Nodes, node)
idx++
}
if len(out.Nodes) == 0 {
return ParsedData{}, fmt.Errorf("no valid proxy links found")
}
names := make([]string, 0, len(out.Nodes))
for _, n := range out.Nodes {
names = append(names, n.RuntimeName)
}
namesJSON, _ := json.Marshal(names)
out.Groups = append(out.Groups, store.ProxyGroup{
Name: fmt.Sprintf("sub%d", subID),
RuntimeName: store.RuntimeName("grp", subID, "auto"),
Type: "url-test",
Proxies: string(namesJSON),
Enabled: true,
})
return out, nil
}
func parseLink(link string, subID int64, idx int) (store.ProxyNode, error) {
lower := strings.ToLower(link)
var proxy map[string]any
var name string
var ptype string
switch {
case strings.HasPrefix(lower, "ss://"):
proxy, name, ptype = parseSS(link)
case strings.HasPrefix(lower, "socks5://"):
proxy, name, ptype = parseSocks5(link)
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
proxy, name, ptype = parseHTTP(link)
case strings.HasPrefix(lower, "trojan://"):
proxy, name, ptype = parseTrojan(link)
default:
return store.ProxyNode{}, fmt.Errorf("unsupported link")
}
if proxy == nil {
return store.ProxyNode{}, fmt.Errorf("failed to parse link")
}
if name == "" {
name = fmt.Sprintf("node%d", idx+1)
}
raw, _ := json.Marshal(proxy)
return store.ProxyNode{
Name: name,
RuntimeName: store.RuntimeName("sub", subID, name),
Type: ptype,
RawConfig: string(raw),
Country: country.Detect(name),
Enabled: true,
}, nil
}
func parseSS(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "ss://")
if payload == "" {
return nil, name, "ss"
}
// SIP002: ss://BASE64(method:password@host:port)#name
if proxy, ok := parseSIP002(payload); ok {
return proxy, name, "ss"
}
// 传统格式: ss://BASE64(method:password)@host:port#name
// 或 ss://BASE64(method:password)@BASE64(host:port)#name(部分机场订阅)
parts := strings.SplitN(payload, "@", 2)
if len(parts) != 2 {
return nil, name, "ss"
}
userinfo := string(decodeBase64Flexible(parts[0]))
if userinfo == "" {
userinfo = parts[0]
}
hostport := string(decodeBase64Flexible(parts[1]))
if hostport == "" {
hostport = parts[1]
}
server, portStr, ok := strings.Cut(hostport, ":")
if !ok {
return nil, name, "ss"
}
var port int
fmt.Sscanf(portStr, "%d", &port)
cipher, password, _ := strings.Cut(userinfo, ":")
if cipher == "" {
cipher = "aes-256-gcm"
}
return map[string]any{
"type": "ss",
"server": server,
"port": port,
"cipher": cipher,
"password": password,
}, name, "ss"
}
func parseSIP002(payload string) (map[string]any, bool) {
decoded := string(decodeBase64Flexible(payload))
at := strings.LastIndex(decoded, "@")
if at <= 0 || !strings.Contains(decoded[:at], ":") {
return nil, false
}
userinfo := decoded[:at]
hostport := decoded[at+1:]
server, portStr, ok := strings.Cut(hostport, ":")
if !ok || server == "" {
return nil, false
}
var port int
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil || port <= 0 {
return nil, false
}
cipher, password, ok := strings.Cut(userinfo, ":")
if !ok || cipher == "" {
return nil, false
}
return map[string]any{
"type": "ss",
"server": server,
"port": port,
"cipher": cipher,
"password": password,
}, true
}
func splitLinkFragment(link, scheme string) (name, payload string) {
rest := strings.TrimPrefix(link, scheme)
if i := strings.Index(rest, "#"); i >= 0 {
name, _ = url.QueryUnescape(rest[i+1:])
rest = rest[:i]
}
return name, rest
}
func decodeBase64Flexible(s string) []byte {
decoders := []func(string) ([]byte, error){
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
base64.URLEncoding.DecodeString,
base64.RawURLEncoding.DecodeString,
}
for _, dec := range decoders {
if b, err := dec(s); err == nil && len(b) > 0 {
return b
}
}
return nil
}
func parseSocks5(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "socks5://")
user, pass := "", ""
if at := strings.LastIndex(payload, "@"); at >= 0 {
auth := payload[:at]
payload = payload[at+1:]
if c := strings.Index(auth, ":"); c >= 0 {
user, pass = auth[:c], auth[c+1:]
}
}
server, portStr, _ := strings.Cut(payload, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
m := map[string]any{"type": "socks5", "server": server, "port": port}
if user != "" {
m["username"] = user
m["password"] = pass
}
return m, name, "socks5"
}
func parseHTTP(link string) (map[string]any, string, string) {
isTLS := strings.HasPrefix(strings.ToLower(link), "https://")
scheme := "http://"
if isTLS {
scheme = "https://"
}
name, payload := splitLinkFragment(link, scheme)
user, pass := "", ""
if at := strings.LastIndex(payload, "@"); at >= 0 {
auth := payload[:at]
payload = payload[at+1:]
if c := strings.Index(auth, ":"); c >= 0 {
user, pass = auth[:c], auth[c+1:]
}
}
server, portStr, _ := strings.Cut(payload, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
m := map[string]any{"type": "http", "server": server, "port": port, "tls": isTLS}
if user != "" {
m["username"] = user
m["password"] = pass
}
return m, name, "http"
}
func parseTrojan(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "trojan://")
pass, rest, _ := strings.Cut(payload, "@")
server, portStr, _ := strings.Cut(rest, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
if q := strings.Index(server, "?"); q >= 0 {
server = server[:q]
}
return map[string]any{
"type": "trojan",
"server": server,
"port": port,
"password": pass,
"sni": server,
}, name, "trojan"
}
func decodeMaybeBase64(body []byte) []byte {
trim := strings.TrimSpace(string(body))
if strings.HasPrefix(trim, "proxies:") || strings.HasPrefix(trim, "{") || strings.HasPrefix(trim, "ss://") {
return body
}
if decoded := decodeBase64Flexible(trim); len(decoded) > 0 {
return decoded
}
return body
}
+63
View File
@@ -0,0 +1,63 @@
package subscription
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
const sampleBase64Sub = `c3M6Ly9ZV1Z6TFRFeU9DMW5ZMjA2Wm1Fd1lqZ3laamN0Tm1VelppMWhZakZoTFdJMk5qZ3RZamhqTlRjeU5ERTJNakpqUUdWNGNHbHlaUzEwYVcxbExtTjVZbVZ5Ym05a1pTNTBiM0E2TVRrNE5ERT0jJUU1JThCJUJGJUU5JTgwJTg5LSVFNSU4OCVCMCVFNiU5QyU5RiVFNiU5NyVCNiVFOSU5NyVCNCVFRiVCQyU5QTIwMjYtMDktMDMKc3M6Ly9ZV1Z6TFRFeU9DMW5ZMjA2Wm1Fd1lqZ3laamN0Tm1VelppMWhZakZoTFdJMk5qZ3RZamhqTlRjeU5ERTJNakpqUUhKbGJXRnBiaTEwY21GbVptbGpMbU41WW1WeWJtOWtaUzUwYjNBNk1UazROREk9IyVFNSU4QiVCRiVFOSU4MCU4OS0lRTUlQkQlOTMlRTYlOUMlODglRTUlODklQTklRTQlQkQlOTklRTYlQjUlODElRTklODclOEYlRUYlQkMlOUEyNi4wOUdCCnNzOi8vWVdWekxURXlPQzFuWTIwNlptRXdZamd5WmpjdE5tVXpaaTFoWWpGaExXSTJOamd0WWpoak5UY3lOREUyTWpKalFERXVNUzR4TGpFNk5UazFNREk9IyVFMyU4MCU5MCVFOSU4MCU5QSVFNyU5RiVBNSVFMyU4MCU5MSVFNSVBNiU4MiVFOSU5QyU4MCVFNiU5QiVCNCVFNSVBNCU5QSVFOCU4QSU4MiVFNyU4MiVCOSVFOCVBRiVCNyVFOSU4NyU4RCVFOCVBMyU4NUFwcApzczovL1lXVnpMVEV5T0MxblkyMDZabUV3WWpneVpqY3RObVV6WmkxaFlqRmhMV0kyTmpndFlqaGpOVGN5TkRFMk1qSmpRREV1TVM0eExqRTZOVGsxTURNPSMlRTglODElOTQlRTclQjMlQkIlRTklODIlQUUlRTclQUUlQjElM0FsaWJjeWJlcjd4MjQlNDBnbWFpbC5jb20Kc3M6Ly9ZV1Z6TFRFeU9DMW5ZMjA2Wm1Fd1lqZ3laamN0Tm1VelppMWhZakZoTFdJMk5qZ3RZamhqTlRjeU5ERTJNakpqUURFd015NHhPREV1TVRZMExqRXdORG8yTVRFd01RPT0jJUU5JUFCJTk4JUU5JTgwJTlGJUU4JUFGJTk1JUU3JTk0JUE4LSVFNiVCMyU5NSVFNSU5QiVCRDEKc3M6Ly9ZV1Z6TFRFeU9DMW5ZMjA2Wm1Fd1lqZ3laamN0Tm1VelppMWhZakZoTFdJMk5qZ3RZamhqTlRjeU5ERTJNakpqUURFd015NHhPREV1TVRZMExqRXdORG8yTURBNE9BPT0jJUU5JUFCJTk4JUU5JTgwJTlGJUU1JUE0JTg3JUU3JTk0JUE4LSVFNCVCQiU4NSVFOSU5OSU5MCVFNCVCOCVCNCVFNiU5NyVCNiVFNCVCRCVCRiVFNyU5NCVBOAo=`
func TestParseXHSCacheBase64Subscription(t *testing.T) {
svc := NewService(nil)
parsed, err := svc.parseSubscription([]byte(sampleBase64Sub), 1, "auto")
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(parsed.Nodes) != 6 {
t.Fatalf("expected 6 nodes, got %d", len(parsed.Nodes))
}
if parsed.Nodes[0].Type != "ss" {
t.Fatalf("expected ss type, got %s", parsed.Nodes[0].Type)
}
var cfg map[string]any
if err := json.Unmarshal([]byte(parsed.Nodes[0].RawConfig), &cfg); err != nil {
t.Fatal(err)
}
if cfg["server"] != "expire-time.cybernode.top" {
t.Fatalf("unexpected server: %v", cfg["server"])
}
if cfg["cipher"] != "aes-128-gcm" {
t.Fatalf("unexpected cipher: %v", cfg["cipher"])
}
if parsed.Nodes[0].Name == "" {
t.Fatal("expected decoded node name")
}
}
func TestDetectFormatBase64Links(t *testing.T) {
if got := detectFormat(string(decodeMaybeBase64([]byte(sampleBase64Sub)))); got != "links" {
t.Fatalf("expected links, got %s", got)
}
}
func TestFetchURLFollowsRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
t.Fatal("missing user agent")
}
_, _ = io.WriteString(w, sampleBase64Sub)
}))
defer srv.Close()
svc := NewService(nil)
body, err := svc.fetchURL(srv.URL)
if err != nil {
t.Fatal(err)
}
if len(body) == 0 {
t.Fatal("empty body")
}
}
+227
View File
@@ -0,0 +1,227 @@
package trafficlog
import (
"context"
"log"
"strings"
"sync"
"time"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/mihomoapi"
"github.com/prism/proxy/internal/store"
)
type Collector struct {
store *store.Store
mu sync.Mutex
client *mihomoapi.Client
active map[string]mihomoapi.Connection
names map[string]string
groups map[string]bool
keep int
lastPollErr string
lastPollErrAt time.Time
}
func NewCollector(s *store.Store, keep int) *Collector {
if keep <= 0 {
keep = 5000
}
return &Collector{
store: s,
active: make(map[string]mihomoapi.Connection),
names: make(map[string]string),
keep: keep,
}
}
func (c *Collector) UpdateClient(client *mihomoapi.Client) {
c.mu.Lock()
c.client = client
c.mu.Unlock()
}
func (c *Collector) Run(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.poll(ctx)
}
}
}
func (c *Collector) poll(ctx context.Context) {
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil {
return
}
snap, err := client.ConnectionSnapshot()
if err != nil {
c.logPollError(err)
return
}
if snap == nil {
return
}
current := make(map[string]mihomoapi.Connection, len(snap.Connections))
for _, conn := range snap.Connections {
if conn.ID == "" {
continue
}
current[conn.ID] = conn
}
c.mu.Lock()
groups := c.groups
names := c.names
for id, conn := range current {
c.active[id] = conn
}
var ended []mihomoapi.Connection
for id, conn := range c.active {
if _, ok := current[id]; ok {
continue
}
ended = append(ended, conn)
delete(c.active, id)
}
c.mu.Unlock()
for _, conn := range ended {
c.save(ctx, conn, groups, names)
}
}
func (c *Collector) save(ctx context.Context, conn mihomoapi.Connection, groups map[string]bool, names map[string]string) {
nodeKey := conn.LeafOutbound(groups)
node := c.resolveName(nodeKey, names)
chain := conn.ChainString()
resolvedChain := c.resolveChainJSON(chain)
if err := c.store.InsertProxyRequestLog(ctx, &store.ProxyRequestLog{
Host: conn.RequestHost(),
Network: conn.Metadata.Network,
RuleType: conn.Rule,
RulePayload: conn.RulePayload,
RuleLine: conn.RuleLine(),
OutboundNode: node,
OutboundChain: resolvedChain,
Upload: conn.Upload,
Download: conn.Download,
Success: conn.IsSuccess(),
StartedAt: conn.Start,
EndedAt: time.Now(),
}); err != nil {
log.Printf("trafficlog: save request log: %v", err)
return
}
_ = c.store.PruneProxyRequestLogs(ctx, c.keep)
}
func (c *Collector) logPollError(err error) {
msg := err.Error()
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
if msg == c.lastPollErr && now.Sub(c.lastPollErrAt) < time.Minute {
return
}
c.lastPollErr = msg
c.lastPollErrAt = now
log.Printf("trafficlog: poll connections: %v", err)
}
func (c *Collector) refreshNames(ctx context.Context) {
nodes, err := c.store.ListProxyNodes(ctx, "", nil)
if err != nil {
return
}
groups, _ := c.store.ListProxyGroups(ctx)
m := map[string]string{
"DIRECT": "DIRECT",
"REJECT": "REJECT",
}
g := map[string]bool{
"GLOBAL": true,
"PROXY": true,
}
for _, n := range nodes {
m[n.RuntimeName] = n.Name
}
for _, grp := range groups {
if grp.Enabled {
g[grp.RuntimeName] = true
}
m[grp.RuntimeName] = grp.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) + "(国家池)"
}
c.mu.Lock()
c.names = m
c.groups = g
c.mu.Unlock()
}
func (c *Collector) resolveName(runtime string, names map[string]string) string {
if runtime == "" {
return ""
}
if name, ok := names[runtime]; ok && name != "" {
return name
}
if strings.HasPrefix(runtime, "country_") {
code := strings.TrimPrefix(runtime, "country_")
return code + "(国家池)"
}
return runtime
}
func (c *Collector) resolveChainJSON(chain string) string {
// chain stored as JSON array string; map runtime names to display names
if chain == "" || chain == "[]" {
return chain
}
// lightweight replace for display
out := chain
c.mu.Lock()
names := c.names
c.mu.Unlock()
for rt, name := range names {
if rt != "" && name != "" {
out = strings.ReplaceAll(out, rt, name)
}
}
return out
}
func (c *Collector) Start(ctx context.Context, client *mihomoapi.Client) {
c.UpdateClient(client)
c.refreshNames(ctx)
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.refreshNames(ctx)
}
}
}()
go c.Run(ctx)
}