diff --git a/api/api.go b/api/api.go index b1bb61c..7cf50f4 100644 --- a/api/api.go +++ b/api/api.go @@ -3,6 +3,7 @@ package api import ( "ghproxy/config" "ghproxy/middleware/nocache" + "ghproxy/stats" "github.com/infinite-iroha/touka" ) @@ -46,9 +47,17 @@ func InitHandleRouter(cfg *config.Config, r *touka.Engine, version string) { apiRouter.GET("/oci_proxy/status", func(c *touka.Context) { ociProxyStatusHandler(cfg, c) }) + apiRouter.GET("/stats", func(c *touka.Context) { + StatsHandler(c) + }) } } +func StatsHandler(c *touka.Context) { + c.SetHeader("Content-Type", "application/json") + c.JSON(200, stats.GetStats()) +} + func SizeLimitHandler(cfg *config.Config, c *touka.Context) { sizeLimit := cfg.Server.SizeLimit c.SetHeader("Content-Type", "application/json") diff --git a/backend/index.html b/backend/index.html new file mode 100644 index 0000000..ba1117a --- /dev/null +++ b/backend/index.html @@ -0,0 +1,28 @@ + + + + + + 后台统计 + + + +
+

IP 代理使用情况统计

+ + + + + + + + + + + + +
IP 地址调用次数总流量 (bytes)最后调用时间
+
+ + + diff --git a/backend/script.js b/backend/script.js new file mode 100644 index 0000000..f0480fd --- /dev/null +++ b/backend/script.js @@ -0,0 +1,36 @@ +document.addEventListener('DOMContentLoaded', function() { + fetch('/api/stats') + .then(response => response.json()) + .then(data => { + const tableBody = document.getElementById('stats-table-body'); + tableBody.innerHTML = ''; // 清空现有内容 + + for (const ip in data) { + const stats = data[ip]; + const row = document.createElement('tr'); + + const ipCell = document.createElement('td'); + ipCell.textContent = stats.ip; + row.appendChild(ipCell); + + const callCountCell = document.createElement('td'); + callCountCell.textContent = stats.call_count; + row.appendChild(callCountCell); + + const transferredCell = document.createElement('td'); + transferredCell.textContent = stats.total_transferred; + row.appendChild(transferredCell); + + const lastCalledCell = document.createElement('td'); + lastCalledCell.textContent = new Date(stats.last_called).toLocaleString(); + row.appendChild(lastCalledCell); + + tableBody.appendChild(row); + } + }) + .catch(error => { + console.error('获取统计数据时出错:', error); + const tableBody = document.getElementById('stats-table-body'); + tableBody.innerHTML = '加载统计数据失败'; + }); +}); diff --git a/config/config.toml b/config/config.toml index ce490ca..7ec7c0e 100644 --- a/config/config.toml +++ b/config/config.toml @@ -25,10 +25,10 @@ rewriteAPI = false [pages] mode = "internal" # "internal" or "external" theme = "bootstrap" # "bootstrap" or "nebula" -staticDir = "/data/www" +staticDir = "pages" [log] -logFilePath = "/data/ghproxy/log/ghproxy.log" +logFilePath = "ghproxy.log" maxLogSize = 5 # MB level = "info" # debug, info, warn, error, none @@ -42,18 +42,18 @@ ForceAllowApi = false ForceAllowApiPassList = false [blacklist] -blacklistFile = "/data/ghproxy/config/blacklist.json" +blacklistFile = "blacklist.json" enabled = false [whitelist] enabled = false -whitelistFile = "/data/ghproxy/config/whitelist.json" +whitelistFile = "whitelist.json" [ipFilter] enabled = false enableAllowList = false enableBlockList = false -ipFilterFile = "/data/ghproxy/config/ipfilter.json" +ipFilterFile = "ipfilter.json" [rateLimit] enabled = false diff --git a/main.go b/main.go index 5f5b3ed..59f0d69 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,8 @@ var ( var ( //go:embed pages/* pagesFS embed.FS + //go:embed backend/* + backendFS embed.FS ) var ( @@ -342,6 +344,7 @@ func main() { } r := touka.Default() + var err error r.SetProtocols(&touka.ProtocolsConfig{ Http1: true, Http2_Cleartext: true, @@ -380,14 +383,15 @@ func main() { } if cfg.IPFilter.Enabled { - var err error - ipAllowList, ipBlockList, err := auth.ReadIPFilterList(cfg) + var ipAllowList, ipBlockList []string + ipAllowList, ipBlockList, err = auth.ReadIPFilterList(cfg) if err != nil { fmt.Printf("Failed to read IP filter list: %v\n", err) logger.Errorf("Failed to read IP filter list: %v", err) os.Exit(1) } - ipBlockFilter, err := ipfilter.NewIPFilter(ipfilter.IPFilterConfig{ + var ipBlockFilter *ipfilter.IPFilter + ipBlockFilter, err = ipfilter.NewIPFilter(ipfilter.IPFilterConfig{ EnableAllowList: cfg.IPFilter.EnableAllowList, EnableBlockList: cfg.IPFilter.EnableBlockList, AllowList: ipAllowList, @@ -403,6 +407,7 @@ func main() { } setupApi(cfg, r, version) setupPages(cfg, r) + setBackendRoute(r) r.SetRedirectTrailingSlash(false) r.GET("/github.com/:user/:repo/releases/*filepath", func(c *touka.Context) { @@ -517,7 +522,7 @@ func main() { defer logger.Close() addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) - err := r.RunShutdown(addr) + err = r.RunShutdown(addr) if err != nil { logger.Errorf("Server Run Error: %v", err) fmt.Printf("Server Run Error: %v\n", err) @@ -525,3 +530,14 @@ func main() { fmt.Println("Program Exit") } + +func setBackendRoute(r *touka.Engine) { + backend, err := fs.Sub(backendFS, "backend") + if err != nil { + logger.Errorf("Failed to load embedded backend pages: %s", err) + fmt.Printf("Failed to load embedded backend pages: %s", err) + os.Exit(1) + } + r.HandleFunc([]string{"GET"}, "/admin", pageCacheHeader(), touka.FileServer(http.FS(backend))) + r.HandleFunc([]string{"GET"}, "/admin/script.js", pageCacheHeader(), touka.FileServer(http.FS(backend))) +} diff --git a/proxy/chunkreq.go b/proxy/chunkreq.go index 9227b78..f580e44 100644 --- a/proxy/chunkreq.go +++ b/proxy/chunkreq.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "ghproxy/config" + "ghproxy/stats" "io" "net/http" "strconv" @@ -124,7 +125,11 @@ func ChunkedProxyRequest(ctx context.Context, c *touka.Context, u string, cfg *c bodyReader = limitreader.NewRateLimitedReader(bodyReader, bandwidthLimit, int(bandwidthBurst), ctx) } - defer bodyReader.Close() + countingReader := NewCountingReader(bodyReader) + defer countingReader.Close() + defer func() { + stats.Record(c.ClientIP(), countingReader.BytesRead()) + }() if MatcherShell(u) && matchString(matcher) && cfg.Shell.Editor { // 判断body是不是gzip @@ -138,7 +143,7 @@ func ChunkedProxyRequest(ctx context.Context, c *touka.Context, u string, cfg *c var reader io.Reader - reader, _, err = processLinks(bodyReader, compress, c.Request.Host, cfg, c) + reader, _, err = processLinks(countingReader, compress, c.Request.Host, cfg, c) c.WriteStream(reader) if err != nil { c.Errorf("%s %s %s %s %s Failed to copy response body: %v", c.ClientIP(), c.Request.Method, u, c.UserAgent(), c.Request.Proto, err) @@ -149,10 +154,10 @@ func ChunkedProxyRequest(ctx context.Context, c *touka.Context, u string, cfg *c if contentLength != "" { c.SetHeader("Content-Length", contentLength) - c.WriteStream(bodyReader) + c.WriteStream(countingReader) return } - c.WriteStream(bodyReader) + c.WriteStream(countingReader) } } diff --git a/proxy/utils.go b/proxy/utils.go index e923742..65f9e10 100644 --- a/proxy/utils.go +++ b/proxy/utils.go @@ -4,10 +4,47 @@ import ( "fmt" "ghproxy/auth" "ghproxy/config" + "io" "github.com/infinite-iroha/touka" ) +// CountingReader is a reader that counts the number of bytes read. +// CountingReader 是一个计算已读字节数的读取器. +type CountingReader struct { + reader io.Reader + bytesRead int64 +} + +// NewCountingReader creates a new CountingReader. +// NewCountingReader 创建一个新的 CountingReader. +func NewCountingReader(reader io.Reader) *CountingReader { + return &CountingReader{ + reader: reader, + } +} + +func (cr *CountingReader) Read(p []byte) (n int, err error) { + n, err = cr.reader.Read(p) + cr.bytesRead += int64(n) + return n, err +} + +// BytesRead returns the number of bytes read. +// BytesRead 返回已读字节数. +func (cr *CountingReader) BytesRead() int64 { + return cr.bytesRead +} + +// Close closes the underlying reader if it implements io.Closer. +// 如果底层读取器实现了 io.Closer, 则关闭它. +func (cr *CountingReader) Close() error { + if closer, ok := cr.reader.(io.Closer); ok { + return closer.Close() + } + return nil +} + func listCheck(cfg *config.Config, c *touka.Context, user string, repo string, rawPath string) bool { if cfg.Auth.ForceAllowApi && cfg.Auth.ForceAllowApiPassList { return false diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 0000000..319f600 --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,44 @@ +package stats + +import ( + "sync" + "time" +) + +// ProxyStats store one ip's proxy stats +// ProxyStats 存储一个IP的代理统计信息 +type ProxyStats struct { + IP string `json:"ip"` + LastCalled time.Time `json:"last_called"` + CallCount int64 `json:"call_count"` + TotalTransferred int64 `json:"total_transferred"` +} + +var ( + statsMap = &sync.Map{} +) + +// Record update a ip's proxy stats +// Record 更新一个IP的代理统计信息 +func Record(ip string, transferred int64) { + s, _ := statsMap.LoadOrStore(ip, &ProxyStats{ + IP: ip, + }) + + ps := s.(*ProxyStats) + ps.LastCalled = time.Now() + ps.CallCount++ + ps.TotalTransferred += transferred + statsMap.Store(ip, ps) +} + +// GetStats return all proxy stats +// GetStats 返回所有的代理统计信息 +func GetStats() map[string]*ProxyStats { + data := make(map[string]*ProxyStats) + statsMap.Range(func(key, value interface{}) bool { + data[key.(string)] = value.(*ProxyStats) + return true + }) + return data +}