Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 Key(prism_ 开头)
|
||||
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'
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+68
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user