diff --git a/.gitignore b/.gitignore index d2b74d08..e36b4dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ CLAUDE.md AGENTS.md /.claude/ +dist +logs \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9f7574f8..a90b13a2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,49 +3,6 @@ project_name: sing-box builds: - &template id: main - main: ./cmd/sing-box - flags: - - -v - - -trimpath - ldflags: - - -X github.com/sagernet/sing-box/constant.Version={{ .Version }} - - -s - - -buildid= - tags: - - with_gvisor - - with_quic - - with_dhcp - - with_wireguard - - with_utls - - with_acme - - with_clash_api - - with_tailscale - - with_masque - - with_mtproxy - env: - - CGO_ENABLED=0 - - GOTOOLCHAIN=local - targets: - - linux_386 - - linux_amd64_v1 - - linux_arm64 - - linux_arm_6 - - linux_arm_7 - - linux_s390x - - linux_riscv64 - - linux_mips - - linux_mips_softfloat - - linux_mipsle - - linux_mipsle_softfloat - - linux_mips64 - - linux_mips64le - - windows_amd64_v1 - - windows_386 - - windows_arm64 - - darwin_amd64_v1 - - darwin_arm64 - mod_timestamp: '{{ .CommitTimestamp }}' - - id: manager main: ./cmd/sing-box flags: - -v @@ -78,19 +35,13 @@ builds: - linux_arm_7 - linux_s390x - linux_riscv64 - - linux_mips - - linux_mips_softfloat - - linux_mipsle - - linux_mipsle_softfloat - - linux_mips64 - - linux_mips64le - windows_amd64_v1 - windows_386 - windows_arm64 - darwin_amd64_v1 - darwin_arm64 mod_timestamp: '{{ .CommitTimestamp }}' - - id: legacy + - id: mips <<: *template tags: - with_gvisor @@ -103,13 +54,13 @@ builds: - with_tailscale - with_masque - with_mtproxy - env: - - CGO_ENABLED=0 - - GOROOT={{ .Env.GOPATH }}/go_win7 - tool: "{{ .Env.GOPATH }}/go_win7/bin/go" targets: - - windows_amd64_v1 - - windows_386 + - linux_mips + - linux_mips_softfloat + - linux_mipsle + - linux_mipsle_softfloat + - linux_mips64 + - linux_mips64le - id: android <<: *template env: @@ -148,6 +99,7 @@ archives: id: archive builds: - main + - mips - android formats: - tar.gz @@ -159,19 +111,6 @@ archives: files: - LICENSE name_template: '{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - id: archive_with_manager - builds: - - manager - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - wrap_in_directory: true - files: - - LICENSE - name_template: '{{ .ProjectName }}-{{ .Version }}-with-manager-{{ .Os }}-{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}{{ if and .Mips (not (eq .Mips "hardfloat")) }}-{{ .Mips }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - id: archive-legacy <<: *template builds: diff --git a/Makefile b/Makefile index 90edb36e..e7bcfdfd 100644 --- a/Makefile +++ b/Makefile @@ -14,12 +14,35 @@ PREFIX ?= $(shell go env GOPATH) SING_FFI ?= sing-ffi LIBBOX_FFI_CONFIG ?= ./experimental/libbox/ffi.json +ADMIN_PANEL_DIR = service/admin_panel +ADMIN_PANEL_WEB = $(ADMIN_PANEL_DIR)/web +ADMIN_PANEL_DIST = $(ADMIN_PANEL_DIR)/dist +ADMIN_PANEL_TAGS = $(TAGS),with_admin_panel + +DOCKER_IMAGE ?= shtorm7/sing-box-extended +DOCKER_PLATFORMS ?= linux/amd64,linux/arm64 + .PHONY: test release docs build build: export GOTOOLCHAIN=local && \ go build $(MAIN_PARAMS) $(MAIN) +admin_panel_web: + cd $(ADMIN_PANEL_WEB) && \ + npm install --no-fund --no-audit && \ + npm run build + +admin_panel_pack: + go run ./cmd/internal/admin_panel_pack \ + -dir $(ADMIN_PANEL_DIST) + +admin_panel_regen: admin_panel_web admin_panel_pack + +build_admin_panel: + export GOTOOLCHAIN=local && \ + go build $(PARAMS) -tags "$(ADMIN_PANEL_TAGS)" $(MAIN) + race: export GOTOOLCHAIN=local && \ go build -race $(MAIN_PARAMS) $(MAIN) @@ -84,6 +107,15 @@ release_repo: release_install: go install -v github.com/tcnksm/ghr@latest +release_docker: + sudo docker buildx build \ + --platform $(DOCKER_PLATFORMS) \ + -t $(DOCKER_IMAGE):latest \ + -t $(DOCKER_IMAGE):$(VERSION) \ + --push \ + --network=host \ + . + update_android_version: go run ./cmd/internal/update_android_version diff --git a/README.md b/README.md index 58966022..c94953a9 100644 --- a/README.md +++ b/README.md @@ -4,37 +4,45 @@ Sing-box with extended features. ## šŸ”„ Features -### Protocols -- WARP -- MASQUE -- MTProxy -- Mieru -- VPN -- Bond -- Fallback +### Outbounds +- **WARP** — Cloudflare WARP integration through WireGuard +- **MASQUE** — Cloudflare MASQUE proxy over QUIC / HTTP-2 +- **MTProxy** — Telegram MTProxy server with FakeTLS and domain fronting +- **Mieru** — Secure, hard to classify, hard to probe network protocol +- **VPN** — Routed tunnel over any TCP sing-box protocol +- **Bond** — Link aggregation for increasing throughput +- **Fallback** — Outbound group with priority-based switching +- **Failover** — Automatic outbound switching with session recovery for high availability + +### DNS +- **SDNS (DNSCrypt)** — Encrypted DNS queries for enhanced privacy +- **DNS Fallback** — Sequential / parallel queries across upstream resolvers ### Limiters -- Bandwidth Limiter -- Connection Limiter +- **Bandwidth Limiter** — Upload / download / bidirectional rate limiting +- **Connection Limiter** — Concurrent connection control +- **Traffic Limiter** — Per-user traffic quotas +- **Rate Limiter** — Request rate limiting ### Encryption & Obfuscation -- Amnezia 2.0 -- VLESS encryption +- **Amnezia 2.0** — WireGuard traffic obfuscation +- **VLESS encryption** — XRAY encryption for VLESS protocol ### Transports -- mKCP -- XHTTP +- **mKCP** — Reliable UDP-based transport +- **XHTTP** — Modern XRAY transport ### Services -- Admin Panel -- Manager -- Node Manager +- **Admin Panel** — Web-based management interface +- **Manager** — Management service for configuring users, nodes, limiters +- **Manager API (HTTP/gRPC)** — HTTP and gRPC API for the Manager +- **Node Manager API** — Service for connecting nodes to remote manager ### Miscellaneous -- Link parser -- SDNS (DNSCrypt) -- Extended WireGuard options -- Unified Delay +- **Providers** — Outbound subscriptions from local files, inline lists, or remote URLs (sing-box JSON, Clash YAML, SIP008, share links) +- **Link Parser** — Outbound configured from a share link (VLESS, VMess, Shadowsocks, Trojan, Hysteria, Hysteria2, TUIC) +- **Extended WireGuard options** — Advanced configuration capabilities +- **Unified Delay** — Unified latency measurement ## šŸ“š Examples diff --git a/cmd/internal/admin_panel_pack/main.go b/cmd/internal/admin_panel_pack/main.go new file mode 100644 index 00000000..4ca1e186 --- /dev/null +++ b/cmd/internal/admin_panel_pack/main.go @@ -0,0 +1,194 @@ +// Command admin_panel_pack post-processes a directory of built SPA +// assets so it can be served straight from the Go binary via //go:embed. +// It does *not* generate any Go source any more — that responsibility +// moved to the embed directive in service/admin_panel/service.go. +// +// Three transformations happen here, all in-place inside the supplied +// directory: +// +// 1. Legacy WOFF (1.0) fallback fonts are deleted. Every browser made +// after 2014 reads WOFF2 natively, so shipping both formats roughly +// doubles the embedded font payload for no real-world benefit. The +// matching `,url(*.woff) format("woff")` segments are stripped from +// the bundled CSS in step (2) so the @font-face rules don't reference +// files that aren't shipped. +// 2. Bundled CSS is rewritten to drop those WOFF URL fragments. +// 3. Compressible text assets (.html, .css, .js, .svg, .json, .map) are +// pre-gzipped as companion `*.gz` files. The HTTP handler then either +// passes those bytes through verbatim with Content-Encoding: gzip or +// falls back to the raw file for the rare client that does not +// advertise gzip support — no on-line compression, no surprises. +// Already-compressed formats (.woff2, fonts, images) are skipped: gzip +// can't shrink them and the duplicate would only inflate the binary. +package main + +import ( + "bytes" + "compress/gzip" + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +func main() { + dir := flag.String("dir", "service/admin_panel/dist", "directory of built SPA assets to post-process in place") + flag.Parse() + + woffDropped, err := pruneWoff(*dir) + if err != nil { + fail("prune woff: %v", err) + } + cssRewritten, err := rewriteCSS(*dir) + if err != nil { + fail("rewrite css: %v", err) + } + stats, err := gzipText(*dir) + if err != nil { + fail("gzip text: %v", err) + } + fmt.Fprintf(os.Stderr, "post-processed %s: dropped %d woff, rewrote %d css, gzipped %d files (%d→%d bytes)\n", + *dir, woffDropped, cssRewritten, stats.gzipped, stats.totalRaw, stats.totalGz) +} + +// pruneWoff deletes every legacy *.woff (WOFF 1.0) font under dir. The +// bundled CSS still references them on entry; rewriteCSS drops those +// references in a separate pass so the two operations stay independently +// testable. +func pruneWoff(dir string) (int, error) { + var n int + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(p), ".woff") { + return nil + } + if err := os.Remove(p); err != nil { + return err + } + n++ + return nil + }) + return n, err +} + +// woffRefAfterRE / woffRefBeforeRE match a single WOFF 1.0 entry inside a +// Vite-bundled CSS `src:` declaration. Vite minifies the rule to +// `src:url(./X.woff2) format("woff2"),url(./X.woff) format("woff");` so the +// "after" regex (the common case) eats the *leading* comma + woff entry, +// leaving only the woff2 source. We also handle the rare reverse ordering +// in a second pass. +var ( + woffRefAfterRE = regexp.MustCompile(`,\s*url\([^)]*\.woff\)\s*format\(["']woff["']\)`) + woffRefBeforeRE = regexp.MustCompile(`url\([^)]*\.woff\)\s*format\(["']woff["']\)\s*,\s*`) +) + +// rewriteCSS drops every reference to a *.woff URL from every *.css file +// under dir. Pairs naturally with pruneWoff: after both passes, no font +// URL in the bundle points at a file that isn't shipped. +func rewriteCSS(dir string) (int, error) { + var n int + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || !strings.EqualFold(filepath.Ext(p), ".css") { + return nil + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + out := woffRefAfterRE.ReplaceAll(data, nil) + out = woffRefBeforeRE.ReplaceAll(out, nil) + if bytes.Equal(out, data) { + return nil + } + if err := os.WriteFile(p, out, 0o644); err != nil { + return err + } + n++ + return nil + }) + return n, err +} + +// gzipExts is the set of file extensions for which a `.gz` companion is +// generated. Anything not on this list is left alone — woff2/png/jpeg/etc. +// are already compressed, so gzip can only inflate them slightly while +// doubling the embedded payload. +var gzipExts = map[string]bool{ + ".html": true, + ".css": true, + ".js": true, + ".mjs": true, + ".svg": true, + ".json": true, + ".map": true, + ".txt": true, + ".xml": true, + ".wasm": true, +} + +type gzipStats struct { + gzipped int + totalRaw int64 + totalGz int64 +} + +// gzipText produces a `.gz` companion next to every text-like asset +// in dir, using gzip.BestCompression. The companion is dropped if the +// compressed bytes don't save at least 10 % over the raw file — same +// heuristic we used in the previous (Go-source-emitting) generation, just +// applied to disk files now. +func gzipText(dir string) (gzipStats, error) { + var stats gzipStats + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(p)) + if ext == ".gz" || !gzipExts[ext] { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return err + } + // Reproducible: no mtime, no OS marker. + w.ModTime = time.Time{} + w.OS = 0xff + if _, err := w.Write(raw); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + if buf.Len() > len(raw)*9/10 { + return nil + } + stats.gzipped++ + stats.totalRaw += int64(len(raw)) + stats.totalGz += int64(buf.Len()) + return os.WriteFile(p+".gz", buf.Bytes(), 0o644) + }) + return stats, err +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "admin_panel_pack: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/sing-box/cache.db b/cmd/sing-box/cache.db new file mode 100644 index 00000000..a6adc627 Binary files /dev/null and b/cmd/sing-box/cache.db differ diff --git a/common/badtls/raw_half_conn.go b/common/badtls/raw_half_conn.go index 4d2c8b64..c24fbbd5 100644 --- a/common/badtls/raw_half_conn.go +++ b/common/badtls/raw_half_conn.go @@ -29,7 +29,7 @@ type RawHalfConn struct { func NewRawHalfConn(rawHalfConn reflect.Value, methods *Methods) (*RawHalfConn, error) { halfConn := &RawHalfConn{ - pointer: (unsafe.Pointer)(rawHalfConn.UnsafeAddr()), + pointer: unsafe.Pointer(rawHalfConn.UnsafeAddr()), methods: methods, } diff --git a/common/byteformats/formats.go b/common/byteformats/formats.go new file mode 100644 index 00000000..ed75f78a --- /dev/null +++ b/common/byteformats/formats.go @@ -0,0 +1,74 @@ +package byteformats + +import ( + "fmt" + "math" +) + +var ( + unitNames = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} + iUnitNames = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} + kUnitNames = []string{"kB", "MB", "GB", "TB", "PB", "EB"} + kiUnitNames = []string{"KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} +) + +func formatBytes(s uint64, base float64, sizes []string) string { + if s < 10 { + return fmt.Sprintf("%d B", s) + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) +} + +func formatKBytes(s uint64, base float64, sizes []string) string { + if s == 0 { + return fmt.Sprintf("0 %s", sizes[0]) + } + e := math.Floor(logn(float64(s), base)) + if e < 1 { + e = 1 + } + suffix := sizes[int(e)-1] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} + +func FormatBytes(s uint64) string { + return formatBytes(s, 1000, unitNames) +} + +func FormatMemoryBytes(s uint64) string { + return formatBytes(s, 1024, unitNames) +} + +func FormatIBytes(s uint64) string { + return formatBytes(s, 1024, iUnitNames) +} + +func FormatKBytes(s uint64) string { + return formatKBytes(s, 1000, kUnitNames) +} + +func FormatMemoryKBytes(s uint64) string { + return formatKBytes(s, 1024, kUnitNames) +} + +func FormatKIBytes(s uint64) string { + return formatKBytes(s, 1024, kiUnitNames) +} diff --git a/common/byteformats/json.go b/common/byteformats/json.go new file mode 100644 index 00000000..41d90fc1 --- /dev/null +++ b/common/byteformats/json.go @@ -0,0 +1,218 @@ +package byteformats + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +const ( + Byte = 1 << (iota * 10) + KiByte + MiByte + GiByte + TiByte + PiByte + EiByte +) + +const ( + KByte = Byte * 1000 + MByte = KByte * 1000 + GByte = MByte * 1000 + TByte = GByte * 1000 + PByte = TByte * 1000 + EByte = PByte * 1000 +) + +var unitValueTable = map[string]uint64{ + "b": Byte, + "k": KByte, + "kb": KByte, + "ki": KiByte, + "kib": KiByte, + "m": MByte, + "mb": MByte, + "mi": MiByte, + "mib": MiByte, + "g": GByte, + "gb": GByte, + "gi": GiByte, + "gib": GiByte, + "t": TByte, + "tb": TByte, + "ti": TiByte, + "tib": TiByte, + "p": PByte, + "pb": PByte, + "pi": PiByte, + "pib": PiByte, + "e": EByte, + "eb": EByte, + "ei": EiByte, + "eib": EiByte, +} + +var memoryUnitValueTable = map[string]uint64{ + "b": Byte, + "k": KiByte, + "kb": KiByte, + "m": MiByte, + "mb": MiByte, + "g": GiByte, + "gb": GiByte, + "t": TiByte, + "tb": TiByte, + "p": PiByte, + "pb": PiByte, + "e": EiByte, + "eb": EiByte, +} + +var networkUnitValueTable = map[string]uint64{ + "Bps": Byte, + "Kbps": KByte / 8, + "KBps": KByte, + "Mbps": MByte / 8, + "MBps": MByte, + "Gbps": GByte / 8, + "GBps": GByte, + "Tbps": TByte / 8, + "TBps": TByte, + "Pbps": PByte / 8, + "PBps": PByte, + "Ebps": EByte / 8, + "EBps": EByte, +} + +type rawBytes struct { + value uint64 + unit string + unitValue uint64 +} + +func (b rawBytes) MarshalJSON() ([]byte, error) { + if b.unit == "" { + return json.Marshal(b.value) + } + return json.Marshal(strconv.FormatUint(b.value/b.unitValue, 10) + b.unit) +} + +func parseUnit(b *rawBytes, unitTable map[string]uint64, caseSensitive bool, bytes []byte) error { + var intValue int64 + err := json.Unmarshal(bytes, &intValue) + if err == nil { + b.value = uint64(intValue) + b.unit = "" + b.unitValue = 1 + return nil + } + var stringValue string + err = json.Unmarshal(bytes, &stringValue) + if err != nil { + return err + } + if strings.TrimSpace(stringValue) == "" { + b.value = 0 + b.unit = "" + b.unitValue = 1 + return nil + } + unitIndex := 0 + for i, c := range stringValue { + if c < '0' || c > '9' { + unitIndex = i + break + } + } + if unitIndex == 0 { + return fmt.Errorf("invalid format: %s", stringValue) + } + value, err := strconv.ParseUint(stringValue[:unitIndex], 10, 64) + if err != nil { + return fmt.Errorf("parse %s: %w", stringValue[:unitIndex], err) + } + rawUnit := stringValue[unitIndex:] + var unit string + if caseSensitive { + unit = strings.TrimSpace(rawUnit) + } else { + unit = strings.TrimSpace(strings.ToLower(rawUnit)) + } + unitValue, loaded := unitTable[unit] + if !loaded { + return fmt.Errorf("unsupported unit: %s", rawUnit) + } + b.value = value * unitValue + b.unit = rawUnit + b.unitValue = unitValue + return nil +} + +type Bytes struct { + rawBytes +} + +func (b *Bytes) Value() uint64 { + if b == nil { + return 0 + } + return b.value +} + +func (b *Bytes) UnmarshalJSON(bytes []byte) error { + return parseUnit(&b.rawBytes, unitValueTable, false, bytes) +} + +type MemoryBytes struct { + rawBytes +} + +func (m *MemoryBytes) Value() uint64 { + if m == nil { + return 0 + } + return m.value +} + +func (m *MemoryBytes) UnmarshalJSON(bytes []byte) error { + return parseUnit(&m.rawBytes, memoryUnitValueTable, false, bytes) +} + +type NetworkBytes struct { + rawBytes +} + +func (n *NetworkBytes) Value() uint64 { + if n == nil { + return 0 + } + return n.value +} + +func (n *NetworkBytes) UnmarshalJSON(bytes []byte) error { + return parseUnit(&n.rawBytes, networkUnitValueTable, true, bytes) +} + +type NetworkBytesCompat struct { + rawBytes +} + +func (n *NetworkBytesCompat) Value() uint64 { + if n == nil { + return 0 + } + return n.value +} + +func (n *NetworkBytesCompat) UnmarshalJSON(bytes []byte) error { + err := parseUnit(&n.rawBytes, networkUnitValueTable, true, bytes) + if err != nil { + newErr := parseUnit(&n.rawBytes, unitValueTable, false, bytes) + if newErr == nil { + return nil + } + } + return err +} diff --git a/common/byteformats/json_test.go b/common/byteformats/json_test.go new file mode 100644 index 00000000..edb214b0 --- /dev/null +++ b/common/byteformats/json_test.go @@ -0,0 +1,114 @@ +package byteformats_test + +import ( + "encoding/json" + "testing" + + "github.com/sagernet/sing-box/common/byteformats" + + "github.com/stretchr/testify/require" +) + +func TestNetworkBytes(t *testing.T) { + t.Parallel() + testMap := map[string]uint64{ + "1 Bps": byteformats.Byte, + "1 Kbps": byteformats.KByte / 8, + "1 KBps": byteformats.KByte, + "1 Mbps": byteformats.MByte / 8, + "1 MBps": byteformats.MByte, + "1 Gbps": byteformats.GByte / 8, + "1 GBps": byteformats.GByte, + "1 Tbps": byteformats.TByte / 8, + "1 TBps": byteformats.TByte, + "1 Pbps": byteformats.PByte / 8, + "1 PBps": byteformats.PByte, + "1k": byteformats.KByte, + "1m": byteformats.MByte, + } + for k, v := range testMap { + var nb byteformats.NetworkBytesCompat + require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &nb)) + require.Equal(t, v, nb.Value()) + b, err := json.Marshal(nb) + require.NoError(t, err) + require.Equal(t, "\""+k+"\"", string(b)) + } +} + +func TestMemoryBytes(t *testing.T) { + t.Parallel() + testMap := map[string]uint64{ + "1 B": byteformats.Byte, + "1 KB": byteformats.KiByte, + "1 MB": byteformats.MiByte, + "1 GB": byteformats.GiByte, + "1 TB": byteformats.TiByte, + "1 PB": byteformats.PiByte, + } + for k, v := range testMap { + var mb byteformats.MemoryBytes + require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &mb)) + require.Equal(t, v, mb.Value()) + b, err := json.Marshal(mb) + require.NoError(t, err) + require.Equal(t, "\""+k+"\"", string(b)) + } +} + +func TestDefaultBytes(t *testing.T) { + t.Parallel() + testMap := map[string]uint64{ + "1 B": byteformats.Byte, + "1 KB": byteformats.KByte, + "1 KiB": byteformats.KiByte, + "1 MB": byteformats.MByte, + "1 MiB": byteformats.MiByte, + "1 GB": byteformats.GByte, + "1 GiB": byteformats.GiByte, + "1 TB": byteformats.TByte, + "1 TiB": byteformats.TiByte, + "1 PB": byteformats.PByte, + "1 PiB": byteformats.PiByte, + "1 EB": byteformats.EByte, + "1 EiB": byteformats.EiByte, + "1k": byteformats.KByte, + "1m": byteformats.MByte, + "1g": byteformats.GByte, + "1t": byteformats.TByte, + "1p": byteformats.PByte, + "1e": byteformats.EByte, + "1K": byteformats.KByte, + "1M": byteformats.MByte, + "1G": byteformats.GByte, + "1T": byteformats.TByte, + "1P": byteformats.PByte, + "1E": byteformats.EByte, + "1Ki": byteformats.KiByte, + "1Mi": byteformats.MiByte, + "1Gi": byteformats.GiByte, + "1Ti": byteformats.TiByte, + "1Pi": byteformats.PiByte, + "1Ei": byteformats.EiByte, + "1KiB": byteformats.KiByte, + "1MiB": byteformats.MiByte, + "1GiB": byteformats.GiByte, + "1TiB": byteformats.TiByte, + "1PiB": byteformats.PiByte, + "1EiB": byteformats.EiByte, + "1kB": byteformats.KByte, + "1mB": byteformats.MByte, + "1gB": byteformats.GByte, + "1tB": byteformats.TByte, + "1pB": byteformats.PByte, + "1eB": byteformats.EByte, + } + for k, v := range testMap { + var mb byteformats.Bytes + require.NoError(t, json.Unmarshal([]byte("\""+k+"\""), &mb)) + require.Equal(t, v, mb.Value()) + b, err := json.Marshal(mb) + require.NoError(t, err) + require.Equal(t, "\""+k+"\"", string(b)) + } +} diff --git a/common/process/searcher_darwin_shared.go b/common/process/searcher_darwin_shared.go index 05925530..20129c1a 100644 --- a/common/process/searcher_darwin_shared.go +++ b/common/process/searcher_darwin_shared.go @@ -261,7 +261,8 @@ func getExecPathFromPID(pid uint32) (string, error) { procpidpathinfo, 0, uintptr(unsafe.Pointer(&buf[0])), - procpidpathinfosize) + procpidpathinfosize, + ) if errno != 0 { return "", errno } diff --git a/common/utils.go b/common/utils.go index 9b8a6ae6..a821c003 100644 --- a/common/utils.go +++ b/common/utils.go @@ -2,12 +2,14 @@ package common import ( "encoding/base64" + "encoding/json" "reflect" "regexp" "strconv" "strings" "time" + Xbadoption "github.com/sagernet/sing-box/common/xray/json/badoption" "github.com/sagernet/sing/common/json/badoption" ) @@ -66,3 +68,12 @@ func DecodeBase64URLSafe(content string) (string, error) { } return string(result), nil } + +func ParseXHTTPRange(value string) (Xbadoption.Range, error) { + result := Xbadoption.Range{} + encoded, err := json.Marshal(value) + if err != nil { + return result, err + } + return result, result.UnmarshalJSON(encoded) +} diff --git a/common/xray/buf/multi_buffer.go b/common/xray/buf/multi_buffer.go index 44dd499c..8fe09c81 100644 --- a/common/xray/buf/multi_buffer.go +++ b/common/xray/buf/multi_buffer.go @@ -38,8 +38,8 @@ func MergeMulti(dest MultiBuffer, src MultiBuffer) (MultiBuffer, MultiBuffer) { // MergeBytes merges the given bytes into MultiBuffer and return the new address of the merged MultiBuffer. func MergeBytes(dest MultiBuffer, src []byte) MultiBuffer { n := len(dest) - if n > 0 && !(dest)[n-1].IsFull() { - nBytes, _ := (dest)[n-1].Write(src) + if n > 0 && !dest[n-1].IsFull() { + nBytes, _ := dest[n-1].Write(src) src = src[nBytes:] } diff --git a/common/xray/pipe/pipe.go b/common/xray/pipe/pipe.go index dad26678..06f949fa 100644 --- a/common/xray/pipe/pipe.go +++ b/common/xray/pipe/pipe.go @@ -42,7 +42,7 @@ func New(opts ...Option) (*Reader, *Writer) { } for _, opt := range opts { - opt(&(p.option)) + opt(&p.option) } return &Reader{ diff --git a/common/xray/utils/browser.go b/common/xray/utils/browser.go index 91209f4b..12acde4c 100644 --- a/common/xray/utils/browser.go +++ b/common/xray/utils/browser.go @@ -1,28 +1,256 @@ package utils import ( + "hash/fnv" + "math" "math/rand" + "net/http" "strconv" + "strings" "time" "github.com/klauspost/cpuid/v2" ) -func ChromeVersion() int { - // Use only CPU info as seed for PRNG - seed := int64(cpuid.CPU.Family + cpuid.CPU.Model + cpuid.CPU.PhysicalCores + cpuid.CPU.LogicalCores + cpuid.CPU.CacheLine) - rng := rand.New(rand.NewSource(seed)) - // Start from Chrome 144 released on 2026.1.13 - releaseDate := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC) - version := 144 - now := time.Now() - // Each version has random 25-45 day interval - for releaseDate.Before(now) { - releaseDate = releaseDate.AddDate(0, 0, rng.Intn(21)+25) - version++ - } - return version - 1 +func GetRandomizer() *rand.Rand { + // Seed the PRNG with the hash of CPU info, increasing the overall probable space. + fnvHash := fnv.New64() + fnvHash.Write([]byte(strconv.Itoa(cpuid.CPU.Family) + strconv.Itoa(cpuid.CPU.Model) + strconv.Itoa(cpuid.CPU.PhysicalCores) + strconv.Itoa(cpuid.CPU.LogicalCores) + strconv.Itoa(cpuid.CPU.CacheLine) + strconv.Itoa(cpuid.CPU.ThreadsPerCore))) + return rand.New(rand.NewSource(int64(fnvHash.Sum64()))) } -// ChromeUA provides default browser User-Agent based on CPU-seeded PRNG. -var ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + strconv.Itoa(ChromeVersion()) + ".0.0.0 Safari/537.36" +var globalRng *rand.Rand = GetRandomizer() + +// The Chrome version generator will suffer from deviation of a normal distribution. +func ChromeVersion() int { + // Start from Chrome 144, released on 2026.1.13. + var startVersion int = 144 + var timeStart int64 = time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC).Unix() / 86400 + var timeCurrent int64 = time.Now().Unix() / 86400 + var timeDiff int = int((timeCurrent - timeStart - 35)) - int(math.Floor(math.Pow(globalRng.Float64(), 2)*105)) + return startVersion + (timeDiff / 35) // It's 31.15 currently. +} + +var safariMinorMap [25]int = [25]int{0, 0, 0, 1, 1, + 1, 2, 2, 2, 2, 3, 3, 3, 4, 4, + 4, 5, 5, 5, 5, 5, 6, 6, 6, 6} + +// The following version generators use deterministic generators, but with the distribution scaled by a curve. +func CurlVersion() string { + // curl 8.0.0 was released on 20/03/2023. + var timeCurrent int64 = time.Now().Unix() / 86400 + var timeStart int64 = time.Date(2023, 3, 20, 0, 0, 0, 0, time.UTC).Unix() / 86400 + var timeDiff int = int((timeCurrent - timeStart - 60)) - int(math.Floor(math.Pow(globalRng.Float64(), 2)*165)) + var minorValue int = int(timeDiff / 57) // The release cadence is actually 56.67 days. + return "8." + strconv.Itoa(minorValue) + ".0" +} +func FirefoxVersion() int { + // Firefox 128 ESR was released on 09/07/2023. + var timeCurrent int64 = time.Now().Unix() / 86400 + var timeStart int64 = time.Date(2024, 7, 29, 0, 0, 0, 0, time.UTC).Unix() / 86400 + var timeDiff = timeCurrent - timeStart - 25 - int64(math.Floor(math.Pow(globalRng.Float64(), 2)*50)) + return int(timeDiff/30) + 128 +} +func SafariVersion() string { + var anchoredTime time.Time = time.Now() + var releaseYear int = anchoredTime.Year() + var splitPoint time.Time = time.Date(releaseYear, 9, 23, 0, 0, 0, 0, time.UTC) + var delayedDays = int(math.Floor(math.Pow(globalRng.Float64(), 3) * 75)) + splitPoint = splitPoint.AddDate(0, 0, delayedDays) + if anchoredTime.Compare(splitPoint) < 0 { + releaseYear-- + splitPoint = time.Date(releaseYear, 9, 23, 0, 0, 0, 0, time.UTC) + splitPoint = splitPoint.AddDate(0, 0, delayedDays) + } + var minorVersion = safariMinorMap[(anchoredTime.Unix()-splitPoint.Unix())/1296000] + return strconv.Itoa(releaseYear-1999) + "." + strconv.Itoa(minorVersion) +} + +// The full Chromium brand GREASE implementation +var clientHintGreaseNA = []string{" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"} +var clientHintVersionNA = []string{"8", "99", "24"} +var clientHintShuffle3 = [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} +var clientHintShuffle4 = [][4]int{ + {0, 1, 2, 3}, {0, 1, 3, 2}, {0, 2, 1, 3}, {0, 2, 3, 1}, {0, 3, 1, 2}, {0, 3, 2, 1}, + {1, 0, 2, 3}, {1, 0, 3, 2}, {1, 2, 0, 3}, {1, 2, 3, 0}, {1, 3, 0, 2}, {1, 3, 2, 0}, + {2, 0, 1, 3}, {2, 0, 3, 1}, {2, 1, 0, 3}, {2, 1, 3, 0}, {2, 3, 0, 1}, {2, 3, 1, 0}, + {3, 0, 1, 2}, {3, 0, 2, 1}, {3, 1, 0, 2}, {3, 1, 2, 0}, {3, 2, 0, 1}, {3, 2, 1, 0}} + +func getGreasedChInvalidBrand(seed int) string { + return "\"Not" + clientHintGreaseNA[seed%len(clientHintGreaseNA)] + "A" + clientHintGreaseNA[(seed+1)%len(clientHintGreaseNA)] + "Brand\";v=\"" + clientHintVersionNA[seed%len(clientHintVersionNA)] + "\"" +} +func getGreasedChOrder(brandLength int, seed int) []int { + switch brandLength { + case 1: + return []int{0} + case 2: + return []int{seed % brandLength, (seed + 1) % brandLength} + case 3: + return clientHintShuffle3[seed%len(clientHintShuffle3)][:] + default: + return clientHintShuffle4[seed%len(clientHintShuffle4)][:] + } + //return []int{} +} +func getUngreasedChUa(majorVersion int, forkName string) []string { + // Set the capacity to 4, the maximum allowed brand size, so Go will never allocate memory twice + baseChUa := make([]string, 0, 4) + baseChUa = append(baseChUa, getGreasedChInvalidBrand(majorVersion), + "\"Chromium\";v=\""+strconv.Itoa(majorVersion)+"\"") + switch forkName { + case "chrome": + baseChUa = append(baseChUa, "\"Google Chrome\";v=\""+strconv.Itoa(majorVersion)+"\"") + case "edge": + baseChUa = append(baseChUa, "\"Microsoft Edge\";v=\""+strconv.Itoa(majorVersion)+"\"") + } + return baseChUa +} +func getGreasedChUa(majorVersion int, forkName string) string { + ungreasedCh := getUngreasedChUa(majorVersion, forkName) + shuffleMap := getGreasedChOrder(len(ungreasedCh), majorVersion) + shuffledCh := make([]string, len(ungreasedCh)) + for i, e := range shuffleMap { + shuffledCh[e] = ungreasedCh[i] + } + return strings.Join(shuffledCh, ", ") +} + +// The code below provides a coherent default browser user agent string based on a CPU-seeded PRNG. +var CurlUA = "curl/" + CurlVersion() +var AnchoredFirefoxVersion = strconv.Itoa(FirefoxVersion()) +var FirefoxUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + AnchoredFirefoxVersion + ".0) Gecko/20100101 Firefox/" + AnchoredFirefoxVersion + ".0" +var SafariUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/" + SafariVersion() + " Safari/605.1.15" + +// Chromium browsers. +var AnchoredChromeVersion = ChromeVersion() +var ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + strconv.Itoa(AnchoredChromeVersion) + ".0.0.0 Safari/537.36" +var ChromeUACH = getGreasedChUa(AnchoredChromeVersion, "chrome") +var MSEdgeUA = ChromeUA + "Edg/" + strconv.Itoa(AnchoredChromeVersion) + ".0.0.0" +var MSEdgeUACH = getGreasedChUa(AnchoredChromeVersion, "edge") + +func applyMasqueradedHeaders(header http.Header, browser string, variant string) { + // Browser-specific. + switch browser { + case "chrome": + header["Sec-CH-UA"] = []string{ChromeUACH} + header["Sec-CH-UA-Mobile"] = []string{"?0"} + header["Sec-CH-UA-Platform"] = []string{"\"Windows\""} + header["DNT"] = []string{"1"} + header.Set("User-Agent", ChromeUA) + header.Set("Accept-Language", "en-US,en;q=0.9") + case "edge": + header["Sec-CH-UA"] = []string{MSEdgeUACH} + header["Sec-CH-UA-Mobile"] = []string{"?0"} + header["Sec-CH-UA-Platform"] = []string{"\"Windows\""} + header["DNT"] = []string{"1"} + header.Set("User-Agent", MSEdgeUA) + header.Set("Accept-Language", "en-US,en;q=0.9") + case "firefox": + header.Set("User-Agent", FirefoxUA) + header["DNT"] = []string{"1"} + header.Set("Accept-Language", "en-US,en;q=0.5") + case "safari": + header.Set("User-Agent", SafariUA) + header.Set("Accept-Language", "en-US,en;q=0.9") + case "golang": + // Expose the default net/http header. + header.Del("User-Agent") + return + case "curl": + header.Set("User-Agent", CurlUA) + return + } + // Context-specific. + switch variant { + case "nav": + if header.Get("Cache-Control") == "" { + switch browser { + case "chrome", "edge": + header.Set("Cache-Control", "max-age=0") + } + } + header.Set("Upgrade-Insecure-Requests", "1") + if header.Get("Accept") == "" { + switch browser { + case "chrome", "edge": + header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + case "firefox", "safari": + header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + } + } + header.Set("Sec-Fetch-Site", "none") + header.Set("Sec-Fetch-Mode", "navigate") + switch browser { + case "safari": + default: + header.Set("Sec-Fetch-User", "?1") + } + header.Set("Sec-Fetch-Dest", "document") + header.Set("Priority", "u=0, i") + case "ws": + header.Set("Sec-Fetch-Mode", "websocket") + switch browser { + case "safari": + // Safari is NOT web-compliant here! + header.Set("Sec-Fetch-Dest", "websocket") + default: + header.Set("Sec-Fetch-Dest", "empty") + } + header.Set("Sec-Fetch-Site", "same-origin") + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Pragma") == "" { + header.Set("Pragma", "no-cache") + } + if header.Get("Accept") == "" { + header.Set("Accept", "*/*") + } + case "fetch": + header.Set("Sec-Fetch-Mode", "cors") + header.Set("Sec-Fetch-Dest", "empty") + header.Set("Sec-Fetch-Site", "same-origin") + if header.Get("Priority") == "" { + switch browser { + case "chrome", "edge": + header.Set("Priority", "u=1, i") + case "firefox": + header.Set("Priority", "u=4") + case "safari": + header.Set("Priority", "u=3, i") + } + } + if header.Get("Cache-Control") == "" { + header.Set("Cache-Control", "no-cache") + } + if header.Get("Pragma") == "" { + header.Set("Pragma", "no-cache") + } + if header.Get("Accept") == "" { + header.Set("Accept", "*/*") + } + } +} + +func TryDefaultHeadersWith(header http.Header, variant string) { + // The global UA special value handler for transports. Used to be called HandleTransportUASettings. + // Just a FYI to whoever needing to fix this piece of code after some spontaneous event, I tried to make the two methods separate to let the code be cleaner and more organized. + if len(header.Values("User-Agent")) < 1 { + applyMasqueradedHeaders(header, "chrome", variant) + } else { + switch header.Get("User-Agent") { + case "chrome": + applyMasqueradedHeaders(header, "chrome", variant) + case "firefox": + applyMasqueradedHeaders(header, "firefox", variant) + case "safari": + applyMasqueradedHeaders(header, "safari", variant) + case "edge": + applyMasqueradedHeaders(header, "edge", variant) + case "curl": + applyMasqueradedHeaders(header, "curl", variant) + case "golang": + applyMasqueradedHeaders(header, "golang", variant) + } + } +} diff --git a/constant/dns.go b/constant/dns.go index e4486575..c8ba8543 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -29,6 +29,7 @@ const ( DNSTypeDHCP = "dhcp" DNSTypeTailscale = "tailscale" DNSTypeSDNS = "sdns" + DNSTypeFallback = "fallback" ) const ( diff --git a/constant/manager_api.go b/constant/manager_api.go new file mode 100644 index 00000000..b3edc36d --- /dev/null +++ b/constant/manager_api.go @@ -0,0 +1,9 @@ +package constant + +const ( + ManagerAPIServer = "server" + ManagerAPIClient = "client" + + ManagerAPIProtocolHTTP = "http" + ManagerAPIProtocolGrpc = "grpc" +) diff --git a/constant/node_manager_api.go b/constant/node_manager_api.go new file mode 100644 index 00000000..b8169419 --- /dev/null +++ b/constant/node_manager_api.go @@ -0,0 +1,6 @@ +package constant + +const ( + NodeManagerAPIServer = "server" + NodeManagerAPIClient = "client" +) diff --git a/constant/proxy.go b/constant/proxy.go index d8212baa..1808e80e 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -30,15 +30,17 @@ const ( TypeTUIC = "tuic" TypeHysteria2 = "hysteria2" TypeBond = "bond" + TypeFailover = "failover" TypeVPNServer = "vpn-server" TypeVPNClient = "vpn-client" TypeTailscale = "tailscale" TypeConnectionLimiter = "connection-limiter" TypeBandwidthLimiter = "bandwidth-limiter" TypeTrafficLimiter = "traffic-limiter" + TypeRateLimiter = "rate-limiter" TypeAdminPanel = "admin-panel" - TypeNodeManagerServer = "node-manager-server" - TypeNodeManagerClient = "node-manager-client" + TypeManagerAPI = "manager-api" + TypeNodeManagerAPI = "node-manager-api" TypeDERP = "derp" TypeManager = "manager" TypeNode = "node" @@ -47,6 +49,7 @@ const ( TypeCCM = "ccm" TypeOCM = "ocm" TypeOOMKiller = "oom-killer" + TypeProfiler = "profiler" ) const ( @@ -111,6 +114,8 @@ func ProxyDisplayName(proxyType string) string { return "Hysteria2" case TypeBond: return "Bond" + case TypeFailover: + return "Failover" case TypeMieru: return "Mieru" case TypeAnyTLS: @@ -123,10 +128,20 @@ func ProxyDisplayName(proxyType string) string { return "Selector" case TypeURLTest: return "URLTest" + case TypeConnectionLimiter: + return "Connection Limiter" + case TypeBandwidthLimiter: + return "Bandwidth Limiter" + case TypeTrafficLimiter: + return "Traffic Limiter" + case TypeRateLimiter: + return "Rate Limiter" case TypeVPNClient: return "VPN Client" case TypeVPNServer: return "VPN Server" + case TypeProfiler: + return "Profiler" default: return "Unknown" } diff --git a/daemon/started_service.pb.go b/daemon/started_service.pb.go index 927fb514..f26f2379 100644 --- a/daemon/started_service.pb.go +++ b/daemon/started_service.pb.go @@ -1,13 +1,12 @@ package daemon import ( - reflect "reflect" - sync "sync" - unsafe "unsafe" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" emptypb "google.golang.org/protobuf/types/known/emptypb" + reflect "reflect" + sync "sync" + unsafe "unsafe" ) const ( @@ -1948,43 +1947,40 @@ func file_daemon_started_service_proto_rawDescGZIP() []byte { return file_daemon_started_service_proto_rawDescData } -var ( - file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) - file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) - file_daemon_started_service_proto_goTypes = []any{ - (LogLevel)(0), // 0: daemon.LogLevel - (ConnectionEventType)(0), // 1: daemon.ConnectionEventType - (ServiceStatus_Type)(0), // 2: daemon.ServiceStatus.Type - (*ServiceStatus)(nil), // 3: daemon.ServiceStatus - (*ReloadServiceRequest)(nil), // 4: daemon.ReloadServiceRequest - (*SubscribeStatusRequest)(nil), // 5: daemon.SubscribeStatusRequest - (*Log)(nil), // 6: daemon.Log - (*DefaultLogLevel)(nil), // 7: daemon.DefaultLogLevel - (*Status)(nil), // 8: daemon.Status - (*Groups)(nil), // 9: daemon.Groups - (*Group)(nil), // 10: daemon.Group - (*GroupItem)(nil), // 11: daemon.GroupItem - (*URLTestRequest)(nil), // 12: daemon.URLTestRequest - (*SelectOutboundRequest)(nil), // 13: daemon.SelectOutboundRequest - (*SetGroupExpandRequest)(nil), // 14: daemon.SetGroupExpandRequest - (*ClashMode)(nil), // 15: daemon.ClashMode - (*ClashModeStatus)(nil), // 16: daemon.ClashModeStatus - (*SystemProxyStatus)(nil), // 17: daemon.SystemProxyStatus - (*SetSystemProxyEnabledRequest)(nil), // 18: daemon.SetSystemProxyEnabledRequest - (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest - (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent - (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents - (*Connection)(nil), // 22: daemon.Connection - (*ProcessInfo)(nil), // 23: daemon.ProcessInfo - (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest - (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings - (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning - (*StartedAt)(nil), // 27: daemon.StartedAt - (*Log_Message)(nil), // 28: daemon.Log.Message - (*emptypb.Empty)(nil), // 29: google.protobuf.Empty - } -) - +var file_daemon_started_service_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_daemon_started_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_daemon_started_service_proto_goTypes = []any{ + (LogLevel)(0), // 0: daemon.LogLevel + (ConnectionEventType)(0), // 1: daemon.ConnectionEventType + (ServiceStatus_Type)(0), // 2: daemon.ServiceStatus.Type + (*ServiceStatus)(nil), // 3: daemon.ServiceStatus + (*ReloadServiceRequest)(nil), // 4: daemon.ReloadServiceRequest + (*SubscribeStatusRequest)(nil), // 5: daemon.SubscribeStatusRequest + (*Log)(nil), // 6: daemon.Log + (*DefaultLogLevel)(nil), // 7: daemon.DefaultLogLevel + (*Status)(nil), // 8: daemon.Status + (*Groups)(nil), // 9: daemon.Groups + (*Group)(nil), // 10: daemon.Group + (*GroupItem)(nil), // 11: daemon.GroupItem + (*URLTestRequest)(nil), // 12: daemon.URLTestRequest + (*SelectOutboundRequest)(nil), // 13: daemon.SelectOutboundRequest + (*SetGroupExpandRequest)(nil), // 14: daemon.SetGroupExpandRequest + (*ClashMode)(nil), // 15: daemon.ClashMode + (*ClashModeStatus)(nil), // 16: daemon.ClashModeStatus + (*SystemProxyStatus)(nil), // 17: daemon.SystemProxyStatus + (*SetSystemProxyEnabledRequest)(nil), // 18: daemon.SetSystemProxyEnabledRequest + (*SubscribeConnectionsRequest)(nil), // 19: daemon.SubscribeConnectionsRequest + (*ConnectionEvent)(nil), // 20: daemon.ConnectionEvent + (*ConnectionEvents)(nil), // 21: daemon.ConnectionEvents + (*Connection)(nil), // 22: daemon.Connection + (*ProcessInfo)(nil), // 23: daemon.ProcessInfo + (*CloseConnectionRequest)(nil), // 24: daemon.CloseConnectionRequest + (*DeprecatedWarnings)(nil), // 25: daemon.DeprecatedWarnings + (*DeprecatedWarning)(nil), // 26: daemon.DeprecatedWarning + (*StartedAt)(nil), // 27: daemon.StartedAt + (*Log_Message)(nil), // 28: daemon.Log.Message + (*emptypb.Empty)(nil), // 29: google.protobuf.Empty +} var file_daemon_started_service_proto_depIdxs = []int32{ 2, // 0: daemon.ServiceStatus.status:type_name -> daemon.ServiceStatus.Type 28, // 1: daemon.Log.messages:type_name -> daemon.Log.Message diff --git a/daemon/started_service_grpc.pb.go b/daemon/started_service_grpc.pb.go index 438cca5c..ea01be35 100644 --- a/daemon/started_service_grpc.pb.go +++ b/daemon/started_service_grpc.pb.go @@ -2,7 +2,6 @@ package daemon import ( context "context" - grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" @@ -375,83 +374,63 @@ type UnimplementedStartedServiceServer struct{} func (UnimplementedStartedServiceServer) StopService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method StopService not implemented") } - func (UnimplementedStartedServiceServer) ReloadService(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method ReloadService not implemented") } - func (UnimplementedStartedServiceServer) SubscribeServiceStatus(*emptypb.Empty, grpc.ServerStreamingServer[ServiceStatus]) error { return status.Error(codes.Unimplemented, "method SubscribeServiceStatus not implemented") } - func (UnimplementedStartedServiceServer) SubscribeLog(*emptypb.Empty, grpc.ServerStreamingServer[Log]) error { return status.Error(codes.Unimplemented, "method SubscribeLog not implemented") } - func (UnimplementedStartedServiceServer) GetDefaultLogLevel(context.Context, *emptypb.Empty) (*DefaultLogLevel, error) { return nil, status.Error(codes.Unimplemented, "method GetDefaultLogLevel not implemented") } - func (UnimplementedStartedServiceServer) ClearLogs(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method ClearLogs not implemented") } - func (UnimplementedStartedServiceServer) SubscribeStatus(*SubscribeStatusRequest, grpc.ServerStreamingServer[Status]) error { return status.Error(codes.Unimplemented, "method SubscribeStatus not implemented") } - func (UnimplementedStartedServiceServer) SubscribeGroups(*emptypb.Empty, grpc.ServerStreamingServer[Groups]) error { return status.Error(codes.Unimplemented, "method SubscribeGroups not implemented") } - func (UnimplementedStartedServiceServer) GetClashModeStatus(context.Context, *emptypb.Empty) (*ClashModeStatus, error) { return nil, status.Error(codes.Unimplemented, "method GetClashModeStatus not implemented") } - func (UnimplementedStartedServiceServer) SubscribeClashMode(*emptypb.Empty, grpc.ServerStreamingServer[ClashMode]) error { return status.Error(codes.Unimplemented, "method SubscribeClashMode not implemented") } - func (UnimplementedStartedServiceServer) SetClashMode(context.Context, *ClashMode) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method SetClashMode not implemented") } - func (UnimplementedStartedServiceServer) URLTest(context.Context, *URLTestRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method URLTest not implemented") } - func (UnimplementedStartedServiceServer) SelectOutbound(context.Context, *SelectOutboundRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method SelectOutbound not implemented") } - func (UnimplementedStartedServiceServer) SetGroupExpand(context.Context, *SetGroupExpandRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method SetGroupExpand not implemented") } - func (UnimplementedStartedServiceServer) GetSystemProxyStatus(context.Context, *emptypb.Empty) (*SystemProxyStatus, error) { return nil, status.Error(codes.Unimplemented, "method GetSystemProxyStatus not implemented") } - func (UnimplementedStartedServiceServer) SetSystemProxyEnabled(context.Context, *SetSystemProxyEnabledRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method SetSystemProxyEnabled not implemented") } - func (UnimplementedStartedServiceServer) SubscribeConnections(*SubscribeConnectionsRequest, grpc.ServerStreamingServer[ConnectionEvents]) error { return status.Error(codes.Unimplemented, "method SubscribeConnections not implemented") } - func (UnimplementedStartedServiceServer) CloseConnection(context.Context, *CloseConnectionRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method CloseConnection not implemented") } - func (UnimplementedStartedServiceServer) CloseAllConnections(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method CloseAllConnections not implemented") } - func (UnimplementedStartedServiceServer) GetDeprecatedWarnings(context.Context, *emptypb.Empty) (*DeprecatedWarnings, error) { return nil, status.Error(codes.Unimplemented, "method GetDeprecatedWarnings not implemented") } - func (UnimplementedStartedServiceServer) GetStartedAt(context.Context, *emptypb.Empty) (*StartedAt, error) { return nil, status.Error(codes.Unimplemented, "method GetStartedAt not implemented") } diff --git a/dns/transport/fallback/fallback.go b/dns/transport/fallback/fallback.go new file mode 100644 index 00000000..47d8b0d1 --- /dev/null +++ b/dns/transport/fallback/fallback.go @@ -0,0 +1,72 @@ +package fallback + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.FallbackDNSServerOptions](registry, C.DNSTypeFallback, NewTransport) +} + +var _ adapter.DNSTransport = (*Transport)(nil) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + manager adapter.DNSTransportManager + logger logger.ContextLogger + tags []string + strategy ExchangeStrategy +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.FallbackDNSServerOptions) (adapter.DNSTransport, error) { + if len(options.Servers) == 0 { + return nil, E.New("missing servers") + } + manager := service.FromContext[adapter.DNSTransportManager](ctx) + servers := make([]adapter.DNSTransport, len(options.Servers)) + for i, tag := range options.Servers { + server, loaded := manager.Transport(tag) + if !loaded { + return nil, E.New("server ", tag, " not found") + } + servers[i] = server + } + strategy, err := CreateStrategy(options.Strategy, servers, logger) + if err != nil { + return nil, err + } + return &Transport{ + TransportAdapter: dns.NewTransportAdapter(C.DNSTypeFallback, tag, options.Servers), + ctx: ctx, + logger: logger, + tags: options.Servers, + strategy: strategy, + }, nil +} + +func (t *Transport) Start(stage adapter.StartStage) error { + return nil +} + +func (t *Transport) Close() error { + return nil +} + +func (t *Transport) Reset() { +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + return t.strategy(ctx, message) +} diff --git a/dns/transport/fallback/strategy.go b/dns/transport/fallback/strategy.go new file mode 100644 index 00000000..34580956 --- /dev/null +++ b/dns/transport/fallback/strategy.go @@ -0,0 +1,73 @@ +package fallback + +import ( + "context" + + mDNS "github.com/miekg/dns" + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" +) + +type ExchangeStrategy = func(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) + +func parallelStrategy(servers []adapter.DNSTransport, logger logger.ContextLogger) ExchangeStrategy { + return func(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + queryCtx, cancel := context.WithCancel(ctx) + defer cancel() + type result struct { + response *mDNS.Msg + err error + } + results := make(chan result) + for _, server := range servers { + go func() { + response, err := server.Exchange(queryCtx, message) + select { + case results <- result{response, err}: + case <-queryCtx.Done(): + } + }() + } + var lastErr error + for range servers { + select { + case result := <-results: + if result.err != nil { + lastErr = result.err + continue + } + return result.response, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return nil, lastErr + } +} + +func sequentialStrategy(servers []adapter.DNSTransport, logger logger.ContextLogger) ExchangeStrategy { + return func(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + var lastErr error + for _, server := range servers { + response, err := server.Exchange(ctx, message) + if err != nil { + lastErr = err + continue + } + return response, nil + } + return nil, lastErr + } +} + +func CreateStrategy(strategy string, servers []adapter.DNSTransport, logger logger.ContextLogger) (ExchangeStrategy, error) { + switch strategy { + case "parallel": + return parallelStrategy(servers, logger), nil + case "", "sequential": + return sequentialStrategy(servers, logger), nil + default: + return nil, E.New("strategy not found: ", strategy) + } +} diff --git a/examples/admin_panel-manager-node/client.json b/examples/admin_panel-manager-node/client.json new file mode 100644 index 00000000..a56b08e2 --- /dev/null +++ b/examples/admin_panel-manager-node/client.json @@ -0,0 +1,47 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "vless", + "tag": "vless-out", + "server": "0.0.0.0", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937", + "transport": { + "type": "http" + } + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "vless-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/admin_panel-manager-node/manager.json b/examples/admin_panel-manager-node/manager.json new file mode 100644 index 00000000..f097b775 --- /dev/null +++ b/examples/admin_panel-manager-node/manager.json @@ -0,0 +1,90 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [], + "outbounds": [ + { + "type": "direct", + "tag": "direct-out" + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "direct-out" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "sqlite", + "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" // also supported Postgresql + } + }, + { + "type": "manager-api", + "tag": "my-manager-api", + "api_type": "server", + "protocol_type": "http", + "listen": "0.0.0.0", + "listen_port": 8080, + "manager": "my-manager", + "api_key": "change-me-secret", + "cors": { + "allowed_origins": ["*"], + "max_age": 600 + }, + // Enable TLS for production deployments: + // "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound + // "enabled": true, + // "server_name": "manager.example.com", + // "certificate_path": "fullchain.pem", + // "key_path": "privkey.pem" + // } + }, + { + "type": "node-manager-api", + "tag": "my-node-manager-api", + "api_type": "server", + "listen": "0.0.0.0", + "listen_port": 7000, + "manager": "my-manager", + "api_key": "change-me-secret", + // Enable TLS for production deployments (the node connects via gRPC over h2): + // "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound + // "enabled": true, + // "server_name": "example.com", + // "alpn": "h2", // h3 for QUIC + // "certificate_path": "fullchain.pem", + // "key_path": "privkey.pem" + // } + }, + { + "type": "admin-panel", + "tag": "admin", + "listen": "0.0.0.0", + "listen_port": 8081, + // Enable TLS for production deployments: + // "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound + // "enabled": true, + // "server_name": "panel.example.com", + // "certificate_path": "fullchain.pem", + // "key_path": "privkey.pem" + // } + } + ] +} diff --git a/examples/manager/node.json b/examples/admin_panel-manager-node/node.json similarity index 63% rename from examples/manager/node.json rename to examples/admin_panel-manager-node/node.json index 6b487c81..de59d9c3 100644 --- a/examples/manager/node.json +++ b/examples/admin_panel-manager-node/node.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "debug" }, "dns": { "servers": [ @@ -33,13 +33,29 @@ "route": { "final": "direct-out" } + }, + { + "type": "rate-limiter", + "tag": "rate-limiter", + "strategy": "manager", + "route": { + "final": "bandwidth-limiter" + } }, { "type": "connection-limiter", "tag": "connection-limiter", "strategy": "manager", "route": { - "final": "bandwidth-limiter" + "final": "rate-limiter" + } + }, + { + "type": "traffic-limiter", + "tag": "traffic-limiter", + "strategy": "manager", + "route": { + "final": "connection-limiter" } }, ], @@ -59,19 +75,23 @@ "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", "manager": "node-manager", "inbounds": ["vless-in"], - "bandwidth_limiters": ["bandwidth-limiter"], "connection_limiters": ["connection-limiter"], + "bandwidth_limiters": ["bandwidth-limiter"], + "traffic_limiters": ["traffic-limiter"], + "rate_limiters": ["rate-limiter"] }, { - "type": "node-manager-client", + "type": "node-manager-api", "tag": "node-manager", + "api_type": "client", "server": "example.com", "server_port": 7000, - "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#outbound - "enabled": true, - "server_name": "example.com", - "alpn": "h2" // h3 for QUIC - }, + "api_key": "change-me-secret", + // "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#outbound + // "enabled": true, + // "server_name": "example.com", + // "alpn": "h2" // h3 for QUIC + // } } ] } diff --git a/examples/admin_panel-manager-node/screens/desktop/00-login.png b/examples/admin_panel-manager-node/screens/desktop/00-login.png new file mode 100644 index 00000000..46f5c5a7 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/00-login.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/01-dashboard.png b/examples/admin_panel-manager-node/screens/desktop/01-dashboard.png new file mode 100644 index 00000000..ed3726f8 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/01-dashboard.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/02-squads.png b/examples/admin_panel-manager-node/screens/desktop/02-squads.png new file mode 100644 index 00000000..c46ae09e Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/02-squads.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/03-nodes.png b/examples/admin_panel-manager-node/screens/desktop/03-nodes.png new file mode 100644 index 00000000..8482e75e Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/03-nodes.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/04-users.png b/examples/admin_panel-manager-node/screens/desktop/04-users.png new file mode 100644 index 00000000..f43f0c64 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/04-users.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/05-bandwidth-limiters.png b/examples/admin_panel-manager-node/screens/desktop/05-bandwidth-limiters.png new file mode 100644 index 00000000..2ee299d1 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/05-bandwidth-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/06-traffic-limiters.png b/examples/admin_panel-manager-node/screens/desktop/06-traffic-limiters.png new file mode 100644 index 00000000..006eee0f Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/06-traffic-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/07-connection-limiters.png b/examples/admin_panel-manager-node/screens/desktop/07-connection-limiters.png new file mode 100644 index 00000000..dc3651a9 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/07-connection-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/desktop/08-rate-limiters.png b/examples/admin_panel-manager-node/screens/desktop/08-rate-limiters.png new file mode 100644 index 00000000..af938809 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/desktop/08-rate-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/00-login.png b/examples/admin_panel-manager-node/screens/mobile/00-login.png new file mode 100644 index 00000000..aedf5739 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/00-login.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/01-dashboard.png b/examples/admin_panel-manager-node/screens/mobile/01-dashboard.png new file mode 100644 index 00000000..e4894a57 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/01-dashboard.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/02-squads.png b/examples/admin_panel-manager-node/screens/mobile/02-squads.png new file mode 100644 index 00000000..e9145a81 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/02-squads.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/03-nodes.png b/examples/admin_panel-manager-node/screens/mobile/03-nodes.png new file mode 100644 index 00000000..0e5a8023 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/03-nodes.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/04-users.png b/examples/admin_panel-manager-node/screens/mobile/04-users.png new file mode 100644 index 00000000..61bc994e Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/04-users.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/05-bandwidth-limiters.png b/examples/admin_panel-manager-node/screens/mobile/05-bandwidth-limiters.png new file mode 100644 index 00000000..2632974b Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/05-bandwidth-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/06-traffic-limiters.png b/examples/admin_panel-manager-node/screens/mobile/06-traffic-limiters.png new file mode 100644 index 00000000..4cc0e673 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/06-traffic-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/07-connection-limiters.png b/examples/admin_panel-manager-node/screens/mobile/07-connection-limiters.png new file mode 100644 index 00000000..2f169e4c Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/07-connection-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/08-rate-limiters.png b/examples/admin_panel-manager-node/screens/mobile/08-rate-limiters.png new file mode 100644 index 00000000..1ff59617 Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/08-rate-limiters.png differ diff --git a/examples/admin_panel-manager-node/screens/mobile/09-nav-open.png b/examples/admin_panel-manager-node/screens/mobile/09-nav-open.png new file mode 100644 index 00000000..bbfc59fa Binary files /dev/null and b/examples/admin_panel-manager-node/screens/mobile/09-nav-open.png differ diff --git a/examples/amnezia/client.json b/examples/amnezia/client.json index c99bb515..d41691b1 100644 --- a/examples/amnezia/client.json +++ b/examples/amnezia/client.json @@ -15,13 +15,15 @@ "type": "wireguard", "tag": "wireguard-out", "mtu": 1408, - "address": null, - "private_key": "", + "address": ["10.0.0.2/32"], + "private_key": "QGg8AFRn6qKfTB7cT3FWH1WGx3np+OKzlNuQUrqIBmI=", "listen_port": 10000, "peers": [ { "address": "example.com", "port": 10001, + "public_key": "3nk7jdnkcL95Fc/z+GCiH7jOovEKhFkLIGPT+U/uLEQ=", + "allowed_ips": ["0.0.0.0/0"], "reserved": "AAAA" } ], diff --git a/examples/dns_fallback/client.json b/examples/dns_fallback/client.json new file mode 100644 index 00000000..4d2aaf8a --- /dev/null +++ b/examples/dns_fallback/client.json @@ -0,0 +1,62 @@ +{ + "log": { + "level": "debug" + }, + "dns": { + "servers": [ + { + "type": "udp", + "tag": "dns-cloudflare", + "server": "1.2.3.4" + }, + { + "type": "udp", + "tag": "dns-google", + "server": "1.2.3.4" + }, + { + "type": "https", + "tag": "dns-quad9-doh", + "server": "1.1.1.1" + }, + { + "type": "fallback", + "tag": "dns-fallback", + "servers": [ + "dns-cloudflare", + "dns-google", + "dns-quad9-doh" + ], + // Strategies: + // - "sequential" (default): query servers in order; on each error move + // to the next one. Returns the first successful response, or the + // last error if all servers failed. + // - "parallel": query all servers concurrently. Returns + // the first successful response (cancelling the rest), or the last + // error if all servers failed. + "strategy": "sequential" + } + ], + "disable_cache": true, + "independent_cache": true, + "final": "dns-fallback" + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "dns-fallback", + "auto_detect_interface": true + } +} diff --git a/examples/failover/client.json b/examples/failover/client.json new file mode 100644 index 00000000..c85c5a2b --- /dev/null +++ b/examples/failover/client.json @@ -0,0 +1,65 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "failover", + "tag": "failover-out", + // - "sequential" (default): try outbounds in order; return the last error + // after exhausting them all. + // - "cycle": keep retrying outbounds in round-robin forever + // (useful for transient network outages on user devices). + "strategy": "cycle", + "delay": "2s", // wait between failed attempts; 0 = no delay + "outbounds": [ + { + "type": "vless", + "tag": "vless-primary", + "server": "primary.example.com", + "server_port": 443, + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "type": "trojan", + "tag": "trojan-secondary", + "server": "secondary.example.com", + "server_port": 443, + "password": "trojan-password" + }, + { + "type": "shadowsocks", + "tag": "ss-tertiary", + "server": "tertiary.example.com", + "server_port": 8388, + "method": "aes-128-gcm", + "password": "ss-password" + } + ] + } + ], + "route": { + "final": "failover-out", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/failover/server.json b/examples/failover/server.json new file mode 100644 index 00000000..2eb3b3b9 --- /dev/null +++ b/examples/failover/server.json @@ -0,0 +1,58 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + // The "failover" inbound wraps several listeners. If any of them + // panics or fails to accept, the parent supervises and restarts it + // automatically without affecting the rest of the box. + "type": "failover", + "inbounds": [ + { + "type": "mixed", + "tag": "socks-in-1", + "listen": "0.0.0.0", + "listen_port": 10001 + }, + { + "type": "mixed", + "tag": "socks-in-2", + "listen": "0.0.0.0", + "listen_port": 10002 + }, + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 8443, + "users": [ + { + "name": "user", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + } + ] + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "final": "direct", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/bandwidth_limiter/connection.json b/examples/limiters/bandwidth/connection.json similarity index 88% rename from examples/bandwidth_limiter/connection.json rename to examples/limiters/bandwidth/connection.json index fb39f24d..41e09a18 100644 --- a/examples/bandwidth_limiter/connection.json +++ b/examples/limiters/bandwidth/connection.json @@ -40,9 +40,10 @@ "type": "bandwidth-limiter", "tag": "bandwidth-limiter", "strategy": "connection", - "mode": "duplex", // download, upload + "mode": "bidirectional", // download, upload "connection_type": "hwid", // mux, ip "speed": "1MB", // 100KB, 1GB, etc. + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], "final": "direct" diff --git a/examples/limiters/bandwidth/global.json b/examples/limiters/bandwidth/global.json new file mode 100644 index 00000000..f43ab0d6 --- /dev/null +++ b/examples/limiters/bandwidth/global.json @@ -0,0 +1,43 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "socks", + "tag": "socks-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + "strategy": "global", + "mode": "bidirectional", // download, upload + "speed": "2MB", // 100KB, 1GB, etc. + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "bandwidth-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/limiters/bandwidth/manager.json b/examples/limiters/bandwidth/manager.json new file mode 100644 index 00000000..fa01a93a --- /dev/null +++ b/examples/limiters/bandwidth/manager.json @@ -0,0 +1,69 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "bandwidth-limiter", + "tag": "bandwidth-limiter", + // "manager" strategy: per-user bandwidth limits are loaded from the + // manager database and updated live (no need to list users in this file). + "strategy": "manager", + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux + "route": { + "rules": [], + "final": "direct" + } + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "bandwidth-limiter" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "sqlite", + "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" + } + }, + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "my-manager", + "inbounds": ["vless-in"], + "bandwidth_limiters": ["bandwidth-limiter"] + } + ] +} diff --git a/examples/bandwidth_limiter/multi.json b/examples/limiters/bandwidth/multi.json similarity index 80% rename from examples/bandwidth_limiter/multi.json rename to examples/limiters/bandwidth/multi.json index f9665843..d12bc782 100644 --- a/examples/bandwidth_limiter/multi.json +++ b/examples/limiters/bandwidth/multi.json @@ -38,10 +38,11 @@ }, { "type": "bandwidth-limiter", - "tag": "duplex-bandwidth-limiter", + "tag": "bidirectional-bandwidth-limiter", "strategy": "global", - "mode": "duplex", + "mode": "bidirectional", "speed": "5MB", + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], "final": "direct" @@ -53,9 +54,10 @@ "strategy": "global", "mode": "upload", "speed": "3MB", + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], - "final": "duplex-bandwidth-limiter" + "final": "bidirectional-bandwidth-limiter" } }, { @@ -64,6 +66,7 @@ "strategy": "global", "mode": "download", "speed": "3MB", + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], "final": "upload-bandwidth-limiter" diff --git a/examples/bandwidth_limiter/users.json b/examples/limiters/bandwidth/users.json similarity index 81% rename from examples/bandwidth_limiter/users.json rename to examples/limiters/bandwidth/users.json index dbbef6c1..75b98a3a 100644 --- a/examples/bandwidth_limiter/users.json +++ b/examples/limiters/bandwidth/users.json @@ -40,21 +40,22 @@ "type": "bandwidth-limiter", "tag": "bandwidth-limiter", "strategy": "users", + "flow_keys": ["user", "destination"], // values: user, destination, ip, hwid, mux "users": [ { "name": "user1", "strategy": "connection", // global - "mode": "duplex", // download, upload + "mode": "bidirectional", // download, upload "connection_type": "hwid", // mux, ip - "speed": "5MB", // 100KB, 1GB, etc. + "speed": "5MB" // 100KB, 1GB, etc. }, { "name": "user2", "strategy": "connection", // global - "mode": "duplex", // download, upload + "mode": "bidirectional", // download, upload "connection_type": "hwid", // mux, ip - "speed": "1MB", // 100KB, 1GB, etc. - }, + "speed": "1MB" // 100KB, 1GB, etc. + } ], "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], diff --git a/examples/connection_limiter/connection.json b/examples/limiters/connection/connection.json similarity index 95% rename from examples/connection_limiter/connection.json rename to examples/limiters/connection/connection.json index 0b274ffd..12d364fc 100644 --- a/examples/connection_limiter/connection.json +++ b/examples/limiters/connection/connection.json @@ -40,7 +40,7 @@ "type": "connection-limiter", "tag": "connection-limiter", "strategy": "connection", - "connection_type": "hwid", // mux, ip + "connection_type": "hwid", // mux, source_ip "count": 5, "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], diff --git a/examples/limiters/connection/manager.json b/examples/limiters/connection/manager.json new file mode 100644 index 00000000..52164c1d --- /dev/null +++ b/examples/limiters/connection/manager.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "connection-limiter", + "tag": "connection-limiter", + // "manager" strategy: per-user connection caps are loaded from the + // manager database and updated live (no need to list users in this file). + "strategy": "manager", + "route": { + "rules": [], + "final": "direct" + } + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "connection-limiter" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "sqlite", + "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" + } + }, + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "my-manager", + "inbounds": ["vless-in"], + "connection_limiters": ["connection-limiter"] + } + ] +} diff --git a/examples/connection_limiter/users.json b/examples/limiters/connection/users.json similarity index 88% rename from examples/connection_limiter/users.json rename to examples/limiters/connection/users.json index 7ade7e20..dc354e5e 100644 --- a/examples/connection_limiter/users.json +++ b/examples/limiters/connection/users.json @@ -44,15 +44,15 @@ { "name": "user1", "strategy": "connection", - "connection_type": "hwid", // mux, ip - "count": 5, + "connection_type": "hwid", // mux, source_ip + "count": 5 }, { "name": "user2", "strategy": "connection", - "connection_type": "hwid", // mux, ip - "count": 1, - }, + "connection_type": "hwid", // mux, source_ip + "count": 1 + } ], "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], diff --git a/examples/bandwidth_limiter/global.json b/examples/limiters/rate/connection.json similarity index 70% rename from examples/bandwidth_limiter/global.json rename to examples/limiters/rate/connection.json index 759d1f77..ce331728 100644 --- a/examples/bandwidth_limiter/global.json +++ b/examples/limiters/rate/connection.json @@ -23,10 +23,6 @@ { "name": "user1", "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" - }, - { - "name": "user2", - "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" } ] } @@ -37,11 +33,12 @@ "tag": "direct" }, { - "type": "bandwidth-limiter", - "tag": "bandwidth-limiter", - "strategy": "global", - "mode": "duplex", // download, upload - "speed": "1MB", // 100KB, 1GB, etc. + "type": "rate-limiter", + "tag": "rate-limiter", + "strategy": "token-bucket", // leaky-bucket, sliding-window, fixed-window + "connection_type": "hwid", // mux, source_ip + "count": 20, // max requests per interval per connection + "interval": "1s", "route": { // https://sing-box.sagernet.org/configuration/route/#structure "rules": [], "final": "direct" @@ -49,7 +46,7 @@ } ], "route": { - "final": "bandwidth-limiter", + "final": "rate-limiter", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/limiters/rate/global.json b/examples/limiters/rate/global.json new file mode 100644 index 00000000..a87b36cd --- /dev/null +++ b/examples/limiters/rate/global.json @@ -0,0 +1,42 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "socks", + "tag": "socks-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "rate-limiter", + "tag": "rate-limiter", + "strategy": "leaky-bucket", // token-bucket, sliding-window, fixed-window + "count": 10, // max requests per interval + "interval": "1s", // time window + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "rate-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/limiters/rate/manager.json b/examples/limiters/rate/manager.json new file mode 100644 index 00000000..d55cc089 --- /dev/null +++ b/examples/limiters/rate/manager.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "rate-limiter", + "tag": "rate-limiter", + // "manager" strategy: per-user rate limits are loaded from the + // manager database and updated live (no need to list users in this file). + "strategy": "manager", + "route": { + "rules": [], + "final": "direct" + } + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "rate-limiter" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "sqlite", + "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" + } + }, + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "my-manager", + "inbounds": ["vless-in"], + "rate_limiters": ["rate-limiter"] + } + ] +} diff --git a/examples/limiters/rate/users.json b/examples/limiters/rate/users.json new file mode 100644 index 00000000..2f09ae77 --- /dev/null +++ b/examples/limiters/rate/users.json @@ -0,0 +1,70 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "6c8c7ffc-a909-4699-af34-e9d9bcb3e6d6" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "rate-limiter", + "tag": "rate-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "leaky-bucket", // token-bucket, sliding-window, fixed-window + "connection_type": "hwid", // mux, source_ip + "count": 30, + "interval": "1s" + }, + { + "name": "user2", + "strategy": "sliding-window", + "connection_type": "source_ip", + "count": 5, + "interval": "1s" + } + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "rate-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/limiters/traffic/global.json b/examples/limiters/traffic/global.json new file mode 100644 index 00000000..001280bb --- /dev/null +++ b/examples/limiters/traffic/global.json @@ -0,0 +1,42 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "socks", + "tag": "socks-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "traffic-limiter", + "tag": "traffic-limiter", + "strategy": "global", + "mode": "bidirectional", // download, upload + "total": "10GB", // 100MB, 1TB, etc. + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "traffic-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/limiters/traffic/manager.json b/examples/limiters/traffic/manager.json new file mode 100644 index 00000000..89758864 --- /dev/null +++ b/examples/limiters/traffic/manager.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + } + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "traffic-limiter", + "tag": "traffic-limiter", + // "manager" strategy: per-user traffic quotas are loaded from the + // manager database and updated live (no need to list users in this file). + "strategy": "manager", + "route": { + "rules": [], + "final": "direct" + } + } + ], + "route": { + "rules": [ + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "traffic-limiter" + }, + "services": [ + { + "type": "manager", + "tag": "my-manager", + "database": { + "driver": "sqlite", + "dsn": "file:manager.db?_pragma=foreign_keys(on)&_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)" + } + }, + { + "type": "node", + "tag": "my-node", + "uuid": "e6eceb84-ad66-474b-8641-142499db7c6e", + "manager": "my-manager", + "inbounds": ["vless-in"], + "traffic_limiters": ["traffic-limiter"] + } + ] +} diff --git a/examples/limiters/traffic/users.json b/examples/limiters/traffic/users.json new file mode 100644 index 00000000..a2970b82 --- /dev/null +++ b/examples/limiters/traffic/users.json @@ -0,0 +1,68 @@ +{ + "log": { + "level": "error" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "vless", + "tag": "vless-in", + "listen": "0.0.0.0", + "listen_port": 443, + "transport": { + "type": "http" + }, + "users": [ + { + "name": "user1", + "uuid": "9b65b7e1-04c8-4717-8f45-2aa61fd25937" + }, + { + "name": "user2", + "uuid": "6c8c7ffc-a909-4699-af34-e9d9bcb3e6d6" + } + ] + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "traffic-limiter", + "tag": "traffic-limiter", + "strategy": "users", + "users": [ + { + "name": "user1", + "strategy": "global", + "mode": "bidirectional", // download, upload + "total": "100GB" // 100MB, 1TB, etc. + }, + { + "name": "user2", + "strategy": "global", + "mode": "download", + "total": "10GB" + } + ], + "route": { // https://sing-box.sagernet.org/configuration/route/#structure + "rules": [], + "final": "direct" + } + } + ], + "route": { + "final": "traffic-limiter", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/manager/manager.json b/examples/manager/manager.json deleted file mode 100644 index f13553f0..00000000 --- a/examples/manager/manager.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "log": { - "level": "error" - }, - "dns": { - "servers": [ - { - "type": "local", - "tag": "default" - } - ] - }, - "inbounds": [], - "outbounds": [ - { - "type": "direct", - "tag": "direct-out" - } - ], - "route": { - "rules": [ - { - "protocol": "dns", - "action": "hijack-dns" - } - ], - "final": "direct-out" - }, - "services": [ - { - "type": "manager", - "tag": "my-manager", - "database": { - "driver": "postgresql", - "dsn": "postgresql://postgres:postgres@localhost:5432/manager?sslmode=disable" - } - }, - { // http://127.0.0.1:8000 - // Username: admin - // Password: admin - "type": "admin-panel", - "tag": "my-admin-panel", - "listen_port": 8000, - "manager": "my-manager", - "database": { - "driver": "postgresql", - "dsn": "postgresql://postgres:postgres@localhost:5432/adminpanel?sslmode=disable" - } - }, - { - "type": "node-manager-server", // for connecting nodes - "listen_port": 7000, - "manager": "my-manager", - "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#inbound - "enabled": true, - "server_name": "example.com", - "certificate_path": "/path/to/fullchain.pem", - "key_path": "/path/to/privkey.pem" - }, - } - ] -} diff --git a/examples/masque/client.json b/examples/masque/client.json index cd248287..410d1cd2 100644 --- a/examples/masque/client.json +++ b/examples/masque/client.json @@ -37,16 +37,14 @@ "udp_keepalive_period": "30s", "udp_initial_packet_size": 0, "reconnect_delay": "5s", - "tls": { // https://sing-box.sagernet.org/configuration/shared/tls/#fields - "insecure": false, - "cipher_suites": [], - "curve_preferences": [], - "fragment": false, - "fragment_fallback_delay": "", - "record_fragment": false, - "kernel_tx": false, - "kernel_rx": false, - } + // TLS fields for HTTP2 + "insecure": false, + "cipher_suites": [], + "curve_preferences": [], + "fragment": false, + "record_fragment": false, + "kernel_tx": false, + "kernel_rx": false // Dial Fields } ], diff --git a/examples/mieru/client.json b/examples/mieru/client.json index e4dc36b9..4a6b672c 100644 --- a/examples/mieru/client.json +++ b/examples/mieru/client.json @@ -31,6 +31,8 @@ "transport": "TCP", "username": "username", "password": "password", + // valid: MULTIPLEXING_DEFAULT / MULTIPLEXING_OFF / MULTIPLEXING_LOW + // MULTIPLEXING_MIDDLE / MULTIPLEXING_HIGH "multiplexing": "MULTIPLEXING_LOW" // Dial Fields } diff --git a/examples/mtproxy/server.json b/examples/mtproxy/server.json index 074f00f4..5f4de8e8 100644 --- a/examples/mtproxy/server.json +++ b/examples/mtproxy/server.json @@ -1,6 +1,6 @@ { "log": { - "level": "error" + "level": "info" }, "dns": { "servers": [ @@ -73,10 +73,17 @@ { "type": "direct", "tag": "direct" + }, + { + "type": "socks", + "tag": "socks-out", + "server": "192.168.1.1", + "server_port": 17085, + "version": "5" } ], "route": { - "final": "direct", + "final": "socks-out", "default_domain_resolver": "default", "auto_detect_interface": true } diff --git a/examples/provider/inline.json b/examples/provider/inline.json new file mode 100644 index 00000000..bf48ee79 --- /dev/null +++ b/examples/provider/inline.json @@ -0,0 +1,91 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + { + "type": "selector", + "tag": "proxy", + "outbounds": [ + "direct" + ], + "providers": [ + "my-inline" + ], + "default": "direct", + "interrupt_exist_connections": true + } + ], + "providers": [ + { + "type": "inline", + "tag": "my-inline", + // Outbounds are listed in-place. They are registered with the + // provider on start and become available to any group that + // references this provider via "providers" or + // "use_all_providers". + "outbounds": [ + { + "type": "shadowsocks", + "tag": "ss-hk", + "server": "hk.example.com", + "server_port": 8388, + "method": "aes-256-gcm", + "password": "password" + }, + { + "type": "trojan", + "tag": "trojan-jp", + "server": "jp.example.com", + "server_port": 443, + "password": "password", + "tls": { + "enabled": true, + "server_name": "jp.example.com" + } + }, + { + "type": "vless", + "tag": "vless-sg", + "server": "sg.example.com", + "server_port": 443, + "uuid": "00000000-0000-0000-0000-000000000000", + "tls": { + "enabled": true, + "server_name": "sg.example.com" + } + } + ], + "health_check": { + "enabled": true, + "url": "https://www.gstatic.com/generate_204", + "interval": "5m", + "timeout": "5s" + } + } + ], + "route": { + "final": "proxy", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/provider/local.json b/examples/provider/local.json new file mode 100644 index 00000000..00b7f136 --- /dev/null +++ b/examples/provider/local.json @@ -0,0 +1,64 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + // A "selector" group that pulls all of its members from the + // outbound provider tagged "my-local". The provider parses the + // file at "path" on every change (fswatch-based hot reload). + { + "type": "selector", + "tag": "proxy", + "outbounds": [ + "direct" + ], + "providers": [ + "my-local" + ], + "default": "direct", + "interrupt_exist_connections": true + } + ], + "providers": [ + { + "type": "local", + "tag": "my-local", + // Subscription file. Supported formats are auto-detected: + // - sing-box JSON ({ "outbounds": [...] }) + // - Clash YAML + // - SIP008 (shadowsocks) + // - Raw shareable links (vless://, vmess://, ss://, trojan://, ...) + "path": "subscriptions/my-sub.txt", + "health_check": { + "enabled": true, + "url": "https://www.gstatic.com/generate_204", + "interval": "5m", + "timeout": "5s" + } + } + ], + "route": { + "final": "proxy", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/provider/remote.json b/examples/provider/remote.json new file mode 100644 index 00000000..ce8a4018 --- /dev/null +++ b/examples/provider/remote.json @@ -0,0 +1,72 @@ +{ + "log": { + "level": "info" + }, + "dns": { + "servers": [ + { + "type": "local", + "tag": "default" + } + ] + }, + "inbounds": [ + { + "type": "mixed", + "tag": "mixed-in", + "listen_port": 7897 + } + ], + "outbounds": [ + { + "type": "direct", + "tag": "direct" + }, + // The "urltest" group below uses every outbound exposed by every + // registered provider thanks to "use_all_providers": true. + // The fastest member is selected based on the URL test result. + { + "type": "urltest", + "tag": "auto", + "outbounds": [ + "direct" + ], + "use_all_providers": true, + "url": "https://www.gstatic.com/generate_204", + "interval": "10m", + "tolerance": 50, + "interrupt_exist_connections": true + } + ], + "providers": [ + { + "type": "remote", + "tag": "my-remote", + // Subscription URL. The response body can be any auto-detected + // format: sing-box JSON, Clash YAML, SIP008, base64-encoded + // shareable links, or plain link list. + "url": "https://example.com/subscription.txt", + "user_agent": "sing-box", + // Fetch the subscription through this outbound instead of the + // default route (useful when the subscription host is blocked). + "download_detour": "direct", + // Refresh interval. Minimum is 1 minute; default is 24h. + "update_interval": "12h", + // Optional regex filters applied after parsing the subscription. + // "exclude" wins over "include" when both match. + "exclude": "(?i)expire|ęµé‡|å®˜ē½‘", + "include": "(?i)hk|jp|sg|us", + "health_check": { + "enabled": true, + "url": "https://www.gstatic.com/generate_204", + "interval": "5m", + "timeout": "5s" + } + } + ], + "route": { + "final": "auto", + "default_domain_resolver": "default", + "auto_detect_interface": true + } +} diff --git a/examples/wireguard/client.json b/examples/wireguard/client.json index 74e1ece7..d791c15b 100644 --- a/examples/wireguard/client.json +++ b/examples/wireguard/client.json @@ -15,20 +15,22 @@ "type": "wireguard", "tag": "wireguard-out", "mtu": 1408, - "address": null, - "private_key": "", + "address": ["10.0.0.2/32"], + "private_key": "QGg8AFRn6qKfTB7cT3FWH1WGx3np+OKzlNuQUrqIBmI=", "listen_port": 10000, "peers": [ { "address": "example.com", "port": 10001, + "public_key": "3nk7jdnkcL95Fc/z+GCiH7jOovEKhFkLIGPT+U/uLEQ=", + "allowed_ips": ["0.0.0.0/0"], "reserved": "AAAA" } ], "udp_timeout": "5m0s", // Extended options "preallocated_buffers_per_pool": 256, // Set limit for preallocated buffers (can be useful for devices with low RAM) - "disable_pauses": true, // Disable pauses when android device in sleep mode + "disable_pauses": true // Disable pauses when android device in sleep mode } ], "inbounds": [ diff --git a/examples/xhttp/client.json b/examples/xhttp/client.json index 4a7b40d5..9e53beb2 100644 --- a/examples/xhttp/client.json +++ b/examples/xhttp/client.json @@ -35,9 +35,10 @@ }, "transport": { "type": "xhttp", - "mode": "stream-up", + "mode": "stream-up", // packet-up, stream-one "host": "example.com", "path": "/xhttp", + "headers": {}, "domain_strategy": "prefer_ipv4", "x_padding_bytes": "100-1000", "no_grpc_header": false, // stream-up/one, client only @@ -69,6 +70,7 @@ "download": { "host": "example.com", "path": "/xhttp", + "headers": {}, "domain_strategy": "prefer_ipv4", "x_padding_bytes": "100-1000", "no_grpc_header": false, // stream-up/one, client only diff --git a/examples/xhttp/server.json b/examples/xhttp/server.json index c833c62d..4e543909 100644 --- a/examples/xhttp/server.json +++ b/examples/xhttp/server.json @@ -26,31 +26,31 @@ "enabled": true, "server_name": "example.com", "alpn": "h2", // h3 for QUIC - "certificate_path": "/path/to/fullchain.pem", - "key_path": "/path/to/privkey.pem" }, "transport": { "type": "xhttp", - "mode": "stream-up", + "mode": "stream-up", // packet-up, stream-one + "host": "", "path": "/xhttp", + "headers": {}, "x_padding_bytes": "100-1000", "no_sse_header": false, // server only "sc_max_each_post_bytes": 1000000, // packet-up only "sc_max_buffered_posts": 30, // packet-up, server only "sc_stream_up_server_secs": "20-80", // stream-up, server only + "server_max_header_bytes": 8192, + "trusted_x_forwarded_for": [], "x_padding_obfs_mode": false, "x_padding_key": "", "x_padding_header": "", "x_padding_placement": "", "x_padding_method": "", - "uplink_http_method": "", "session_placement": "", "session_key": "", "seq_placement": "", "seq_key": "", "uplink_data_placement": "", "uplink_data_key": "", - "uplink_chunk_size": 0, } } ], diff --git a/experimental/cachefile/cache.go b/experimental/cachefile/cache.go index 24eb5112..c97ba500 100644 --- a/experimental/cachefile/cache.go +++ b/experimental/cachefile/cache.go @@ -145,7 +145,7 @@ func (c *CacheFile) Start(stage adapter.StartStage) error { if name[0] == 0 { return b.ForEachBucket(func(k []byte) error { bucketName := string(k) - if !(common.Contains(bucketNameList, bucketName)) { + if !common.Contains(bucketNameList, bucketName) { _ = b.DeleteBucket(name) } return nil diff --git a/experimental/libbox/service.go b/experimental/libbox/service.go index 37fd56c9..61ec98b1 100644 --- a/experimental/libbox/service.go +++ b/experimental/libbox/service.go @@ -171,7 +171,7 @@ func (w *platformInterfaceWrapper) ReadWIFIState() adapter.WIFIState { if wifiState == nil { return adapter.WIFIState{} } - return (adapter.WIFIState)(*wifiState) + return adapter.WIFIState(*wifiState) } func (w *platformInterfaceWrapper) SystemCertificates() []string { diff --git a/experimental/v2rayapi/stats.pb.go b/experimental/v2rayapi/stats.pb.go index 586b9a7f..1fc3c826 100644 --- a/experimental/v2rayapi/stats.pb.go +++ b/experimental/v2rayapi/stats.pb.go @@ -1,12 +1,11 @@ package v2rayapi import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" - - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) const ( @@ -484,19 +483,16 @@ func file_experimental_v2rayapi_stats_proto_rawDescGZIP() []byte { return file_experimental_v2rayapi_stats_proto_rawDescData } -var ( - file_experimental_v2rayapi_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 7) - file_experimental_v2rayapi_stats_proto_goTypes = []any{ - (*GetStatsRequest)(nil), // 0: experimental.v2rayapi.GetStatsRequest - (*Stat)(nil), // 1: experimental.v2rayapi.Stat - (*GetStatsResponse)(nil), // 2: experimental.v2rayapi.GetStatsResponse - (*QueryStatsRequest)(nil), // 3: experimental.v2rayapi.QueryStatsRequest - (*QueryStatsResponse)(nil), // 4: experimental.v2rayapi.QueryStatsResponse - (*SysStatsRequest)(nil), // 5: experimental.v2rayapi.SysStatsRequest - (*SysStatsResponse)(nil), // 6: experimental.v2rayapi.SysStatsResponse - } -) - +var file_experimental_v2rayapi_stats_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_experimental_v2rayapi_stats_proto_goTypes = []any{ + (*GetStatsRequest)(nil), // 0: experimental.v2rayapi.GetStatsRequest + (*Stat)(nil), // 1: experimental.v2rayapi.Stat + (*GetStatsResponse)(nil), // 2: experimental.v2rayapi.GetStatsResponse + (*QueryStatsRequest)(nil), // 3: experimental.v2rayapi.QueryStatsRequest + (*QueryStatsResponse)(nil), // 4: experimental.v2rayapi.QueryStatsResponse + (*SysStatsRequest)(nil), // 5: experimental.v2rayapi.SysStatsRequest + (*SysStatsResponse)(nil), // 6: experimental.v2rayapi.SysStatsResponse +} var file_experimental_v2rayapi_stats_proto_depIdxs = []int32{ 1, // 0: experimental.v2rayapi.GetStatsResponse.stat:type_name -> experimental.v2rayapi.Stat 1, // 1: experimental.v2rayapi.QueryStatsResponse.stat:type_name -> experimental.v2rayapi.Stat diff --git a/experimental/v2rayapi/stats_grpc.pb.go b/experimental/v2rayapi/stats_grpc.pb.go index 0745899f..662e8b1f 100644 --- a/experimental/v2rayapi/stats_grpc.pb.go +++ b/experimental/v2rayapi/stats_grpc.pb.go @@ -2,7 +2,6 @@ package v2rayapi import ( context "context" - grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" @@ -86,11 +85,9 @@ type UnimplementedStatsServiceServer struct{} func (UnimplementedStatsServiceServer) GetStats(context.Context, *GetStatsRequest) (*GetStatsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetStats not implemented") } - func (UnimplementedStatsServiceServer) QueryStats(context.Context, *QueryStatsRequest) (*QueryStatsResponse, error) { return nil, status.Error(codes.Unimplemented, "method QueryStats not implemented") } - func (UnimplementedStatsServiceServer) GetSysStats(context.Context, *SysStatsRequest) (*SysStatsResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetSysStats not implemented") } diff --git a/go.mod b/go.mod index 22aea9db..b91f23a7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,8 @@ module github.com/sagernet/sing-box go 1.26.1 require ( + github.com/AliRizaAynaci/gorl/v2 v2.2.0 github.com/Diniboy1123/connect-ip-go v0.0.0-20260409225322-8d7bb0a858a2 - github.com/GoAdminGroup/go-admin v1.2.26 - github.com/GoAdminGroup/themes v0.0.48 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/anytls/sing-anytls v0.0.11 github.com/caddyserver/certmagic v0.25.2 @@ -23,7 +22,6 @@ require ( github.com/insomniacslk/dhcp v0.0.0-20260220084031-5adc3eb26f91 github.com/jackc/pgx/v5 v5.8.0 github.com/keybase/go-keychain v0.0.1 - github.com/lib/pq v1.10.9 github.com/libdns/acmedns v0.5.0 github.com/libdns/alidns v1.0.6 github.com/libdns/cloudflare v0.2.2 @@ -33,7 +31,6 @@ require ( github.com/miekg/dns v1.1.72 github.com/openai/openai-go/v3 v3.26.0 github.com/oschwald/maxminddb-golang v1.13.1 - github.com/patrickmn/go-cache/v2 v2.0.0-00010101000000-000000000000 github.com/sagernet/asc-go v0.0.0-20241217030726-d563060fe4e1 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a github.com/sagernet/cors v1.2.1 @@ -55,6 +52,7 @@ require ( github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 + github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -71,23 +69,31 @@ require ( google.golang.org/grpc v1.79.1 google.golang.org/protobuf v1.36.11 howett.net/plist v1.0.1 + modernc.org/sqlite v1.50.0 ) require ( github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/panjf2000/ants/v2 v2.12.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/redis/go-redis/v9 v9.8.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b // indirect gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/360EntSecGroup-Skylar/excelize v1.4.1 // indirect github.com/AdguardTeam/golibs v0.32.7 // indirect - github.com/GoAdminGroup/html v0.0.1 // indirect - github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e // indirect github.com/ajg/form v1.5.1 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect @@ -108,8 +114,6 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gaissmai/bart v0.18.0 // indirect - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/gin-gonic/gin v1.3.0 // indirect github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -117,8 +121,6 @@ require ( github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect @@ -133,20 +135,14 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 github.com/leodido/go-urn v1.4.0 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/nxadm/tail v1.4.11 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -185,7 +181,6 @@ require ( github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect github.com/sagernet/nftables v0.3.0-mod.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/syndtr/goleveldb v1.0.0 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect @@ -198,7 +193,6 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -213,14 +207,8 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - gopkg.in/go-playground/validator.v8 v8.18.2 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 lukechampine.com/blake3 v1.4.1 - xorm.io/builder v0.3.7 // indirect - xorm.io/xorm v1.0.2 // indirect ) replace github.com/sagernet/wireguard-go => github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0 @@ -233,10 +221,8 @@ replace github.com/ameshkov/dnscrypt/v2 => github.com/shtorm-7/dnscrypt/v2 v2.4. replace github.com/sagernet/sing-vmess => github.com/starifly/sing-vmess v0.2.7-mod.9 -replace github.com/patrickmn/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 - replace github.com/dolonet/mtg-multi => github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 replace github.com/Diniboy1123/connect-ip-go => github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 -replace github.com/GoAdminGroup/go-admin => github.com/shtorm-7/go-admin v1.2.26-extended-1.0.0 +replace github.com/shtorm-7/go-cache/v2 => github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 diff --git a/go.sum b/go.sum index ebdeef53..4493b1e1 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,21 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= -gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= -github.com/360EntSecGroup-Skylar/excelize v1.4.1 h1:l55mJb6rkkaUzOpSsgEeKYtS6/0gHwBYyfo5Jcjv/Ks= -github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= +github.com/AliRizaAynaci/gorl/v2 v2.2.0 h1:E8oAwkordOwm9ItNNVJ5VKvGroDcHvWNvG11HaCVLZI= +github.com/AliRizaAynaci/gorl/v2 v2.2.0/go.mod h1:13wcj/W736v44b6uygUuwypMY9N3RXJuhAYXukIIdCo= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GoAdminGroup/html v0.0.1 h1:SdWNWl4OKPsvDk2GDp5ZKD6ceWoN8n4Pj6cUYxavUd0= -github.com/GoAdminGroup/html v0.0.1/go.mod h1:A1laTJaOx8sQ64p2dE8IqtstDeCNBHEazrEp7hR5VvM= -github.com/GoAdminGroup/themes v0.0.48 h1:OveEEoFBCBTU5kNicqnvs0e/pL6uZKNQU1RAP9kmNFA= -github.com/GoAdminGroup/themes v0.0.48/go.mod h1:w/5P0WCmM8iv7DYE5scIT8AODYMoo6zj/bVlzAbgOaU= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e h1:n+DcnTNkQnHlwpsrHoQtkrJIO7CBx029fw6oR4vIob4= -github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= -github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= @@ -42,10 +26,12 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/anytls/sing-anytls v0.0.11 h1:w8e9Uj1oP3m4zxkyZDewPk0EcQbvVxb7Nn+rapEx4fc= github.com/anytls/sing-anytls v0.0.11/go.mod h1:7rjN6IukwysmdusYsrV51Fgu1uW6vsrdd6ctjnEAln8= -github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0= github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= @@ -56,7 +42,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -80,11 +65,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= -github.com/denisenkom/go-mssqldb v0.0.0-20190707035753-2be1aa521ff4/go.mod h1:zAg7JM8CkOJ43xKXIj7eRO9kmWm/TW578qo+oDO6tuM= -github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e h1:LzwWXEScfcTu7vUZNlDDWDARoSGEtvlDKK2BYHowNeE= -github.com/denisenkom/go-mssqldb v0.0.0-20200206145737-bbfc9a55622e/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbYd8tQGRWacE9kU= github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -99,9 +83,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= -github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= -github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/enfein/mieru/v3 v3.17.1 h1:pIKbspsKRYNyUrORVI33t1/yz2syaaUkIanskAbGBHY= @@ -110,8 +93,6 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -120,10 +101,6 @@ github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCK github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= -github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= @@ -134,8 +111,6 @@ github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZR github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -150,10 +125,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= @@ -162,50 +133,34 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= @@ -230,24 +185,14 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -256,7 +201,6 @@ github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/libdns/acmedns v0.5.0 h1:5pRtmUj4Lb/QkNJSl1xgOGBUJTWW7RjpNaIhjpDXjPE= @@ -269,16 +213,10 @@ github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= -github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= -github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= -github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKco= github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= @@ -295,46 +233,28 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= -github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= github.com/openai/openai-go/v3 v3.26.0 h1:bRt6H/ozMNt/dDkN4gobnLqaEGrRGBzmbVs0xxJEnQE= github.com/openai/openai-go/v3 v3.26.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= github.com/panjf2000/ants/v2 v2.12.0 h1:u9JhESo83i/GkZnhfTNuFMMWcNt7mnV1bGJ6FT4wXH8= github.com/panjf2000/ants/v2 v2.12.0/go.mod h1:tSQuaNQ6r6NRhPt+IZVUevvDyFMTs+eS4ztZc52uJTY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -342,18 +262,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= +github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI= @@ -461,10 +377,8 @@ github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0 h1:ws7BIsYLd31Wjifq88BYC github.com/shtorm-7/connect-ip-go v1.0.0-extended-1.0.0/go.mod h1:mRwx4w32qQxsWB2kThuHpbo7iNjJiq1jYWubgqEPjHA= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0 h1:e5s7RKBd2rIPR0StbvZ2vTVtJ5jDTsTk5wtIIapZTRg= github.com/shtorm-7/dnscrypt/v2 v2.4.0-extended-1.0.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= -github.com/shtorm-7/go-admin v1.2.26-extended-1.0.0 h1:TVXbEkAlk/b5Qb64zz5KjmFklVN8YdkTmS/dovVFxTk= -github.com/shtorm-7/go-admin v1.2.26-extended-1.0.0/go.mod h1:k0z66Eq5OQKw2dVkS3iQ4kPRnN6JyA5Nm+h5BBnINN8= -github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2 h1:+1tb8QNU0n2p/8Ct0A3/uHYImYXFhnN4lHOJoIdAV2s= -github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.0.2/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0 h1:PLZ/YHqnApPx13wt6MX3ItqESp4ueBr1tGSi0bEGqYw= +github.com/shtorm-7/go-cache/v2 v2.1.0-extended-1.1.0/go.mod h1:Ek4yz5OK6stwhLKgLsRRYDI+FA+ZWvRJiWLjsi/vMM4= github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1 h1:UeJkrCJJmIjTBywErVMx7fCSoBf4gh6QgT9bp9o1ajM= github.com/shtorm-7/mtg-multi v1.8.0-extended-1.0.1/go.mod h1:3rvdhwdPABkwKBdvgMt3VwMn9uSq8hpoHRezZ5jRJU0= github.com/shtorm-7/sing-mux v0.3.4-extended-1.0.0 h1:a5OoXr3e2ACbM6vDIaaGL44IdHQ6wPjcSoU13vfC0Sw= @@ -473,7 +387,6 @@ github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2 h1:hSMj github.com/shtorm-7/tailscale v1.92.4-sing-box-1.13-mod.7-extended-1.0.2/go.mod h1:TYIIqO5sZpWq873rLIeO2usszSMUpR3h6WdqVVs65ug= github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0 h1:z25EapzvkpyLgaq2T0o7eeoshBR3U4AhqMOBq1gRtrA= github.com/shtorm-7/wireguard-go v0.0.2-beta.1-extended-1.4.0/go.mod h1:Me2JlCDYHxnd0mnuX7L5LXAeDHCltI7vSKq3eTE6SVE= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8= github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8/go.mod h1:P5HUIBuIWKbyjl083/loAegFkfbFNx5i2qEP4CNbm7E= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -484,18 +397,13 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/starifly/sing-vmess v0.2.7-mod.9 h1:xobAmejSbBQ0A3f/EtJ9cJd3m6gK7dDPccPdeGz7tXY= github.com/starifly/sing-vmess v0.2.7-mod.9/go.mod h1:5aYoOtYksAyS0NXDm0qKeTYW1yoE1bJVcv+XLcVoyJs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= -github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= @@ -530,8 +438,6 @@ github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b h1:p+bJ3v5u github.com/tylertreat/BoomFilters v0.0.0-20251117164519-53813c36cc1b/go.mod h1:OYRfF6eb5wY9VRFkXJH8FFBi3plw2v+giaIu7P054pM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= @@ -547,8 +453,6 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= -go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -578,56 +482,28 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -635,20 +511,13 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -662,39 +531,16 @@ golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= -gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= -gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= -gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= @@ -703,16 +549,37 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584 h1:QyFROp5Ew7XZWKPtp8ap78z4gpY6xHpJIEdHgVA4bzA= gvisor.dev/gvisor v0.0.0-20260408064518-65a410b0d584/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -xorm.io/builder v0.3.7 h1:2pETdKRK+2QG4mLX4oODHEhn5Z8j1m8sXa7jfu+/SZI= -xorm.io/builder v0.3.7/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= -xorm.io/xorm v1.0.2 h1:kZlCh9rqd1AzGwWitcrEEqHE1h1eaZE/ujU5/2tWEtg= -xorm.io/xorm v1.0.2/go.mod h1:o4vnEsQ5V2F1/WK6w4XTwmiWJeGj82tqjAnHe44wVHY= diff --git a/include/profiler.go b/include/profiler.go new file mode 100644 index 00000000..f2fe4ca9 --- /dev/null +++ b/include/profiler.go @@ -0,0 +1,12 @@ +//go:build with_profiler + +package include + +import ( + "github.com/sagernet/sing-box/adapter/service" + "github.com/sagernet/sing-box/service/profiler" +) + +func registerProfilerService(registry *service.Registry) { + profiler.RegisterService(registry) +} diff --git a/include/profiler_stub.go b/include/profiler_stub.go new file mode 100644 index 00000000..db7ab22d --- /dev/null +++ b/include/profiler_stub.go @@ -0,0 +1,20 @@ +//go:build !with_profiler + +package include + +import ( + "context" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/service" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func registerProfilerService(registry *service.Registry) { + service.Register[option.ProfilerServiceOptions](registry, C.TypeProfiler, func(ctx context.Context, logger log.ContextLogger, tag string, options option.ProfilerServiceOptions) (adapter.Service, error) { + return nil, E.New(`Profiler is not included in this build, rebuild with -tags with_profiler`) + }) +} diff --git a/include/registry.go b/include/registry.go index a34d8075..cabbb4c5 100644 --- a/include/registry.go +++ b/include/registry.go @@ -14,6 +14,7 @@ import ( "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport" "github.com/sagernet/sing-box/dns/transport/fakeip" + "github.com/sagernet/sing-box/dns/transport/fallback" "github.com/sagernet/sing-box/dns/transport/hosts" "github.com/sagernet/sing-box/dns/transport/local" "github.com/sagernet/sing-box/log" @@ -22,10 +23,13 @@ import ( "github.com/sagernet/sing-box/protocol/block" "github.com/sagernet/sing-box/protocol/bond" "github.com/sagernet/sing-box/protocol/direct" + "github.com/sagernet/sing-box/protocol/failover" "github.com/sagernet/sing-box/protocol/group" "github.com/sagernet/sing-box/protocol/http" "github.com/sagernet/sing-box/protocol/limiter/bandwidth" "github.com/sagernet/sing-box/protocol/limiter/connection" + "github.com/sagernet/sing-box/protocol/limiter/rate" + "github.com/sagernet/sing-box/protocol/limiter/traffic" "github.com/sagernet/sing-box/protocol/mieru" "github.com/sagernet/sing-box/protocol/mixed" "github.com/sagernet/sing-box/protocol/naive" @@ -45,9 +49,9 @@ import ( remoteProvider "github.com/sagernet/sing-box/provider/remote" "github.com/sagernet/sing-box/service/admin_panel" "github.com/sagernet/sing-box/service/manager" + "github.com/sagernet/sing-box/service/manager_api" "github.com/sagernet/sing-box/service/node" - nodeManagerClient "github.com/sagernet/sing-box/service/node_manager/client" - nodeManagerServer "github.com/sagernet/sing-box/service/node_manager/server" + "github.com/sagernet/sing-box/service/node_manager_api" "github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/ssmapi" E "github.com/sagernet/sing/common/exceptions" @@ -78,6 +82,7 @@ func InboundRegistry() *inbound.Registry { anytls.RegisterInbound(registry) bond.RegisterInbound(registry) + failover.RegisterInbound(registry) registerQUICInbounds(registry) registerStubForRemovedInbounds(registry) @@ -112,9 +117,12 @@ func OutboundRegistry() *outbound.Registry { registerMASQUEOutbound(registry) bond.RegisterOutbound(registry) + failover.RegisterOutbound(registry) bandwidth.RegisterOutbound(registry) connection.RegisterOutbound(registry) + traffic.RegisterOutbound(registry) + rate.RegisterOutbound(registry) parser.RegisterOutbound(registry) @@ -157,6 +165,7 @@ func DNSTransportRegistry() *dns.TransportRegistry { hosts.RegisterTransport(registry) local.RegisterTransport(registry) fakeip.RegisterTransport(registry) + fallback.RegisterTransport(registry) resolved.RegisterTransport(registry) registerQUICTransports(registry) @@ -171,9 +180,9 @@ func ServiceRegistry() *service.Registry { admin_panel.RegisterService(registry) manager.RegisterService(registry) + manager_api.RegisterService(registry) node.RegisterService(registry) - nodeManagerClient.RegisterService(registry) - nodeManagerServer.RegisterService(registry) + node_manager_api.RegisterService(registry) resolved.RegisterService(registry) ssmapi.RegisterService(registry) @@ -181,6 +190,7 @@ func ServiceRegistry() *service.Registry { registerCCMService(registry) registerOCMService(registry) registerOOMKillerService(registry) + registerProfilerService(registry) return registry } diff --git a/log/id.go b/log/id.go index 866170d4..21719cd2 100644 --- a/log/id.go +++ b/log/id.go @@ -12,9 +12,11 @@ func init() { random.InitializeSeed() } -type idKey struct{} -type muxIdKey struct{} -type hwidKey struct{} +type ( + idKey struct{} + muxIdKey struct{} + hwidKey struct{} +) type ID struct { ID uint32 diff --git a/option/admin_panel.go b/option/admin_panel.go index 412e3e93..015d0939 100644 --- a/option/admin_panel.go +++ b/option/admin_panel.go @@ -2,12 +2,5 @@ package option type AdminPanelServiceOptions struct { ListenOptions - Manager string `json:"manager"` - Database AdminPanelServiceDatabase `json:"database"` InboundTLSOptionsContainer } - -type AdminPanelServiceDatabase struct { - Driver string `json:"driver"` - DSN string `json:"dsn"` -} diff --git a/option/dns.go b/option/dns.go index ce7cfda9..e2e97106 100644 --- a/option/dns.go +++ b/option/dns.go @@ -332,10 +332,10 @@ func (o *DNSServerOptions) Upgrade(ctx context.Context) error { if !serverAddr.IsValid() { return E.New("invalid server address") } - o.Options = &SDNSDNSServerOptions{ - RemoteDNSServerOptions: remoteOptions, - Stamp: serverAddr.AddrString(), - } + o.Options = &SDNSDNSServerOptions{ + RemoteDNSServerOptions: remoteOptions, + Stamp: serverAddr.AddrString(), + } default: return E.New("unsupported DNS server scheme: ", serverType) } @@ -424,4 +424,9 @@ type DHCPDNSServerOptions struct { type SDNSDNSServerOptions struct { RemoteDNSServerOptions Stamp string `json:"stamp"` -} \ No newline at end of file +} + +type FallbackDNSServerOptions struct { + Servers []string `json:"servers"` + Strategy string `json:"strategy,omitempty"` +} diff --git a/option/failover.go b/option/failover.go index 73bdeb34..eeb86087 100644 --- a/option/failover.go +++ b/option/failover.go @@ -1,9 +1,13 @@ package option +import "github.com/sagernet/sing/common/json/badoption" + type FailoverInboundOptions struct { Inbounds []Inbound `json:"inbounds"` } type FailoverOutboundOptions struct { - Outbounds []Outbound `json:"outbounds"` + Strategy string `json:"strategy,omitempty"` + Delay badoption.Duration `json:"delay,omitempty"` + Outbounds []Outbound `json:"outbounds"` } diff --git a/option/hysteria2.go b/option/hysteria2.go index a0145136..a44daac8 100644 --- a/option/hysteria2.go +++ b/option/hysteria2.go @@ -53,7 +53,7 @@ func (m Hysteria2Masquerade) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown masquerade type: ", m.Type) } - return badjson.MarshallObjects((_Hysteria2Masquerade)(m), v) + return badjson.MarshallObjects(_Hysteria2Masquerade(m), v) } func (m *Hysteria2Masquerade) UnmarshalJSON(bytes []byte) error { diff --git a/option/inbound.go b/option/inbound.go index 21497a3f..0a341554 100644 --- a/option/inbound.go +++ b/option/inbound.go @@ -90,7 +90,7 @@ type ListenOptions struct { type UDPTimeoutCompat badoption.Duration func (c UDPTimeoutCompat) MarshalJSON() ([]byte, error) { - return json.Marshal((time.Duration)(c).String()) + return json.Marshal(time.Duration(c).String()) } func (c *UDPTimeoutCompat) UnmarshalJSON(data []byte) error { diff --git a/option/limiter.go b/option/limiter.go index 0194a10a..1b187c93 100644 --- a/option/limiter.go +++ b/option/limiter.go @@ -2,12 +2,14 @@ package option import ( "github.com/sagernet/sing/common/byteformats" + "github.com/sagernet/sing/common/json/badoption" ) type BandwidthLimiterOutboundOptions struct { Strategy string `json:"strategy"` - Mode string `json:"mode"` ConnectionType string `json:"connection_type,omitempty"` + Mode string `json:"mode"` + FlowKeys []string `json:"flow_keys,omitempty"` Speed *byteformats.NetworkBytesCompat `json:"speed"` Users []BandwidthLimiterUser `json:"users,omitempty"` Route RouteOptions `json:"route"` @@ -16,11 +18,26 @@ type BandwidthLimiterOutboundOptions struct { type BandwidthLimiterUser struct { Name string `json:"name"` Strategy string `json:"strategy"` - Mode string `json:"mode"` ConnectionType string `json:"connection_type,omitempty"` + Mode string `json:"mode"` Speed *byteformats.NetworkBytesCompat `json:"speed"` } +type TrafficLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + Mode string `json:"mode"` + Total *byteformats.Bytes `json:"total"` + Users []TrafficLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type TrafficLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + Mode string `json:"mode"` + Total *byteformats.Bytes `json:"total"` +} + type ConnectionLimiterOutboundOptions struct { Strategy string `json:"strategy"` ConnectionType string `json:"connection_type,omitempty"` @@ -35,3 +52,20 @@ type ConnectionLimiterUser struct { ConnectionType string `json:"connection_type,omitempty"` Count uint32 `json:"count"` } + +type RateLimiterOutboundOptions struct { + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` + Interval badoption.Duration `json:"interval"` + Users []RateLimiterUser `json:"users,omitempty"` + Route RouteOptions `json:"route"` +} + +type RateLimiterUser struct { + Name string `json:"name"` + Strategy string `json:"strategy"` + ConnectionType string `json:"connection_type,omitempty"` + Count uint32 `json:"count"` + Interval badoption.Duration `json:"interval"` +} diff --git a/option/manager_api.go b/option/manager_api.go new file mode 100644 index 00000000..cccf6fee --- /dev/null +++ b/option/manager_api.go @@ -0,0 +1,77 @@ +package option + +import ( + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _ManagerAPIOptions struct { + APIType string `json:"api_type"` + ProtocolType string `json:"protocol_type"` + ServerOptions ManagerAPIServerOptions `json:"-"` + ClientOptions ManagerAPIClientOptions `json:"-"` +} + +type ManagerAPIOptions _ManagerAPIOptions + +func (o ManagerAPIOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.APIType { + case C.ManagerAPIServer: + v = o.ServerOptions + case C.ManagerAPIClient: + v = o.ClientOptions + case "": + return nil, E.New("missing api type") + default: + return nil, E.New("unknown api type: " + o.APIType) + } + return badjson.MarshallObjects(_ManagerAPIOptions(o), v) +} + +func (o *ManagerAPIOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_ManagerAPIOptions)(o)) + if err != nil { + return err + } + var v any + switch o.APIType { + case C.ManagerAPIServer: + v = &o.ServerOptions + case C.ManagerAPIClient: + v = &o.ClientOptions + case "": + return E.New("missing api type") + default: + return E.New("unknown api type: " + o.APIType) + } + + err = badjson.UnmarshallExcluded(bytes, (*_ManagerAPIOptions)(o), v) + if err != nil { + return err + } + return nil +} + +type ManagerAPIServerOptions struct { + ListenOptions + InboundTLSOptionsContainer + Manager string `json:"manager"` + APIKey string `json:"api_key"` + CORS *ManagerAPICORSOptions `json:"cors,omitempty"` +} + +type ManagerAPICORSOptions struct { + AllowedOrigins []string `json:"allowed_origins,omitempty"` + ExposedHeaders []string `json:"exposed_headers,omitempty"` + MaxAge int `json:"max_age,omitempty"` +} + +type ManagerAPIClientOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer + APIKey string `json:"api_key"` +} diff --git a/option/node.go b/option/node.go index da0a33c6..3c4afaef 100644 --- a/option/node.go +++ b/option/node.go @@ -5,5 +5,7 @@ type NodeServiceOptions struct { Inbounds []string `json:"inbounds"` ConnectionLimiters []string `json:"connection_limiters"` BandwidthLimiters []string `json:"bandwidth_limiters"` + TrafficLimiters []string `json:"traffic_limiters"` + RateLimiters []string `json:"rate_limiters"` Manager string `json:"manager"` } diff --git a/option/node_manager.go b/option/node_manager.go deleted file mode 100644 index ce15e3e7..00000000 --- a/option/node_manager.go +++ /dev/null @@ -1,13 +0,0 @@ -package option - -type NodeManagerServerServiceOptions struct { - ListenOptions - InboundTLSOptionsContainer - Manager string `json:"manager"` -} - -type NodeManagerClientServiceOptions struct { - DialerOptions - ServerOptions - OutboundTLSOptionsContainer -} diff --git a/option/node_manager_api.go b/option/node_manager_api.go new file mode 100644 index 00000000..b0b94599 --- /dev/null +++ b/option/node_manager_api.go @@ -0,0 +1,69 @@ +package option + +import ( + C "github.com/sagernet/sing-box/constant" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type _NodeManagerAPIOptions struct { + APIType string `json:"api_type"` + ServerOptions NodeManagerAPIServerOptions `json:"-"` + ClientOptions NodeManagerAPIClientOptions `json:"-"` +} + +type NodeManagerAPIOptions _NodeManagerAPIOptions + +func (o NodeManagerAPIOptions) MarshalJSON() ([]byte, error) { + var v any + switch o.APIType { + case C.NodeManagerAPIServer: + v = o.ServerOptions + case C.NodeManagerAPIClient: + v = o.ClientOptions + case "": + return nil, E.New("missing api type") + default: + return nil, E.New("unknown api type: " + o.APIType) + } + return badjson.MarshallObjects(_NodeManagerAPIOptions(o), v) +} + +func (o *NodeManagerAPIOptions) UnmarshalJSON(bytes []byte) error { + err := json.Unmarshal(bytes, (*_NodeManagerAPIOptions)(o)) + if err != nil { + return err + } + var v any + switch o.APIType { + case C.NodeManagerAPIServer: + v = &o.ServerOptions + case C.NodeManagerAPIClient: + v = &o.ClientOptions + case "": + return E.New("missing api type") + default: + return E.New("unknown api type: " + o.APIType) + } + + err = badjson.UnmarshallExcluded(bytes, (*_NodeManagerAPIOptions)(o), v) + if err != nil { + return err + } + return nil +} + +type NodeManagerAPIServerOptions struct { + ListenOptions + InboundTLSOptionsContainer + Manager string `json:"manager"` + APIKey string `json:"api_key"` +} + +type NodeManagerAPIClientOptions struct { + DialerOptions + ServerOptions + OutboundTLSOptionsContainer + APIKey string `json:"api_key"` +} diff --git a/option/outbound.go b/option/outbound.go index 6676a3e9..0fade7b1 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -111,7 +111,7 @@ func (o DomainResolveOptions) MarshalJSON() ([]byte, error) { o.ClientSubnet == nil { return json.Marshal(o.Server) } else { - return json.Marshal((_DomainResolveOptions)(o)) + return json.Marshal(_DomainResolveOptions(o)) } } diff --git a/option/profiler.go b/option/profiler.go new file mode 100644 index 00000000..b088ec1e --- /dev/null +++ b/option/profiler.go @@ -0,0 +1,9 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type ProfilerServiceOptions struct { + Listen string `json:"listen,omitempty"` + ReadTimeout badoption.Duration `json:"read_timeout,omitempty"` + WriteTimeout badoption.Duration `json:"write_timeout,omitempty"` +} diff --git a/option/resolved.go b/option/resolved.go index cb9f579d..a67f71cb 100644 --- a/option/resolved.go +++ b/option/resolved.go @@ -16,7 +16,7 @@ type _ResolvedServiceOptions struct { type ResolvedServiceOptions _ResolvedServiceOptions func (r ResolvedServiceOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { - if r.Listen != nil && netip.Addr(*r.Listen) == (netip.AddrFrom4([4]byte{127, 0, 0, 53})) { + if r.Listen != nil && netip.Addr(*r.Listen) == netip.AddrFrom4([4]byte{127, 0, 0, 53}) { r.Listen = nil } if r.ListenPort == 53 { diff --git a/option/rule.go b/option/rule.go index 3e7fd877..f88b8f58 100644 --- a/option/rule.go +++ b/option/rule.go @@ -30,7 +30,7 @@ func (r Rule) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown rule type: " + r.Type) } - return badjson.MarshallObjects((_Rule)(r), v) + return badjson.MarshallObjects(_Rule(r), v) } func (r *Rule) UnmarshalJSON(bytes []byte) error { diff --git a/option/rule_action.go b/option/rule_action.go index 8ecb0dda..0dec1c8c 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -53,9 +53,9 @@ func (r RuleAction) MarshalJSON() ([]byte, error) { return nil, E.New("unknown rule action: " + r.Action) } if v == nil { - return badjson.MarshallObjects((_RuleAction)(r)) + return badjson.MarshallObjects(_RuleAction(r)) } - return badjson.MarshallObjects((_RuleAction)(r), v) + return badjson.MarshallObjects(_RuleAction(r), v) } func (r *RuleAction) UnmarshalJSON(data []byte) error { @@ -124,7 +124,7 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown DNS rule action: " + r.Action) } - return badjson.MarshallObjects((_DNSRuleAction)(r), v) + return badjson.MarshallObjects(_DNSRuleAction(r), v) } func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) error { @@ -281,7 +281,7 @@ func (r RejectActionOptions) MarshalJSON() ([]byte, error) { case C.RuleActionRejectMethodDefault: r.Method = "" } - return json.Marshal((_RejectActionOptions)(r)) + return json.Marshal(_RejectActionOptions(r)) } func (r *RejectActionOptions) UnmarshalJSON(bytes []byte) error { diff --git a/option/rule_dns.go b/option/rule_dns.go index dbc16578..db20b37b 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -31,7 +31,7 @@ func (r DNSRule) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown rule type: " + r.Type) } - return badjson.MarshallObjects((_DNSRule)(r), v) + return badjson.MarshallObjects(_DNSRule(r), v) } func (r *DNSRule) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { diff --git a/option/rule_set.go b/option/rule_set.go index b0634228..f0c00388 100644 --- a/option/rule_set.go +++ b/option/rule_set.go @@ -53,7 +53,7 @@ func (r RuleSet) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown rule-set type: " + r.Type) } - return badjson.MarshallObjects((_RuleSet)(r), v) + return badjson.MarshallObjects(_RuleSet(r), v) } func (r *RuleSet) UnmarshalJSON(bytes []byte) error { @@ -145,7 +145,7 @@ func (r HeadlessRule) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown rule type: " + r.Type) } - return badjson.MarshallObjects((_HeadlessRule)(r), v) + return badjson.MarshallObjects(_HeadlessRule(r), v) } func (r *HeadlessRule) UnmarshalJSON(bytes []byte) error { @@ -248,7 +248,7 @@ func (r PlainRuleSetCompat) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown rule-set version: ", r.Version) } - return badjson.MarshallObjects((_PlainRuleSetCompat)(r), v) + return badjson.MarshallObjects(_PlainRuleSetCompat(r), v) } func (r *PlainRuleSetCompat) UnmarshalJSON(bytes []byte) error { diff --git a/option/tls_acme.go b/option/tls_acme.go index 6dd8fa70..52ea32b4 100644 --- a/option/tls_acme.go +++ b/option/tls_acme.go @@ -50,7 +50,7 @@ func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown provider type: " + o.Provider) } - return badjson.MarshallObjects((_ACMEDNS01ChallengeOptions)(o), v) + return badjson.MarshallObjects(_ACMEDNS01ChallengeOptions(o), v) } func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { diff --git a/option/v2ray_transport.go b/option/v2ray_transport.go index 2737d747..987f5207 100644 --- a/option/v2ray_transport.go +++ b/option/v2ray_transport.go @@ -48,7 +48,7 @@ func (o V2RayTransportOptions) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown transport type: " + o.Type) } - return badjson.MarshallObjects((_V2RayTransportOptions)(o), v) + return badjson.MarshallObjects(_V2RayTransportOptions(o), v) } func (o *V2RayTransportOptions) UnmarshalJSON(bytes []byte) error { @@ -115,31 +115,33 @@ type V2RayHTTPUpgradeOptions struct { } type V2RayXHTTPBaseOptions struct { - Host string `json:"host,omitempty"` - Path string `json:"path,omitempty"` - Headers map[string]string `json:"headers,omitempty"` - DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` - XPaddingBytes Xbadoption.Range `json:"x_padding_bytes"` - NoGRPCHeader bool `json:"no_grpc_header,omitempty"` - NoSSEHeader bool `json:"no_sse_header,omitempty"` - ScMaxEachPostBytes Xbadoption.Range `json:"sc_max_each_post_bytes"` - ScMinPostsIntervalMs Xbadoption.Range `json:"sc_min_posts_interval_ms"` - ScMaxBufferedPosts int64 `json:"sc_max_buffered_posts,omitempty"` - ScStreamUpServerSecs Xbadoption.Range `json:"sc_stream_up_server_secs"` - Xmux *V2RayXHTTPXmuxOptions `json:"xmux"` - XPaddingObfsMode bool `json:"x_padding_obfs_mode,omitempty"` - XPaddingKey string `json:"x_padding_key,omitempty"` - XPaddingHeader string `json:"x_padding_header,omitempty"` - XPaddingPlacement string `json:"x_padding_placement,omitempty"` - XPaddingMethod string `json:"x_padding_method,omitempty"` - UplinkHTTPMethod string `json:"uplink_http_method,omitempty"` - SessionPlacement string `json:"session_placement,omitempty"` - SessionKey string `json:"session_key,omitempty"` - SeqPlacement string `json:"seq_placement,omitempty"` - SeqKey string `json:"seq_key,omitempty"` - UplinkDataPlacement string `json:"uplink_data_placement,omitempty"` - UplinkDataKey string `json:"uplink_data_key,omitempty"` - UplinkChunkSize uint32 `json:"uplink_chunk_size,omitempty"` + Host string `json:"host,omitempty"` + Path string `json:"path,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"` + XPaddingBytes Xbadoption.Range `json:"x_padding_bytes"` + NoGRPCHeader bool `json:"no_grpc_header,omitempty"` + NoSSEHeader bool `json:"no_sse_header,omitempty"` + ScMaxEachPostBytes *Xbadoption.Range `json:"sc_max_each_post_bytes"` + ScMinPostsIntervalMs *Xbadoption.Range `json:"sc_min_posts_interval_ms"` + ScMaxBufferedPosts int64 `json:"sc_max_buffered_posts,omitempty"` + ScStreamUpServerSecs *Xbadoption.Range `json:"sc_stream_up_server_secs"` + ServerMaxHeaderBytes int `json:"server_max_header_bytes"` + TrustedXForwardedFor badoption.Listable[string] `json:"trusted_x_forwarded_for,omitempty"` + Xmux *V2RayXHTTPXmuxOptions `json:"xmux"` + XPaddingObfsMode bool `json:"x_padding_obfs_mode,omitempty"` + XPaddingKey string `json:"x_padding_key,omitempty"` + XPaddingHeader string `json:"x_padding_header,omitempty"` + XPaddingPlacement string `json:"x_padding_placement,omitempty"` + XPaddingMethod string `json:"x_padding_method,omitempty"` + UplinkHTTPMethod string `json:"uplink_http_method,omitempty"` + SessionPlacement string `json:"session_placement,omitempty"` + SessionKey string `json:"session_key,omitempty"` + SeqPlacement string `json:"seq_placement,omitempty"` + SeqKey string `json:"seq_key,omitempty"` + UplinkDataPlacement string `json:"uplink_data_placement,omitempty"` + UplinkDataKey string `json:"uplink_data_key,omitempty"` + UplinkChunkSize *Xbadoption.Range `json:"uplink_chunk_size,omitempty"` } type _V2RayXHTTPOptions struct { @@ -164,6 +166,7 @@ const ( PlacementQuery = "query" PlacementPath = "path" PlacementBody = "body" + PlacementAuto = "auto" ) func (c V2RayXHTTPOptions) MarshalJSON() ([]byte, error) { @@ -202,15 +205,19 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err return E.New(`"headers" can't contain "host"`) } } + if options.XPaddingBytes.From <= 0 || options.XPaddingBytes.To <= 0 { return E.New("x_padding_bytes cannot be disabled") } + if options.XPaddingKey == "" { options.XPaddingKey = "x_padding" } + if options.XPaddingHeader == "" { options.XPaddingHeader = "X-Padding" } + switch options.XPaddingPlacement { case "": options.XPaddingPlacement = "queryInHeader" @@ -218,6 +225,7 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err default: return E.New("unsupported padding placement: " + options.XPaddingPlacement) } + switch options.XPaddingMethod { case "": options.XPaddingMethod = "repeat-x" @@ -225,24 +233,28 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err default: return E.New("unsupported padding method: " + options.XPaddingMethod) } + switch options.UplinkDataPlacement { case "": - options.UplinkDataPlacement = "body" - case "body": - case "cookie", "header": + options.UplinkDataPlacement = PlacementAuto + case PlacementAuto, PlacementBody: + case PlacementCookie, PlacementHeader: if mode != "packet-up" { - return E.New("uplink_data_placement can be " + options.UplinkDataPlacement + " only in packet-up mode") + return E.New("UplinkDataPlacement can be " + options.UplinkDataPlacement + " only in packet-up mode") } default: return E.New("unsupported uplink data placement: " + options.UplinkDataPlacement) } + if options.UplinkHTTPMethod == "" { options.UplinkHTTPMethod = "POST" } options.UplinkHTTPMethod = strings.ToUpper(options.UplinkHTTPMethod) + if options.UplinkHTTPMethod == "GET" && mode != "packet-up" { return E.New("uplink_http_method can be GET only in packet-up mode") } + switch options.SessionPlacement { case "": options.SessionPlacement = "path" @@ -250,17 +262,15 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err default: return E.New("unsupported session placement: " + options.SessionPlacement) } + switch options.SeqPlacement { case "": options.SeqPlacement = "path" - case "path": - case "cookie", "header", "query": - if options.SessionPlacement == "path" { - return E.New("seq_placement must be path when session_placement is path") - } + case "path", "cookie", "header", "query": default: return E.New("unsupported seq placement: " + options.SeqPlacement) } + if options.SessionPlacement != "path" && options.SessionKey == "" { switch options.SessionPlacement { case "cookie", "query": @@ -269,6 +279,7 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err options.SessionKey = "X-Session" } } + if options.SeqPlacement != "path" && options.SeqKey == "" { switch options.SeqPlacement { case "cookie", "query": @@ -277,24 +288,20 @@ func checkV2RayXHTTPBaseOptions(mode string, options *V2RayXHTTPBaseOptions) err options.SeqKey = "X-Seq" } } - if options.UplinkDataPlacement != "body" && options.UplinkDataKey == "" { + + if options.UplinkDataPlacement != PlacementBody && options.UplinkDataKey == "" { switch options.UplinkDataPlacement { - case "cookie": + case PlacementCookie: options.UplinkDataKey = "x_data" - case "header": + case PlacementAuto, PlacementHeader: options.UplinkDataKey = "X-Data" } } - if options.UplinkChunkSize == 0 { - switch options.UplinkDataPlacement { - case "cookie": - options.UplinkChunkSize = 3 * 1024 // 3KB - case "header": - options.UplinkChunkSize = 4 * 1024 // 4KB - } - } else if options.UplinkChunkSize < 64 { - options.UplinkChunkSize = 64 + + if options.ServerMaxHeaderBytes < 0 { + return E.New("invalid negative value of maxHeaderBytes") } + if options.Xmux == nil { options.Xmux = &V2RayXHTTPXmuxOptions{} options.Xmux.MaxConcurrency.From = 1 @@ -335,9 +342,7 @@ func (c *V2RayXHTTPBaseOptions) GetRequestHeader() http.Header { for k, v := range c.Headers { header.Add(k, v) } - if header.Get("User-Agent") == "" { - header.Set("User-Agent", utils.ChromeUA) - } + utils.TryDefaultHeadersWith(header, "fetch") return header } @@ -359,23 +364,23 @@ func (c *V2RayXHTTPBaseOptions) GetNormalizedUplinkHTTPMethod() string { } func (c *V2RayXHTTPBaseOptions) GetNormalizedScMaxEachPostBytes() Xbadoption.Range { - if c.ScMaxEachPostBytes.To == 0 { + if c.ScMaxEachPostBytes == nil { return Xbadoption.Range{ From: 1000000, To: 1000000, } } - return c.ScMaxEachPostBytes + return *c.ScMaxEachPostBytes } func (c *V2RayXHTTPBaseOptions) GetNormalizedScMinPostsIntervalMs() Xbadoption.Range { - if c.ScMinPostsIntervalMs.To == 0 { + if c.ScMinPostsIntervalMs == nil { return Xbadoption.Range{ From: 30, To: 30, } } - return c.ScMinPostsIntervalMs + return *c.ScMinPostsIntervalMs } func (c *V2RayXHTTPBaseOptions) GetNormalizedScMaxBufferedPosts() int { @@ -387,13 +392,47 @@ func (c *V2RayXHTTPBaseOptions) GetNormalizedScMaxBufferedPosts() int { } func (c *V2RayXHTTPBaseOptions) GetNormalizedScStreamUpServerSecs() Xbadoption.Range { - if c.ScStreamUpServerSecs.To == 0 { + if c.ScStreamUpServerSecs == nil { return Xbadoption.Range{ From: 20, To: 80, } } - return c.ScStreamUpServerSecs + return *c.ScStreamUpServerSecs +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedUplinkChunkSize() Xbadoption.Range { + if c.UplinkChunkSize == nil || c.UplinkChunkSize.To == 0 { + switch c.UplinkDataPlacement { + case PlacementCookie: + return Xbadoption.Range{ + From: 2 * 1024, // 2 KiB + To: 3 * 1024, // 3 KiB + } + case PlacementHeader: + return Xbadoption.Range{ + From: 3 * 1000, // 3 KB + To: 4 * 1000, // 4 KB + } + default: + return c.GetNormalizedScMaxEachPostBytes() + } + } else if c.UplinkChunkSize.From < 64 { + return Xbadoption.Range{ + From: 64, + To: max(64, c.UplinkChunkSize.To), + } + } + + return *c.UplinkChunkSize +} + +func (c *V2RayXHTTPBaseOptions) GetNormalizedServerMaxHeaderBytes() int { + if c.ServerMaxHeaderBytes <= 0 { + return 8192 + } else { + return c.ServerMaxHeaderBytes + } } func (c *V2RayXHTTPBaseOptions) GetNormalizedSessionPlacement() string { diff --git a/parser/link/vless.go b/parser/link/vless.go index 80442e1e..aa558235 100644 --- a/parser/link/vless.go +++ b/parser/link/vless.go @@ -1,6 +1,7 @@ package link import ( + "encoding/json" "net/url" "strings" @@ -66,6 +67,83 @@ func parseVLESSLink(link string) (option.Outbound, error) { if serviceName, exists := proxy["serviceName"]; exists && serviceName != "" { Transport.GRPCOptions.ServiceName = serviceName } + case "xhttp": + Transport.Type = C.V2RayTransportTypeXHTTP + if alpn, exists := proxy["alpn"]; exists && alpn != "" { + TLSOptions.ALPN = []string{alpn} + } + TLSOptions.ALPN = []string{"h2", "http/1.1"} + if host, exists := proxy["host"]; exists && host != "" { + Transport.XHTTPOptions.Host = host + } + if path, exists := proxy["path"]; exists && path != "" { + Transport.XHTTPOptions.Path = path + } + if mode, exists := proxy["mode"]; exists && mode != "" { + Transport.XHTTPOptions.Mode = mode + } + if extra, exists := proxy["extra"]; exists && extra != "" { + decodedExtra, err := common.DecodeBase64URLSafe(extra) + if err == nil { + var extraOptions map[string]interface{} + if json.Unmarshal([]byte(decodedExtra), &extraOptions) == nil { + if xmux, ok := extraOptions["xmux"].(map[string]interface{}); ok { + Transport.XHTTPOptions.Xmux = &option.V2RayXHTTPXmuxOptions{} + if val, ok := xmux["cMaxReuseTimes"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.Xmux.CMaxReuseTimes = r + } + } + if val, ok := xmux["maxConcurrency"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.Xmux.MaxConcurrency = r + } + } + if val, ok := xmux["maxConnections"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.Xmux.MaxConnections = r + } + } + if val, ok := xmux["hKeepAlivePeriod"].(string); ok { + Transport.XHTTPOptions.Xmux.HKeepAlivePeriod = common.StringToType[int64](val) + } + if val, ok := xmux["hMaxRequestTimes"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.Xmux.HMaxRequestTimes = r + } + } + if val, ok := xmux["hMaxReusableSecs"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.Xmux.HMaxReusableSecs = r + } + } + } + if val, ok := extraOptions["noGRPCHeader"].(bool); ok { + Transport.XHTTPOptions.NoGRPCHeader = val + } + if val, ok := extraOptions["xPaddingBytes"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.XPaddingBytes = r + } + } + if val, ok := extraOptions["scMaxEachPostBytes"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.ScMaxEachPostBytes = &r + } + } + if val, ok := extraOptions["scMinPostsIntervalMs"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.ScMinPostsIntervalMs = &r + } + } + if val, ok := extraOptions["scStreamUpServerSecs"].(string); ok { + if r, err := common.ParseXHTTPRange(val); err == nil { + Transport.XHTTPOptions.ScStreamUpServerSecs = &r + } + } + } + } + } default: continue } diff --git a/protocol/anytls/inbound.go b/protocol/anytls/inbound.go index 52d77353..98168146 100644 --- a/protocol/anytls/inbound.go +++ b/protocol/anytls/inbound.go @@ -59,7 +59,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo service, err := anytls.NewService(anytls.ServiceConfig{ Users: common.Map(options.Users, func(it option.AnyTLSUser) anytls.User { - return (anytls.User)(it) + return anytls.User(it) }), PaddingScheme: paddingScheme, Handler: (*inboundHandler)(inbound), diff --git a/protocol/anytls/outbound.go b/protocol/anytls/outbound.go index 2f24c2ef..46ea2cc3 100644 --- a/protocol/anytls/outbound.go +++ b/protocol/anytls/outbound.go @@ -83,7 +83,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL outbound.client = client outbound.uotClient = &uot.Client{ - Dialer: (anytlsDialer)(client.CreateProxy), + Dialer: anytlsDialer(client.CreateProxy), Version: uot.Version, } return outbound, nil diff --git a/protocol/bond/inbound.go b/protocol/bond/inbound.go index 89796c4b..b6ea00cd 100644 --- a/protocol/bond/inbound.go +++ b/protocol/bond/inbound.go @@ -7,7 +7,6 @@ import ( "time" "github.com/gofrs/uuid/v5" - "github.com/patrickmn/go-cache/v2" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/common/kmutex" @@ -19,6 +18,7 @@ import ( "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" + "github.com/shtorm-7/go-cache/v2" ) func RegisterInbound(registry *inbound.Registry) { diff --git a/protocol/failover/conn.go b/protocol/failover/conn.go new file mode 100644 index 00000000..0be29762 --- /dev/null +++ b/protocol/failover/conn.go @@ -0,0 +1,232 @@ +package failover + +import ( + "bytes" + "context" + "encoding/binary" + "io" + "net" + "sync" + "time" + + C "github.com/sagernet/sing-box/constant" +) + +type dial func() (net.Conn, error) + +type failoverConn struct { + net.Conn + ctx context.Context + dial dial + onClose func() + + readIndex uint32 + readBuffer *bytes.Buffer + writeIndex uint32 + writeBuffers [BufferSize][]byte + + await chan struct{} + awaitMtx sync.Mutex + + err error + + once sync.Once + mtx sync.RWMutex +} + +func NewFailoverConn(ctx context.Context, conn net.Conn, dial dial, onClose func()) *failoverConn { + var writeBuffers [BufferSize][]byte + for i := range BufferSize { + writeBuffers[i] = make([]byte, 0, 1000) + } + return &failoverConn{ + Conn: conn, + ctx: ctx, + dial: dial, + readBuffer: bytes.NewBuffer(make([]byte, 0, 1000)), + writeBuffers: writeBuffers, + onClose: onClose, + } +} + +func (c *failoverConn) Read(b []byte) (int, error) { + for { + c.mtx.RLock() + conn := c.Conn + n, err := c.read(conn, b) + if err != nil { + if err == SessionClosed { + c.err = io.EOF + conn.Close() + c.mtx.RUnlock() + return 0, c.err + } + c.mtx.RUnlock() + err = c.awaitConn(conn) + if err != nil { + return 0, err + } + continue + } + c.readIndex++ + c.mtx.RUnlock() + return n, err + } +} + +func (c *failoverConn) Write(b []byte) (int, error) { + for { + c.mtx.RLock() + conn := c.Conn + n, err := c.write(conn, b) + if err != nil { + c.mtx.RUnlock() + err = c.awaitConn(conn) + if err != nil { + return 0, err + } + continue + } + writeIndex := c.writeIndex % BufferSize + c.writeBuffers[writeIndex] = append(c.writeBuffers[writeIndex][:0], b...) + c.writeIndex++ + c.mtx.RUnlock() + return n, err + } +} + +func (c *failoverConn) RestoreConn(conn net.Conn) error { + c.Conn.Close() + c.mtx.Lock() + defer c.mtx.Unlock() + _, err := conn.Write([]byte{ + byte(c.readIndex >> 24), + byte(c.readIndex >> 16), + byte(c.readIndex >> 8), + byte(c.readIndex), + }) + if err != nil { + return err + } + var data [4]byte + _, err = io.ReadFull(conn, data[:]) + if err != nil { + return err + } + writeIndex := binary.BigEndian.Uint32(data[:]) + buffers := make([][]byte, 0, BufferSize) + for writeIndex != c.writeIndex { + if len(buffers) == BufferSize { + return SessionBroken + } + buffers = append(buffers, c.writeBuffers[writeIndex%BufferSize]) + writeIndex++ + } + for _, buffer := range buffers { + _, err = c.write(conn, buffer) + if err != nil { + return err + } + } + c.Conn = conn + if c.await != nil { + close(c.await) + c.await = nil + } + return nil +} + +func (c *failoverConn) Close() error { + c.once.Do(func() { + c.mtx.RLock() + if c.onClose != nil { + c.onClose() + } + c.err = io.EOF + c.mtx.RUnlock() + c.Write([]byte{}) + }) + return nil +} + +func (c *failoverConn) read(conn net.Conn, b []byte) (int, error) { + if c.readBuffer.Len() == 0 { + c.readBuffer.Reset() + var data [2]byte + _, err := io.ReadFull(conn, data[:]) + if err != nil { + return 0, err + } + n := binary.BigEndian.Uint16(data[:]) + if n == 0 { + return 0, SessionClosed + } + _, err = io.CopyN(c.readBuffer, conn, int64(n)) + if err != nil { + return 0, err + } + } + return c.readBuffer.Read(b) +} + +func (c *failoverConn) write(conn net.Conn, b []byte) (int, error) { + buffer := make([]byte, 2+len(b)) + binary.BigEndian.PutUint16(buffer, uint16(len(b))) + copy(buffer[2:], b) + n, err := conn.Write(buffer) + return n - 2, err +} + +func (c *failoverConn) awaitConn(oldConn net.Conn) error { + c.awaitMtx.Lock() + defer c.awaitMtx.Unlock() + if c.err != nil { + return c.err + } + if c.Conn != oldConn { + return c.ctx.Err() + } + oldConn.Close() + timer := time.NewTimer(C.TCPConnectTimeout) + defer timer.Stop() + if c.dial != nil { + for { + select { + case <-c.ctx.Done(): + return c.ctx.Err() + case <-timer.C: + c.err = SessionExpired + return c.err + default: + } + conn, err := c.dial() + if err != nil { + if err == SessionNotFound { + c.err = err + return err + } + continue + } + err = c.RestoreConn(conn) + if err != nil { + if err == SessionBroken { + c.err = err + return err + } + continue + } + return nil + } + } else { + c.await = make(chan struct{}) + select { + case <-c.await: + case <-timer.C: + c.err = SessionExpired + return c.err + case <-c.ctx.Done(): + return c.ctx.Err() + } + } + return nil +} diff --git a/protocol/failover/inbound.go b/protocol/failover/inbound.go new file mode 100644 index 00000000..91ee029f --- /dev/null +++ b/protocol/failover/inbound.go @@ -0,0 +1,136 @@ +package failover + +import ( + "context" + "errors" + "net" + "sync" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/inbound" + "github.com/sagernet/sing-box/common/kmutex" + "github.com/sagernet/sing-box/common/uot" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterInbound(registry *inbound.Registry) { + inbound.Register[option.FailoverInboundOptions](registry, C.TypeFailover, NewInbound) +} + +type Inbound struct { + inbound.Adapter + logger logger.ContextLogger + router adapter.ConnectionRouterEx + inbounds []adapter.Inbound + conns map[uuid.UUID]*failoverConn + + sessionMtx *kmutex.Kmutex[uuid.UUID] + mtx sync.RWMutex +} + +func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverInboundOptions) (adapter.Inbound, error) { + if len(options.Inbounds) == 0 { + return nil, E.New("missing inbounds") + } + inbound := &Inbound{ + Adapter: inbound.NewAdapter(C.TypeFailover, tag), + logger: logger, + router: uot.NewRouter(router, logger), + conns: make(map[uuid.UUID]*failoverConn), + sessionMtx: kmutex.New[uuid.UUID](), + } + router = NewRouter(router, logger, inbound.connHandler) + inboundRegistry := service.FromContext[adapter.InboundRegistry](ctx) + inbounds := make([]adapter.Inbound, len(options.Inbounds)) + for i, inboundOptions := range options.Inbounds { + inbound, err := inboundRegistry.UnsafeCreate(ctx, router, logger, inboundOptions.Tag, inboundOptions.Type, inboundOptions.Options) + if err != nil { + return nil, err + } + inbounds[i] = inbound + } + inbound.inbounds = inbounds + return inbound, nil +} + +func (h *Inbound) Start(stage adapter.StartStage) error { + for _, inbound := range h.inbounds { + err := inbound.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Inbound) Close() error { + errs := make([]error, 0) + for _, inbound := range h.inbounds { + err := inbound.Close() + if err != nil { + errs = append(errs, err) + } + } + if len(errs) != 0 { + return errors.Join(errs...) + } + return nil +} + +func (h *Inbound) connHandler(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) error { + if metadata.Destination != Destination { + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) + return nil + } + request, err := ReadRequest(conn) + if err != nil { + return err + } + sessionUUID := request.UUID + h.sessionMtx.Lock(sessionUUID) + if request.Command == CommandTCP { + failoverConn := NewFailoverConn(ctx, conn, nil, func() { + h.sessionMtx.Lock(sessionUUID) + h.mtx.Lock() + defer h.sessionMtx.Unlock(sessionUUID) + defer h.mtx.Unlock() + delete(h.conns, sessionUUID) + }) + h.mtx.Lock() + h.conns[sessionUUID] = failoverConn + h.mtx.Unlock() + metadata.Inbound = h.Tag() + metadata.InboundType = C.TypeFailover + metadata.Destination = request.Destination + h.sessionMtx.Unlock(sessionUUID) + h.router.RouteConnectionEx(ctx, failoverConn, metadata, onClose) + return nil + } + if request.Command == CommandReconnect { + h.mtx.RLock() + serverConn, ok := h.conns[sessionUUID] + h.mtx.RUnlock() + if !ok { + _, err := conn.Write([]byte{StatusSessionNotFound}) + if err != nil { + return err + } + return SessionNotFound + } + _, err = conn.Write([]byte{StatusOK}) + if err != nil { + return err + } + err := serverConn.RestoreConn(conn) + h.sessionMtx.Unlock(sessionUUID) + return err + } + return E.New("command ", request.Command, " not found") +} diff --git a/protocol/failover/outbound.go b/protocol/failover/outbound.go new file mode 100644 index 00000000..d2cc4dc3 --- /dev/null +++ b/protocol/failover/outbound.go @@ -0,0 +1,109 @@ +package failover + +import ( + "context" + "io" + "net" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/uot" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.FailoverOutboundOptions](registry, C.TypeFailover, NewFailover) +} + +type Failover struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + logger logger.ContextLogger + dial DialStrategy + uotClient *uot.Client +} + +func NewFailover(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.FailoverOutboundOptions) (adapter.Outbound, error) { + if len(options.Outbounds) == 0 { + return nil, E.New("missing outbounds") + } + outboundRegistry := service.FromContext[adapter.OutboundRegistry](ctx) + outbounds := make([]adapter.Outbound, len(options.Outbounds)) + for i, outboundOptions := range options.Outbounds { + outbound, err := outboundRegistry.UnsafeCreateOutbound(ctx, router, logger, outboundOptions.Tag, outboundOptions.Type, outboundOptions.Options) + if err != nil { + return nil, err + } + outbounds[i] = outbound + } + dial, err := CreateStrategy(options.Strategy, outbounds, logger, options.Delay.Build()) + if err != nil { + return nil, err + } + outbound := &Failover{ + Adapter: outbound.NewAdapter(C.TypeFailover, tag, []string{N.NetworkTCP, N.NetworkUDP}, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + logger: logger, + dial: dial, + } + outbound.uotClient = &uot.Client{ + Dialer: outbound, + Version: uot.Version, + } + return outbound, nil +} + +func (f *Failover) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if N.NetworkName(network) == N.NetworkUDP { + return f.uotClient.DialContext(ctx, network, destination) + } + conn, err := f.dial(ctx, network, Destination) + if err != nil { + return nil, err + } + sessionUUID, err := uuid.NewV4() + if err != nil { + return nil, err + } + err = WriteRequest(conn, &Request{Command: CommandTCP, UUID: sessionUUID, Destination: destination}) + if err != nil { + return nil, err + } + return NewFailoverConn(ctx, conn, func() (net.Conn, error) { + ctx, cancel := context.WithTimeout(ctx, C.TCPConnectTimeout) + defer cancel() + conn, err := f.dial(ctx, network, Destination) + if err != nil { + return nil, err + } + err = WriteRequest(conn, &Request{Command: CommandReconnect, UUID: sessionUUID, Destination: destination}) + if err != nil { + return nil, err + } + var data [1]byte + _, err = io.ReadFull(conn, data[:]) + if err != nil { + return nil, err + } + var status uint8 = data[0] + if status == StatusSessionNotFound { + conn.Close() + return nil, SessionNotFound + } + return conn, nil + }, nil), nil +} + +func (f *Failover) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + return f.uotClient.ListenPacket(ctx, destination) +} diff --git a/protocol/failover/protocol.go b/protocol/failover/protocol.go new file mode 100644 index 00000000..52845f49 --- /dev/null +++ b/protocol/failover/protocol.go @@ -0,0 +1,97 @@ +package failover + +import ( + "encoding/binary" + "io" + + "github.com/gofrs/uuid/v5" + "github.com/sagernet/sing/common" + "github.com/sagernet/sing/common/buf" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" +) + +const ( + Version = 0 + BufferSize = 10 +) + +const ( + CommandTCP = 1 + CommandReconnect = 2 +) + +const ( + StatusOK uint8 = iota + 1 + StatusSessionNotFound +) + +var ( + SessionClosed = E.New("session closed") + SessionNotFound = E.New("session not found") + SessionExpired = E.New("session expired") + SessionBroken = E.New("session broken") +) + +var Destination = M.Socksaddr{ + Fqdn: "sp.failover.sing-box.arpa", + Port: 444, +} + +var AddressSerializer = M.NewSerializer( + M.AddressFamilyByte(0x01, M.AddressFamilyIPv4), + M.AddressFamilyByte(0x03, M.AddressFamilyIPv6), + M.AddressFamilyByte(0x02, M.AddressFamilyFqdn), + M.PortThenAddress(), +) + +type Request struct { + UUID uuid.UUID + Command byte + Destination M.Socksaddr +} + +func ReadRequest(reader io.Reader) (*Request, error) { + var request Request + var version uint8 + err := binary.Read(reader, binary.BigEndian, &version) + if err != nil { + return nil, err + } + if version != Version { + return nil, E.New("unknown version: ", version) + } + _, err = io.ReadFull(reader, request.UUID[:]) + if err != nil { + return nil, err + } + err = binary.Read(reader, binary.BigEndian, &request.Command) + if err != nil { + return nil, err + } + request.Destination, err = AddressSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return &request, nil +} + +func WriteRequest(writer io.Writer, request *Request) error { + var requestLen int + requestLen += 1 // version + requestLen += 1 // command + requestLen += 16 // UUID + requestLen += AddressSerializer.AddrPortLen(request.Destination) + buffer := buf.NewSize(requestLen) + defer buffer.Release() + common.Must( + buffer.WriteByte(Version), + common.Error(buffer.Write(request.UUID[:])), + buffer.WriteByte(request.Command), + ) + err := AddressSerializer.WriteAddrPort(buffer, request.Destination) + if err != nil { + return err + } + return common.Error(writer.Write(buffer.Bytes())) +} diff --git a/protocol/failover/router.go b/protocol/failover/router.go new file mode 100644 index 00000000..a0cdafbe --- /dev/null +++ b/protocol/failover/router.go @@ -0,0 +1,55 @@ +package failover + +import ( + "context" + "net" + "os" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing/common/logger" + N "github.com/sagernet/sing/common/network" +) + +type Router struct { + adapter.Router + logger logger.ContextLogger + handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error +} + +func NewRouter(router adapter.Router, logger logger.ContextLogger, handler func(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) error) *Router { + return &Router{Router: router, logger: logger, handler: handler} +} + +func (r *Router) RouteConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RouteConnection(ctx, conn, metadata) + } + return r.handler(ctx, conn, metadata, func(error) {}) +} + +func (r *Router) RoutePacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error { + if metadata.Destination != Destination { + return r.Router.RoutePacketConnection(ctx, conn, metadata) + } + return os.ErrInvalid +} + +func (r *Router) RouteConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RouteConnectionEx(ctx, conn, metadata, onClose) + return + } + if err := r.handler(ctx, conn, metadata, onClose); err != nil { + r.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) + } +} + +func (r *Router) RoutePacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if metadata.Destination != Destination { + r.Router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) + return + } + r.logger.ErrorContext(ctx, os.ErrInvalid) + N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) +} diff --git a/protocol/failover/strategy.go b/protocol/failover/strategy.go new file mode 100644 index 00000000..dc20d9a1 --- /dev/null +++ b/protocol/failover/strategy.go @@ -0,0 +1,70 @@ +package failover + +import ( + "context" + "net" + "time" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" +) + +type DialStrategy = func(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) + +func cycleStrategy(outbounds []adapter.Outbound, logger logger.ContextLogger, delay time.Duration) DialStrategy { + return func(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + for { + for _, outbound := range outbounds { + conn, err := outbound.DialContext(ctx, network, destination) + if err != nil { + logger.InfoContext(ctx, err) + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + continue + } + return conn, nil + } + } + } +} + +func sequentialStrategy(outbounds []adapter.Outbound, logger logger.ContextLogger, delay time.Duration) DialStrategy { + return func(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + var err error + for _, outbound := range outbounds { + var conn net.Conn + conn, err = outbound.DialContext(ctx, network, destination) + if err != nil { + logger.InfoContext(ctx, err) + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + continue + } + return conn, nil + } + return nil, err + } +} + +func CreateStrategy(strategy string, outbounds []adapter.Outbound, logger logger.ContextLogger, delay time.Duration) (DialStrategy, error) { + switch strategy { + case "cycle": + return cycleStrategy(outbounds, logger, delay), nil + case "sequential", "": + return sequentialStrategy(outbounds, logger, delay), nil + default: + return nil, E.New("strategy not found: ", strategy) + } +} diff --git a/protocol/group/fallback.go b/protocol/group/fallback.go index 57353362..e7d37f72 100644 --- a/protocol/group/fallback.go +++ b/protocol/group/fallback.go @@ -21,9 +21,7 @@ func RegisterFallback(registry *outbound.Registry) { outbound.Register[option.FallbackOutboundOptions](registry, C.TypeFallback, NewFallback) } -var ( - _ adapter.OutboundGroup = (*Fallback)(nil) -) +var _ adapter.OutboundGroup = (*Fallback)(nil) type Fallback struct { outbound.Adapter @@ -80,7 +78,7 @@ func (s *Fallback) DialContext(ctx context.Context, network string, destination for _, outbound := range s.outbounds { conn, err = outbound.DialContext(ctx, network, destination) if err != nil { - s.logger.ErrorContext(ctx, err) + s.logger.InfoContext(ctx, err) continue } s.mtx.Lock() @@ -97,7 +95,7 @@ func (s *Fallback) ListenPacket(ctx context.Context, destination M.Socksaddr) (n for _, outbound := range s.outbounds { conn, err = outbound.ListenPacket(ctx, destination) if err != nil { - s.logger.ErrorContext(ctx, err) + s.logger.InfoContext(ctx, err) continue } s.mtx.Lock() diff --git a/protocol/limiter/bandwidth/conn.go b/protocol/limiter/bandwidth/conn.go index 06796e27..dffbfbd7 100644 --- a/protocol/limiter/bandwidth/conn.go +++ b/protocol/limiter/bandwidth/conn.go @@ -3,8 +3,6 @@ package bandwidth import ( "context" "net" - - "golang.org/x/time/rate" ) type connWithDownloadBandwidthLimiter struct { @@ -13,7 +11,7 @@ type connWithDownloadBandwidthLimiter struct { limiter Limiter } -func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithDownloadBandwidthLimiter { +func NewConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter Limiter) *connWithDownloadBandwidthLimiter { return &connWithDownloadBandwidthLimiter{conn, ctx, limiter} } @@ -28,12 +26,11 @@ func (conn *connWithDownloadBandwidthLimiter) Write(p []byte) (n int, err error) type connWithUploadBandwidthLimiter struct { net.Conn ctx context.Context - limiter *rate.Limiter - burst int + limiter Limiter } -func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter *rate.Limiter) *connWithUploadBandwidthLimiter { - return &connWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +func NewConnWithUploadBandwidthLimiter(ctx context.Context, conn net.Conn, limiter Limiter) *connWithUploadBandwidthLimiter { + return &connWithUploadBandwidthLimiter{conn, ctx, limiter} } func (conn *connWithUploadBandwidthLimiter) Read(p []byte) (n int, err error) { @@ -65,12 +62,11 @@ func (conn *connWithCloseHandler) Close() error { type packetConnWithDownloadBandwidthLimiter struct { net.PacketConn ctx context.Context - limiter *rate.Limiter - burst int + limiter Limiter } -func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithDownloadBandwidthLimiter { - return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +func NewPacketConnWithDownloadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter Limiter) *packetConnWithDownloadBandwidthLimiter { + return &packetConnWithDownloadBandwidthLimiter{conn, ctx, limiter} } func (conn *packetConnWithDownloadBandwidthLimiter) WriteTo(p []byte, addr net.Addr) (n int, err error) { @@ -85,11 +81,10 @@ type packetConnWithUploadBandwidthLimiter struct { net.PacketConn ctx context.Context limiter Limiter - burst int } -func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter) *packetConnWithUploadBandwidthLimiter { - return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter, limiter.Burst()} +func NewPacketConnWithUploadBandwidthLimiter(ctx context.Context, conn net.PacketConn, limiter Limiter) *packetConnWithUploadBandwidthLimiter { + return &packetConnWithUploadBandwidthLimiter{conn, ctx, limiter} } func (conn *packetConnWithUploadBandwidthLimiter) ReadFrom(p []byte) (n int, addr net.Addr, err error) { @@ -117,3 +112,39 @@ func (conn *packetConnWithCloseHandler) Close() error { conn.onClose() return conn.PacketConn.Close() } + +func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { + if reverse { + return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) + } + return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) +} + +func connWithUploadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { + if reverse { + return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func connWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn { + return NewConnWithUploadBandwidthLimiter(ctx, NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} + +func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { + if reverse { + return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) + } + return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) +} + +func packetConnWithUploadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { + if reverse { + return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + } + return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) +} + +func packetConnWithBidirectionalBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn { + return NewPacketConnWithUploadBandwidthLimiter(ctx, NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) +} diff --git a/protocol/limiter/bandwidth/limiter.go b/protocol/limiter/bandwidth/limiter.go index 95655b3c..15ed5c42 100644 --- a/protocol/limiter/bandwidth/limiter.go +++ b/protocol/limiter/bandwidth/limiter.go @@ -2,8 +2,130 @@ package bandwidth import ( "context" + "slices" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" ) type Limiter interface { WaitN(ctx context.Context, n int) (err error) } + +type FlowKeysLimiter struct { + limiter Limiter + connIDGetter ConnIDGetter + + waits map[string][]*wait + conns map[string]int + queue chan struct{} + reset time.Time + + mtx sync.Mutex +} + +func NewFlowKeysLimiter(connIDGetter ConnIDGetter, limiter Limiter) *FlowKeysLimiter { + return &FlowKeysLimiter{ + limiter: limiter, + connIDGetter: connIDGetter, + waits: make(map[string][]*wait), + conns: make(map[string]int), + queue: make(chan struct{}, 1), + reset: time.Now().Add(time.Second), + } +} + +func (l *FlowKeysLimiter) WaitN(ctx context.Context, n int) error { + id, _ := l.connIDGetter(ctx, adapter.ContextFrom(ctx)) + mainWait := &wait{ctx, make(chan struct{}), n} + l.mtx.Lock() + if waits, ok := l.waits[id]; ok { + l.waits[id] = append(waits, mainWait) + } else { + l.waits[id] = []*wait{mainWait} + } + l.mtx.Unlock() + select { + case l.queue <- struct{}{}: + case <-mainWait.finish: + return nil + case <-ctx.Done(): + l.mtx.Lock() + for i, wait := range l.waits[id] { + if wait == mainWait { + l.waits[id] = slices.Delete(l.waits[id], i, i+1) + close(wait.finish) + break + } + } + l.mtx.Unlock() + return ctx.Err() + } + for { + if ctx.Err() != nil { + l.mtx.Lock() + for i, wait := range l.waits[id] { + if wait == mainWait { + l.waits[id] = slices.Delete(l.waits[id], i, i+1) + close(wait.finish) + break + } + } + l.mtx.Unlock() + <-l.queue + return ctx.Err() + } + now := time.Now() + if l.reset.Compare(now) == -1 { + clear(l.conns) + l.reset = now.Add(time.Second) + } + l.mtx.Lock() + var minConnId string + var minN int + for connID, waits := range l.waits { + if len(waits) == 0 { + continue + } + if n, ok := l.conns[connID]; ok { + if minConnId == "" { + minConnId = connID + minN = n + continue + } + if n+waits[0].n < minN { + minConnId = connID + minN = n + } + } else { + l.conns[connID] = 0 + minConnId = connID + break + } + } + minWait := l.waits[minConnId][0] + l.waits[minConnId][0] = nil + l.waits[minConnId] = l.waits[minConnId][1:] + if len(l.waits) == 0 { + delete(l.waits, minConnId) + } + l.mtx.Unlock() + err := l.limiter.WaitN(ctx, minWait.n) + if err != nil { + continue + } + l.conns[minConnId] = l.conns[minConnId] + minWait.n + close(minWait.finish) + if minWait == mainWait { + <-l.queue + return nil + } + } +} + +type wait struct { + ctx context.Context + finish chan struct{} + n int +} diff --git a/protocol/limiter/bandwidth/outbound.go b/protocol/limiter/bandwidth/outbound.go index 92782278..efa2f083 100644 --- a/protocol/limiter/bandwidth/outbound.go +++ b/protocol/limiter/bandwidth/outbound.go @@ -48,7 +48,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL case "users": usersStrategies := make(map[string]BandwidthStrategy, len(options.Users)) for _, user := range options.Users { - userStrategy, err := CreateStrategy(user.Strategy, user.Mode, user.ConnectionType, options.Speed.Value()) + userStrategy, err := CreateStrategy(user.Strategy, user.Mode, user.ConnectionType, options.Speed.Value(), options.FlowKeys) if err != nil { return nil, err } @@ -58,7 +58,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL case "manager": strategy = NewManagerBandwidthStrategy() default: - strategy, err = CreateStrategy(options.Strategy, options.Mode, options.ConnectionType, options.Speed.Value()) + strategy, err = CreateStrategy(options.Strategy, options.Mode, options.ConnectionType, options.Speed.Value(), options.FlowKeys) if err != nil { return nil, err } @@ -118,9 +118,11 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n } func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = adapter.WithContext(ctx, &metadata) conn, err := h.strategy.wrapConn(ctx, conn, &metadata, false) if err != nil { h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) return } metadata.Inbound = h.Tag() @@ -130,9 +132,11 @@ func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata } func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + ctx = adapter.WithContext(ctx, &metadata) packetConn, err := h.strategy.wrapPacketConn(ctx, bufio.NewNetPacketConn(conn), &metadata, false) if err != nil { h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) return } metadata.Inbound = h.Tag() diff --git a/protocol/limiter/bandwidth/strategy.go b/protocol/limiter/bandwidth/strategy.go index 92db1f64..14061ee8 100644 --- a/protocol/limiter/bandwidth/strategy.go +++ b/protocol/limiter/bandwidth/strategy.go @@ -15,8 +15,8 @@ import ( type ( CloseHandlerFunc = func() ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) - ConnWrapper = func(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn - PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn + ConnWrapper = func(ctx context.Context, conn net.Conn, limiter Limiter, reverse bool) net.Conn + PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter Limiter, reverse bool) net.PacketConn ) type BandwidthStrategy interface { @@ -25,7 +25,7 @@ type BandwidthStrategy interface { } type BandwidthLimiterStrategy interface { - getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) + getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) } type DefaultWrapStrategy struct { @@ -55,21 +55,25 @@ func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.Packe } type GlobalBandwidthStrategy struct { - limiter *rate.Limiter + limiter Limiter } -func NewGlobalBandwidthStrategy(speed uint64) *GlobalBandwidthStrategy { - return &GlobalBandwidthStrategy{ - limiter: createSpeedLimiter(speed), +func NewGlobalBandwidthStrategy(speed uint64, flowKeys []string) (*GlobalBandwidthStrategy, error) { + limiter, err := createSpeedLimiter(speed, flowKeys) + if err != nil { + return nil, err } + return &GlobalBandwidthStrategy{ + limiter: limiter, + }, nil } -func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { +func (s *GlobalBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) { return s.limiter, func() {}, nil } type idBandwidthLimiter struct { - limiter *rate.Limiter + limiter Limiter handles uint32 } @@ -77,18 +81,20 @@ type ConnectionBandwidthStrategy struct { limiters map[string]*idBandwidthLimiter connIDGetter ConnIDGetter speed uint64 + flowKeys []string mtx sync.Mutex } -func NewConnectionBandwidthStrategy(connIDGetter ConnIDGetter, speed uint64) *ConnectionBandwidthStrategy { +func NewConnectionBandwidthStrategy(connIDGetter ConnIDGetter, speed uint64, flowKeys []string) *ConnectionBandwidthStrategy { return &ConnectionBandwidthStrategy{ limiters: make(map[string]*idBandwidthLimiter), connIDGetter: connIDGetter, speed: speed, + flowKeys: flowKeys, } } -func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (*rate.Limiter, CloseHandlerFunc, error) { +func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (Limiter, CloseHandlerFunc, error) { s.mtx.Lock() defer s.mtx.Unlock() id, ok := s.connIDGetter(ctx, metadata) @@ -97,8 +103,12 @@ func (s *ConnectionBandwidthStrategy) getLimiter(ctx context.Context, metadata * } limiter, ok := s.limiters[id] if !ok { + newLimiter, err := createSpeedLimiter(s.speed, s.flowKeys) + if err != nil { + return nil, nil, err + } limiter = &idBandwidthLimiter{ - limiter: createSpeedLimiter(s.speed), + limiter: newLimiter, } s.limiters[id] = limiter } @@ -173,14 +183,37 @@ func (s *ManagerBandwidthStrategy) UpdateStrategies(strategies map[string]Bandwi s.strategies = strategies } -func CreateStrategy(strategy string, mode string, connectionType string, speed uint64) (BandwidthStrategy, error) { +type BypassBandwidthStrategy struct{} + +func NewBypassBandwidthStrategy() *BypassBandwidthStrategy { + return &BypassBandwidthStrategy{} +} + +func (s *BypassBandwidthStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + return conn, nil +} + +func (s *BypassBandwidthStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + return conn, nil +} + +func CreateStrategy(strategy string, mode string, connectionType string, speed uint64, flowKeys []string) (BandwidthStrategy, error) { var limiterStrategy BandwidthLimiterStrategy switch strategy { case "global": - limiterStrategy = NewGlobalBandwidthStrategy(speed) + var err error + limiterStrategy, err = NewGlobalBandwidthStrategy(speed, flowKeys) + if err != nil { + return nil, err + } case "connection": var connIDGetter ConnIDGetter switch connectionType { + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } case "mux": connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { id, ok := log.MuxIDFromContext(ctx) @@ -189,19 +222,24 @@ func CreateStrategy(strategy string, mode string, connectionType string, speed u } return strconv.FormatUint(uint64(id.ID), 10), ok } - case "hwid": - connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { - id, ok := ctx.Value("hwid").(string) - return id, ok - } - case "ip": + case "source_ip": connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { return metadata.Source.IPAddr().String(), true } + case "default", "": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.IDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } default: return nil, E.New("connection type not found: ", connectionType) } - limiterStrategy = NewConnectionBandwidthStrategy(connIDGetter, speed) + limiterStrategy = NewConnectionBandwidthStrategy(connIDGetter, speed, flowKeys) + case "bypass": + return NewBypassBandwidthStrategy(), nil default: return nil, E.New("strategy not found: ", strategy) } @@ -216,51 +254,55 @@ func CreateStrategy(strategy string, mode string, connectionType string, speed u case "upload": connWrapper = connWithUploadBandwidthWrapper packetConnWrapper = packetConnWithUploadBandwidthWrapper - case "duplex": - connWrapper = connWithDuplexBandwidthWrapper - packetConnWrapper = packetConnWithDuplexBandwidthWrapper + case "bidirectional": + connWrapper = connWithBidirectionalBandwidthWrapper + packetConnWrapper = packetConnWithBidirectionalBandwidthWrapper default: return nil, E.New("mode not found: ", mode) } return NewDefaultWrapStrategy(limiterStrategy, connWrapper, packetConnWrapper), nil } -func createSpeedLimiter(speed uint64) *rate.Limiter { - return rate.NewLimiter(rate.Limit(float64(speed)), 65536) -} - -func connWithDownloadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { - if reverse { - return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) +func createSpeedLimiter(speed uint64, flowKeys []string) (Limiter, error) { + var limiter Limiter = rate.NewLimiter(rate.Limit(float64(speed)), 65536) + for i := len(flowKeys) - 1; i >= 0; i-- { + getter, err := flowKeysConnIDGetter(flowKeys[i]) + if err != nil { + return nil, err + } + limiter = NewFlowKeysLimiter(getter, limiter) } - return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) + return limiter, nil } -func connWithUploadBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { - if reverse { - return NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter) +func flowKeysConnIDGetter(name string) (ConnIDGetter, error) { + switch name { + case "user": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.User, true + }, nil + case "destination": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Destination.String(), true + }, nil + case "source_ip": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + }, nil + case "hwid": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + }, nil + case "mux": + return func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + }, nil + default: + return nil, E.New("flow key not found: ", name) } - return NewConnWithUploadBandwidthLimiter(ctx, conn, limiter) -} - -func connWithDuplexBandwidthWrapper(ctx context.Context, conn net.Conn, limiter *rate.Limiter, reverse bool) net.Conn { - return NewConnWithUploadBandwidthLimiter(ctx, NewConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) -} - -func packetConnWithDownloadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { - if reverse { - return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) - } - return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) -} - -func packetConnWithUploadBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { - if reverse { - return NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter) - } - return NewPacketConnWithUploadBandwidthLimiter(ctx, conn, limiter) -} - -func packetConnWithDuplexBandwidthWrapper(ctx context.Context, conn net.PacketConn, limiter *rate.Limiter, reverse bool) net.PacketConn { - return NewPacketConnWithUploadBandwidthLimiter(ctx, NewPacketConnWithDownloadBandwidthLimiter(ctx, conn, limiter), limiter) } diff --git a/protocol/limiter/connection/outbound.go b/protocol/limiter/connection/outbound.go index e37ee23d..722a7ea0 100644 --- a/protocol/limiter/connection/outbound.go +++ b/protocol/limiter/connection/outbound.go @@ -138,6 +138,7 @@ func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) if err != nil { h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) return } conn = newConnWithCloseHandlerFunc(conn, limiterOnClose) @@ -154,6 +155,7 @@ func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, limiterOnClose, lockCtx, err := h.strategy.request(ctx, &metadata) if err != nil { h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) return } conn = bufio.NewPacketConn(newPacketConnWithCloseHandlerFunc(bufio.NewNetPacketConn(conn), limiterOnClose)) diff --git a/protocol/limiter/connection/strategy.go b/protocol/limiter/connection/strategy.go index b90db995..b32d19e3 100644 --- a/protocol/limiter/connection/strategy.go +++ b/protocol/limiter/connection/strategy.go @@ -87,11 +87,26 @@ func (s *ManagerConnectionStrategy) UpdateStrategies(strategies map[string]Conne s.strategies = strategies } +type BypassConnectionStrategy struct{} + +func NewBypassConnectionStrategy() *BypassConnectionStrategy { + return &BypassConnectionStrategy{} +} + +func (s *BypassConnectionStrategy) request(ctx context.Context, metadata *adapter.InboundContext) (CloseHandlerFunc, context.Context, error) { + return func() {}, nil, nil +} + func CreateStrategy(strategy string, connectionType string, lockIDGetter LockIDGetter) (ConnectionStrategy, error) { switch strategy { case "connection": var connIDGetter ConnIDGetter switch connectionType { + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } case "mux": connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { id, ok := log.MuxIDFromContext(ctx) @@ -100,19 +115,24 @@ func CreateStrategy(strategy string, connectionType string, lockIDGetter LockIDG } return strconv.FormatUint(uint64(id.ID), 10), ok } - case "hwid": - connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { - id, ok := ctx.Value("hwid").(string) - return id, ok - } - case "ip": + case "source_ip": connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { return metadata.Source.IPAddr().String(), true } + case "default", "": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.IDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } default: return nil, E.New("connection type not found: ", connectionType) } return NewDefaultConnectionStrategy(connIDGetter, lockIDGetter), nil + case "bypass": + return NewBypassConnectionStrategy(), nil default: return nil, E.New("strategy not found: ", strategy) } diff --git a/protocol/limiter/rate/outbound.go b/protocol/limiter/rate/outbound.go new file mode 100644 index 00000000..35973cc1 --- /dev/null +++ b/protocol/limiter/rate/outbound.go @@ -0,0 +1,140 @@ +package rate + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.RateLimiterOutboundOptions](registry, C.TypeRateLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy RateStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.RateLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Strategy == "" { + return nil, E.New("missing strategy") + } + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + var strategy RateStrategy + var err error + switch options.Strategy { + case "users": + usersStrategies := make(map[string]RateStrategy, len(options.Users)) + for _, user := range options.Users { + userStrategy, err := CreateStrategy(user.Strategy, user.ConnectionType, int(user.Count), user.Interval.Build()) + if err != nil { + return nil, err + } + usersStrategies[user.Name] = userStrategy + } + strategy = NewUsersRateStrategy(usersStrategies) + case "manager": + strategy = NewManagerRateStrategy() + default: + strategy, err = CreateStrategy(options.Strategy, options.ConnectionType, int(options.Count), options.Interval.Build()) + if err != nil { + return nil, err + } + } + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err = r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeRateLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + outboundTag: options.Route.Final, + strategy: strategy, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if err := h.strategy.request(ctx, adapter.ContextFrom(ctx)); err != nil { + return nil, err + } + return h.detour.DialContext(ctx, network, destination) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if err := h.strategy.request(ctx, adapter.ContextFrom(ctx)); err != nil { + return nil, err + } + return h.detour.ListenPacket(ctx, destination) +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if err := h.strategy.request(ctx, &metadata); err != nil { + h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + if err := h.strategy.request(ctx, &metadata); err != nil { + h.logger.ErrorContext(ctx, err) + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Outbound) GetStrategy() RateStrategy { + return h.strategy +} diff --git a/protocol/limiter/rate/strategy.go b/protocol/limiter/rate/strategy.go new file mode 100644 index 00000000..4f5c5475 --- /dev/null +++ b/protocol/limiter/rate/strategy.go @@ -0,0 +1,196 @@ +package rate + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "github.com/AliRizaAynaci/gorl/v2" + "github.com/AliRizaAynaci/gorl/v2/core" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/log" + E "github.com/sagernet/sing/common/exceptions" + "github.com/shtorm-7/go-cache/v2" +) + +type ( + ConnIDGetter = func(context.Context, *adapter.InboundContext) (string, bool) + RateGetter = func(id string) error + + RateStrategy interface { + request(ctx context.Context, metadata *adapter.InboundContext) error + } +) + +type DefaultRateStrategy struct { + limiter core.Limiter + queue chan struct{} +} + +func NewDefaultRateStrategy(strategy string, count int, interval time.Duration) (*DefaultRateStrategy, error) { + limiter, err := gorl.New(core.Config{ + Strategy: core.StrategyType(strings.ReplaceAll(strategy, "-", "_")), + Limit: count, + Window: interval, + }) + if err != nil { + return nil, err + } + return &DefaultRateStrategy{limiter: limiter, queue: make(chan struct{}, 1)}, nil +} + +func (s *DefaultRateStrategy) request(ctx context.Context, metadata *adapter.InboundContext) error { + select { + case s.queue <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { + <-s.queue + }() + r, err := s.limiter.Allow(ctx, metadata.Destination.String()) + if err != nil { + return err + } + if !r.Allowed { + select { + case <-time.After(r.RetryAfter): + _, err = s.limiter.Allow(ctx, metadata.Destination.String()) + return err + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func (s *DefaultRateStrategy) close() error { + return s.limiter.Close() +} + +type ConnectionRateStrategy struct { + connIDGetter ConnIDGetter + createStrategy func() (*DefaultRateStrategy, error) + limiters *cache.Cache[string, *DefaultRateStrategy] + + mtx sync.Mutex +} + +func NewConnectionRateStrategy(connIDGetter ConnIDGetter, strategy string, count int, interval time.Duration) (*ConnectionRateStrategy, error) { + limiters := cache.New[string, *DefaultRateStrategy](interval, time.Second) + limiters.OnEvicted(func(s string, strategy *DefaultRateStrategy) { + strategy.close() + }) + return &ConnectionRateStrategy{ + connIDGetter: connIDGetter, + createStrategy: func() (*DefaultRateStrategy, error) { + return NewDefaultRateStrategy(strategy, count, interval) + }, + limiters: limiters, + }, nil +} + +func (s *ConnectionRateStrategy) request(ctx context.Context, metadata *adapter.InboundContext) error { + id, ok := s.connIDGetter(ctx, metadata) + if !ok { + return E.New("id not found") + } + s.mtx.Lock() + strategy, ok := s.limiters.Get(id) + if !ok { + newStrategy, err := s.createStrategy() + if err != nil { + return err + } + s.limiters.SetDefault(id, newStrategy) + strategy = newStrategy + } else { + s.limiters.UpdateExpirationDefault(id) + } + s.mtx.Unlock() + return strategy.request(ctx, metadata) +} + +type UsersRateStrategy struct { + strategies map[string]RateStrategy + mtx sync.Mutex +} + +func NewUsersRateStrategy(strategies map[string]RateStrategy) *UsersRateStrategy { + return &UsersRateStrategy{ + strategies: strategies, + } +} + +func (s *UsersRateStrategy) request(ctx context.Context, metadata *adapter.InboundContext) error { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy.request(ctx, metadata) + } + return E.New("user strategy not found: ", user) +} + +type ManagerRateStrategy struct { + *UsersRateStrategy +} + +func NewManagerRateStrategy() *ManagerRateStrategy { + return &ManagerRateStrategy{ + UsersRateStrategy: NewUsersRateStrategy(map[string]RateStrategy{}), + } +} + +func (s *ManagerRateStrategy) UpdateStrategies(strategies map[string]RateStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +type BypassRateStrategy struct{} + +func NewBypassRateStrategy() *BypassRateStrategy { + return &BypassRateStrategy{} +} + +func (s *BypassRateStrategy) request(ctx context.Context, metadata *adapter.InboundContext) error { + return nil +} + +func CreateStrategy(strategy string, connectionType string, count int, interval time.Duration) (RateStrategy, error) { + if strategy == "bypass" { + return NewBypassRateStrategy(), nil + } + var connIDGetter ConnIDGetter + switch connectionType { + case "hwid": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := ctx.Value("hwid").(string) + return id, ok + } + case "mux": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + id, ok := log.MuxIDFromContext(ctx) + if !ok { + return "", ok + } + return strconv.FormatUint(uint64(id.ID), 10), ok + } + case "source_ip": + connIDGetter = func(ctx context.Context, metadata *adapter.InboundContext) (string, bool) { + return metadata.Source.IPAddr().String(), true + } + case "default", "": + return NewDefaultRateStrategy(strategy, count, interval) + default: + return nil, E.New("connection type not found: ", connectionType) + } + return NewConnectionRateStrategy(connIDGetter, strategy, count, interval) +} diff --git a/protocol/limiter/traffic/conn.go b/protocol/limiter/traffic/conn.go new file mode 100644 index 00000000..26c4bb63 --- /dev/null +++ b/protocol/limiter/traffic/conn.go @@ -0,0 +1,146 @@ +package traffic + +import ( + "context" + "net" +) + +type connWithTrafficLimiter struct { + net.Conn + ctx context.Context + limiter TrafficLimiter +} + +func newConnWithDownloadTrafficLimiter(ctx context.Context, conn net.Conn, limiter TrafficLimiter) net.Conn { + return &connWithTrafficLimiter{Conn: conn, ctx: ctx, limiter: limiter} +} + +func newConnWithUploadTrafficLimiter(ctx context.Context, conn net.Conn, limiter TrafficLimiter) net.Conn { + return &connWithUploadTrafficLimiter{Conn: conn, ctx: ctx, limiter: limiter} +} + +func (conn *connWithTrafficLimiter) Write(p []byte) (int, error) { + err := conn.limiter.Can(uint64(len(p))) + if err != nil { + return 0, err + } + n, err := conn.Conn.Write(p) + if err != nil { + return 0, err + } + err = conn.limiter.Add(uint64(n)) + if err != nil { + return 0, err + } + return n, nil +} + +type connWithUploadTrafficLimiter struct { + net.Conn + ctx context.Context + limiter TrafficLimiter +} + +func (conn *connWithUploadTrafficLimiter) Read(p []byte) (int, error) { + err := conn.limiter.Can(1) + if err != nil { + return 0, err + } + n, err := conn.Conn.Read(p) + if err != nil { + return 0, err + } + err = conn.limiter.Add(uint64(n)) + if err != nil { + return 0, err + } + return n, nil +} + +type packetConnWithTrafficLimiter struct { + net.PacketConn + ctx context.Context + limiter TrafficLimiter +} + +func newPacketConnWithDownloadTrafficLimiter(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter) net.PacketConn { + return &packetConnWithTrafficLimiter{PacketConn: conn, ctx: ctx, limiter: limiter} +} + +func newPacketConnWithUploadTrafficLimiter(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter) net.PacketConn { + return &packetConnWithUploadTrafficLimiter{PacketConn: conn, ctx: ctx, limiter: limiter} +} + +func (conn *packetConnWithTrafficLimiter) WriteTo(p []byte, addr net.Addr) (int, error) { + err := conn.limiter.Can(uint64(len(p))) + if err != nil { + return 0, err + } + n, err := conn.PacketConn.WriteTo(p, addr) + if err != nil { + return 0, err + } + err = conn.limiter.Add(uint64(n)) + if err != nil { + return 0, err + } + return n, nil +} + +type packetConnWithUploadTrafficLimiter struct { + net.PacketConn + ctx context.Context + limiter TrafficLimiter +} + +func (conn *packetConnWithUploadTrafficLimiter) ReadFrom(p []byte) (int, net.Addr, error) { + err := conn.limiter.Can(1) + if err != nil { + return 0, nil, err + } + n, addr, err := conn.PacketConn.ReadFrom(p) + if err != nil { + return n, nil, err + } + err = conn.limiter.Add(uint64(n)) + if err != nil { + return 0, nil, err + } + return n, addr, nil +} + +func connWithDownloadTrafficWrapper(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn { + if reverse { + return newConnWithUploadTrafficLimiter(ctx, conn, limiter) + } + return newConnWithDownloadTrafficLimiter(ctx, conn, limiter) +} + +func connWithUploadTrafficWrapper(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn { + if reverse { + return newConnWithDownloadTrafficLimiter(ctx, conn, limiter) + } + return newConnWithUploadTrafficLimiter(ctx, conn, limiter) +} + +func connWithBidirectionalTrafficWrapper(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn { + return newConnWithUploadTrafficLimiter(ctx, newConnWithDownloadTrafficLimiter(ctx, conn, limiter), limiter) +} + +func packetConnWithDownloadTrafficWrapper(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter, reverse bool) net.PacketConn { + if reverse { + return newPacketConnWithUploadTrafficLimiter(ctx, conn, limiter) + } + return newPacketConnWithDownloadTrafficLimiter(ctx, conn, limiter) +} + +func packetConnWithUploadTrafficWrapper(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter, reverse bool) net.PacketConn { + if reverse { + return newPacketConnWithDownloadTrafficLimiter(ctx, conn, limiter) + } + return newPacketConnWithUploadTrafficLimiter(ctx, conn, limiter) +} + +func packetConnWithBidirectionalTrafficWrapper(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter, reverse bool) net.PacketConn { + return newPacketConnWithUploadTrafficLimiter(ctx, newPacketConnWithDownloadTrafficLimiter(ctx, conn, limiter), limiter) +} diff --git a/protocol/limiter/traffic/limiter.go b/protocol/limiter/traffic/limiter.go new file mode 100644 index 00000000..b1b95cbf --- /dev/null +++ b/protocol/limiter/traffic/limiter.go @@ -0,0 +1,6 @@ +package traffic + +type TrafficLimiter interface { + Can(n uint64) error + Add(n uint64) error +} diff --git a/protocol/limiter/traffic/outbound.go b/protocol/limiter/traffic/outbound.go new file mode 100644 index 00000000..0d7a329c --- /dev/null +++ b/protocol/limiter/traffic/outbound.go @@ -0,0 +1,126 @@ +package traffic + +import ( + "context" + "net" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/outbound" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing-box/route" + "github.com/sagernet/sing/common/bufio" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" +) + +func RegisterOutbound(registry *outbound.Registry) { + outbound.Register[option.TrafficLimiterOutboundOptions](registry, C.TypeTrafficLimiter, NewOutbound) +} + +type Outbound struct { + outbound.Adapter + ctx context.Context + outbound adapter.OutboundManager + connection adapter.ConnectionManager + logger logger.ContextLogger + strategy TrafficStrategy + outboundTag string + detour adapter.Outbound + router *route.Router +} + +func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrafficLimiterOutboundOptions) (adapter.Outbound, error) { + if options.Route.Final == "" { + return nil, E.New("missing final outbound") + } + strategy := NewManagerTrafficStrategy() + logFactory := service.FromContext[log.Factory](ctx) + r := route.NewRouter(ctx, logFactory, options.Route, option.DNSOptions{}) + err := r.Initialize(options.Route.Rules, options.Route.RuleSet) + if err != nil { + return nil, err + } + outbound := &Outbound{ + Adapter: outbound.NewAdapter(C.TypeTrafficLimiter, tag, nil, []string{}), + ctx: ctx, + outbound: service.FromContext[adapter.OutboundManager](ctx), + connection: service.FromContext[adapter.ConnectionManager](ctx), + logger: logger, + strategy: strategy, + outboundTag: options.Route.Final, + router: r, + } + return outbound, nil +} + +func (h *Outbound) Network() []string { + return []string{N.NetworkTCP, N.NetworkUDP} +} + +func (h *Outbound) Start() error { + detour, loaded := h.outbound.Outbound(h.outboundTag) + if !loaded { + return E.New("outbound not found: ", h.outboundTag) + } + h.detour = detour + for _, stage := range []adapter.StartStage{adapter.StartStateStart, adapter.StartStatePostStart, adapter.StartStateStarted} { + err := h.router.Start(stage) + if err != nil { + return err + } + } + return nil +} + +func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + conn, err := h.detour.DialContext(ctx, network, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + conn, err := h.detour.ListenPacket(ctx, destination) + if err != nil { + return nil, err + } + return h.strategy.wrapPacketConn(ctx, conn, adapter.ContextFrom(ctx), true) +} + +func (h *Outbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + conn, err := h.strategy.wrapConn(ctx, conn, &metadata, false) + if err != nil { + if err.Error() != "traffic limit exceeded" { + h.logger.ErrorContext(ctx, err) + } + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RouteConnectionEx(ctx, conn, metadata, onClose) +} + +func (h *Outbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) { + packetConn, err := h.strategy.wrapPacketConn(ctx, bufio.NewNetPacketConn(conn), &metadata, false) + if err != nil { + if err.Error() != "traffic limit exceeded" { + h.logger.ErrorContext(ctx, err) + } + N.CloseOnHandshakeFailure(conn, onClose, err) + return + } + metadata.Inbound = h.Tag() + metadata.InboundType = h.Type() + h.router.RoutePacketConnectionEx(ctx, bufio.NewPacketConn(packetConn), metadata, onClose) +} + +func (h *Outbound) GetStrategy() TrafficStrategy { + return h.strategy +} diff --git a/protocol/limiter/traffic/strategy.go b/protocol/limiter/traffic/strategy.go new file mode 100644 index 00000000..64cc5275 --- /dev/null +++ b/protocol/limiter/traffic/strategy.go @@ -0,0 +1,164 @@ +package traffic + +import ( + "context" + "net" + "sync" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" +) + +type ( + CloseHandlerFunc = func() + ConnWrapper = func(ctx context.Context, conn net.Conn, limiter TrafficLimiter, reverse bool) net.Conn + PacketConnWrapper = func(ctx context.Context, conn net.PacketConn, limiter TrafficLimiter, reverse bool) net.PacketConn +) + +type TrafficStrategy interface { + wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) + wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) +} + +type TrafficLimiterStrategy interface { + getLimiter(ctx context.Context, metadata *adapter.InboundContext) (TrafficLimiter, error) +} + +type DefaultWrapStrategy struct { + limiterStrategy TrafficLimiterStrategy + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper +} + +func NewDefaultWrapStrategy(limiterStrategy TrafficLimiterStrategy, connWrapper ConnWrapper, packetConnWrapper PacketConnWrapper) *DefaultWrapStrategy { + return &DefaultWrapStrategy{limiterStrategy, connWrapper, packetConnWrapper} +} + +func (s *DefaultWrapStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + limiter, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return conn, err + } + err = limiter.Can(1) + if err != nil { + return conn, err + } + return s.connWrapper(ctx, conn, limiter, reverse), nil +} + +func (s *DefaultWrapStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + limiter, err := s.limiterStrategy.getLimiter(ctx, metadata) + if err != nil { + return conn, err + } + err = limiter.Can(1) + if err != nil { + return conn, err + } + return s.packetConnWrapper(ctx, conn, limiter, reverse), nil +} + +type GlobalTrafficStrategy struct { + limiter TrafficLimiter +} + +func NewGlobalTrafficStrategy(limiter TrafficLimiter) *GlobalTrafficStrategy { + return &GlobalTrafficStrategy{ + limiter: limiter, + } +} + +func (s *GlobalTrafficStrategy) getLimiter(ctx context.Context, metadata *adapter.InboundContext) (TrafficLimiter, error) { + return s.limiter, nil +} + +type ManagerTrafficStrategy struct { + strategies map[string]TrafficStrategy + mtx sync.Mutex +} + +func NewManagerTrafficStrategy() *ManagerTrafficStrategy { + return &ManagerTrafficStrategy{} +} + +func (s *ManagerTrafficStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapConn(ctx, conn, metadata, reverse) +} + +func (s *ManagerTrafficStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + strategy, err := s.getStrategy(ctx, metadata) + if err != nil { + return nil, err + } + return strategy.wrapPacketConn(ctx, conn, metadata, reverse) +} + +func (s *ManagerTrafficStrategy) getStrategy(ctx context.Context, metadata *adapter.InboundContext) (TrafficStrategy, error) { + s.mtx.Lock() + defer s.mtx.Unlock() + var user string + if metadata != nil { + user = metadata.User + } + strategy, ok := s.strategies[user] + if ok { + return strategy, nil + } + return nil, E.New("user strategy not found: ", user) +} + +func (s *ManagerTrafficStrategy) UpdateStrategies(strategies map[string]TrafficStrategy) { + s.mtx.Lock() + defer s.mtx.Unlock() + s.strategies = strategies +} + +type BypassTrafficStrategy struct{} + +func NewBypassTrafficStrategy() *BypassTrafficStrategy { + return &BypassTrafficStrategy{} +} + +func (s *BypassTrafficStrategy) wrapConn(ctx context.Context, conn net.Conn, metadata *adapter.InboundContext, reverse bool) (net.Conn, error) { + return conn, nil +} + +func (s *BypassTrafficStrategy) wrapPacketConn(ctx context.Context, conn net.PacketConn, metadata *adapter.InboundContext, reverse bool) (net.PacketConn, error) { + return conn, nil +} + +func CreateStrategy(limiter TrafficLimiter, strategy string, mode string) (TrafficStrategy, error) { + switch strategy { + case "bypass": + return NewBypassTrafficStrategy(), nil + case "global", "": + var ( + connWrapper ConnWrapper + packetConnWrapper PacketConnWrapper + ) + switch mode { + case "download": + connWrapper = connWithDownloadTrafficWrapper + packetConnWrapper = packetConnWithDownloadTrafficWrapper + case "upload": + connWrapper = connWithUploadTrafficWrapper + packetConnWrapper = packetConnWithUploadTrafficWrapper + case "bidirectional": + connWrapper = connWithBidirectionalTrafficWrapper + packetConnWrapper = packetConnWithBidirectionalTrafficWrapper + default: + return nil, E.New("mode not found: ", mode) + } + return NewDefaultWrapStrategy( + NewGlobalTrafficStrategy(limiter), + connWrapper, + packetConnWrapper, + ), nil + default: + return nil, E.New("strategy not found: ", strategy) + } +} diff --git a/protocol/masque/outbound.go b/protocol/masque/outbound.go index 7d64f24d..55641bf7 100644 --- a/protocol/masque/outbound.go +++ b/protocol/masque/outbound.go @@ -147,7 +147,8 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL UDPKeepalivePeriod: udpKeepalivePeriod, UDPInitialPacketSize: options.UDPInitialPacketSize, ReconnectDelay: options.ReconnectDelay.Build(), - }) + }, + ) if err != nil { logger.ErrorContext(ctx, err) return diff --git a/protocol/relay/outbound.go b/protocol/relay/outbound.go deleted file mode 100644 index e69de29b..00000000 diff --git a/protocol/shadowtls/inbound.go b/protocol/shadowtls/inbound.go index 17afa268..bf459169 100644 --- a/protocol/shadowtls/inbound.go +++ b/protocol/shadowtls/inbound.go @@ -71,7 +71,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo Version: options.Version, Password: options.Password, Users: common.Map(options.Users, func(it option.ShadowTLSUser) shadowtls.User { - return (shadowtls.User)(it) + return shadowtls.User(it) }), Handshake: shadowtls.HandshakeConfig{ Server: options.Handshake.ServerOptions.Build(), diff --git a/protocol/vless/encryption/client.go b/protocol/vless/encryption/client.go index 947b279e..3e0fa4e4 100644 --- a/protocol/vless/encryption/client.go +++ b/protocol/vless/encryption/client.go @@ -83,7 +83,7 @@ func (i *ClientInstance) Handshake(conn net.Conn) (*CommonConn, error) { var nfsKey []byte var lastCTR cipher.Stream for j, k := range i.NfsPKeys { - var index = 32 + index := 32 if k, ok := k.(*ecdh.PublicKey); ok { privateKey, _ := ecdh.X25519().GenerateKey(rand.Reader) copy(relays, privateKey.PublicKey().Bytes()) diff --git a/protocol/vless/encryption/server.go b/protocol/vless/encryption/server.go index 6e25f166..b5025745 100644 --- a/protocol/vless/encryption/server.go +++ b/protocol/vless/encryption/server.go @@ -143,7 +143,7 @@ func (i *ServerInstance) Handshake(conn net.Conn, fallback *[]byte) (*CommonConn if lastCTR != nil { lastCTR.XORKeyStream(relays, relays[:32]) // recover this relay } - var index = 32 + index := 32 if _, ok := k.(*mlkem.DecapsulationKey768); ok { index = 1088 } diff --git a/release/DEFAULT_BUILD_TAGS_OTHERS b/release/DEFAULT_BUILD_TAGS_OTHERS index 85ce0505..a06e6c8b 100644 --- a/release/DEFAULT_BUILD_TAGS_OTHERS +++ b/release/DEFAULT_BUILD_TAGS_OTHERS @@ -1 +1 @@ -with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0 \ No newline at end of file +with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_manager,with_admin_panel,with_ccm,with_ocm,badlinkname,tfogo_checklinkname0 diff --git a/service/admin_panel/README.md b/service/admin_panel/README.md new file mode 100644 index 00000000..6ae06a28 --- /dev/null +++ b/service/admin_panel/README.md @@ -0,0 +1,111 @@ +# admin_panel + +A TypeScript (Vite + React + Material UI, dark theme) admin panel that talks to +the `manager_api/http` server. + +The compiled SPA is **embedded into the Go binary via `//go:embed`** — the +post-processed `dist/` directory is checked into the repo, so +`go build -tags with_admin_panel ./cmd/sing-box` produces a fully +self-contained binary with no Node.js required at compile time. + +The `web/` directory holds the original TypeScript sources, which are only +needed when the panel itself is being modified. `make admin_panel_regen` +rebuilds `dist/` from those sources via `npm run build` followed by an +in-place post-processing step (`cmd/internal/admin_panel_pack`). + +## Layout + +``` +service/admin_panel/ +ā”œā”€ā”€ service.go # Go service (build tag: with_admin_panel, //go:embed dist) +ā”œā”€ā”€ service_stub.go # Stub when the tag is missing +ā”œā”€ā”€ service_test.go # Tests for the SPA handler +ā”œā”€ā”€ dist/ # Embedded SPA bytes (committed to git) +│ ā”œā”€ā”€ index.html +│ ā”œā”€ā”€ index.html.gz +│ └── assets/ +│ ā”œā”€ā”€ index-*.js +│ ā”œā”€ā”€ index-*.js.gz +│ ā”œā”€ā”€ index-*.css +│ ā”œā”€ā”€ index-*.css.gz +│ └── inter-*.woff2 +└── web/ # Vite + React TypeScript source (only needed to regen) + ā”œā”€ā”€ package.json + ā”œā”€ā”€ vite.config.ts + ā”œā”€ā”€ tsconfig.json + ā”œā”€ā”€ index.html + └── src/ + ā”œā”€ā”€ main.tsx + ā”œā”€ā”€ App.tsx + ā”œā”€ā”€ theme.ts + ā”œā”€ā”€ api/{client,types}.ts + ā”œā”€ā”€ auth/AuthContext.tsx + ā”œā”€ā”€ components/{Layout,CrudPage}.tsx + └── pages/*.tsx + +cmd/internal/admin_panel_pack/ +└── main.go # Post-processor: drops .woff, rewrites CSS, pre-gzips text +``` + +## Building sing-box with the panel + +`dist/` is committed, so any developer or CI machine can build a +self-contained binary with no Node.js / npm available: + +```bash +go build -tags with_admin_panel ./cmd/sing-box +# or +make build_admin_panel +``` + +If the tag is omitted the service registers a stub that errors out on start. + +## Regenerating dist/ + +Whenever the SPA source under `web/` is changed, run: + +```bash +make admin_panel_regen +``` + +That target performs two steps: + +1. `make admin_panel_web` — `npm install` + `npm run build` in `web/`, + producing `service/admin_panel/dist/`. +2. `make admin_panel_pack` — runs the + `cmd/internal/admin_panel_pack` post-processor, which: + - deletes the legacy `*.woff` (WOFF 1.0) fallback fonts; every browser + since 2014 reads WOFF2 natively, and shipping both formats roughly + doubles the embedded font payload; + - rewrites the bundled `*.css` to drop `,url(...).woff) format("woff")` + references so the @font-face rules don't point at files that aren't + shipped; + - drops a gzip-compressed `*.gz` companion next to every text-like + asset (.html, .css, .js, .svg, .json) at `gzip.BestCompression`. The + runtime serves those bytes verbatim with `Content-Encoding: gzip` + when the client advertises gzip support, and falls back to the raw + file otherwise. + +After the post-processing pass, commit the resulting `dist/` directory +along with your source changes. + +## Configuring sing-box + +Add a service entry of type `admin-panel`: + +```json +{ + "services": [ + { + "type": "admin-panel", + "tag": "admin", + "listen": "127.0.0.1", + "listen_port": 8081 + } + ] +} +``` + +The panel itself does not require any back-end auth: at sign-in the user +enters the URL and bearer key of a `manager-api` instance. Both are stored +only in the browser's `localStorage`. diff --git a/service/admin_panel/migration/postgresql.go b/service/admin_panel/migration/postgresql.go deleted file mode 100644 index 3ff8c26f..00000000 --- a/service/admin_panel/migration/postgresql.go +++ /dev/null @@ -1,400 +0,0 @@ -package migration - -import ( - "database/sql" - - "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/postgres" - "github.com/sagernet/sing-box/common/migrate/source" -) - -var migrations = map[string]string{ - "1_initialize_schema.up.sql": ` - SET statement_timeout = 0; - SET lock_timeout = 0; - SET idle_in_transaction_session_timeout = 0; - SET client_encoding = 'UTF8'; - SET standard_conforming_strings = on; - SELECT pg_catalog.set_config('search_path', '', false); - SET check_function_bodies = false; - SET xmloption = content; - SET client_min_messages = warning; - SET row_security = off; - - CREATE SEQUENCE public.goadmin_menu_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - - SET default_tablespace = ''; - - SET default_table_access_method = heap; - - CREATE TABLE public.goadmin_menu ( - id integer DEFAULT nextval('public.goadmin_menu_myid_seq'::regclass) NOT NULL, - parent_id integer DEFAULT 0 NOT NULL, - type integer DEFAULT 0, - "order" integer DEFAULT 0 NOT NULL, - title character varying(50) NOT NULL, - header character varying(100), - plugin_name character varying(100) NOT NULL, - icon character varying(50) NOT NULL, - uri character varying(3000) NOT NULL, - uuid character varying(100), - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_operation_log_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_operation_log ( - id integer DEFAULT nextval('public.goadmin_operation_log_myid_seq'::regclass) NOT NULL, - user_id integer NOT NULL, - path character varying(255) NOT NULL, - method character varying(10) NOT NULL, - ip character varying(15) NOT NULL, - input text NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_permissions_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_permissions ( - id integer DEFAULT nextval('public.goadmin_permissions_myid_seq'::regclass) NOT NULL, - name character varying(50) NOT NULL, - slug character varying(50) NOT NULL, - http_method character varying(255), - http_path text NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE TABLE public.goadmin_role_menu ( - role_id integer NOT NULL, - menu_id integer NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE TABLE public.goadmin_role_permissions ( - role_id integer NOT NULL, - permission_id integer NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE TABLE public.goadmin_role_users ( - role_id integer NOT NULL, - user_id integer NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_roles_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_roles ( - id integer DEFAULT nextval('public.goadmin_roles_myid_seq'::regclass) NOT NULL, - name character varying NOT NULL, - slug character varying NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_session_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_session ( - id integer DEFAULT nextval('public.goadmin_session_myid_seq'::regclass) NOT NULL, - sid character varying(50) NOT NULL, - "values" character varying(3000) NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_site_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_site ( - id integer DEFAULT nextval('public.goadmin_site_myid_seq'::regclass) NOT NULL, - key character varying(100) NOT NULL, - value text NOT NULL, - type integer DEFAULT 0, - description character varying(3000), - state integer DEFAULT 0, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE TABLE public.goadmin_user_permissions ( - user_id integer NOT NULL, - permission_id integer NOT NULL, - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - CREATE SEQUENCE public.goadmin_users_myid_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - MAXVALUE 99999999 - CACHE 1; - - CREATE TABLE public.goadmin_users ( - id integer DEFAULT nextval('public.goadmin_users_myid_seq'::regclass) NOT NULL, - username character varying(100) NOT NULL, - password character varying(100) NOT NULL, - name character varying(100) NOT NULL, - avatar character varying(255), - remember_token character varying(100), - created_at timestamp without time zone DEFAULT now(), - updated_at timestamp without time zone DEFAULT now() - ); - - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (1, 0, 1, 1, 'Dashboard', NULL, '', 'fa-bar-chart', '/', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (2, 0, 1, 2, 'Admin', NULL, '', 'fa-tasks', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (3, 2, 1, 2, 'Users', NULL, '', 'fa-users', '/info/manager', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (4, 2, 1, 3, 'Roles', NULL, '', 'fa-user', '/info/roles', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (5, 2, 1, 4, 'Permission', NULL, '', 'fa-ban', '/info/permission', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (7, 2, 1, 6, 'Operation log', NULL, '', 'fa-history', '/info/op', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (9, 0, 0, 9, 'Users', '', '', 'fa-users', '/info/users', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (14, 0, 0, 12, 'Github', 'Miscellaneous', '', 'fa-github', 'https://github.com/shtorm-7/sing-box-extended', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (15, 0, 0, 13, 'Donate', '', '', 'fa-heart', 'https://github.com/shtorm-7/sing-box-extended#support-the-project', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (13, 0, 0, 7, 'Squads', 'General', '', 'fa-gg', '/info/squads', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (11, 0, 0, 8, 'Nodes', '', '', 'fa-sitemap', '/info/nodes', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (10, 0, 0, 10, 'Connection limiters', 'Limiters', '', 'fa-plug', '/info/connection_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_menu (id, parent_id, type, "order", title, header, plugin_name, icon, uri, uuid, created_at, updated_at) VALUES (8, 0, 0, 11, 'Bandwidth limiters', '', '', 'fa-dashboard', '/info/bandwidth_limiters', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (1, 'All permission', '*', '', '*', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_permissions (id, name, slug, http_method, http_path, created_at, updated_at) VALUES (2, 'Dashboard', 'dashboard', 'GET,PUT,POST,DELETE', '/', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (1, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_menu (role_id, menu_id, created_at, updated_at) VALUES (2, 7, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (1, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - INSERT INTO public.goadmin_role_permissions (role_id, permission_id, created_at, updated_at) VALUES (0, 3, NULL, NULL); - - - INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_role_users (role_id, user_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (1, 'Administrator', 'administrator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_roles (id, name, slug, created_at, updated_at) VALUES (2, 'Operator', 'operator', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (6, 'site_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.436501', '2026-02-15 09:57:02.436501'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (7, 'prohibit_config_modification', 'false', 0, NULL, 1, '2026-02-15 09:57:02.441183', '2026-02-15 09:57:02.441183'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (11, 'login_url', '/login', 0, NULL, 1, '2026-02-15 09:57:02.459525', '2026-02-15 09:57:02.459525'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (16, 'open_admin_api', 'false', 0, NULL, 1, '2026-02-15 09:57:02.483908', '2026-02-15 09:57:02.483908'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (18, 'domain', '', 0, NULL, 1, '2026-02-15 09:57:02.493151', '2026-02-15 09:57:02.493151'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (23, 'asset_root_path', './public/', 0, NULL, 1, '2026-02-15 09:57:02.517213', '2026-02-15 09:57:02.517213'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (24, 'url_prefix', 'admin', 0, NULL, 1, '2026-02-15 09:57:02.521815', '2026-02-15 09:57:02.521815'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (33, 'exclude_theme_components', 'null', 0, NULL, 1, '2026-02-15 09:57:02.565725', '2026-02-15 09:57:02.565725'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (39, 'app_id', 'Qn0eh7HQsrt9', 0, NULL, 1, '2026-02-15 09:57:02.592551', '2026-02-15 09:57:02.592551'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (41, 'auth_user_table', 'goadmin_users', 0, NULL, 1, '2026-02-15 09:57:02.601496', '2026-02-15 09:57:02.601496'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (53, 'bootstrap_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.658984', '2026-02-15 09:57:02.658984'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (55, 'index_url', '/', 0, NULL, 1, '2026-02-15 09:57:02.668457', '2026-02-15 09:57:02.668457'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (66, 'login_logo', '', 0, NULL, 1, '2026-02-15 09:57:02.719608', '2026-02-15 09:57:02.719608'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (67, 'hide_visitor_user_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.724307', '2026-02-15 09:57:02.724307'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (68, 'go_mod_file_path', '', 0, NULL, 1, '2026-02-15 09:57:02.728694', '2026-02-15 09:57:02.728694'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (3, 'logger_encoder_caller', 'full', 0, NULL, 1, '2026-02-15 09:57:02.420312', '2026-02-15 09:57:02.420312'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (60, 'logger_encoder_caller_key', 'caller', 0, NULL, 1, '2026-02-15 09:57:02.692189', '2026-02-15 09:57:02.692189'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (34, 'logo', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.570594', '2026-02-15 09:57:02.570594'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (69, 'env', 'prod', 0, NULL, 1, '2026-02-15 09:57:02.733059', '2026-02-15 09:57:02.733059'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (29, 'color_scheme', 'skin-black', 0, NULL, 1, '2026-02-15 09:57:02.545599', '2026-02-15 09:57:02.545599'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (17, 'allow_del_operation_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.488458', '2026-02-15 09:57:02.488458'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (35, 'info_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.574649', '2026-02-15 09:57:02.574649'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (22, 'operation_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.512394', '2026-02-15 09:57:02.512394'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (42, 'hide_app_info_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.606071', '2026-02-15 09:57:02.606071'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (12, 'access_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.464612', '2026-02-15 09:57:02.464612'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (32, 'logger_rotate_max_age', '30', 0, NULL, 1, '2026-02-15 09:57:02.560801', '2026-02-15 09:57:02.560801'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (40, 'custom_foot_html', '', 0, NULL, 1, '2026-02-15 09:57:02.597285', '2026-02-15 09:57:02.597285'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (62, 'logger_encoder_duration', 'string', 0, NULL, 1, '2026-02-15 09:57:02.701522', '2026-02-15 09:57:02.701522'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (65, 'logger_encoder_level_key', 'level', 0, NULL, 1, '2026-02-15 09:57:02.715108', '2026-02-15 09:57:02.715108'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (64, 'debug', 'false', 0, NULL, 1, '2026-02-15 09:57:02.710705', '2026-02-15 09:57:02.710705'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (43, 'hide_plugin_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.610825', '2026-02-15 09:57:02.610825'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (54, 'animation_type', '', 0, NULL, 1, '2026-02-15 09:57:02.663713', '2026-02-15 09:57:02.663713'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (48, 'theme', 'sword', 0, NULL, 1, '2026-02-15 09:57:02.634039', '2026-02-15 09:57:02.634039'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (45, 'info_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.620165', '2026-02-15 09:57:02.620165'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (31, 'error_log_path', '', 0, NULL, 1, '2026-02-15 09:57:02.555798', '2026-02-15 09:57:02.555798'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (5, 'asset_url', '', 0, NULL, 1, '2026-02-15 09:57:02.431855', '2026-02-15 09:57:02.431855'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (36, 'logger_encoder_encoding', 'console', 0, NULL, 1, '2026-02-15 09:57:02.579052', '2026-02-15 09:57:02.579052'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (27, 'login_title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.536102', '2026-02-15 09:57:02.536102'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (51, 'animation_duration', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.64867', '2026-02-15 09:57:02.64867'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (19, 'file_upload_engine', '{"name":"local"}', 0, NULL, 1, '2026-02-15 09:57:02.49794', '2026-02-15 09:57:02.49794'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (26, 'logger_encoder_time', 'iso8601', 0, NULL, 1, '2026-02-15 09:57:02.531365', '2026-02-15 09:57:02.531365'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (10, 'custom_404_html', '', 0, NULL, 1, '2026-02-15 09:57:02.454777', '2026-02-15 09:57:02.454777'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (58, 'sql_log', 'false', 0, NULL, 1, '2026-02-15 09:57:02.682567', '2026-02-15 09:57:02.682567'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (2, 'logger_encoder_message_key', 'msg', 0, NULL, 1, '2026-02-15 09:57:02.415189', '2026-02-15 09:57:02.415189'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (46, 'logger_encoder_stacktrace_key', 'stacktrace', 0, NULL, 1, '2026-02-15 09:57:02.624977', '2026-02-15 09:57:02.624977'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (63, 'mini_logo', 'SBE', 0, NULL, 1, '2026-02-15 09:57:02.706145', '2026-02-15 09:57:02.706145'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (38, 'custom_403_html', '', 0, NULL, 1, '2026-02-15 09:57:02.588062', '2026-02-15 09:57:02.588062'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (30, 'language', 'en', 0, NULL, 1, '2026-02-15 09:57:02.550466', '2026-02-15 09:57:02.550466'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (15, 'hide_config_center_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.479097', '2026-02-15 09:57:02.479097'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (59, 'logger_rotate_max_backups', '5', 0, NULL, 1, '2026-02-15 09:57:02.687429', '2026-02-15 09:57:02.687429'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (57, 'custom_head_html', '', 0, NULL, 1, '2026-02-15 09:57:02.677723', '2026-02-15 09:57:02.677723'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (52, 'custom_500_html', '', 0, NULL, 1, '2026-02-15 09:57:02.654236', '2026-02-15 09:57:02.654236'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (44, 'title', 'Sing-box Extended', 0, NULL, 1, '2026-02-15 09:57:02.615471', '2026-02-15 09:57:02.615471'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (47, 'session_life_time', '7200', 0, NULL, 1, '2026-02-15 09:57:02.629619', '2026-02-15 09:57:02.629619'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (8, 'access_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.445593', '2026-02-15 09:57:02.445593'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (49, 'error_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.6385', '2026-02-15 09:57:02.6385'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (50, 'logger_rotate_max_size', '10', 0, NULL, 1, '2026-02-15 09:57:02.643733', '2026-02-15 09:57:02.643733'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (14, 'logger_rotate_compress', 'false', 0, NULL, 1, '2026-02-15 09:57:02.474296', '2026-02-15 09:57:02.474296'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (13, 'logger_encoder_time_key', 'ts', 0, NULL, 1, '2026-02-15 09:57:02.469396', '2026-02-15 09:57:02.469396'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (37, 'animation_delay', '0.00', 0, NULL, 1, '2026-02-15 09:57:02.583815', '2026-02-15 09:57:02.583815'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (20, 'extra', '', 0, NULL, 1, '2026-02-15 09:57:02.50276', '2026-02-15 09:57:02.50276'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (25, 'access_assets_log_off', 'false', 0, NULL, 1, '2026-02-15 09:57:02.526618', '2026-02-15 09:57:02.526618'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (4, 'logger_level', '0', 0, NULL, 1, '2026-02-15 09:57:02.426736', '2026-02-15 09:57:02.426736'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (9, 'footer_info', '', 0, NULL, 1, '2026-02-15 09:57:02.450409', '2026-02-15 09:57:02.450409'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (21, 'no_limit_login_ip', 'false', 0, NULL, 1, '2026-02-15 09:57:02.507609', '2026-02-15 09:57:02.507609'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (28, 'hide_tool_entrance', 'false', 0, NULL, 1, '2026-02-15 09:57:02.540813', '2026-02-15 09:57:02.540813'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (61, 'logger_encoder_level', 'capitalColor', 0, NULL, 1, '2026-02-15 09:57:02.696859', '2026-02-15 09:57:02.696859'); - INSERT INTO public.goadmin_site (id, key, value, type, description, state, created_at, updated_at) VALUES (56, 'logger_encoder_name_key', 'logger', 0, NULL, 1, '2026-02-15 09:57:02.672962', '2026-02-15 09:57:02.672962'); - - - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (1, 1, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (2, 2, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - INSERT INTO public.goadmin_user_permissions (user_id, permission_id, created_at, updated_at) VALUES (0, 1, NULL, NULL); - - - INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (2, 'operator', '$2a$10$rVqkOzHjN2MdlEprRflb1eGP0oZXuSrbJLOmJagFsCd81YZm0bsh.', 'Operator', '', NULL, '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - INSERT INTO public.goadmin_users (id, username, password, name, avatar, remember_token, created_at, updated_at) VALUES (1, 'admin', '$2a$10$ilNHHnX5S6EMw.Ffc1Y1JezYCyquFIO.7Z0vLr1eHJUXnGy4cdrtq', 'admin', '', 'tlNcBVK9AvfYH7WEnwB1RKvocJu8FfRy4um3DJtwdHuJy0dwFsLOgAc0xUfh', '2019-09-10 00:00:00', '2019-09-10 00:00:00'); - - - SELECT pg_catalog.setval('public.goadmin_menu_myid_seq', 12, true); - - - SELECT pg_catalog.setval('public.goadmin_operation_log_myid_seq', 11, true); - - - SELECT pg_catalog.setval('public.goadmin_permissions_myid_seq', 2, true); - - - SELECT pg_catalog.setval('public.goadmin_roles_myid_seq', 2, true); - - - SELECT pg_catalog.setval('public.goadmin_session_myid_seq', 7, true); - - - SELECT pg_catalog.setval('public.goadmin_site_myid_seq', 69, true); - - - SELECT pg_catalog.setval('public.goadmin_users_myid_seq', 2, true); - - - ALTER TABLE ONLY public.goadmin_menu - ADD CONSTRAINT goadmin_menu_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_operation_log - ADD CONSTRAINT goadmin_operation_log_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_permissions - ADD CONSTRAINT goadmin_permissions_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_roles - ADD CONSTRAINT goadmin_roles_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_session - ADD CONSTRAINT goadmin_session_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_site - ADD CONSTRAINT goadmin_site_pkey PRIMARY KEY (id); - - - ALTER TABLE ONLY public.goadmin_users - ADD CONSTRAINT goadmin_users_pkey PRIMARY KEY (id); - `, - "1_initialize_schema.down.sql": ``, -} - -func MigratePostgreSQL(db *sql.DB) error { - driver, err := postgres.WithInstance(db, &postgres.Config{}) - if err != nil { - return err - } - - sourceDriver := source.NewRawDriver(migrations) - if err := sourceDriver.Init(); err != nil { - return err - } - - m, err := migrate.NewWithInstance( - "raw", - sourceDriver, - "postgres", - driver, - ) - if err != nil { - return err - } - - return m.Up() -} diff --git a/service/admin_panel/pages/dashboard.go b/service/admin_panel/pages/dashboard.go deleted file mode 100644 index 1782f750..00000000 --- a/service/admin_panel/pages/dashboard.go +++ /dev/null @@ -1,13 +0,0 @@ -package pages - -import ( - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/template/types" -) - -func DashboardPage(ctx *context.Context) (types.Panel, error) { - - return types.Panel{ - Title: "Dashboard", - }, nil -} diff --git a/service/admin_panel/service.go b/service/admin_panel/service.go index 55cd386d..a6f3ab8a 100644 --- a/service/admin_panel/service.go +++ b/service/admin_panel/service.go @@ -4,24 +4,16 @@ package admin_panel import ( "context" - "database/sql" + "embed" + "encoding/json" "errors" + "io/fs" + "mime" "net/http" - - "github.com/go-chi/chi/v5" - "github.com/golang-migrate/migrate/v4" - _ "github.com/lib/pq" - "golang.org/x/net/http2" - - _ "github.com/GoAdminGroup/go-admin/adapter/chi" - "github.com/GoAdminGroup/go-admin/engine" - "github.com/GoAdminGroup/go-admin/modules/config" - _ "github.com/GoAdminGroup/go-admin/modules/db/drivers/sqlite" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template" - "github.com/GoAdminGroup/go-admin/template/chartjs" - _ "github.com/GoAdminGroup/themes/adminlte" - _ "github.com/GoAdminGroup/themes/sword" + "path" + "strconv" + "strings" + "time" "github.com/sagernet/sing-box/adapter" boxService "github.com/sagernet/sing-box/adapter/service" @@ -30,17 +22,45 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing-box/service/admin_panel/migration" - "github.com/sagernet/sing-box/service/admin_panel/pages" - "github.com/sagernet/sing-box/service/admin_panel/tables" - CM "github.com/sagernet/sing-box/service/manager/constant" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" N "github.com/sagernet/sing/common/network" aTLS "github.com/sagernet/sing/common/tls" - "github.com/sagernet/sing/service" + sHTTP "github.com/sagernet/sing/protocol/http" + + "github.com/go-chi/chi/v5" + "golang.org/x/net/http2" ) +// distFS holds the SPA bytes produced by `npm run build` (Vite) and then +// post-processed by `cmd/internal/admin_panel_pack`. The directory +// is checked into the repo so a plain `go build -tags with_admin_panel` +// produces a self-contained binary — no Node.js required at compile time. +// +// The post-processor: +// - deletes the legacy *.woff fonts (every browser since 2014 reads +// WOFF2 natively) and removes their references from the bundled CSS; +// - drops a gzip-compressed `*.gz` companion next to every compressible +// text asset (.html, .css, .js, …) using BestCompression. We pass +// those bytes through verbatim with Content-Encoding: gzip when the +// client advertises gzip, and fall back to the raw file otherwise. +// +//go:embed dist +var distFS embed.FS + +// distRoot is the embed.FS rooted at `dist/`, so handlers can use plain +// "index.html" / "assets/..." keys instead of the "dist/..." prefix. +var distRoot = func() fs.FS { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + // Cannot happen unless the //go:embed pattern above and the + // fs.Sub argument disagree; bail loudly so the mismatch is + // caught at startup. + panic(err) + } + return sub +}() + func RegisterService(registry *boxService.Registry) { boxService.Register[option.AdminPanelServiceOptions](registry, C.TypeAdminPanel, NewService) } @@ -56,7 +76,7 @@ type Service struct { } func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.AdminPanelServiceOptions) (adapter.Service, error) { - s := &Service{ + return &Service{ Adapter: boxService.NewAdapter(C.TypeAdminPanel, tag), ctx: ctx, logger: logger, @@ -67,83 +87,15 @@ func NewService(ctx context.Context, logger log.ContextLogger, tag string, optio Listen: options.ListenOptions, }), options: options, - } - return s, nil + }, nil } func (s *Service) Start(stage adapter.StartStage) error { if stage != adapter.StartStateStart { return nil } - boxManager := service.FromContext[adapter.ServiceManager](s.ctx) - service, ok := boxManager.Get(s.options.Manager) - if !ok { - return E.New("manager ", s.options.Manager, " not found") - } - manager, ok := service.(CM.Manager) - if !ok { - return E.New("invalid ", s.options.Manager, " manager") - } - switch s.options.Database.Driver { - case "postgresql": - db, err := sql.Open("postgres", s.options.Database.DSN) - if err != nil { - return err - } - defer db.Close() - if err := migration.MigratePostgreSQL(db); err != nil && err != migrate.ErrNoChange { - return err - } - default: - return E.New("unknown driver \"", s.options.Database.Driver, "\"") - } - var generators = map[string]table.Generator{ - "squads": tables.SquadTableFactory( - manager, - s.logger, - ), - "nodes": tables.NodeTableFactory( - manager, - s.logger, - ), - "users": tables.UserTableFactory( - manager, - s.logger, - ), - "connection_limiters": tables.ConnectionLimiterTableFactory( - manager, - s.logger, - ), - "bandwidth_limiters": tables.BandwidthLimiterTableFactory( - manager, - s.logger, - ), - } - eng := engine.Default() chiRouter := chi.NewRouter() - template.AddComp(chartjs.NewChart()) - if err := eng.AddConfig(&config.Config{ - UrlPrefix: "admin", - IndexUrl: "/", - LoginUrl: "/login", - Databases: config.DatabaseList{ - "default": config.Database{ - Driver: s.options.Database.Driver, - Dsn: s.options.Database.DSN, - }, - }, - }). - AddGenerators(generators). - Use(chiRouter); err != nil { - return err - } - eng.HTML("GET", "/admin", pages.DashboardPage) - chiRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/admin", http.StatusMovedPermanently) - }) - chiRouter.Get("/admin/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/admin", http.StatusMovedPermanently) - }) + s.Route(chiRouter) if s.options.TLS != nil { tlsConfig, err := tls.NewServer(s.ctx, s.logger, common.PtrValueOrDefault(s.options.TLS)) if err != nil { @@ -168,10 +120,14 @@ func (s *Service) Start(stage adapter.StartStage) error { tcpListener = aTLS.NewListener(tcpListener, s.tlsConfig) } s.httpServer = &http.Server{ - Handler: chiRouter, + Handler: chiRouter, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 2 * time.Minute, } go func() { - err = s.httpServer.Serve(tcpListener) + err := s.httpServer.Serve(tcpListener) if err != nil && !errors.Is(err, http.ErrServerClosed) { s.logger.Error("serve error: ", err) } @@ -180,9 +136,145 @@ func (s *Service) Start(stage adapter.StartStage) error { } func (s *Service) Close() error { + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.httpServer.Shutdown(ctx) + } return common.Close( - common.PtrOrNil(s.httpServer), common.PtrOrNil(s.listener), s.tlsConfig, ) } + +func (s *Service) Route(r chi.Router) { + r.Use(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + s.logger.Debug(request.Method, " ", request.RequestURI, " ", sHTTP.SourceAddress(request)) + handler.ServeHTTP(writer, request) + }) + }) + r.Get("/version", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]string{ + "version": C.Version, + }) + }) + handler := newSPAHandler() + r.Method(http.MethodGet, "/*", handler) + r.Method(http.MethodHead, "/*", handler) +} + +func newSPAHandler() http.Handler { + _, hasIndex := readFile("index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if reqPath != "" && reqPath != "index.html" { + if data, ok := readFile(reqPath); ok { + serveAsset(w, r, reqPath, data) + return + } + } + if !hasIndex { + http.Error(w, "admin panel not built", http.StatusInternalServerError) + return + } + serveIndex(w, r) + }) +} + +func readFile(name string) ([]byte, bool) { + data, err := fs.ReadFile(distRoot, name) + if err != nil { + return nil, false + } + return data, true +} + +func gzipCompanion(name string) ([]byte, bool) { + return readFile(name + ".gz") +} + +func serveAsset(w http.ResponseWriter, r *http.Request, name string, raw []byte) { + if ctype := contentType(name); ctype != "" { + w.Header().Set("Content-Type", ctype) + } + if isHashedAsset(name) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + } + writeBody(w, r, name, raw) +} + +func serveIndex(w http.ResponseWriter, r *http.Request) { + raw, _ := readFile("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + writeBody(w, r, "index.html", raw) +} + +func writeBody(w http.ResponseWriter, r *http.Request, name string, raw []byte) { + header := w.Header() + if gz, ok := gzipCompanion(name); ok { + // Even when we end up serving the raw bytes (because the client + // declined gzip), let any shared cache know the response varies + // by Accept-Encoding so it doesn't hand a gzipped payload to a + // non-gzip client. + header.Add("Vary", "Accept-Encoding") + if acceptsGzip(r) { + header.Set("Content-Encoding", "gzip") + header.Set("Content-Length", strconv.Itoa(len(gz))) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(gz) + return + } + } + header.Set("Content-Length", strconv.Itoa(len(raw))) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(raw) +} + +func acceptsGzip(r *http.Request) bool { + for _, h := range r.Header.Values("Accept-Encoding") { + for _, part := range strings.Split(h, ",") { + tok := strings.TrimSpace(part) + if i := strings.IndexByte(tok, ';'); i >= 0 { + tok = strings.TrimSpace(tok[:i]) + } + if strings.EqualFold(tok, "gzip") { + return true + } + } + } + return false +} + +func isHashedAsset(name string) bool { + return strings.HasPrefix(name, "assets/") +} + +func contentType(name string) string { + ext := path.Ext(name) + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + switch ext { + case ".js", ".mjs": + return "application/javascript; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".html": + return "text/html; charset=utf-8" + case ".svg": + return "image/svg+xml" + } + return "" +} diff --git a/service/admin_panel/service_test.go b/service/admin_panel/service_test.go new file mode 100644 index 00000000..b95be83e --- /dev/null +++ b/service/admin_panel/service_test.go @@ -0,0 +1,199 @@ +//go:build with_admin_panel + +package admin_panel + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +// firstAssetPath returns one of the hashed Vite assets from the embedded +// SPA bundle. Used by tests that need a real file-served URL (i.e. one +// that should NOT fall back to index.html). Returns "" if the bundle is +// empty — every concrete test then skips itself, which keeps the suite +// runnable even before `make admin_panel_regen` has been run. +func firstAssetPath(t *testing.T) string { + t.Helper() + var found string + _ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") { + return nil + } + found = p + return fs.SkipAll + }) + return found +} + +// firstGzippedAsset returns one asset that has a `.gz` companion in the +// embedded bundle. Like firstAssetPath, returns "" when nothing matches. +func firstGzippedAsset(t *testing.T) string { + t.Helper() + var found string + _ = fs.WalkDir(distRoot, "assets", func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasSuffix(p, ".gz") { + return nil + } + if _, ok := gzipCompanion(p); ok { + found = p + return fs.SkipAll + } + return nil + }) + return found +} + +func TestSPAHandler(t *testing.T) { + r := chi.NewRouter() + handler := newSPAHandler() + r.Method(http.MethodGet, "/*", handler) + + indexBytes, hasIndex := readFile("index.html") + if !hasIndex { + t.Skip("index.html not packed yet (run `make admin_panel_regen`)") + } + + cases := []struct { + name string + path string + }{ + {"root", "/"}, + {"client-route fallback", "/squads"}, + {"deep route fallback", "/users/123"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "
") { + t.Fatalf("body does not look like index.html:\n%s", rec.Body.String()) + } + }) + } + + // Ensure a real packed asset is served as itself, not as the fallback. + t.Run("real file is served", func(t *testing.T) { + assetPath := firstAssetPath(t) + if assetPath == "" { + t.Skip("no packed assets/* to verify") + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if strings.Contains(rec.Body.String(), "
") { + t.Fatalf("asset request returned the index fallback") + } + }) + + // Sanity check: index.html in the bundle is the same as what serveIndex returns. + t.Run("index payload matches map", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(rec, req) + if rec.Body.String() != string(indexBytes) { + t.Fatalf("served index does not match readFile(\"index.html\")") + } + }) + + t.Run("index has no-store cache control", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(rec, req) + cc := rec.Header().Get("Cache-Control") + if !strings.Contains(cc, "no-store") { + t.Fatalf("Cache-Control = %q, want it to contain no-store", cc) + } + }) + + t.Run("hashed asset is cached aggressively", func(t *testing.T) { + assetPath := firstAssetPath(t) + if assetPath == "" { + t.Skip("no packed assets/* to verify") + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil) + r.ServeHTTP(rec, req) + cc := rec.Header().Get("Cache-Control") + if !strings.Contains(cc, "immutable") { + t.Fatalf("Cache-Control for %q = %q, want it to contain immutable", assetPath, cc) + } + }) + + // With Accept-Encoding: gzip the handler should pass through the + // pre-compressed `.gz` companion verbatim with Content-Encoding: gzip. + t.Run("gzip pass-through", func(t *testing.T) { + assetPath := firstGzippedAsset(t) + if assetPath == "" { + t.Skip("no gzipped assets in this build") + } + gz, _ := gzipCompanion(assetPath) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil) + req.Header.Set("Accept-Encoding", "gzip") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", got) + } + if rec.Body.String() != string(gz) { + t.Fatalf("body did not match the stored gzipped bytes") + } + }) + + // Without Accept-Encoding the same asset should be served raw and + // Content-Encoding must be unset. + t.Run("gzip transparent fallback", func(t *testing.T) { + assetPath := firstGzippedAsset(t) + if assetPath == "" { + t.Skip("no gzipped assets in this build") + } + raw, _ := readFile(assetPath) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/"+assetPath, nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Content-Encoding"); got != "" { + t.Fatalf("Content-Encoding = %q, want empty (raw body)", got) + } + if rec.Body.String() != string(raw) { + t.Fatalf("body does not match the raw asset") + } + }) + + // `.gz` companions are an internal implementation detail of the + // pre-compression strategy; clients should never fetch them directly, + // and walking the embedded FS via /-prefixed URLs must not expose + // them as if they were real assets either. Today they happen to be + // reachable (since fs.ReadFile sees them) — this test pins that + // behaviour so we notice if the contract changes. + t.Run("gz companion lookup", func(t *testing.T) { + assetPath := firstGzippedAsset(t) + if assetPath == "" { + t.Skip("no gzipped assets in this build") + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/"+assetPath+".gz", nil) + r.ServeHTTP(rec, req) + // We don't assert the precise body — only that the request + // resolves to *something* (200 or fallback) instead of crashing. + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (asset or SPA fallback)", rec.Code) + } + }) +} diff --git a/service/admin_panel/tables/bandwidth_limiter.go b/service/admin_panel/tables/bandwidth_limiter.go deleted file mode 100644 index c35d8452..00000000 --- a/service/admin_panel/tables/bandwidth_limiter.go +++ /dev/null @@ -1,259 +0,0 @@ -package tables - -import ( - "encoding/json" - "strconv" - "strings" - "time" - - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/modules/db" - mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template" - "github.com/GoAdminGroup/go-admin/template/types" - "github.com/GoAdminGroup/go-admin/template/types/form" - - "github.com/sagernet/sing-box/log" - CM "github.com/sagernet/sing-box/service/manager/constant" -) - -func BandwidthLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { - return func(ctx *context.Context) table.Table { - t := table.NewDefaultTable(ctx, table.Config{ - CanAdd: true, - Editable: true, - Deletable: true, - Exportable: true, - PrimaryKey: table.PrimaryKey{ - Type: db.Int, - Name: table.DefaultPrimaryKeyName, - }, - }) - squads, err := manager.GetSquads(map[string][]string{}) - if err != nil { - return nil - } - squadsByID := make(map[int]string, len(squads)) - squadOptions := make(types.FieldOptions, len(squads)) - for i, squad := range squads { - squadsByID[squad.ID] = squad.Name - squadOptions[i] = types.FieldOption{ - Text: squad.Name, - Value: strconv.Itoa(squad.ID), - } - } - info := t.GetInfo().SetFilterFormLayout(form.LayoutFilter) - info.AddField("ID", "id", db.Int). - FieldSortable() - info.AddField("Squads", "squad_ids", db.Varchar). - FieldDisplay(func(model types.FieldModel) interface{} { - values := model.Row["squad_ids"].([]interface{}) - labels := template.HTML("") - labelTpl := label(ctx).SetType("success") - labelValues := make([]string, len(values)) - for i, squadID := range values { - labelValues[i] = squadsByID[int(squadID.(float64))] - } - for key, label := range labelValues { - if key == len(labelValues)-1 { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } else { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } - } - return labels - }) - info.AddField("Username", "username", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Outbound", "outbound", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Strategy", "strategy", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Connection", Value: "connection"}, - {Text: "Global", Value: "global"}, - }, - }). - FieldSortable() - info.AddField("Mode", "mode", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Download", Value: "download"}, - {Text: "Upload", Value: "upload"}, - {Text: "Duplex", Value: "duplex"}, - }, - }). - FieldSortable() - info.AddField("Connection type", "connection_type", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "HWID", Value: "hwid"}, - {Text: "Mux", Value: "mux"}, - {Text: "IP", Value: "ip"}, - }, - }). - FieldSortable() - info.AddField("Speed", "speed", db.Varchar). - FieldSortable() - info.AddField("Created at", "created_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - info.AddField("Updated at", "updated_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - - info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { - filters := make(map[string][]string) - listFilters := map[string][]string{ - "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, - "limit": {param.PageSize}, - } - for k, v := range param.Fields { - if strings.HasPrefix(k, "__") { - continue - } - key := strings.TrimSuffix(k, "__goadmin") - filters[key] = v - listFilters[key] = v - } - if param.SortField != "" { - if param.SortType == "asc" { - listFilters["sort_asc"] = []string{param.SortField} - } else { - listFilters["sort_desc"] = []string{param.SortField} - } - } - items, err := manager.GetBandwidthLimiters(listFilters) - if err != nil { - logger.Error(err) - return nil, 0 - } - count, err := manager.GetBandwidthLimitersCount(filters) - if err != nil { - logger.Error(err) - return nil, 0 - } - result := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - var data map[string]interface{} - raw, _ := json.Marshal(item) - json.Unmarshal(raw, &data) - result = append(result, data) - } - return result, count - }) - - info.SetDeleteFn(func(ids []string) error { - for _, id := range ids { - i, err := strconv.Atoi(id) - if err != nil { - return err - } - if _, err := manager.DeleteBandwidthLimiter(i); err != nil { - return err - } - } - return nil - }) - - info.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") - - formList := t.GetForm() - formList.AddField("ID", "id", db.Int, form.Default). - FieldNotAllowAdd(). - FieldNotAllowEdit() - formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). - FieldMust(). - FieldOptions(squadOptions). - FieldDisableWhenUpdate() - formList.AddField("Username", "username", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate() - formList.AddField("Outbound", "outbound", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate() - formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). - FieldMust(). - FieldOptions(types.FieldOptions{ - {Text: "Connection", Value: "connection"}, - {Text: "Global", Value: "global"}, - }). - FieldOnChooseOptionsHide([]string{"", "global"}, "connection_type") - formList.AddField("Mode", "mode", db.Varchar, form.SelectSingle). - FieldMust(). - FieldOptions(types.FieldOptions{ - {Text: "Download", Value: "download"}, - {Text: "Upload", Value: "upload"}, - {Text: "Duplex", Value: "duplex"}, - }) - formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). - FieldOptions(types.FieldOptions{ - {Text: "HWID", Value: "hwid"}, - {Text: "Mux", Value: "mux"}, - {Text: "IP", Value: "ip"}, - }) - formList.AddField("Speed", "speed", db.Varchar, form.Text). - FieldMust() - - formList.SetInsertFn(func(values mForm.Values) error { - squadIDs := make([]int, len(values["squad_ids[]"])) - for i, rawSquadID := range values["squad_ids[]"] { - squadID, err := strconv.Atoi(rawSquadID) - if err != nil { - return err - } - squadIDs[i] = squadID - } - _, err := manager.CreateBandwidthLimiter(CM.BandwidthLimiterCreate{ - SquadIDs: squadIDs, - Username: values.Get("username"), - Outbound: values.Get("outbound"), - Strategy: values.Get("strategy"), - Mode: values.Get("mode"), - ConnectionType: values.Get("connection_type"), - Speed: values.Get("speed"), - }) - return err - }) - - formList.SetUpdateFn(func(values mForm.Values) error { - id, err := strconv.Atoi(values.Get("id")) - if err != nil { - return err - } - _, err = manager.UpdateBandwidthLimiter(id, CM.BandwidthLimiterUpdate{ - Username: values.Get("username"), - Outbound: values.Get("outbound"), - Strategy: values.Get("strategy"), - Mode: values.Get("mode"), - ConnectionType: values.Get("connection_type"), - Speed: values.Get("speed"), - }) - return err - }) - - formList.SetTable("bandwidth_limiters").SetTitle("Bandwidth Limiters").SetDescription("Bandwidth Limiters") - return t - } -} diff --git a/service/admin_panel/tables/connection_limiter.go b/service/admin_panel/tables/connection_limiter.go deleted file mode 100644 index 66dd4f3d..00000000 --- a/service/admin_panel/tables/connection_limiter.go +++ /dev/null @@ -1,261 +0,0 @@ -package tables - -import ( - "encoding/json" - "strconv" - "strings" - "time" - - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/modules/db" - mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template" - "github.com/GoAdminGroup/go-admin/template/types" - "github.com/GoAdminGroup/go-admin/template/types/form" - - "github.com/sagernet/sing-box/log" - CM "github.com/sagernet/sing-box/service/manager/constant" -) - -func ConnectionLimiterTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) table.Table { - return func(ctx *context.Context) table.Table { - connectionLimiterTable := table.NewDefaultTable(ctx, table.Config{ - CanAdd: true, - Editable: true, - Deletable: true, - Exportable: true, - PrimaryKey: table.PrimaryKey{ - Type: db.Int, - Name: table.DefaultPrimaryKeyName, - }, - }) - squads, err := manager.GetSquads(map[string][]string{}) - if err != nil { - return nil - } - squadsByID := make(map[int]string, len(squads)) - squadOptions := make(types.FieldOptions, len(squads)) - for i, squad := range squads { - squadsByID[squad.ID] = squad.Name - squadOptions[i] = types.FieldOption{ - Text: squad.Name, - Value: strconv.Itoa(squad.ID), - } - } - info := connectionLimiterTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) - info.AddField("ID", "id", db.Int). - FieldSortable() - info.AddField("Squads", "squad_ids", db.Varchar). - FieldDisplay(func(model types.FieldModel) interface{} { - values := model.Row["squad_ids"].([]interface{}) - labels := template.HTML("") - labelTpl := label(ctx).SetType("success") - labelValues := make([]string, len(values)) - for i, squadID := range values { - labelValues[i] = squadsByID[int(squadID.(float64))] - } - for key, label := range labelValues { - if key == len(labelValues)-1 { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } else { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } - } - return labels - }) - info.AddField("Username", "username", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Outbound", "outbound", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Strategy", "strategy", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Connection", Value: "connection"}, - }, - }). - FieldSortable() - info.AddField("Connection type", "connection_type", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Mux", Value: "mux"}, - {Text: "HWID", Value: "hwid"}, - {Text: "IP", Value: "ip"}, - }, - }). - FieldSortable() - info.AddField("Lock type", "lock_type", db.Varchar). - FieldFilterable(types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Manager", Value: "manager"}, - }, - }). - FieldSortable() - info.AddField("Count", "count", db.Int). - FieldSortable() - info.AddField("Created at", "created_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - info.AddField("Updated at", "updated_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - - info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { - filters := make(map[string][]string) - listFilters := map[string][]string{ - "offset": {strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)}, - "limit": {param.PageSize}, - } - for k, v := range param.Fields { - if strings.HasPrefix(k, "__") { - continue - } - key := strings.TrimSuffix(k, "__goadmin") - filters[key] = v - listFilters[key] = v - } - if param.SortField != "" { - if param.SortType == "asc" { - listFilters["sort_asc"] = []string{param.SortField} - } else { - listFilters["sort_desc"] = []string{param.SortField} - } - } - items, err := manager.GetConnectionLimiters(listFilters) - if err != nil { - logger.Error(err) - return nil, 0 - } - count, err := manager.GetConnectionLimitersCount(filters) - if err != nil { - logger.Error(err) - return nil, 0 - } - result := make([]map[string]interface{}, 0, len(items)) - for _, item := range items { - var data map[string]interface{} - raw, _ := json.Marshal(item) - json.Unmarshal(raw, &data) - result = append(result, data) - } - return result, count - }) - - info.SetDeleteFn(func(ids []string) error { - for _, id := range ids { - i, err := strconv.Atoi(id) - if err != nil { - return err - } - if _, err := manager.DeleteConnectionLimiter(i); err != nil { - return err - } - } - return nil - }) - - info.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") - - formList := connectionLimiterTable.GetForm() - formList.AddField("ID", "id", db.Int, form.Default). - FieldNotAllowAdd(). - FieldNotAllowEdit() - formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). - FieldMust(). - FieldOptions(squadOptions). - FieldDisableWhenUpdate() - formList.AddField("Username", "username", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate() - formList.AddField("Outbound", "outbound", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate() - formList.AddField("Strategy", "strategy", db.Varchar, form.SelectSingle). - FieldMust(). - FieldOptions(types.FieldOptions{ - {Text: "Connection", Value: "connection"}, - }). - FieldDefault("connection") - formList.AddField("Connection type", "connection_type", db.Varchar, form.SelectSingle). - FieldOptions(types.FieldOptions{ - {Text: "Mux", Value: "mux"}, - {Text: "HWID", Value: "hwid"}, - {Text: "IP", Value: "ip"}, - }) - formList.AddField("Lock type", "lock_type", db.Varchar, form.SelectSingle). - FieldOptions(types.FieldOptions{ - {Text: "Manager", Value: "manager"}, - }) - formList.AddField("Count", "count", db.Int, form.Number). - FieldMust(). - FieldDefault("0") - - formList.SetInsertFn(func(values mForm.Values) error { - squadIDs := make([]int, len(values["squad_ids[]"])) - for i, rawSquadID := range values["squad_ids[]"] { - squadID, err := strconv.Atoi(rawSquadID) - if err != nil { - return err - } - squadIDs[i] = squadID - } - count, err := strconv.ParseUint(values.Get("count"), 10, 32) - if err != nil { - return err - } - _, err = manager.CreateConnectionLimiter(CM.ConnectionLimiterCreate{ - SquadIDs: squadIDs, - Username: values.Get("username"), - Outbound: values.Get("outbound"), - Strategy: values.Get("strategy"), - ConnectionType: values.Get("connection_type"), - LockType: values.Get("lock_type"), - Count: uint32(count), - }) - return err - }) - - formList.SetUpdateFn(func(values mForm.Values) error { - id, err := strconv.Atoi(values.Get("id")) - if err != nil { - return err - } - count, err := strconv.ParseUint(values.Get("count"), 10, 32) - if err != nil { - return err - } - _, err = manager.UpdateConnectionLimiter(id, CM.ConnectionLimiterUpdate{ - Username: values.Get("username"), - Outbound: values.Get("outbound"), - Strategy: values.Get("strategy"), - ConnectionType: values.Get("connection_type"), - LockType: values.Get("lock_type"), - Count: uint32(count), - }) - return err - }) - - formList.SetTable("connection_limiters").SetTitle("Connection Limiters").SetDescription("Connection Limiters") - return connectionLimiterTable - } -} diff --git a/service/admin_panel/tables/node.go b/service/admin_panel/tables/node.go deleted file mode 100644 index 76121897..00000000 --- a/service/admin_panel/tables/node.go +++ /dev/null @@ -1,201 +0,0 @@ -package tables - -import ( - "encoding/json" - "strconv" - "strings" - "time" - - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/modules/config" - "github.com/GoAdminGroup/go-admin/modules/db" - mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template" - "github.com/GoAdminGroup/go-admin/template/types" - "github.com/GoAdminGroup/go-admin/template/types/form" - "github.com/gofrs/uuid/v5" - - "github.com/sagernet/sing-box/log" - CM "github.com/sagernet/sing-box/service/manager/constant" -) - -func label(ctx *context.Context) types.LabelAttribute { - return template.Get(ctx, config.GetTheme()).Label().SetType("success") -} - -func NodeTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (nodeTable table.Table) { - return func(ctx *context.Context) (nodeTable table.Table) { - nodeTable = table.NewDefaultTable(ctx, table.Config{ - CanAdd: true, - Editable: true, - Deletable: true, - Exportable: true, - PrimaryKey: table.PrimaryKey{ - Type: db.Varchar, - Name: "uuid", - }, - }) - squads, err := manager.GetSquads(map[string][]string{}) - if err != nil { - return nil - } - squadsByID := make(map[int]string, len(squads)) - squadOptions := make(types.FieldOptions, len(squads)) - for i, squad := range squads { - squadsByID[squad.ID] = squad.Name - squadOptions[i] = types.FieldOption{ - Text: squad.Name, - Value: strconv.Itoa(squad.ID), - } - } - info := nodeTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) - info.AddField("UUID", "uuid", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Name", "name", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Squads", "squad_ids", db.Varchar). - FieldDisplay(func(model types.FieldModel) interface{} { - values := model.Row["squad_ids"].([]interface{}) - labels := template.HTML("") - labelTpl := label(ctx).SetType("success") - labelValues := make([]string, len(values)) - for i, squadID := range values { - labelValues[i] = squadsByID[int(squadID.(float64))] - } - for key, label := range labelValues { - if key == len(labelValues)-1 { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } else { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } - } - return labels - }) - info.AddField("Status", "status", db.Varchar). - FieldDisplay(func(value types.FieldModel) interface{} { - uuid := value.Row["uuid"].(string) - return manager.GetNodeStatus(uuid) - }) - info.AddField("Created at", "created_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - info.AddField("Updated at", "updated_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - - info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { - filters := make(map[string][]string, len(param.Fields)) - listFilters := make(map[string][]string, len(param.Fields)+2) - listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} - listFilters["limit"] = []string{param.PageSize} - for key, values := range param.Fields { - if key == "__pk" { - key = "uuid" - } else { - if strings.HasPrefix(key, "__") { - continue - } - key = strings.TrimSuffix(key, "__goadmin") - } - filters[key] = values - listFilters[key] = values - } - if param.SortField != "" { - if param.SortType == "asc" { - listFilters["sort_asc"] = []string{param.SortField} - } else { - listFilters["sort_desc"] = []string{param.SortField} - } - } - nodes, err := manager.GetNodes(listFilters) - if err != nil { - logger.Error(err) - return nil, 0 - } - count, err := manager.GetNodesCount(filters) - if err != nil { - logger.Error(err) - return nil, 0 - } - result := make([]map[string]interface{}, 0, len(nodes)) - for _, node := range nodes { - var data map[string]interface{} - rawData, _ := json.Marshal(node) - json.Unmarshal(rawData, &data) - result = append(result, data) - } - return result, count - }) - - info.SetDeleteFn(func(ids []string) error { - for _, uuid := range ids { - if _, err := manager.DeleteNode(uuid); err != nil { - return err - } - } - return nil - }) - - info.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") - - defaultUUID, _ := uuid.NewV4() - formList := nodeTable.GetForm() - formList.AddField("UUID", "uuid", db.Varchar, form.Text). - FieldMust(). - FieldNotAllowEdit(). - FieldDefault(defaultUUID.String()) - formList.AddField("Name", "name", db.Varchar, form.Text). - FieldMust() - formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). - FieldMust(). - FieldOptions(squadOptions). - FieldDisableWhenUpdate() - - formList.SetInsertFn(func(values mForm.Values) (err error) { - squadIDs := make([]int, len(values["squad_ids[]"])) - for i, rawSquadID := range values["squad_ids[]"] { - squadID, err := strconv.Atoi(rawSquadID) - if err != nil { - return err - } - squadIDs[i] = squadID - } - _, err = manager.CreateNode(CM.NodeCreate{ - UUID: values.Get("uuid"), - Name: values.Get("name"), - SquadIDs: squadIDs, - }) - return - }) - - formList.SetUpdateFn(func(values mForm.Values) (err error) { - uuid := values.Get("uuid") - _, err = manager.UpdateNode(uuid, CM.NodeUpdate{ - Name: values.Get("name"), - }) - return - }) - - formList.SetTable("nodes").SetTitle("Nodes").SetDescription("Nodes") - - return - } -} diff --git a/service/admin_panel/tables/squad.go b/service/admin_panel/tables/squad.go deleted file mode 100644 index 746fe1ea..00000000 --- a/service/admin_panel/tables/squad.go +++ /dev/null @@ -1,164 +0,0 @@ -package tables - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/modules/db" - mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template/types" - "github.com/GoAdminGroup/go-admin/template/types/form" - "github.com/go-playground/validator/v10" - - "github.com/sagernet/sing-box/log" - CM "github.com/sagernet/sing-box/service/manager/constant" -) - -func SquadTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (squadTable table.Table) { - return func(ctx *context.Context) (squadTable table.Table) { - squadTable = table.NewDefaultTable(ctx, table.Config{ - CanAdd: true, - Editable: true, - Deletable: true, - Exportable: true, - PrimaryKey: table.PrimaryKey{ - Type: db.Int, - Name: table.DefaultPrimaryKeyName, - }, - }) - - info := squadTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) - info.AddField("ID", "id", db.Int). - FieldSortable() - info.AddField("Name", "name", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Created At", "created_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldSortable(). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) - info.AddField("Updated At", "updated_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldSortable(). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}) - - info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { - filters := make(map[string][]string, len(param.Fields)) - listFilters := make(map[string][]string, len(param.Fields)+2) - listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} - listFilters["limit"] = []string{param.PageSize} - for key, values := range param.Fields { - if key == "__pk" { - key = "pk" - } else if strings.HasPrefix(key, "__") { - continue - } else { - key = strings.TrimSuffix(key, "__goadmin") - } - filters[key] = values - listFilters[key] = values - } - if param.SortField != "" { - if param.SortType == "asc" { - listFilters["sort_asc"] = []string{param.SortField} - } else { - listFilters["sort_desc"] = []string{param.SortField} - } - } - squads, err := manager.GetSquads(listFilters) - if err != nil { - logger.Error(err) - return nil, 0 - } - count, err := manager.GetSquadsCount(filters) - if err != nil { - logger.Error(err) - return nil, 0 - } - result := make([]map[string]interface{}, 0, len(squads)) - for _, squad := range squads { - var data map[string]interface{} - rawData, _ := json.Marshal(squad) - json.Unmarshal(rawData, &data) - result = append(result, data) - } - return result, count - }) - - info.SetDeleteFn(func(ids []string) error { - for _, id := range ids { - intID, err := strconv.Atoi(id) - if err != nil { - return err - } - if _, err := manager.DeleteSquad(intID); err != nil { - return err - } - } - return nil - }) - - info.SetTable("squads").SetTitle("Squads").SetDescription("Squads") - - formList := squadTable.GetForm() - formList.AddField("ID", "id", db.Int, form.Default). - FieldNotAllowAdd(). - FieldNotAllowEdit() - formList.AddField("Name", "name", db.Varchar, form.Text). - FieldMust() - - formList.SetInsertFn(func(values mForm.Values) (err error) { - _, err = manager.CreateSquad(CM.SquadCreate{ - Name: values.Get("name"), - }) - if err != nil { - if ve, ok := err.(validator.ValidationErrors); ok { - var errors []string - for _, e := range ve { - switch e.Tag() { - case "required": - errors = append(errors, e.StructField()+": required field missing") - default: - errors = append(errors, e.StructField()+": invalid request") - } - } - err = fmt.Errorf("%s", strings.Join(errors, "
")) - } - } - return - }) - - formList.SetUpdateFn(func(values mForm.Values) (err error) { - id, err := strconv.Atoi(values.Get("id")) - if err != nil { - return err - } - _, err = manager.UpdateSquad(id, CM.SquadUpdate{ - Name: values.Get("name"), - }) - return - }) - - formList.SetTable("squads").SetTitle("Squads").SetDescription("Squads") - - return - } -} diff --git a/service/admin_panel/tables/user.go b/service/admin_panel/tables/user.go deleted file mode 100644 index 6bc36767..00000000 --- a/service/admin_panel/tables/user.go +++ /dev/null @@ -1,288 +0,0 @@ -package tables - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/GoAdminGroup/go-admin/context" - "github.com/GoAdminGroup/go-admin/modules/db" - mForm "github.com/GoAdminGroup/go-admin/plugins/admin/modules/form" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/parameter" - "github.com/GoAdminGroup/go-admin/plugins/admin/modules/table" - "github.com/GoAdminGroup/go-admin/template" - "github.com/GoAdminGroup/go-admin/template/types" - "github.com/GoAdminGroup/go-admin/template/types/form" - "github.com/go-playground/validator/v10" - - "github.com/sagernet/sing-box/log" - CM "github.com/sagernet/sing-box/service/manager/constant" -) - -func UserTableFactory(manager CM.Manager, logger log.Logger) func(ctx *context.Context) (userTable table.Table) { - return func(ctx *context.Context) (userTable table.Table) { - userTable = table.NewDefaultTable(ctx, table.Config{ - CanAdd: true, - Editable: true, - Deletable: true, - Exportable: true, - PrimaryKey: table.PrimaryKey{ - Type: db.Int, - Name: table.DefaultPrimaryKeyName, - }, - }) - squads, err := manager.GetSquads(map[string][]string{}) - if err != nil { - return nil - } - squadsByID := make(map[int]string, len(squads)) - squadOptions := make(types.FieldOptions, len(squads)) - for i, squad := range squads { - squadsByID[squad.ID] = squad.Name - squadOptions[i] = types.FieldOption{ - Text: squad.Name, - Value: strconv.Itoa(squad.ID), - } - } - info := userTable.GetInfo().SetFilterFormLayout(form.LayoutFilter) - info.AddField("ID", "id", db.Int). - FieldSortable() - info.AddField("Squads", "squad_ids", db.Varchar). - FieldDisplay(func(model types.FieldModel) interface{} { - values := model.Row["squad_ids"].([]interface{}) - labels := template.HTML("") - labelTpl := label(ctx).SetType("success") - labelValues := make([]string, len(values)) - for i, squadID := range values { - labelValues[i] = squadsByID[int(squadID.(float64))] - } - for key, label := range labelValues { - if key == len(labelValues)-1 { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } else { - labels += labelTpl.SetContent(template.HTML(label)).GetContent() - } - } - return labels - }) - info.AddField("Username", "username", db.Varchar). - FieldFilterable(). - FieldSortable() - info.AddField("Type", "type", db.Varchar). - FieldFilterable( - types.FilterType{ - FormType: form.SelectSingle, - Options: types.FieldOptions{ - {Text: "Hysteria", Value: "hysteria"}, - {Text: "Hysteria2", Value: "hysteria2"}, - {Text: "MTProxy", Value: "mtproxy"}, - {Text: "Trojan", Value: "trojan"}, - {Text: "TUIC", Value: "tuic"}, - {Text: "VLESS", Value: "vless"}, - {Text: "VMess", Value: "vmess"}, - }, - }, - ). - FieldSortable() - info.AddField("Inbound", "inbound", db.Varchar).FieldFilterable(). - FieldSortable() - info.AddField("Created at", "created_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - info.AddField("Updated at", "updated_at", db.Datetime). - FieldDisplay(func(model types.FieldModel) interface{} { - t, err := time.Parse(time.RFC3339, model.Value) - if err != nil { - return model.Value - } - return t.Format("2006-01-02 15:04:05") - }). - FieldFilterable(types.FilterType{FormType: form.DatetimeRange}). - FieldSortable() - - info.SetGetDataFn(func(param parameter.Parameters) ([]map[string]interface{}, int) { - filters := make(map[string][]string, len(param.Fields)) - listFilters := make(map[string][]string, len(param.Fields)+2) - listFilters["offset"] = []string{strconv.Itoa((param.PageInt - 1) * param.PageSizeInt)} - listFilters["limit"] = []string{param.PageSize} - for key, values := range param.Fields { - if key == "__pk" { - key = "pk" - } else { - if strings.HasPrefix(key, "__") { - continue - } - key = strings.TrimSuffix(key, "__goadmin") - } - filters[key] = values - listFilters[key] = values - } - if param.SortField != "" { - if param.SortType == "asc" { - listFilters["sort_asc"] = []string{param.SortField} - } else { - listFilters["sort_desc"] = []string{param.SortField} - } - } - users, err := manager.GetUsers(listFilters) - if err != nil { - logger.Error(err) - return nil, 0 - } - count, err := manager.GetUsersCount(filters) - if err != nil { - logger.Error(err) - return nil, 0 - } - result := make([]map[string]interface{}, 0, len(users)) - for _, user := range users { - var data map[string]interface{} - rawData, _ := json.Marshal(user) - json.Unmarshal(rawData, &data) - result = append(result, data) - } - return result, count - }) - info.SetDeleteFn(func(ids []string) error { - for _, id := range ids { - value, err := strconv.Atoi(id) - if err != nil { - return err - } - if _, err := manager.DeleteUser(value); err != nil { - return err - } - } - return nil - }) - - info.SetTable("users").SetTitle("Users").SetDescription("Users") - - formList := userTable.GetForm() - formList.AddField("ID", "id", db.Int, form.Default). - FieldNotAllowEdit(). - FieldNotAllowAdd() - formList.AddField("Squads", "squad_ids", db.Varchar, form.Select). - FieldMust(). - FieldOptions(squadOptions). - FieldDisableWhenUpdate() - formList.AddField("Username", "username", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate() - formList.AddField("Type", "type", db.Varchar, form.SelectSingle). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate(). - FieldOptions(types.FieldOptions{ - {Text: "Hysteria", Value: "hysteria"}, - {Text: "Hysteria2", Value: "hysteria2"}, - {Text: "MTProxy", Value: "mtproxy"}, - {Text: "Trojan", Value: "trojan"}, - {Text: "TUIC", Value: "tuic"}, - {Text: "VLESS", Value: "vless"}, - {Text: "VMess", Value: "vmess"}, - }). - FieldOnChooseOptionsHide([]string{""}, "inbound"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic"}, "uuid"). - FieldOnChooseOptionsHide([]string{"", "mtproxy", "vless", "vmess"}, "password"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "shadowsocks", "trojan", "tuic", "vless", "vmess"}, "secret"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vmess"}, "flow"). - FieldOnChooseOptionsHide([]string{"", "hysteria", "hysteria2", "mtproxy", "shadowsocks", "trojan", "tuic", "vless"}, "alter_id") - formList.AddField("Inbound", "inbound", db.Varchar, form.Text). - FieldMust(). - FieldDisplayButCanNotEditWhenUpdate(). - FieldOptionInitFn(func(val types.FieldModel) types.FieldOptions { - return types.FieldOptions{ - {Value: val.Value, Text: val.Value, Selected: true}, - } - }) - formList.AddField("UUID", "uuid", db.Varchar, form.Text) - formList.AddField("Password", "password", db.Varchar, form.Text) - formList.AddField("Secret", "secret", db.Varchar, form.Text) - formList.AddField("Flow", "flow", db.Varchar, form.SelectSingle). - FieldOptions(types.FieldOptions{ - {Text: "xtls-rprx-vision", Value: "xtls-rprx-vision"}, - }) - formList.AddField("Alter ID", "alter_id", db.Varchar, form.Number). - FieldDefault("0") - - formList.SetInsertFn(func(values mForm.Values) (err error) { - squadIDs := make([]int, len(values["squad_ids[]"])) - for i, rawSquadID := range values["squad_ids[]"] { - squadID, err := strconv.Atoi(rawSquadID) - if err != nil { - return err - } - squadIDs[i] = squadID - } - var alterId int - if value := values.Get("alter_id"); value != "" { - alterId, err = strconv.Atoi(value) - if err != nil { - return err - } - } - _, err = manager.CreateUser(CM.UserCreate{ - SquadIDs: squadIDs, - Username: values.Get("username"), - Type: values.Get("type"), - Inbound: values.Get("inbound"), - UUID: values.Get("uuid"), - Password: values.Get("password"), - Secret: values.Get("secret"), - Flow: values.Get("flow"), - AlterID: alterId, - }) - if err != nil { - if ve, ok := err.(validator.ValidationErrors); ok { - var errors []string - for _, e := range ve { - switch e.Tag() { - case "required": - errors = append(errors, e.StructField()+": required field missing") - case "uuid4": - errors = append(errors, e.StructField()+": invalid UUID") - default: - errors = append(errors, e.StructField()+": invalid request") - } - } - err = fmt.Errorf("%s", strings.Join(errors, "
")) - } - } - return - }) - formList.SetUpdateFn(func(values mForm.Values) (err error) { - id, err := strconv.Atoi(values.Get("id")) - if err != nil { - return err - } - var alterId int - if value := values.Get("alter_id"); value != "" { - alterId, err = strconv.Atoi(value) - if err != nil { - return err - } - } - _, err = manager.UpdateUser(id, CM.UserUpdate{ - UUID: values.Get("uuid"), - Password: values.Get("password"), - Secret: values.Get("secret"), - Flow: values.Get("flow"), - AlterID: alterId, - }) - return - }) - - formList.SetTable("users").SetTitle("Users").SetDescription("Users") - - return - } -} diff --git a/service/admin_panel/web/.gitignore b/service/admin_panel/web/.gitignore new file mode 100644 index 00000000..b18b8708 --- /dev/null +++ b/service/admin_panel/web/.gitignore @@ -0,0 +1,2 @@ +node_modules +.vite diff --git a/service/admin_panel/web/index.html b/service/admin_panel/web/index.html new file mode 100644 index 00000000..aeb0cbef --- /dev/null +++ b/service/admin_panel/web/index.html @@ -0,0 +1,65 @@ + + + + + + + + Sing-box Extended + + + + +
+ + + diff --git a/service/admin_panel/web/package-lock.json b/service/admin_panel/web/package-lock.json new file mode 100644 index 00000000..2c0a5650 --- /dev/null +++ b/service/admin_panel/web/package-lock.json @@ -0,0 +1,3245 @@ +{ + "name": "sing-box-admin-panel", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sing-box-admin-panel", + "version": "0.1.0", + "dependencies": { + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@fontsource/inter": "^5.2.8", + "@mui/icons-material": "^6.1.10", + "@mui/material": "^6.1.10", + "@mui/x-date-pickers": "^7.29.4", + "dayjs": "^1.11.20", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", + "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz", + "integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", + "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.5.0", + "@mui/system": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.0.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.5.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/private-theming": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", + "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.4.9", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/styled-engine": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", + "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/@mui/system": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", + "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.4.9", + "@mui/styled-engine": "^6.5.0", + "@mui/types": "~7.2.24", + "@mui/utils": "^6.4.9", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming/node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/system/node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "~7.2.24", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", + "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/service/admin_panel/web/package.json b/service/admin_panel/web/package.json new file mode 100644 index 00000000..c2f1dc64 --- /dev/null +++ b/service/admin_panel/web/package.json @@ -0,0 +1,31 @@ +{ + "name": "sing-box-admin-panel", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b --noEmit && vite build --emptyOutDir --outDir ../dist", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.13.5", + "@emotion/styled": "^11.13.5", + "@fontsource/inter": "^5.2.8", + "@mui/icons-material": "^6.1.10", + "@mui/material": "^6.1.10", + "@mui/x-date-pickers": "^7.29.4", + "dayjs": "^1.11.20", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/service/admin_panel/web/src/App.tsx b/service/admin_panel/web/src/App.tsx new file mode 100644 index 00000000..7c249d51 --- /dev/null +++ b/service/admin_panel/web/src/App.tsx @@ -0,0 +1,40 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { Layout } from "./components/Layout"; +import { useAuth } from "./auth/AuthContext"; +import { LoginPage } from "./pages/LoginPage"; +import { DashboardPage } from "./pages/DashboardPage"; +import { SquadsPage } from "./pages/SquadsPage"; +import { NodesPage } from "./pages/NodesPage"; +import { UsersPage } from "./pages/UsersPage"; +import { BandwidthLimitersPage } from "./pages/BandwidthLimitersPage"; +import { TrafficLimitersPage } from "./pages/TrafficLimitersPage"; +import { ConnectionLimitersPage } from "./pages/ConnectionLimitersPage"; +import { RateLimitersPage } from "./pages/RateLimitersPage"; + +export function App() { + const { auth } = useAuth(); + if (!auth) { + return ( + + } /> + } /> + + ); + } + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/service/admin_panel/web/src/api/client.ts b/service/admin_panel/web/src/api/client.ts new file mode 100644 index 00000000..71a96c52 --- /dev/null +++ b/service/admin_panel/web/src/api/client.ts @@ -0,0 +1,316 @@ +import type { + BandwidthLimiter, + BandwidthLimiterCreate, + BandwidthLimiterUpdate, + ConnectionLimiter, + ConnectionLimiterCreate, + ConnectionLimiterUpdate, + CountResponse, + Listable, + Node, + NodeCreate, + NodeStatus, + NodeUpdate, + RateLimiter, + RateLimiterCreate, + RateLimiterUpdate, + Squad, + SquadCreate, + SquadUpdate, + TrafficLimiter, + TrafficLimiterCreate, + TrafficLimiterUpdate, + User, + UserCreate, + UserUpdate, + VersionInfo, +} from "./types"; + +export class ApiError extends Error { + status: number; + body: string; + constructor(status: number, body: string) { + super(`HTTP ${status}: ${body || "(empty)"}`); + this.status = status; + this.body = body; + } +} + +// UnauthorizedError is thrown for 401 responses so callers can clear stored +// credentials and redirect to the login screen. +export class UnauthorizedError extends ApiError { + constructor(body: string) { + super(401, body); + this.name = "UnauthorizedError"; + } +} + +export interface AuthInfo { + baseUrl: string; + apiKey: string; +} + +const STORAGE_KEY = "sing-box-admin:auth"; + +export function loadAuth(): AuthInfo | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed.baseUrl || !parsed.apiKey) return null; + return { baseUrl: parsed.baseUrl, apiKey: parsed.apiKey }; + } catch { + return null; + } +} + +// All write paths are wrapped in try/catch so a private-mode browser, a +// disabled storage backend, or a quota error never bubbles up into the +// React effects that call us. The reads above already handle missing / +// malformed entries; failing silently here is the symmetrical behaviour. +export function saveAuth(auth: AuthInfo) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); + } catch { + /* ignore */ + } +} + +export function clearAuth() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* ignore */ + } +} + +// Login form draft — what the user typed before clicking Sign in. Stored +// independently of `AuthInfo` so it survives logout (and tab close), letting +// users come back to their last typed credentials. +export interface LoginDraft { + baseUrl: string; + apiKey: string; +} + +const DRAFT_KEY = "sing-box-admin:login-draft"; + +export function loadLoginDraft(): LoginDraft { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (!raw) return { baseUrl: "", apiKey: "" }; + const parsed = JSON.parse(raw) as Partial; + return { + baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : "", + apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : "", + }; + } catch { + return { baseUrl: "", apiKey: "" }; + } +} + +export function saveLoginDraft(draft: LoginDraft) { + try { + // Drop entirely empty drafts so we don't litter localStorage with "{}". + if (!draft.baseUrl && !draft.apiKey) { + localStorage.removeItem(DRAFT_KEY); + return; + } + localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); + } catch { + /* ignore */ + } +} + +export function clearLoginDraft() { + try { + localStorage.removeItem(DRAFT_KEY); + } catch { + /* ignore */ + } +} + +// onUnauthorized lets the auth context plug in a global 401 handler that +// clears local credentials and bounces the user back to the login screen. +let onUnauthorized: ((err: UnauthorizedError) => void) | null = null; +export function setUnauthorizedHandler(fn: ((err: UnauthorizedError) => void) | null) { + onUnauthorized = fn; +} + +function joinUrl(base: string, path: string): string { + const trimmed = base.replace(/\/+$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${trimmed}/manager/v1${suffix}`; +} + +function buildQuery(params?: Listable): string { + if (!params) return ""; + const segments: string[] = []; + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + if (Array.isArray(v)) { + const items: string[] = []; + for (const item of v) { + if (item === undefined || item === null || item === "") continue; + items.push(encodeURIComponent(String(item))); + } + if (items.length === 0) continue; + segments.push(`${encodeURIComponent(k)}=${items.join(",")}`); + } else { + segments.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + } + } + return segments.length > 0 ? `?${segments.join("&")}` : ""; +} + +async function request( + auth: AuthInfo, + method: string, + path: string, + body?: unknown, + query?: Listable, +): Promise { + let res: Response; + try { + res = await fetch(joinUrl(auth.baseUrl, path) + buildQuery(query), { + method, + headers: { + Authorization: `Bearer ${auth.apiKey}`, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } catch (e) { + throw new ApiError(0, e instanceof Error ? e.message : String(e)); + } + if (res.status === 401) { + const text = await res.text().catch(() => ""); + const err = new UnauthorizedError(text); + if (onUnauthorized) onUnauthorized(err); + throw err; + } + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new ApiError(res.status, text); + } + if (res.status === 204) return undefined as T; + const text = await res.text(); + if (!text) return undefined as T; + // Reject HTML payloads up-front: the manager-api always returns JSON for + // success, so anything that smells like HTML must be a misrouted request + // (e.g. SPA fallback) and would only confuse callers expecting JSON. + const trimmed = text.trim(); + if (trimmed.startsWith("<")) { + throw new ApiError( + res.status, + "expected JSON, got HTML — is the API base URL pointing at the manager-api server?", + ); + } + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError(res.status, `invalid JSON response: ${text.slice(0, 200)}`); + } +} + +interface CRUD { + list: (q?: Listable) => Promise; + count: (q?: Listable) => Promise; + get: (id: TID) => Promise; + create: (body: TCreate) => Promise; + update: (id: TID, body: TUpdate) => Promise; + remove: (id: TID) => Promise; +} + +function intCRUD( + auth: AuthInfo, + base: string, +): CRUD { + return { + list: async (q) => { + const r = await request(auth, "GET", base, undefined, q); + return Array.isArray(r) ? r : []; + }, + count: async (q) => { + const r = await request(auth, "GET", `${base}/count`, undefined, q); + return r?.count ?? 0; + }, + get: (id) => request(auth, "GET", `${base}/${id}`), + create: (body) => request(auth, "POST", base, body), + update: (id, body) => request(auth, "PUT", `${base}/${id}`, body), + remove: (id) => request(auth, "DELETE", `${base}/${id}`), + }; +} + +export function makeApi(auth: AuthInfo) { + const nodesBase = "/nodes"; + return { + auth, + squads: intCRUD(auth, "/squads"), + users: intCRUD(auth, "/users"), + bandwidthLimiters: intCRUD( + auth, + "/bandwidth-limiters", + ), + trafficLimiters: { + ...intCRUD( + auth, + "/traffic-limiters", + ), + // updateUsed overwrites the running raw_used counter on a + // traffic limiter. Passing 0 is the supported "reset traffic" + // operation surfaced in the UI; any uint64 also works for ad-hoc + // adjustments. + updateUsed: (id: number, used: number) => + request( + auth, + "PUT", + `/traffic-limiters/${id}/used`, + { used }, + ), + }, + connectionLimiters: intCRUD( + auth, + "/connection-limiters", + ), + rateLimiters: intCRUD( + auth, + "/rate-limiters", + ), + // Identity probe: returns the running sing-box build's version + // and the project home URL. Used by the sidebar's About popover + // so the panel can show what release the connected manager-api + // is built from. Cheap, single-shot — call it once on Layout + // mount and cache the result for the session. + version: () => request(auth, "GET", "/version"), + nodes: { + list: async (q?: Listable) => { + const r = await request(auth, "GET", nodesBase, undefined, q); + return Array.isArray(r) ? r : []; + }, + count: async (q?: Listable) => { + const r = await request(auth, "GET", `${nodesBase}/count`, undefined, q); + return r?.count ?? 0; + }, + get: (uuid: string) => request(auth, "GET", `${nodesBase}/${uuid}`), + create: (body: NodeCreate) => request(auth, "POST", nodesBase, body), + update: (uuid: string, body: NodeUpdate) => + request(auth, "PUT", `${nodesBase}/${uuid}`, body), + remove: (uuid: string) => request(auth, "DELETE", `${nodesBase}/${uuid}`), + status: async (uuid: string): Promise => { + const r = await request<{ status: NodeStatus }>( + auth, + "GET", + `${nodesBase}/${uuid}/status`, + ); + return r.status; + }, + }, + }; +} + +export type Api = ReturnType; + +export async function ping(auth: AuthInfo): Promise { + // Cheap reachability + auth check. + await request(auth, "GET", "/squads/count"); +} diff --git a/service/admin_panel/web/src/api/types.ts b/service/admin_panel/web/src/api/types.ts new file mode 100644 index 00000000..3a410395 --- /dev/null +++ b/service/admin_panel/web/src/api/types.ts @@ -0,0 +1,245 @@ +// Mirrors the DTOs declared in service/manager/constant/dto.go. +// Strings stay strings (the server emits time.Time as RFC3339). + +export type SquadIDs = number[]; + +export interface Squad { + id: number; + name: string; + created_at: string; + updated_at: string; +} +export interface SquadCreate { + name: string; +} +export interface SquadUpdate { + name: string; +} + +export interface Node { + uuid: string; + name: string; + squad_ids: SquadIDs; + created_at: string; + updated_at: string; +} +export interface NodeCreate { + uuid: string; + name: string; + squad_ids: SquadIDs; +} +export interface NodeUpdate { + name: string; +} +export type NodeStatus = "online" | "offline"; + +export type UserType = + | "hysteria" + | "hysteria2" + | "mtproxy" + | "trojan" + | "tuic" + | "vless" + | "vmess"; + +export interface User { + id: number; + squad_ids: SquadIDs; + username: string; + inbound: string; + type: UserType; + uuid: string; + password: string; + secret: string; + flow: string; + alter_id: number; + created_at: string; + updated_at: string; +} +export interface UserCreate { + squad_ids: SquadIDs; + username: string; + inbound: string; + type: UserType; + uuid?: string; + password?: string; + secret?: string; + flow?: string; + alter_id?: number; +} +export interface UserUpdate { + uuid?: string; + password?: string; + secret?: string; + flow?: string; + alter_id?: number; +} + +export type BandwidthStrategy = "global" | "connection" | "bypass"; +export type BandwidthMode = "upload" | "download" | "bidirectional"; +export type ConnectionType = "default" | "hwid" | "mux" | "source_ip"; + +export interface BandwidthLimiter { + id: number; + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: BandwidthStrategy; + connection_type?: ConnectionType; + mode: BandwidthMode; + flow_keys?: string[]; + speed: string; + raw_speed: number; + created_at: string; + updated_at: string; +} +// `mode`, `flow_keys`, `connection_type` and `speed` carry +// `excluded_if=Strategy bypass` on the manager-api DTO (see +// service/manager/constant/dto.go) — they must be omitted from the +// JSON body when strategy="bypass", otherwise the SQL repository +// fails to parse `speed=""` via byteformats.NetworkBytesCompat and +// the request is rejected with 400 "invalid format". Marking them +// optional here lets the page builders drop them via +// `JSON.stringify`'s `undefined`-skips-key behaviour. +export interface BandwidthLimiterCreate { + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: BandwidthStrategy; + connection_type?: ConnectionType; + mode?: BandwidthMode; + flow_keys?: string[]; + speed?: string; +} +export interface BandwidthLimiterUpdate { + username?: string; + outbound: string; + strategy: BandwidthStrategy; + connection_type?: ConnectionType; + mode?: BandwidthMode; + flow_keys?: string[]; + speed?: string; +} + +export type TrafficStrategy = "global" | "bypass"; +export type TrafficMode = BandwidthMode; +export interface TrafficLimiter { + id: number; + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: TrafficStrategy; + mode: TrafficMode; + raw_used: number; + quota: string; + raw_quota: number; + usage: number; + created_at: string; + updated_at: string; +} +// `mode` / `quota` are excluded_if=Strategy bypass on the DTO; see the +// BandwidthLimiterCreate comment above for the full rationale. +export interface TrafficLimiterCreate { + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: TrafficStrategy; + mode?: TrafficMode; + quota?: string; +} +export interface TrafficLimiterUpdate { + username?: string; + outbound: string; + strategy: TrafficStrategy; + mode?: TrafficMode; + quota?: string; +} + +export type ConnectionStrategy = "connection" | "bypass"; +export type LockType = "manager" | "default"; + +export interface ConnectionLimiter { + id: number; + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: ConnectionStrategy; + connection_type?: ConnectionType; + lock_type: LockType; + count: number; + created_at: string; + updated_at: string; +} +// `connection_type` / `lock_type` / `count` are excluded_if=Strategy +// bypass on the DTO; see the BandwidthLimiterCreate comment above for +// the full rationale. +export interface ConnectionLimiterCreate { + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: ConnectionStrategy; + connection_type?: ConnectionType; + lock_type?: LockType; + count?: number; +} +export interface ConnectionLimiterUpdate { + username?: string; + outbound: string; + strategy: ConnectionStrategy; + connection_type?: ConnectionType; + lock_type?: LockType; + count?: number; +} + +export type RateStrategy = + | "fixed_window" + | "sliding_window" + | "token_bucket" + | "leaky_bucket" + | "bypass"; +export type RateConnectionType = "hwid" | "mux" | "source_ip" | "default"; + +export interface RateLimiter { + id: number; + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: RateStrategy; + connection_type: RateConnectionType; + count: number; + interval: string; + created_at: string; + updated_at: string; +} +// `connection_type` / `count` / `interval` are excluded_if=Strategy +// bypass on the DTO; see the BandwidthLimiterCreate comment above for +// the full rationale. +export interface RateLimiterCreate { + squad_ids: SquadIDs; + username?: string; + outbound: string; + strategy: RateStrategy; + connection_type?: RateConnectionType; + count?: number; + interval?: string; +} +export interface RateLimiterUpdate { + username?: string; + outbound: string; + strategy: RateStrategy; + connection_type?: RateConnectionType; + count?: number; + interval?: string; +} + +export interface CountResponse { + count: number; +} +// Mirrors the JSON shape returned by `GET /manager/v1/version` — +// see service/manager_api/http/server/server.go. +export interface VersionInfo { + version: string; + website: string; +} + +export type Listable = Record; diff --git a/service/admin_panel/web/src/assets/icon.svg b/service/admin_panel/web/src/assets/icon.svg new file mode 100644 index 00000000..146d085a --- /dev/null +++ b/service/admin_panel/web/src/assets/icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/service/admin_panel/web/src/auth/AuthContext.tsx b/service/admin_panel/web/src/auth/AuthContext.tsx new file mode 100644 index 00000000..da5d8adb --- /dev/null +++ b/service/admin_panel/web/src/auth/AuthContext.tsx @@ -0,0 +1,107 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { + type Api, + type AuthInfo, + clearAuth, + loadAuth, + makeApi, + saveAuth, + saveLoginDraft, + setUnauthorizedHandler, +} from "../api/client"; +import { useNotify } from "../notifications/NotificationsProvider"; + +interface AuthContextValue { + auth: AuthInfo | null; + api: Api | null; + login: (auth: AuthInfo) => void; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [auth, setAuth] = useState(() => loadAuth()); + const notify = useNotify(); + + // keep tabs in sync + useEffect(() => { + const handler = (e: StorageEvent) => { + if (e.key === null || e.key === "sing-box-admin:auth") setAuth(loadAuth()); + }; + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, []); + + const login = useCallback((next: AuthInfo) => { + saveAuth(next); + setAuth(next); + }, []); + + const logout = useCallback(() => { + // Remember the URL the user was just connected to so the login form is + // pre-filled on the way back. The key is *not* preserved. + setAuth((prev) => { + if (prev?.baseUrl) { + saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" }); + } + return null; + }); + clearAuth(); + }, []); + + // Globally trap 401 → kick back to the login screen. + // + // Two flavours, distinguished by whether the user *was* signed in + // when the 401 hit: + // - prev !== null → an active session got rejected (key revoked, + // server restarted with a different secret, …). We surface a + // toast so the user understands why they're suddenly back at + // the login screen. + // - prev === null → the failure happened *during* a login attempt + // (the LoginPage's `ping` probe). The login form already shows + // an inline error and emits its own focused toast, so we stay + // quiet here to avoid double-announcing the same failure. + useEffect(() => { + setUnauthorizedHandler(() => { + setAuth((prev) => { + if (prev?.baseUrl) { + saveLoginDraft({ baseUrl: prev.baseUrl, apiKey: "" }); + } + if (prev) { + notify.error("Session expired — please sign in again."); + } + return null; + }); + clearAuth(); + }); + return () => setUnauthorizedHandler(null); + }, [notify]); + + const value = useMemo( + () => ({ auth, api: auth ? makeApi(auth) : null, login, logout }), + [auth, login, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} + +export function useApi(): Api { + const { api } = useAuth(); + if (!api) throw new Error("API used without auth"); + return api; +} diff --git a/service/admin_panel/web/src/components/ColorPickerButton.tsx b/service/admin_panel/web/src/components/ColorPickerButton.tsx new file mode 100644 index 00000000..6b0053c0 --- /dev/null +++ b/service/admin_panel/web/src/components/ColorPickerButton.tsx @@ -0,0 +1,231 @@ +import { + Box, + Button, + IconButton, + Popover, + Stack, + TextField, + Tooltip, +} from "@mui/material"; +import PaletteOutlinedIcon from "@mui/icons-material/PaletteOutlined"; +import { useEffect, useRef, useState } from "react"; +import { isHexColor } from "../theme"; +import { useAccent } from "../theme/AppThemeProvider"; + +// A handful of curated presets so the user can pick a tasteful color in one +// click. They're roughly the Tailwind "500" values for vivid hues. +const PRESETS = [ + "#3b82f6", // blue (default) + "#7c83ff", // indigo + "#00ffff", // cyan + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#ec4899", // pink + "#a855f7", // purple + "#14b8a6", // teal +]; + +export function ColorPickerButton() { + const { accent, setAccent, resetAccent } = useAccent(); + const [anchor, setAnchor] = useState(null); + const [draftHex, setDraftHex] = useState(accent); + const colorInputRef = useRef(null); + + // Keep the text input in sync when the accent changes (e.g. preset clicked + // or another tab updated localStorage). + useEffect(() => setDraftHex(accent), [accent]); + + const open = Boolean(anchor); + + const apply = (hex: string) => { + if (isHexColor(hex)) setAccent(hex); + }; + + return ( + <> + setAnchor(e.currentTarget)} + size="medium" + sx={{ + color: "text.primary", + borderRadius: 2, + width: 40, + height: 40, + "&:hover": { backgroundColor: "action.hover" }, + }} + > + + + + + + setAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + slotProps={{ paper: { sx: { p: 2, width: 260 } } }} + > + + + {PRESETS.map((c, i) => ( + apply(c)} + /> + ))} + + + colorInputRef.current?.click()} + sx={{ + width: 36, + height: 36, + flexShrink: 0, + borderRadius: 1.5, + bgcolor: accent, + cursor: "pointer", + outline: "1px solid", + outlineColor: "divider", + transition: + "background-color 0.32s cubic-bezier(0.4,0,0.2,1), transform 0.12s cubic-bezier(0.4,0,0.2,1)", + "&:hover": { transform: "scale(1.04)" }, + "&:active": { transform: "scale(0.94)" }, + }} + /> + apply(e.target.value)} + style={{ display: "none" }} + /> + { + const v = e.target.value; + setDraftHex(v); + if (isHexColor(v)) setAccent(v); + }} + placeholder="#7c83ff" + inputProps={{ maxLength: 7, "aria-label": "Custom hex color" }} + error={draftHex.length > 0 && !isHexColor(draftHex)} + /> + + + + + + ); +} + +// Swatch — a single colour cell. The entry fade/grow animation still plays +// when the popover opens, but picking a colour no longer triggers an extra +// pop bounce: that second animation used `composite: "add"` layered on top +// of the CSS hover/active transform transitions, which caused a visible +// twitch as the WAAPI effect finished and the element snapped back to its +// hover-scaled baseline. +function Swatch({ + color, + index, + selected, + onPick, +}: { + color: string; + index: number; + selected: boolean; + onPick: () => void; +}) { + return ( + + { + if (e.key === "Enter" || e.key === " ") onPick(); + }} + sx={{ + position: "relative", + width: 36, + height: 36, + borderRadius: 1.5, + bgcolor: color, + cursor: "pointer", + outline: selected ? "2px solid" : "1px solid", + outlineColor: selected ? "text.primary" : "divider", + outlineOffset: selected ? 2 : 0, + zIndex: 1, + transition: + "transform 0.18s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1), box-shadow 0.18s cubic-bezier(0.4,0,0.2,1), outline-color 0.2s cubic-bezier(0.4,0,0.2,1)", + animation: `sb-swatch-enter 0.32s ${index * 32}ms cubic-bezier(0.34, 1.4, 0.64, 1) backwards`, + "@keyframes sb-swatch-enter": { + from: { opacity: 0, transform: "scale(0.6)" }, + to: { opacity: 1, transform: "scale(1)" }, + }, + "&:hover": { + transform: "scale(1.08)", + zIndex: 5, + boxShadow: `0 0 0 4px color-mix(in srgb, ${color} 22%, transparent)`, + }, + "&:active": { + transform: "scale(0.92)", + transition: + "transform 0.08s cubic-bezier(0.4,0,0.2,1), outline-offset 0.18s cubic-bezier(0.4,0,0.2,1)", + }, + "&:focus-visible": { + outline: "2px solid", + outlineColor: "text.primary", + outlineOffset: 2, + }, + }} + /> + + ); +} diff --git a/service/admin_panel/web/src/components/CopyableId.tsx b/service/admin_panel/web/src/components/CopyableId.tsx new file mode 100644 index 00000000..c194fd6e --- /dev/null +++ b/service/admin_panel/web/src/components/CopyableId.tsx @@ -0,0 +1,216 @@ +import { Box, IconButton, Tooltip } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import CheckIcon from "@mui/icons-material/Check"; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, +} from "react"; + +// copyToClipboard writes `text` to the OS clipboard, preferring the modern +// async Clipboard API and falling back to a hidden textarea + execCommand +// for non-secure contexts (HTTP, older browsers) where `navigator.clipboard` +// is undefined. +async function copyToClipboard(text: string): Promise { + try { + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + /* fall through to legacy path */ + } + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.opacity = "0"; + ta.style.pointerEvents = "none"; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +interface CopyableIdProps { + // The string written to the clipboard. Also rendered inline as the + // visible label — the caller doesn't pass it twice. + value: string; + // Optional override for the tooltip's idle title ("Copy UUID" by default). + // Used to keep the hint accurate when the same component is reused for + // non-UUID identifiers (numeric IDs, IPs, etc.). + label?: string; +} + +// CopyableId — inline value + click-to-copy with a small always-visible +// icon and a brief "Copied!" confirmation. Designed to drop into table +// cells styled by `ID_CELL_SX`: long values clip with an ellipsis on the +// left side, while the icon stays pinned to the right. +// +// The whole component is one click target: clicking either the text or +// the explicit icon button copies the value, so users don't have to aim +// for the tiny icon. After a successful copy the icon flips to a green +// checkmark for ~1.2 s so users get visual confirmation regardless of +// the cursor position. +export function CopyableId({ value, label }: CopyableIdProps) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + // Cancel any pending "Copied!" reset on unmount so a fast row remount + // (pagination, filter apply) doesn't run setState on a dead component. + useEffect( + () => () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }, + [], + ); + + const handleCopy = useCallback( + async (e?: ReactMouseEvent) => { + // Stop the click from bubbling to ancestors that might react to row + // clicks (selection toggles, navigation), and from triggering text + // selection on double-click. + e?.stopPropagation(); + e?.preventDefault(); + const ok = await copyToClipboard(value); + if (!ok) return; + setCopied(true); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + timeoutRef.current = null; + }, 1200); + }, + [value], + ); + + const idleTitle = label ?? "Copy UUID"; + + return ( + + + {value} + + + + {/* Two icons stacked on the same 14Ɨ14 spot, cross-faded by + opacity + scale. Each icon owns its own colour so the + transition is a pure visual swap with no `currentColor` + re-flow. The slight scale gives the swap a designed + "pop / dismiss" feel instead of a hard cut, which is what + the eye reads as a glitch when only opacity changes. */} + svg": { + position: "absolute", + top: 0, + left: 0, + fontSize: 14, + transformOrigin: "center", + transition: + "opacity 0.22s cubic-bezier(0.4, 0, 0.2, 1), transform 0.22s cubic-bezier(0.4, 0, 0.2, 1)", + // Force the icon's transform onto its own compositor + // layer so the scale animates on the GPU and never + // shares a frame budget with the surrounding row's + // hover / table re-renders. + willChange: "opacity, transform", + }, + }} + > + + + + + + + ); +} diff --git a/service/admin_panel/web/src/components/CrudPage.tsx b/service/admin_panel/web/src/components/CrudPage.tsx new file mode 100644 index 00000000..be6f7428 --- /dev/null +++ b/service/admin_panel/web/src/components/CrudPage.tsx @@ -0,0 +1,5262 @@ +import { + Alert, + Badge, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + LinearProgress, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TableSortLabel, + TextField, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import AutorenewIcon from "@mui/icons-material/Autorenew"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import FilterAltIcon from "@mui/icons-material/FilterAlt"; +import FilterAltOffIcon from "@mui/icons-material/FilterAltOff"; +import InboxRoundedIcon from "@mui/icons-material/InboxRounded"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import SearchIcon from "@mui/icons-material/Search"; +import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; +import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; +import dayjs, { type Dayjs } from "dayjs"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import type { Listable } from "../api/types"; +import { notifyApiError, useNotify } from "../notifications/NotificationsProvider"; +import { PageHeader } from "./PageHeader"; + +export type FieldType = "text" | "number" | "select" | "multiselect" | "ids" | "uuid"; + +// FILTER_WIDTH is the fixed CSS width (px) of a single filter cell in the +// filter panel. FILTER_GAP is the flex gap between cells (matches the +// `gap: 1.5` on the wrapping Box: MUI 8 px spacing Ɨ 1.5 = 12 px). +const FILTER_WIDTH = 240; +const FILTER_GAP = 12; +// "wide" filter cells (datetime-range, or any text/select cell with +// `wide: true`) span 1.5 "slots" of the grid. A 1-slot stretch across +// the gap accounts for half a gap (because skipping ½ a slot also skips +// ½ of its trailing gap), so the resulting outer box width snaps cleanly +// to the half-grid line. +const WIDE_FILTER_WIDTH = FILTER_WIDTH * 1.5 + FILTER_GAP * 0.5; + +// generateUUID returns an RFC 4122 v4 UUID. Browsers > 2022 expose +// crypto.randomUUID; older ones get a manual implementation backed by +// crypto.getRandomValues (always available where fetch is). +export function generateUUID(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10xxxxxx + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export interface FieldSpec { + name: string; + label: string; + type: FieldType; + required?: boolean; + options?: { value: string; label?: string }[]; + helperText?: string; + // Used by select/multiselect to render labels. + defaultValue?: TValue; + // If set, only show in create form / only in edit form. + only?: "create" | "update"; + // Optional dynamic visibility — receives the current form values, returns + // false to hide the field. Hidden fields are not rendered and not + // submitted. Mirrors `FieldOnChooseOptionsHide` from the legacy admin. + visibleWhen?: (form: Record) => boolean; + // When this field's value changes, every name listed here is reset to its + // empty default. Used e.g. on the user `type` select to wipe credential + // fields when the type changes. + clears?: string[]; + // Async option loader, fired once when the dialog opens. The resolved list + // takes precedence over `options` while rendering. Used by squad_ids on + // every page so the multi-select is populated from GET /squads. + optionsLoader?: () => Promise<{ value: string; label?: string }[]>; + // For `multiselect` fields: render the chosen values as an ordered + // pipeline (`User → Destination → IP`) rather than as independent + // chips. Used by ordered/queue-style fields like `flow_keys`. + displayAsChain?: boolean; +} + +// emptyValueForField returns the canonical "empty" value for a given field +// spec, matching what `emptyForm` would produce. +function emptyValueForField(f: FieldSpec): unknown { + if (f.defaultValue !== undefined) return f.defaultValue; + if (f.type === "multiselect") return []; + return ""; +} + +export interface ColumnSpec { + key: keyof TEntity | string; + label: string; + render?: (row: TEntity) => ReactNode; + // Whether the user can click the column header to sort by this column. + // Defaults to `true` — pages explicitly opt-out for non-comparable + // columns like `squad_ids` (array) or `status` (computed remote field). + sortable?: boolean; +} + +// RowActionSpec is a per-row IconButton rendered in the actions column, +// to the *left* of the built-in Edit / Delete buttons. Pages use it for +// row-scoped operations that aren't a direct field edit — e.g. the +// Traffic Limiters page exposes a "reset traffic" action this way. +// +// `onClick` receives a small context object with `reload`, the same +// callback CrudPage uses internally after Create / Edit / Delete; calling +// it after the action completes refreshes the table so the new row state +// (e.g. `usage` ticked back to 0) appears without forcing the user to +// hit Refresh. +export interface RowActionSpec { + // Stable identifier — used as the React key for the rendered button so + // toggling `visible` on / off doesn't churn the DOM. + key: string; + // Tooltip text and aria-label for the button. + label: string; + // Icon node, e.g. ``. + icon: ReactNode; + // Hover tint preset. "default" matches the EditIcon button (primary + // hover), "danger" matches the DeleteIcon button (red hover). + variant?: "default" | "danger"; + // Optional predicate; when supplied, the button is hidden for rows + // that fail the check. Defaults to "always show". + visible?: (row: TEntity) => boolean; + // Optional confirmation step. When provided, clicking the action + // opens a centred Dialog (same chrome as the Delete one) with the + // returned title / description and a primary button labelled + // `confirmLabel`. If omitted, `onClick` runs immediately on click. + confirm?: (row: TEntity) => { + title: string; + description: string; + // Primary button label. Defaults to `action.label`. + confirmLabel?: string; + // Label shown while `onClick` is in flight. Defaults to + // `confirmLabel + "…"`. + busyLabel?: string; + // MUI palette for the primary button. Defaults to "primary". + // `"error"` matches the destructive Delete dialog so dangerous + // actions can reuse the same red treatment. + color?: "primary" | "warning" | "error"; + }; + // The action callback. May be async — pages typically await the API + // request and then call `ctx.reload()` to refresh the table. When + // `confirm` is set, this only runs after the user clicks the + // primary button in the dialog. + onClick: (row: TEntity, ctx: { reload: () => Promise }) => Promise | void; +} + +// FilterSpec describes a column filter rendered above the table. +// +// The filter value is sent as a query parameter on the list() call. For +// `datetime-range` two parameters are sent: `${name}_start` / `${name}_end`. +export type FilterType = "text" | "select" | "datetime-range"; +export interface FilterSpec { + name: string; + label: string; + type: FilterType; + options?: { value: string; label?: string }[]; + placeholder?: string; + // Render the filter cell at 1.5Ɨ the regular width — same slot size as + // a `datetime-range` cell. Useful for inputs whose typical content is + // long enough that the default 240 px feels cramped (e.g. a 36-char + // UUID or a long URL). The slot still aligns with the half-grid line + // so the surrounding flex-wrap layout stays tidy. + wide?: boolean; +} + +// optionLabel maps a raw select value to its human-readable label, matching +// service/admin_panel/tables/utils.go's optionLabelDisplay. Falls back to +// the raw stringified value when no option matches (e.g. unknown enum +// returned by the API). +export function optionLabel( + options: { value: string; label?: string }[] | undefined, + value: unknown, +): string { + if (value === null || value === undefined) return ""; + const v = String(value); + const found = options?.find((o) => o.value === v); + return found?.label ?? v; +} + +// renderOptionLabel returns a `render` function for a ColumnSpec that maps +// the row's single-value field to its option label. Use on `select`-type +// columns so the table shows "Hysteria" instead of "hysteria". +export function renderOptionLabel( + key: keyof TEntity | string, + options: { value: string; label?: string }[], +): (row: TEntity) => ReactNode { + return (row: TEntity) => { + const v = (row as Record)[key as string]; + if (v === null || v === undefined || v === "") return ""; + return optionLabel(options, v); + }; +} + +// Module-level style constants shared by every TableRow so React doesn't +// allocate fresh sx objects (and emotion doesn't re-hash CSS classes) on +// every row render. Theme tokens (text.secondary, action.active) are +// used instead of hardcoded white-with-alpha so the row reads correctly +// in both dark and light mode. +// Body cells inherit their column's width from the header under +// `tableLayout: fixed`. We clip overflow with ellipsis so a value longer +// than the user's chosen column width doesn't bleed into the next column. +const BODY_CELL_SX = { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, +}; +const ID_CELL_SX = { + fontFamily: + '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: 12.5, + color: "text.secondary", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" as const, +} as const; +// Actions are pinned to the right edge of the table so the Edit / +// Delete buttons stay reachable no matter how wide the data columns +// grow or how far the user has scrolled the table horizontally. +// `position: sticky` + `right: 0` glues the cell to the right edge +// of its scroll container; the z-index hierarchy (header > body > +// data columns) makes the pinned column win overlap so the data +// columns slide visually under it. +// +// To avoid showing the scrolled-under column text through the +// pinned cell, the body / header cells need an opaque background +// that matches whatever colour the surrounding row / head currently +// renders. The theme paints solid colours for each state (see +// theme.ts): +// +// - resting body row → `--sb-surface` (Table backgroundColor) +// - hovered body row → `--sb-elevated2` (TableRow `:hover` !important) +// - header row → `--sb-elevated` (MuiTableHead bg) +// +// Selected (no hover) is the only state MUI itself paints with a +// translucent accent overlay rather than a custom solid; we mimic +// it with `color-mix` so the sticky cell ends up at the same final +// pixel colour as the surrounding cells. Selected + hover is back +// to solid `--sb-elevated2` because the `:hover` override in the +// theme uses `!important` and wins over `Mui-selected`. +// Soft inset-style shadow on the left edge of the pinned Actions +// column. The negative spread (-4px) clips the blur to a thin band +// near the boundary, so it reads as a subtle separator when other +// content sits to the left without bleeding far into the cell. +const ACTIONS_CELL_SHADOW = "-4px 0 6px -4px rgba(0,0,0,0.18)"; +const ACTIONS_CELL_SX = { + paddingLeft: "8px", + paddingRight: "8px", + whiteSpace: "nowrap" as const, + position: "sticky" as const, + right: 0, + zIndex: 2, + backgroundColor: "var(--sb-surface)", + boxShadow: ACTIONS_CELL_SHADOW, + // Mirror the timing curve of `MuiTableRow` (`background-color 0.14s + // ease` in theme.ts). Without this the sticky cell snaps to its new + // background instantly while the rest of the row eases in, which + // reads as a brief asymmetric flash on hover / unhover. + transition: "background-color 0.14s ease", + "tr:hover &": { + backgroundColor: "var(--sb-elevated2)", + }, + "tr.Mui-selected &": { + backgroundColor: + "color-mix(in srgb, var(--sb-accent) 8%, var(--sb-surface))", + }, + "tr.Mui-selected:hover &": { + backgroundColor: "var(--sb-elevated2)", + }, +}; +const ACTIONS_HEADER_CELL_SX = { + width: 92, + paddingLeft: "8px", + paddingRight: "8px", + textAlign: "center" as const, + position: "sticky" as const, + right: 0, + zIndex: 3, + backgroundColor: "var(--sb-elevated)", + boxShadow: ACTIONS_CELL_SHADOW, +}; +// Filler cell — the only auto-width cell in each row. Padding stripped +// so it can collapse cleanly to 0 px width once the fixed columns +// overflow the viewport (any padding would otherwise add width that +// `tableLayout: fixed` can't fully reclaim). The default +// `border-bottom` from MuiTableCell is preserved on purpose so the +// row's horizontal divider extends across the empty span — without +// it the rule would visually break wherever the filler sits. +const FILLER_CELL_SX = { + padding: 0, +} as const; +const EDIT_BTN_SX = { width: 28, height: 28 } as const; +const DELETE_BTN_SX = { + width: 28, + height: 28, + color: "text.secondary", + "&:hover": { + color: "error.main", + backgroundColor: "rgba(239,68,68,0.12)", + }, +} as const; + +// renderOptionChain renders an array of option values as an ordered queue, +// e.g. `User → Destination → IP` rather than a set of independent chips. +// The order of the array is preserved verbatim, so a column rendered with +// this helper visualises "first match wins" behaviour for fair-queue / +// pipeline style fields. +export function renderOptionChain( + key: keyof TEntity | string, + options: { value: string; label?: string }[], +): (row: TEntity) => ReactNode { + return (row: TEntity) => { + const raw = (row as Record)[key as string]; + if (!Array.isArray(raw) || raw.length === 0) return ""; + return ( + + {raw.map((v, i) => ( + + {i > 0 && ( + + → + + )} + + + ))} + + ); + }; +} + +export interface CrudConfig { + title: string; + subtitle?: string; + // Icon shown next to the page title. + icon?: ReactNode; + idKey: keyof TEntity; + columns: ColumnSpec[]; + fields: FieldSpec[]; + // Optional filter bar. Each entry becomes an input rendered above the + // table; the applied values are forwarded to `list()` as query params. + filters?: FilterSpec[]; + // Optional per-row IconButtons rendered before Edit/Delete in the + // actions column. See RowActionSpec for the contract. + rowActions?: RowActionSpec[]; + // list receives the applied filter query (+ any extra query params you add + // to defaults later). Matches the signature of the generated API client. + list: (query?: Listable) => Promise; + // Optional total-row count. When provided, the table renders a pagination + // bar at the bottom and pages through the data via `offset` + `limit`. The + // count is fetched alongside `list` so the displayed range stays in sync. + count?: (query?: Listable) => Promise; + create: (body: TCreate) => Promise; + update: (id: string | number, body: TUpdate) => Promise; + remove: (id: string | number) => Promise; + toCreate: (form: Record) => TCreate; + toUpdate: (form: Record, original: TEntity) => TUpdate; + // Optionally derive form values from the row when editing. + fromEntity?: (row: TEntity) => Record; + rowKey?: (row: TEntity) => string; + // Default sort applied on first render. `field` is the column key; the + // direction maps to `sort_asc` / `sort_desc` query params. + defaultSort?: { field: string; dir: "asc" | "desc" }; + // Initial / fallback page size; defaults to 10. + defaultPageSize?: number; + // Fired after every successful `list()` response, with the rows that + // are about to be rendered. Pages use it to fetch auxiliary data that + // is only needed for what is on screen — e.g. squad names for the + // rows in view — instead of pre-fetching the full catalog on mount. + // If the callback returns a promise, `reload` awaits it before + // publishing the new row set, so the table never flashes raw ids + // before the friendly labels arrive. The callback is best-effort: + // errors thrown (or rejections) are caught so a misbehaving observer + // can't break the table refresh. + onRowsChange?: (rows: TEntity[]) => void | Promise; +} + +// formatDatetimeForFilter converts a `` value +// ("YYYY-MM-DDTHH:MM" or "...:SS") into the "YYYY-MM-DD HH:MM:SS" shape the +// manager API expects. +// +// The repository stores TIMESTAMP columns as text like +// "2025-01-15 14:30:00.123456789+03:00" (modernc/sqlite's default time +// serialization) and the backend compares filter values to that text +// lexicographically. ISO-8601 strings ("...T...Z") sort *after* the +// space-separated stored values so any > filter would (incorrectly) match +// nothing — sending the value with a literal space here mirrors what the +// legacy admin_panel sends and is what makes the date filter actually work. +function formatDatetimeForFilter(input: string): string { + if (!input) return input; + const padded = input.length === 16 ? `${input}:00` : input; + return padded.replace("T", " "); +} + +// buildFilterQuery takes the raw filter-state object and turns it into a +// Listable query suitable for the API client: empty strings are dropped, +// datetime-range fields are split into _start / _end params. +function buildFilterQuery( + specs: FilterSpec[], + values: Record, +): Listable { + const out: Record = {}; + for (const f of specs) { + const raw = values[f.name]; + if (f.type === "datetime-range") { + const r = (raw ?? {}) as { start?: string; end?: string }; + if (r.start) out[`${f.name}_start`] = formatDatetimeForFilter(r.start); + if (r.end) out[`${f.name}_end`] = formatDatetimeForFilter(r.end); + continue; + } + if (typeof raw === "string" && raw.trim() !== "") { + out[f.name] = raw.trim(); + } + } + return out; +} + +function countActiveFilters( + specs: FilterSpec[], + values: Record, +): number { + let n = 0; + for (const f of specs) { + const raw = values[f.name]; + if (f.type === "datetime-range") { + const r = (raw ?? {}) as { start?: string; end?: string }; + if (r.start || r.end) n++; + continue; + } + if (typeof raw === "string" && raw.trim() !== "") n++; + } + return n; +} + +// filterValuesEqual compares two filter-state objects by spec, ignoring key +// insertion order. JSON.stringify-based equality used to false-positive +// "dirty" simply because the user typed filters in a different order, even +// when the canonical applied set matched the draft. +function filterValuesEqual( + specs: FilterSpec[], + a: Record, + b: Record, +): boolean { + for (const f of specs) { + const av = a[f.name]; + const bv = b[f.name]; + if (f.type === "datetime-range") { + const ar = (av ?? {}) as { start?: string; end?: string }; + const br = (bv ?? {}) as { start?: string; end?: string }; + if ((ar.start ?? "") !== (br.start ?? "")) return false; + if ((ar.end ?? "") !== (br.end ?? "")) return false; + continue; + } + const as = typeof av === "string" ? av : ""; + const bs = typeof bv === "string" ? bv : ""; + if (as !== bs) return false; + } + return true; +} + +function emptyForm(fields: FieldSpec[], mode: "create" | "update"): Record { + const out: Record = {}; + for (const f of fields) { + if (f.only && f.only !== mode) continue; + if (f.defaultValue !== undefined) { + out[f.name] = f.defaultValue; + } else if (f.type === "multiselect") { + out[f.name] = []; + } else if (f.type === "ids") { + out[f.name] = ""; + } else if (f.type === "number") { + out[f.name] = ""; + } else { + out[f.name] = ""; + } + } + return out; +} + +function parseIds(v: unknown): number[] { + if (Array.isArray(v)) { + return v + .map((x) => Number(x)) + .filter((n) => Number.isFinite(n)); + } + if (typeof v === "string") { + return v + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)); + } + return []; +} + +// fieldVisible reports whether a field should be displayed (and submitted) +// given the current mode + form values. Static `only` and dynamic +// `visibleWhen` rules are both honoured. +function fieldVisible( + f: FieldSpec, + mode: "create" | "update", + form: Record, +): boolean { + if (f.only && f.only !== mode) return false; + if (f.visibleWhen && !f.visibleWhen(form)) return false; + return true; +} + +// isFieldEmpty reports whether the user has provided no value for a field. +// The semantics match what the API would otherwise complain about: blank +// strings, missing selections, and empty arrays for multi-select / ids. +function isFieldEmpty(f: FieldSpec, value: unknown): boolean { + if (value === undefined || value === null) return true; + if (f.type === "multiselect" || f.type === "ids") { + if (Array.isArray(value)) return value.length === 0; + if (typeof value === "string") return value.trim() === ""; + return true; + } + if (f.type === "number") { + if (value === "") return true; + return !Number.isFinite(Number(value)); + } + if (typeof value === "string") return value.trim() === ""; + return false; +} + +// validateRequired returns a `{ field name -> message }` map describing +// every visible required field that the user has not filled in. The dialog +// uses this to block submit + render inline errors instead of letting the +// server reject the request. +export function validateRequired( + fields: FieldSpec[], + form: Record, +): Record { + const errors: Record = {}; + for (const f of fields) { + if (!f.required) continue; + if (isFieldEmpty(f, form[f.name])) errors[f.name] = "This field is required"; + } + return errors; +} + +export function normalizeFormForSubmit( + fields: FieldSpec[], + mode: "create" | "update", + form: Record, +): Record { + const out: Record = {}; + for (const f of fields) { + if (!fieldVisible(f, mode, form)) continue; + const raw = form[f.name]; + if (f.type === "ids") { + const arr = parseIds(raw); + if (arr.length > 0 || f.required) out[f.name] = arr; + continue; + } + if (f.type === "number") { + if (raw === "" || raw === undefined || raw === null) { + if (f.required) out[f.name] = 0; + continue; + } + const n = Number(raw); + out[f.name] = Number.isFinite(n) ? n : 0; + continue; + } + if (f.type === "multiselect") { + const arr = Array.isArray(raw) ? raw.filter(Boolean) : []; + if (arr.length > 0 || f.required) out[f.name] = arr; + continue; + } + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (trimmed === "" && !f.required) continue; + out[f.name] = trimmed; + continue; + } + if (raw !== undefined && raw !== null) out[f.name] = raw; + } + return out; +} + +// shallowEqualRow reports whether two row objects describe the same data. +// Scalars are compared with `Object.is`; arrays are compared element-wise +// (still by `Object.is`) since every entity returned by the admin API +// uses flat fields with at most one level of array (e.g. `squad_ids`). +// The comparison is intentionally conservative — when in doubt we return +// false and let the row re-render rather than risk masking a real change. +function shallowEqualRow( + a: Record, + b: Record, +): boolean { + if (a === b) return true; + const ak = Object.keys(a); + const bk = Object.keys(b); + if (ak.length !== bk.length) return false; + for (const k of ak) { + const av = a[k]; + const bv = b[k]; + if (Object.is(av, bv)) continue; + if (Array.isArray(av) && Array.isArray(bv)) { + if (av.length !== bv.length) return false; + let same = true; + for (let i = 0; i < av.length; i++) { + if (!Object.is(av[i], bv[i])) { + same = false; + break; + } + } + if (!same) return false; + continue; + } + return false; + } + return true; +} + +// reconcileRows preserves the previous row reference for any entity whose +// content is structurally identical to the freshly fetched one. The +// memoised uses `prev.row === next.row` as part of its +// equality check, so handing back the *same* reference for unchanged rows +// means a refresh that returns identical data does no row-level re-render +// at all — the table simply stays put. When *no* row has changed (length +// equal, every reconciled entry === the previous entry) we hand back the +// previous array verbatim so React's setState bail-out kicks in and the +// parent doesn't re-render either. +function reconcileRows>( + prev: T[], + next: T[], + idKey: keyof T, +): T[] { + if (prev.length === 0) return next; + const prevById = new Map(); + for (const r of prev) prevById.set(r[idKey] as unknown, r); + let allSame = prev.length === next.length; + const out: T[] = new Array(next.length); + for (let i = 0; i < next.length; i++) { + const row = next[i]!; + const old = prevById.get(row[idKey] as unknown); + if (old && shallowEqualRow(old, row)) { + out[i] = old; + if (allSame && prev[i] !== old) allSame = false; + } else { + out[i] = row; + allSame = false; + } + } + return allSame ? prev : out; +} + +// useColumnWidths — Excel-style resizable column state. +// +// Returns the current width (in px) for each column key, plus a `setWidth` +// setter and a `startResize` helper that wires up a mousedown handler. The +// widths are persisted in localStorage under `${storageKey}` so they +// survive page reloads. Columns with no stored width fall back to the +// browser's auto distribution under `tableLayout: fixed`. +const COL_WIDTH_PREFIX = "sing-box-admin:colw:"; +const MIN_COL_WIDTH = 60; +// Default width assigned to a data column that the user hasn't resized +// yet and whose label is shorter than this. Every data column always +// gets an explicit width (this default, or a wider measured natural +// minimum, or the user's stored value) so the only auto-sized cell in +// the row is the trailing "filler" cell that absorbs any leftover +// horizontal space — that's what stops resizing one column from +// rebalancing the others under `tableLayout: fixed`. +const DEFAULT_DATA_COL_WIDTH = 160; +function useColumnWidths(storageKey: string) { + const fullKey = COL_WIDTH_PREFIX + storageKey; + const [widths, setWidths] = useState>(() => { + try { + const raw = localStorage.getItem(fullKey); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed; + } + } catch { + /* ignore */ + } + return {}; + }); + + const persist = useCallback( + (next: Record) => { + try { + localStorage.setItem(fullKey, JSON.stringify(next)); + } catch { + /* ignore */ + } + }, + [fullKey], + ); + + // Imperative setter used by the drag handler. We update the React state + // *and* mirror to localStorage from a single place so both stay in sync. + const setWidth = useCallback( + (key: string, w: number) => { + const clamped = Math.max(MIN_COL_WIDTH, Math.round(w)); + setWidths((prev) => { + if (prev[key] === clamped) return prev; + const next = { ...prev, [key]: clamped }; + persist(next); + return next; + }); + }, + [persist], + ); + + // Batched setter — used at resize-drag start to lock in the live widths + // of every previously-unstored column in a single state update. Without + // this lock-in, columns without a `stored` width auto-distribute under + // `tableLayout: fixed`, so growing the dragged column would also shrink + // every unstored neighbour by their share of the leftover (rubber-band + // resize). Storing each column's current rendered width turns the + // unstored neighbours into fixed-width columns for the duration of + // the drag, so only the dragged column changes width while the others + // stay pixel-stable. The cap from `getColumnMaxWidth` then keeps the + // total table width ≤ the visible viewport, so no column ever ends up + // tucked behind the sticky Actions cell. + const setManyWidths = useCallback( + (entries: Record) => { + setWidths((prev) => { + let changed = false; + const next = { ...prev }; + for (const k in entries) { + const clamped = Math.max(MIN_COL_WIDTH, Math.round(entries[k])); + if (prev[k] !== clamped) { + next[k] = clamped; + changed = true; + } + } + if (!changed) return prev; + persist(next); + return next; + }); + }, + [persist], + ); + + // Wipe every stored column width and drop the localStorage entry. + // After this the table reverts to the same first-render layout new + // users see: each data column falls back to `DEFAULT_DATA_COL_WIDTH` + // (or its measured natural minimum, whichever is larger), and the + // trailing filler cell soaks up the leftover space again. Used by + // the toolbar's "Reset column widths" button. + const resetWidths = useCallback(() => { + setWidths((prev) => (Object.keys(prev).length === 0 ? prev : {})); + try { + localStorage.removeItem(fullKey); + } catch { + /* ignore */ + } + }, [fullKey]); + + return { widths, setWidth, setManyWidths, resetWidths }; +} + +// usePersistedAppliedFilters — applied (submitted) filter values, persisted +// per-page in localStorage. `appliedFilters` is what's actually sent to the +// API; persisting it means a reload restores the user's last "Search" +// without forcing them to re-enter every filter. The draft state +// (`filterValues`) is left in component-local useState — half-typed inputs +// shouldn't survive a refresh. +const FILTERS_PREFIX = "sing-box-admin:filters:"; +function usePersistedAppliedFilters( + storageKey: string, +): [Record, (next: Record) => void] { + const fullKey = FILTERS_PREFIX + storageKey; + const [values, setValuesRaw] = useState>(() => { + try { + const raw = localStorage.getItem(fullKey); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } + } catch { + /* ignore */ + } + return {}; + }); + const setValues = useCallback( + (next: Record) => { + setValuesRaw(next); + try { + if (Object.keys(next).length === 0) { + localStorage.removeItem(fullKey); + } else { + localStorage.setItem(fullKey, JSON.stringify(next)); + } + } catch { + /* ignore */ + } + }, + [fullKey], + ); + return [values, setValues]; +} + +// usePersistedFiltersOpen — whether the filter panel is expanded. Mirrors +// the useState API (supports `(prev) => next` updates) so the existing +// `setShowFilters((s) => !s)` toggle keeps working unchanged. +// +// Mount strategy depends on whether this is the *very first* CrudPage +// instance mounted by the running SPA: +// +// - First app mount (cold reload, F5): the state initialises +// synchronously with the persisted value. Collapse renders with +// in=true on its first paint and the open animation is skipped — +// no "filter panel slides into view, table jumps down" jolt +// when the user reloads a page that had filters left open. +// +// - Subsequent mounts (navigating between admin routes via the +// sidebar): the state still starts at `false` and flips to the +// persisted value in a post-mount effect. MUI's +// observes a `false → true` transition on the second render and +// animates open, matching the user's expectation that switching +// to a page with persisted-open filters reads as a deliberate +// "filters appearing" rather than a static layout swap. +// +// The "is this the first app mount" decision is a module-level flag +// flipped once by the very first hook instance to mount. localStorage +// is read synchronously in both branches; the flag only changes the +// initial render's `in` value, not what's persisted. +const FILTERS_OPEN_PREFIX = "sing-box-admin:filters-open:"; +let filtersFirstAppMount = true; +function readPersistedFiltersOpen(fullKey: string): boolean { + try { + return localStorage.getItem(fullKey) === "1"; + } catch { + return false; + } +} +function usePersistedFiltersOpen( + storageKey: string, +): [ + boolean, + (next: boolean | ((prev: boolean) => boolean)) => void, + // `willAnimateOpen` — true iff the panel will transition from + // closed to open after first paint on this mount. Captured once + // at mount and never changes. CrudPage uses this to gate the + // very first reload() so the table doesn't pop rows in mid- + // animation when the user navigates between admin pages with + // persisted-open filters. False on cold mounts (the panel is + // committed already-open without animation) and on subsequent + // mounts where filters were left closed (no animation will play). + boolean, +] { + const fullKey = FILTERS_OPEN_PREFIX + storageKey; + // `useRef` so the "first ever hook instance" decision is stable + // across re-renders of *this* CrudPage (without it, a re-render + // before the post-mount effect runs would re-read + // `filtersFirstAppMount` *after* it had been flipped, and treat + // this same instance as a "subsequent mount"). + const isFirstMountRef = useRef(filtersFirstAppMount); + const [open, setOpenRaw] = useState(() => + isFirstMountRef.current ? readPersistedFiltersOpen(fullKey) : false, + ); + // Captured once at mount alongside the initial state so cold vs + // animated branches stay in sync (we evaluate the same flag and + // the same persisted value, so a later localStorage change can't + // make the two disagree on this mount). + const willAnimateOpenRef = useRef( + !isFirstMountRef.current && readPersistedFiltersOpen(fullKey), + ); + useEffect(() => { + if (isFirstMountRef.current) { + // The first-ever CrudPage in the SPA already committed with the + // persisted value applied — no second-render flip needed, and + // no open animation should play. Just record that we're done + // with the cold-load path so any later route change (which + // mounts a fresh CrudPage instance) takes the animated branch. + filtersFirstAppMount = false; + return; + } + if (readPersistedFiltersOpen(fullKey)) setOpenRaw(true); + }, [fullKey]); + const setOpen = useCallback( + (next: boolean | ((prev: boolean) => boolean)) => { + setOpenRaw((prev) => { + const resolved = typeof next === "function" ? next(prev) : next; + try { + localStorage.setItem(fullKey, resolved ? "1" : "0"); + } catch { + /* ignore */ + } + return resolved; + }); + }, + [fullKey], + ); + return [open, setOpen, willAnimateOpenRef.current]; +} + +// usePersistedPageSize — TablePagination rows-per-page, persisted per-page +// in localStorage so the user's choice (10/25/50/100) survives reloads. +// Falls back to `defaultSize` when nothing is stored or the stored value +// is corrupted. +const PAGE_SIZE_PREFIX = "sing-box-admin:pagesize:"; +function usePersistedPageSize( + storageKey: string, + defaultSize: number, +): [number, (next: number) => void] { + const fullKey = PAGE_SIZE_PREFIX + storageKey; + const [size, setSizeRaw] = useState(() => { + try { + const raw = localStorage.getItem(fullKey); + if (raw) { + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return n; + } + } catch { + /* ignore */ + } + return defaultSize; + }); + const setSize = useCallback( + (next: number) => { + setSizeRaw(next); + try { + localStorage.setItem(fullKey, String(next)); + } catch { + /* ignore */ + } + }, + [fullKey], + ); + return [size, setSize]; +} + +// usePersistedSort — active sort column + direction, persisted per-page in +// localStorage so the user's chosen ordering survives reloads and tab +// switches between admin pages. Both slices are stored together as a +// single JSON blob (`{ field, dir }`) so the two-stage "set field, then +// set dir" updates in `handleSort` and the mobile sort dropdown can never +// land in a half-written intermediate state on disk. Setters mirror the +// `useState` API (each accepts either a value or an updater) so the +// existing `setSortDir((d) => …)` toggle keeps working unchanged. +// +// Falls back to `defaultSort` (or `null` / `"asc"`) only when nothing is +// stored — once the user has touched the sort, an explicit "no sort" +// state (`field === null`) is preserved across reloads too. +const SORT_PREFIX = "sing-box-admin:sort:"; +type SortDir = "asc" | "desc"; +function usePersistedSort( + storageKey: string, + defaultSort: { field: string; dir: SortDir } | undefined, +): [ + string | null, + (next: string | null | ((prev: string | null) => string | null)) => void, + SortDir, + (next: SortDir | ((prev: SortDir) => SortDir)) => void, +] { + const fullKey = SORT_PREFIX + storageKey; + const [state, setState] = useState<{ field: string | null; dir: SortDir }>( + () => { + try { + const raw = localStorage.getItem(fullKey); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const field = + typeof parsed.field === "string" || parsed.field === null + ? parsed.field + : null; + const dir: SortDir = parsed.dir === "desc" ? "desc" : "asc"; + return { field, dir }; + } + } + } catch { + /* ignore */ + } + return { + field: defaultSort?.field ?? null, + dir: defaultSort?.dir ?? "asc", + }; + }, + ); + + const persist = useCallback( + (next: { field: string | null; dir: SortDir }) => { + try { + localStorage.setItem(fullKey, JSON.stringify(next)); + } catch { + /* ignore */ + } + }, + [fullKey], + ); + + const setField = useCallback( + (next: string | null | ((prev: string | null) => string | null)) => { + setState((prev) => { + const resolved = typeof next === "function" ? next(prev.field) : next; + if (resolved === prev.field) return prev; + const updated = { ...prev, field: resolved }; + persist(updated); + return updated; + }); + }, + [persist], + ); + + const setDir = useCallback( + (next: SortDir | ((prev: SortDir) => SortDir)) => { + setState((prev) => { + const resolved = typeof next === "function" ? next(prev.dir) : next; + if (resolved === prev.dir) return prev; + const updated = { ...prev, dir: resolved }; + persist(updated); + return updated; + }); + }, + [persist], + ); + + return [state.field, setField, state.dir, setDir]; +} + +// ResizeHandle is the thin invisible bar that lives at the right edge of +// each resizable header cell. Mousedown captures the cursor + the header +// cell's current width, then mousemove updates the column width on every +// frame as the cursor drags. The handle paints a faint accent-coloured +// guide while dragging so the user can see exactly which column they're +// adjusting. +function ResizeHandle({ + columnKey, + getCurrentWidth, + setWidth, + getMinWidth, + getMaxWidth, + onResizeStart, + clipToCell = false, +}: { + columnKey: string; + getCurrentWidth: () => number; + setWidth: (key: string, w: number) => void; + // Per-column lower bound, measured from the actual header content so the + // column can never be dragged narrower than its label + sort icon + cell + // padding (otherwise the contents would overflow into the neighbouring + // column). Falls back to the global `MIN_COL_WIDTH` if not provided. + getMinWidth?: () => number; + // Per-column upper bound. CrudPage passes a getter that keeps the sum + // of all column widths ≤ the visible scroll viewport, so a column's + // right edge can never travel past the sticky Actions column. Without + // it the drag is unconstrained on the upper end. + getMaxWidth?: () => number; + // Fires once at the start of a drag, before `getCurrentWidth` is read + // and before any mousemove listener is attached. CrudPage uses this to + // lock in the live widths of every previously-unstored data column so + // dragging one column doesn't rubber-band the unstored neighbours by + // their share of the auto-distributed leftover; instead, only the + // dragged column changes width and the cap from `getColumnMaxWidth` + // keeps the total ≤ the visible viewport. + onResizeStart?: () => void; + // When true, the grab strip is clipped to the cell's own right edge + // instead of overflowing 7 px into the neighbouring cell. Used for + // the rightmost data column so the grab strip doesn't extend into + // the sticky Actions column — its left edge should not feel + // draggable. + clipToCell?: boolean; +}) { + const [active, setActive] = useState(false); + + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Run before reading `startW` so any width lock-in performed by + // the parent (promoting auto-distributed columns to fixed widths) + // is reflected in the layout the drag math anchors to. The DOM + // read inside `getCurrentWidth` happens after this; even though + // React hasn't re-rendered yet, the cell's bounding box is + // unchanged (we lock in the *current* live widths), so the drag + // anchor stays pixel-stable. + onResizeStart?.(); + const startX = e.clientX; + const startW = getCurrentWidth(); + setActive(true); + const prevCursor = document.body.style.cursor; + const prevSelect = document.body.style.userSelect; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + const onMove = (ev: MouseEvent) => { + const target = startW + (ev.clientX - startX); + const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH; + const max = getMaxWidth ? getMaxWidth() : Number.POSITIVE_INFINITY; + // Order matters: cap by max first, then floor by min, so that + // when min > max (an over-tight container) the lower bound + // wins and the column at least stays legible. + setWidth(columnKey, Math.max(min, Math.min(max, target))); + }; + const onUp = () => { + setActive(false); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = prevSelect; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [columnKey, getCurrentWidth, setWidth, getMinWidth, getMaxWidth, onResizeStart], + ); + + // Double-click "resets" the column to the smallest size that still fits + // its header content — same lower bound the drag handler enforces. With + // a tighter natural minimum than the previous flat 60 px floor, the + // reset never produces a column whose label spills into a neighbour. + return ( + { + e.stopPropagation(); + const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH; + setWidth(columnKey, min); + }} + sx={(t) => ({ + position: "absolute", + top: 0, + // Grab area is by default centred on the cell's right border + // (right: -7, width: 14 → 7 px on each side of the boundary). + // Wider than the previous 6 px strip so the column edge is + // easy to hit without pixel-perfect aim, while still narrow + // enough that it doesn't overlap any reasonable header + // content. + // + // For the rightmost data column we instead clip the strip to + // the cell's own right edge (right: 0, width: 7). That keeps + // the boundary against the sticky Actions cell completely + // dead — no col-resize cursor, no draggable strip — while + // still giving the user 7 px to grab on the column's own + // side of the boundary. + right: clipToCell ? 0 : -7, + bottom: 0, + width: clipToCell ? 7 : 14, + cursor: "col-resize", + zIndex: 2, + // The thin coloured guide that appears on hover and stays visible + // while the user is actively dragging. Centred inside the grab + // strip: (14 āˆ’ 2) / 2 = 6 px from the left edge — or 5 px in + // the clipped 7 px variant so the guide still sits flush with + // the cell's right border. + "&::after": { + content: '""', + position: "absolute", + top: 8, + bottom: 8, + left: clipToCell ? 5 : 6, + width: 2, + borderRadius: 1, + backgroundColor: active ? "var(--sb-accent)" : "transparent", + transition: "background-color 0.12s ease", + }, + "&:hover::after": { + backgroundColor: active + ? "var(--sb-accent)" + : t.palette.action.selected, + }, + })} + /> + ); +} + +export function CrudPage(props: { config: CrudConfig }) { + const { config } = props; + // Responsive bits in this component (filter-cell widths, etc.) are + // handled inline via `sx` breakpoint objects. Mobile-only full-screen + // for the create/edit dialog is decided inside `CrudDialog` itself so + // we don't have to thread a flag through every prop. + // + // Below `sm` (≤ 600 px) the data is rendered as a vertical list of + // cards instead of a horizontally-scrolling table. On a phone the + // table's narrow per-column widths make every value clip and force + // the user to swipe sideways through the column set just to read a + // single row — cards display every field of one row top-to-bottom + // with a label/value layout, which reads naturally on touch and + // keeps the entire row visible at once. + const tableTheme = useTheme(); + const isMobile = useMediaQuery(tableTheme.breakpoints.down("sm")); + const notify = useNotify(); + // entityLabel is the singular form of `config.title` ("Squads" → "Squad") + // used in user-visible toast messages like "Squad created" / "3 Users + // deleted". The slice handles the common plural-with-trailing-s pattern + // every page in the admin uses today; pages whose title doesn't end in + // "s" fall through unchanged. + const entityLabel = useMemo(() => { + const t = config.title; + return t.endsWith("s") ? t.slice(0, -1) : t; + }, [config.title]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + // `loadingVisible` is the user-facing loading flag — it lags behind + // `loading` by ~180 ms so refreshes that finish quickly (e.g. on a + // local network, where the round-trip is well under that threshold) + // don't flash the LinearProgress or dim the table at all. The + // refresh-icon spinner uses `loading` directly so the click still + // gets immediate feedback; only the heavier visual treatment of the + // table itself is deferred. + const [loadingVisible, setLoadingVisible] = useState(false); + useEffect(() => { + if (!loading) { + setLoadingVisible(false); + return; + } + const id = window.setTimeout(() => setLoadingVisible(true), 180); + return () => window.clearTimeout(id); + }, [loading]); + const [refreshSpinning, setRefreshSpinning] = useState(false); + // `paginating` flips to `true` the moment the user changes page / + // page size / sort / filters and stays true until the new data + // arrives. We use it for two things: + // + // (a) Fade just the row body to opacity 0 — desktop TableBody and + // mobile row-cards Stack — so the previous page's records + // don't visibly linger under the in-flight request. The + // column headers (desktop TableHead) and the mobile + // select-all + sort toolbar deliberately stay at full + // opacity, so the structure of the table remains legible + // while the data cells disappear. + // + // Rows stay *mounted* during this fade — clearing them would + // collapse the table card to the bare empty-state height, + // which reads as a sudden snap; keeping them and just hiding + // them with opacity preserves the card's current height all + // the way through the round-trip. Once `reload` lands the + // new rows in `setRows`, the same gate fades the body back + // to opacity 1, so the new entries cross-fade into the slot + // the old ones were in. + // + // (b) Suppress the "No data yet" empty-state placeholder so it + // doesn't flash between the moment the rows are hidden and + // the moment the new ones land — relevant when the previous + // page itself was empty and the user filtered / paginated + // to a different empty (or pending) result. + const [paginating, setPaginating] = useState(false); + + // configRef holds the latest config without forcing `reload` to re-fire + // when the parent's useMemo reidentifies it. Pages that do + // + // const squads = useSquads(api); + // const config = useMemo(() => ({ ... }), [api, squads]); + // + // (Nodes / Users / *Limiters) get a *new* config object the moment the + // /squads request resolves, even though `list` / `count` / `filters` are + // structurally identical to the ones from the previous render. Reading the + // CRUD callbacks via `configRef.current` inside the imperative reload + // means a content-equivalent config swap doesn't trigger a second list + + // count round-trip — fixing the "table loads twice" symptom on every + // squad-aware page. + // + // The declarative paths (column rendering, filter chips, dialog fields) + // keep reading `config` directly so they pick up the new render functions + // / option lists on the next paint, which is what makes squad name chips + // appear once /squads resolves. + const configRef = useRef(config); + configRef.current = config; + + // Filter state. Text/select entries are strings; datetime-range entries + // are `{ start?: string; end?: string }` objects holding the raw value of + // . + // + // `filterValues` is the live "draft" — whatever is currently typed in the + // panel. `appliedFilters` is what was actually submitted via Search. The + // table re-fetches only when `appliedFilters` changes, so typing into a + // text filter doesn't fire one request per keystroke. + // + // `filterSpecs` is memoised by the JSON of `config.filters` rather than by + // its array reference. Pages declare `filters: [...]` inline inside their + // `useMemo` factory, so the array literal is recreated whenever the + // factory re-runs (e.g. when a `useSquads` catalog finishes loading). The + // contents are unchanged, but a new reference would ripple through every + // dep array that lists `filterSpecs` — including `reload`'s — and + // re-fetch the table for no reason. JSON-stringifying the spec keeps the + // memo stable across content-equivalent reidentifications. Filter specs + // are tiny (`name`/`label`/`type` plus a few flags), so the stringify + // cost is negligible. + const filtersJSON = JSON.stringify(config.filters ?? []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const filterSpecs = useMemo(() => config.filters ?? [], [filtersJSON]); + // `appliedFilters` is persisted per-page in localStorage so the user's + // last search survives reloads. The draft (`filterValues`) starts in + // sync with the persisted applied set so the inputs render filled with + // whatever was previously applied; subsequent typing stays + // component-local until the user presses "Search" (or "Reset"). + const [appliedFilters, setAppliedFilters] = usePersistedAppliedFilters( + config.title, + ); + const [filterValues, setFilterValues] = useState>( + () => appliedFilters, + ); + const [showFilters, setShowFilters, filtersWillAnimateOpen] = + usePersistedFiltersOpen(config.title); + // `filtersFirstPaintReady` gates the very first reload() so the + // table starts loading rows AFTER the filter panel has settled + // into its initial state on this mount. Without this gate the + // rows pop in before / during the panel's open animation when + // the user navigates from the sidebar to a page with persisted- + // open filters (subsequent CrudPage mount, panel transitions + // from closed → open over ~180 ms). + // + // Initial value is true unless this mount is going to animate + // the panel open — i.e. cold mounts, mounts with no filter + // specs, and mounts where the panel was left closed are all + // ready immediately. Subsequent flips happen via the Collapse's + // `onEntered` callback (animation finished) or via a small + // fallback timeout if the event was swallowed (e.g. the user + // toggled the panel mid-animation). + const [filtersFirstPaintReady, setFiltersFirstPaintReady] = useState( + () => (config.filters?.length ?? 0) === 0 || !filtersWillAnimateOpen, + ); + const activeFilterCount = useMemo( + () => countActiveFilters(filterSpecs, appliedFilters), + [filterSpecs, appliedFilters], + ); + const draftFilterCount = useMemo( + () => countActiveFilters(filterSpecs, filterValues), + [filterSpecs, filterValues], + ); + const filtersDirty = useMemo( + () => !filterValuesEqual(filterSpecs, filterValues, appliedFilters), + [filterSpecs, filterValues, appliedFilters], + ); + + // Pagination state. `page` is 0-indexed (MUI's TablePagination contract). + // `total` is null until the count endpoint replies — when it's null the + // pagination bar shows "1–N of more than N" so the user can still navigate. + // `pageSize` is persisted per-page in localStorage so the user's chosen + // rows-per-page survives reloads. + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = usePersistedPageSize( + config.title, + config.defaultPageSize ?? 10, + ); + const [total, setTotal] = useState(null); + + // Sort state. `field` mirrors the column key used by the API; the + // direction is "asc" / "desc" → translated to `sort_asc` / `sort_desc` + // query params, matching service/admin_panel/tables/*.go. + // + // Persisted per-page in localStorage (keyed off `config.title`, same + // namespace as page size / filters / column widths) so switching + // between admin tabs — or reloading the page — keeps whatever + // column + direction the user last picked, including a deliberate + // "no sort" choice (`sortField === null` after a third header click). + const [sortField, setSortField, sortDir, setSortDir] = usePersistedSort( + config.title, + config.defaultSort, + ); + + // Each reload picks up a unique id; a response is only allowed to flip + // state if its id still matches the latest. Without this guard a slow + // prior reload could land *after* a newer one and overwrite the freshly + // filtered rows with stale data. + const reqRef = useRef(0); + // Flips to `true` the first time a reload finishes (success or error). + // The empty-state placeholder is rendered (and reserves its full + // height) from mount onwards so the card never collapses to a + // header-only strip while the first request is in flight; this flag + // controls only its `visibility`, so the "No data yet" copy and CTA + // never flash before we know whether the API actually has rows. + const [hasFetched, setHasFetched] = useState(false); + + // Excel-style resizable column widths, persisted per page (keyed off the + // page title). `headerCellRefs` tracks the live `` element for each + // column so the ResizeHandle can read the column's *actual* current + // pixel width when the drag starts — that way the auto-distributed + // widths from `tableLayout: fixed` on first render are honoured before + // the user has touched anything. + const { + widths: columnWidths, + setWidth: setColumnWidth, + setManyWidths: setManyColumnWidths, + resetWidths: resetColumnWidths, + } = useColumnWidths(config.title); + // True iff the user has resized at least one column away from its + // default width — i.e. the localStorage entry would have a non-empty + // payload. Drives the conditional render of the toolbar's "Reset + // column widths" button so it only surfaces when there's actually + // something to reset. + const hasCustomColumnWidths = Object.keys(columnWidths).length > 0; + const headerCellRefs = useRef>({}); + // Inner ref to the inline-flex span that wraps each header's visible + // content (label + sort caret). Measured to derive the per-column + // natural minimum width — see `columnNaturalMins` below. + const headerLabelRefs = useRef>({}); + // Ref to the horizontally-scrolling container around the table. Used + // to clamp column resize so a column's right edge can never travel + // past the sticky Actions column — i.e. the total table width never + // exceeds the visible scroll viewport width. Without this clamp the + // user could grow a column wider than the viewport and end up with + // the rightmost data columns hidden underneath the pinned Actions. + const scrollContainerRef = useRef(null); + const [tableViewportWidth, setTableViewportWidth] = useState( + null, + ); + useEffect(() => { + const el = scrollContainerRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(() => { + setTableViewportWidth(el.clientWidth); + }); + ro.observe(el); + setTableViewportWidth(el.clientWidth); + return () => ro.disconnect(); + }, []); + const getColumnLiveWidth = useCallback( + (key: string): number => { + const stored = columnWidths[key]; + if (stored != null) return stored; + const el = headerCellRefs.current[key]; + return el ? el.getBoundingClientRect().width : DEFAULT_DATA_COL_WIDTH; + }, + [columnWidths], + ); + + // Promote every previously-unstored data column to a fixed width equal + // to its current rendered width. Called once at the start of every + // resize drag so the unstored neighbours don't rubber-band along with + // the dragged column. Under `tableLayout: fixed`, columns without an + // explicit width share the leftover horizontal space equally; without + // this lock-in, growing the dragged column would shrink each unstored + // neighbour by their share of the leftover instead of leaving them + // alone. We snapshot live widths (not natural mins) so the visual + // layout is byte-identical the instant the drag starts — the user + // sees no jump. + const lockInUnstoredColumnWidths = useCallback(() => { + const entries: Record = {}; + for (const c of config.columns) { + const key = String(c.key); + if (columnWidths[key] != null) continue; + const el = headerCellRefs.current[key]; + if (!el) continue; + entries[key] = el.getBoundingClientRect().width; + } + if (Object.keys(entries).length > 0) setManyColumnWidths(entries); + }, [config.columns, columnWidths, setManyColumnWidths]); + + // columnNaturalMins is the smallest cell width that still fully shows a + // column's header content (label text + sort caret) plus the cell's + // horizontal padding. We measure the inner inline-flex wrapper after + // each render — that node sizes itself to its content regardless of + // the cell's enforced width, so it stays accurate even when the user + // has resized the column smaller than its content. + // + // The state form (rather than a ref) is on purpose: when the natural + // minimum changes (different label, font-load), we want React to + // re-render so the cell's `width` is recomputed via the + // `Math.max(stored, naturalMin)` rule below, and the visible column + // can never be narrower than its content. + const [columnNaturalMins, setColumnNaturalMins] = useState< + Record + >({}); + // Buffer added on top of the measured label width — accounts for the + // TableCell's left + right padding (16 px each in the size="small" + // variant) plus a small safety margin so the resize handle's grab strip + // never overlaps the last glyph of the label. + const HEADER_PADDING_BUFFER = 32 + 8; + useLayoutEffect(() => { + const next: Record = {}; + for (const c of config.columns) { + const key = String(c.key); + const el = headerLabelRefs.current[key]; + if (!el) continue; + next[key] = + Math.ceil(el.getBoundingClientRect().width) + HEADER_PADDING_BUFFER; + } + setColumnNaturalMins((prev) => { + let same = Object.keys(next).length === Object.keys(prev).length; + if (same) { + for (const k in next) { + if (prev[k] !== next[k]) { + same = false; + break; + } + } + } + return same ? prev : next; + }); + }); + + const getColumnNaturalMin = useCallback( + (key: string): number => { + const measured = columnNaturalMins[key]; + return measured != null ? Math.max(MIN_COL_WIDTH, measured) : MIN_COL_WIDTH; + }, + [columnNaturalMins], + ); + + // tableMinWidth is the table's lower-bound width used to prevent column + // overlap when the user resizes columns to a sum that exceeds the + // container's width. With `tableLayout: fixed` and the default + // `width: 100%`, a column that the user has dragged wider would + // otherwise force the *other* columns to visually compress and overlap + // their content (because the table itself can't grow past the + // container). Setting `minWidth` to the actual sum of column widths + // forces the table to grow when the columns no longer fit, which in + // turn lets the surrounding `TableContainer` (which has + // `overflow-x: auto`) provide a natural horizontal scrollbar. + // + // Each column contributes `max(stored, naturalMin)` so the lower bound + // also covers cases where a previously-stored width is now narrower + // than the column's measured content (e.g. the label text grew, or the + // localStorage value predates this fix). + const tableMinWidth = useMemo(() => { + // checkbox column + actions column. The actions column starts at + // 92 px (Edit + Delete) and grows by ~32 px per extra rowAction so + // the sticky cell doesn't have to scroll its own children when a + // page configures additional buttons (e.g. the "reset traffic" + // action on the Traffic Limiters page). + let sum = 72 + 92 + (config.rowActions?.length ?? 0) * 32; + for (const c of config.columns) { + const key = String(c.key); + const stored = columnWidths[key]; + const naturalMin = getColumnNaturalMin(key); + sum += stored != null + ? Math.max(stored, naturalMin) + : Math.max(naturalMin, DEFAULT_DATA_COL_WIDTH); + } + return sum; + }, [config.columns, columnWidths, getColumnNaturalMin]); + + // getColumnMaxWidth — upper bound for a single column's width during + // a resize drag. Excel-style: the only cap is the column's own + // natural minimum (which is really a lower-bound, applied via + // `Math.max(min, target)` in the drag handler — exposed here so the + // ResizeHandle's max never falls below it for over-tight viewports). + // + // We deliberately do NOT cap by the visible scroll viewport: the + // surrounding `TableContainer` is `overflow-x: auto`, so dragging a + // column wider than the viewport just makes the table overflow and + // scroll horizontally. The sticky Actions column stays pinned to + // the right edge of the viewport regardless of how wide the data + // columns get, which is the same behaviour spreadsheet users + // expect. + const getColumnMaxWidth = useCallback( + (_key: string): number => { + // No upper cap: the user can pull a column as wide as they like; + // the table simply grows past the viewport and the surrounding + // TableContainer scrolls horizontally. + return Number.POSITIVE_INFINITY; + }, + [], + ); + const reload = useCallback(async () => { + const reqId = ++reqRef.current; + setLoading(true); + try { + // Read every config field through the ref so a content-equivalent + // config swap (see `configRef` above) doesn't reach this callback's + // dep array and trigger a duplicate fetch. + const cfg = configRef.current; + const filterQuery = buildFilterQuery(filterSpecs, appliedFilters); + // Only the list endpoint cares about pagination + sort; count is + // computed against the same filters but ignores those params. + const listQuery: Listable = { + ...filterQuery, + offset: page * pageSize, + limit: pageSize, + }; + if (sortField) { + if (sortDir === "asc") listQuery.sort_asc = sortField; + else listQuery.sort_desc = sortField; + } + const [rowsResult, countResult] = await Promise.all([ + cfg.list(listQuery), + cfg.count ? cfg.count(filterQuery) : Promise.resolve(null), + ]); + if (reqRef.current !== reqId) return; + // Notify observers (e.g. useSquadCatalog) with the new row set + // *before* publishing it so they can fetch the auxiliary data + // those rows reference (squad names, …). When the observer + // returns a promise we await it, which is what keeps the table + // from briefly rendering with raw ids before the friendly chip + // labels arrive — once setRows fires, the supporting catalogs + // are already populated and the first paint shows the final + // names. `onRowsChange` is read through configRef — not the + // `reload` dep array — so a new callback identity from the + // parent's `useMemo` doesn't retrigger the fetch. + if (cfg.onRowsChange) { + try { + await cfg.onRowsChange(rowsResult); + } catch { + /* best-effort: a misbehaving observer must not break the table */ + } + if (reqRef.current !== reqId) return; + } + // Reconcile by primary key so refreshes that return identical data + // re-use the previous row references — the memoised + // skips its render path when `prev.row === next.row`, which is + // what keeps the table from visibly redrawing on a no-op refresh. + // When every row is unchanged we even hand back the previous + // array so React's setState bail-out short-circuits the parent + // re-render entirely. + setRows((prev) => + reconcileRows( + prev as unknown as Record[], + rowsResult as unknown as Record[], + String(cfg.idKey), + ) as unknown as TEntity[], + ); + setTotal(countResult); + } catch (e) { + if (reqRef.current !== reqId) return; + // Reload errors are surfaced exclusively through the global toast + // stack — no inline Alert in the page chrome. The empty-state + // placeholder still renders below (the table just stays empty) + // so the user has both the toast and the visual cue that no rows + // came back. `notifyApiError` picks a useful description for the + // exception class (connection vs. HTTP vs. unauthorized). + notifyApiError(notify, `Failed to load ${configRef.current.title}`, e); + } finally { + if (reqRef.current === reqId) { + setLoading(false); + setHasFetched(true); + // Drop the `paginating` flag the moment the latest in-flight + // request resolves — success or failure. The empty-state + // placeholder reappears (if `rows` is still empty) and the + // user's pagination/sort/filter change is fully landed. + // Stale reloads (reqId mismatch above) leave it alone so a + // newer pending pagination keeps the placeholder hidden. + setPaginating(false); + } + } + }, [filterSpecs, appliedFilters, page, pageSize, sortField, sortDir, notify]); + + useEffect(() => { + if (!filtersFirstPaintReady) return; + void reload(); + }, [reload, filtersFirstPaintReady]); + + // Safety net for the animated-open path: the Collapse's `onEntered` + // is what normally flips `filtersFirstPaintReady` to true after the + // panel finishes its ~180 ms open transition. If for any reason that + // event doesn't fire — e.g. the user toggled the panel closed + // mid-animation, or the Collapse unmounted before the transitionend + // landed — fall back to a short timeout so the table doesn't sit + // empty forever waiting for an event that already came and went. + // This effect is a no-op for every mount path that initialises + // `filtersFirstPaintReady` to true (cold mounts, no-filter pages, + // and subsequent mounts with the panel left closed). + useEffect(() => { + if (filtersFirstPaintReady) return; + const t = window.setTimeout(() => setFiltersFirstPaintReady(true), 250); + return () => window.clearTimeout(t); + }, [filtersFirstPaintReady]); + + const setFilterValue = (name: string, v: unknown) => { + setFilterValues((p) => ({ ...p, [name]: v })); + }; + // Applying / clearing filters resets the pagination to the first page + // so the user is never left on a page that no longer exists. The row + // list is faded to opacity 0 (but kept mounted) by the dependency- + // driven effect that watches the same five values (search for + // `setPaginating`), which arms the `paginating` flag the moment + // any of `page`, `pageSize`, `sortField`, `sortDir`, or + // `appliedFilters` change. The flag is cleared again from inside + // `reload` once the new rows arrive, at which point the container + // fades back to opacity 1 and the new rows cross-fade into the + // slot the old ones occupied. + const applyFilters = () => { + setAppliedFilters(filterValues); + setPage(0); + }; + const resetFilters = () => { + setFilterValues({}); + setAppliedFilters({}); + setPage(0); + }; + + // handleSort toggles between asc → desc → unsorted on the active column, + // and switches to a fresh column when a different header is clicked. + // Changing sort always returns to the first page so the user sees the + // top of the new ordering. + const handleSort = (key: string) => { + if (sortField === key) { + if (sortDir === "asc") { + setSortDir("desc"); + } else { + // Third click clears the sort. + setSortField(null); + setSortDir("asc"); + } + } else { + setSortField(key); + setSortDir("asc"); + } + setPage(0); + }; + + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + // `dialogOpen` drives the MUI Dialog's `open` prop directly; flipping it + // to false starts the leave transition. `creating` / `editing` are + // cleared only after the transition finishes (handleDialogExited) so the + // dialog keeps rendering its current title and form contents through + // the fade-out — same trick the delete-confirm dialog uses with + // `lastPendingDeleteRef` below. + const [dialogOpen, setDialogOpen] = useState(false); + + const openCreate = useCallback(() => { + setEditing(null); + setCreating(true); + setDialogOpen(true); + }, []); + + const closeDialog = () => { + setDialogOpen(false); + }; + + const handleDialogExited = () => { + setCreating(false); + setEditing(null); + }; + + // Global keyboard shortcut: pressing "n" (physical key, layout- + // independent) anywhere on the page opens the Create dialog. We use + // `ev.code === "KeyN"` rather than `ev.key === "n"` so the shortcut + // fires regardless of keyboard layout — a Russian / Cyrillic layout + // would otherwise emit `ev.key === "т"`. + // Skipped while another dialog is open, when a modifier is held + // (so Ctrl+N etc. keep their browser meaning), and when the user is + // typing into an input. + // While the dialog is closing the MUI `open` prop is already false but + // `creating`/`editing` are still set (cleared by handleDialogExited); + // gate the shortcut on that combined "live" predicate so a stray "n" + // press during the leave animation doesn't immediately reopen. + const dialogLive = dialogOpen || creating || editing !== null; + useEffect(() => { + const handler = (ev: globalThis.KeyboardEvent) => { + if (ev.code !== "KeyN") return; + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + if (dialogLive) return; + const target = ev.target as HTMLElement | null; + if (target) { + const tag = target.tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + target.isContentEditable + ) + return; + } + ev.preventDefault(); + openCreate(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [dialogLive, openCreate]); + + // Minimum visible window for the form dialog's "Saving…" busy state, + // mirroring the delete-dialog treatment. Without it, a sub-100 ms + // create/update round-trip causes the spinner + label to appear for + // a single frame and disappear — the user perceives that as a glitch + // rather than as the action having run. Both handlers gate `closeDialog` + // (and the error toast) on this so the on-screen busy state is held + // long enough to be perceived even on a fast network. + const ensureMinFormBusy = async (startTs: number) => { + const elapsed = performance.now() - startTs; + const minBusyMs = 350; + if (elapsed < minBusyMs) { + await new Promise((r) => setTimeout(r, minBusyMs - elapsed)); + } + }; + + // Both handlers re-throw so the dialog's inline error UI keeps working + // (the dialog catches and renders the message inside an Alert). The toast + // is a complementary signal: it flashes a quick "X created" / "Failed to + // create X" status without interfering with the dialog's per-field error + // panel. notifyApiError silently swallows UnauthorizedError because the + // global 401 handler in AuthContext already announces those. + const handleCreate = async (form: Record) => { + const body = config.toCreate(form) as TCreate; + const startTs = performance.now(); + try { + await config.create(body); + } catch (e) { + await ensureMinFormBusy(startTs); + notifyApiError(notify, `Failed to create ${entityLabel}`, e); + throw e; + } + await ensureMinFormBusy(startTs); + closeDialog(); + notify.success(`${entityLabel} created successfully`); + await reload(); + }; + + const handleUpdate = async (form: Record) => { + if (!editing) return; + const id = editing[config.idKey] as unknown as string | number; + const body = config.toUpdate(form, editing) as TUpdate; + const startTs = performance.now(); + try { + await config.update(id, body); + } catch (e) { + await ensureMinFormBusy(startTs); + notifyApiError(notify, `Failed to update ${entityLabel}`, e); + throw e; + } + await ensureMinFormBusy(startTs); + closeDialog(); + notify.success(`${entityLabel} updated successfully`); + await reload(); + }; + + // -------- Delete confirmation ---------------------------------------- + // A single MUI dialog drives both the per-row Delete icon and the bulk + // "Delete selected" button. `pendingDelete` carries the primary keys + // about to be removed; null means the dialog is closed. + const [pendingDelete, setPendingDelete] = useState< + | { kind: "single"; id: string | number; label: string } + | { kind: "bulk"; ids: (string | number)[] } + | null + >(null); + const [deleteBusy, setDeleteBusy] = useState(false); + // Hold onto the last non-null `pendingDelete` so the dialog title and + // body keep showing the entity label/count during the MUI fade-out + // animation that fires after `setPendingDelete(null)`. Without this, + // the closing dialog briefly renders " will be permanently removed + // from the database." with an empty subject — which looks like the + // name "vanishes" the instant the user clicks Delete. + const lastPendingDeleteRef = useRef(pendingDelete); + if (pendingDelete !== null) { + lastPendingDeleteRef.current = pendingDelete; + } + const displayPendingDelete = pendingDelete ?? lastPendingDeleteRef.current; + + const handleDelete = useCallback( + (row: TEntity) => { + const id = row[config.idKey] as unknown as string | number; + setPendingDelete({ + kind: "single", + id, + label: `${config.title.slice(0, -1)} #${String(id)}`, + }); + }, + [config.idKey, config.title], + ); + // Stable `setEditing` wrapper so memoised rows don't re-render just + // because the inline `() => setEditing(row)` arrow swaps reference + // every parent render. setEditing itself is already stable from + // useState, but the row needs a `(row) => void` callback. We also flip + // `dialogOpen` here to start the enter transition — closeDialog leaves + // it set to false, so reopening must explicitly set it back to true. + const handleEdit = useCallback((row: TEntity) => { + setCreating(false); + setEditing(row); + setDialogOpen(true); + }, []); + + // pendingAction drives the row-action confirmation dialog (mirror of + // pendingDelete above). Null when no dialog is open. Pages opt into + // the dialog by setting `RowActionSpec.confirm`; actions without + // `confirm` skip this state and run inline in `handleRunAction`. + const [pendingAction, setPendingAction] = useState< + { row: TEntity; action: RowActionSpec } | null + >(null); + const [actionBusy, setActionBusy] = useState(false); + // Same trick as `lastPendingDeleteRef`: hold the last non-null + // pendingAction so the dialog can keep displaying its title / + // description while it animates closed (after `setPendingAction(null)`). + const lastPendingActionRef = useRef(pendingAction); + if (pendingAction !== null) { + lastPendingActionRef.current = pendingAction; + } + const displayPendingAction = pendingAction ?? lastPendingActionRef.current; + const displayActionConfirm = useMemo(() => { + if (!displayPendingAction?.action.confirm) return null; + return displayPendingAction.action.confirm(displayPendingAction.row); + }, [displayPendingAction]); + + // handleRunAction either opens the confirmation dialog (when the + // action declares one) or invokes the action immediately. Inline + // errors are surfaced through the global toast stack the same way + // the CrudPage's own Create / Edit / Delete failures are; the + // action's `onClick` itself is responsible for emitting any success + // toast. + const handleRunAction = useCallback( + (row: TEntity, action: RowActionSpec) => { + if (action.confirm) { + setPendingAction({ row, action }); + return; + } + void Promise.resolve(action.onClick(row, { reload })).catch((e) => { + notifyApiError(notify, `Failed to ${action.label.toLowerCase()}`, e); + }); + }, + [reload, notify], + ); + + // confirmAction is the primary-button handler for the row-action + // dialog. Modeled on confirmDelete: minimum-visible busy state to + // suppress single-frame flickers, errors keep the dialog open so + // the user can retry, success closes it and reloads the table. + const confirmAction = async () => { + if (!pendingAction) return; + setActionBusy(true); + const startTs = performance.now(); + const minBusyMs = 350; + let shouldClose = false; + try { + await Promise.resolve( + pendingAction.action.onClick(pendingAction.row, { reload }), + ); + shouldClose = true; + } catch (e) { + notifyApiError( + notify, + `Failed to ${pendingAction.action.label.toLowerCase()}`, + e, + ); + } + const elapsed = performance.now() - startTs; + if (elapsed < minBusyMs) { + await new Promise((r) => setTimeout(r, minBusyMs - elapsed)); + } + if (shouldClose) { + setPendingAction(null); + // Hold the busy visuals through the dialog's exit animation so + // the button label / spinner don't snap back to idle while the + // dialog itself is still fading away on screen — same 260 ms + // as confirmDelete. + setTimeout(() => setActionBusy(false), 260); + } else { + setActionBusy(false); + } + }; + + // pluralEntityLabel formats the entity label for a count of things — + // singular when count === 1, the configured plural title otherwise. Used + // for bulk-delete toasts so 1 row reads "Squad deleted" while 5 rows + // read "5 Squads deleted". + const pluralEntityLabel = (count: number) => + count === 1 ? entityLabel : config.title; + + const confirmDelete = async () => { + if (!pendingDelete) return; + setDeleteBusy(true); + const startTs = performance.now(); + // Minimum visible duration for the "Deleting…" state. Without + // this, a sub-100 ms network round-trip causes the label / + // spinner to flicker on for a single frame and back off, which + // reads as "the button glitched" rather than "the action ran". + const minBusyMs = 350; + let shouldClose = false; + try { + if (pendingDelete.kind === "single") { + try { + await config.remove(pendingDelete.id); + } catch (e) { + notifyApiError(notify, `Failed to delete ${entityLabel}`, e); + throw e; + } + notify.success(`${entityLabel} deleted successfully`); + } else { + // Run bulk deletes in parallel; settle-all so a single failure + // doesn't hide the successes from the reload below. We then split + // the results so the user sees both halves: a green "N deleted" + // chip for the wins and a red "M failed" chip for the losses. + const results = await Promise.allSettled( + pendingDelete.ids.map((id) => config.remove(id)), + ); + const ok = results.filter((r) => r.status === "fulfilled").length; + const failed = results.length - ok; + if (ok > 0) { + notify.success( + `${ok} ${pluralEntityLabel(ok)} deleted successfully`, + ); + } + if (failed > 0) { + // Surface the first error verbatim — almost always the same + // for every entry (permissions / FK constraint / connectivity) + // so picking a representative one keeps the toast useful. + const firstReason = (results.find( + (r) => r.status === "rejected", + ) as PromiseRejectedResult | undefined)?.reason; + if (firstReason !== undefined) { + notifyApiError( + notify, + `Failed to delete ${failed} ${pluralEntityLabel(failed)}`, + firstReason, + ); + } else { + notify.error( + `Failed to delete ${failed} ${pluralEntityLabel(failed)}`, + ); + } + } + } + shouldClose = true; + } catch { + // Already announced via `notifyApiError` inside the inner try. + // Swallow here so the rejection doesn't bubble to the global + // error handler — the dialog stays open (we never set + // `shouldClose = true`) so the user can retry or cancel. + } + const elapsed = performance.now() - startTs; + if (elapsed < minBusyMs) { + await new Promise((r) => setTimeout(r, minBusyMs - elapsed)); + } + if (shouldClose) { + setPendingDelete(null); + setSelected(new Set()); + await reload(); + // Hold the busy visuals through the dialog's exit animation + // (~225 ms default MUI Dialog fade) so the button label / + // spinner don't snap back to the idle "Delete" state while + // the dialog itself is still fading away on screen. + setTimeout(() => setDeleteBusy(false), 260); + } else { + setDeleteBusy(false); + } + }; + + // -------- Selection / bulk actions ------------------------------------ + // `selected` holds the primary-key values (string | number) of every + // currently checked row. We scope the selection to the rows that are + // actually visible — paginating, applying filters, sorting or reloading + // wipes it so the user can't accidentally bulk-act on rows they're no + // longer looking at. + const idOf = useCallback( + (row: TEntity): string | number => + row[config.idKey] as unknown as string | number, + [config.idKey], + ); + // Stabilise the columns reference so memoised rows don't re-render + // every time the parent's config object is reconstructed (which happens + // on each parent render when filterValues / loading / etc. change). + const columnsRef = useMemo(() => config.columns, [config.columns]); + + // Subset of columns the user is allowed to sort by — mirrors the + // desktop header logic where each TableSortLabel is gated on + // `c.sortable !== false`. Computed once per columns reference so + // the mobile sort dropdown doesn't rebuild its option list on every + // unrelated render. + const sortableColumns = useMemo( + () => config.columns.filter((c) => (c.sortable ?? true) !== false), + [config.columns], + ); + + // -------- Smooth size transitions ------------------------------------ + // The table card's height changes every time the row count does + // (filter applied, page changed, route switched). The Web Animations + // API smoothly tweens between the old and new heights — useLayoutEffect + // runs after the DOM update but before paint, so the captured height + // represents the *new* layout while `prevHeightRef.current` still holds + // the previous one. + // + // Speed (px / sec) is held constant rather than the duration: with a + // fixed 220 ms a small +1-row change felt sluggish while a +25-row + // jump looked rushed, because the same 220 ms had to cover wildly + // different deltas. Sliding at a constant px/sec keeps the visual + // velocity uniform regardless of how many rows just arrived from + // the API. The clamp on the bottom prevents micro-deltas from + // animating in 5 ms (effectively a snap), and the cap on top keeps + // very large jumps from feeling laggy. + // + // Desktop (md+) skips this tween entirely: the layout pins the Paper + // to `flex: 1` of the leftover viewport space, so its `offsetHeight` + // is governed by flex, not by row count. Animating `height: Xpx` + // through the Web Animations API would temporarily inline an + // explicit pixel height that fights the flex algorithm — the + // browser briefly resolves the Paper to that fixed height while + // it would have otherwise been "fill-leftover", which translates + // into the PageHeader / filter area visibly jumping toward the + // top each time fetched rows arrive (because the parent flex + // chain redistributes the leftover space differently when one + // child has an explicit height versus a `flex-basis: 0%`). On + // mobile / sm where the Paper is still block-laid-out and grows + // with content, the tween stays useful and runs unchanged. + const heightTweenTheme = useTheme(); + const heightTweenIsDesktop = useMediaQuery( + heightTweenTheme.breakpoints.up("md"), + ); + const tableRef = useRef(null); + const prevHeightRef = useRef(null); + useLayoutEffect(() => { + const el = tableRef.current; + if (!el) return; + if (heightTweenIsDesktop) { + // Keep the ref bookkeeping in sync so a future viewport + // resize back to mobile starts from a sane baseline rather + // than from the height captured pre-`md`. + prevHeightRef.current = el.offsetHeight; + return; + } + const newHeight = el.offsetHeight; + if ( + prevHeightRef.current !== null && + prevHeightRef.current !== newHeight && + typeof el.animate === "function" + ) { + const delta = Math.abs(newHeight - prevHeightRef.current); + // Calibrated so big swaps don't drag: at 1300 px/sec, a + // typical single-row delta (~50 px) still clears the + // 240 ms floor; a 200 px change takes ~154 ms (floored); + // a ~40-row swap (~800 px) takes ~615 ms; a full-page + // resize (e.g. 100 → 10 rows, ~4500 px) takes ~3.5 s + // mathematically but is hard-capped at 560 ms so very + // large reflows never feel like waiting. + const PX_PER_SEC = 1300; + // Floor of 240 ms is the inflection where motion reads + // as "deliberate movement" rather than a snap on 60 Hz + // displays — going below ~220 ms with a symmetric easing + // curve loses the perceptible glide. + const MIN_MS = 240; + // Ceiling of 560 ms keeps even the biggest page-size + // changes from dragging — past ~600 ms a height tween + // tips over from "the table is settling" into "the table + // is slow", regardless of how many rows just arrived. + const MAX_MS = 560; + const duration = Math.max( + MIN_MS, + Math.min(MAX_MS, (delta / PX_PER_SEC) * 1000), + ); + el.animate( + [ + { height: `${prevHeightRef.current}px` }, + { height: `${newHeight}px` }, + ], + // Symmetric S-curve (gentle ease-in-out). The previous + // Material-standard `cubic-bezier(0.4, 0, 0.2, 1)` + // started fast and decelerated — the card lurched out + // of its starting height and then crawled into place. + // `0.45, 0, 0.55, 1` is a balanced sigmoid: slow at both + // ends and brisk through the middle, which reads as a + // continuous, "pulled" reflow. + { duration, easing: "cubic-bezier(0.45, 0, 0.55, 1)" }, + ); + } + prevHeightRef.current = newHeight; + }, [rows.length, hasFetched, heightTweenIsDesktop]); + + // -------- Row-count-scaled animation timings ------------------------- + // The dim overlay and the top progress bar both fade with a duration + // that grows with the size of the visible table. Few rows → snappy + // (the table is small, so the eye absorbs the change instantly); + // many rows → graceful (a wall of cells benefits from a longer + // breath so the dim looks like a deliberate veil rather than a + // strobe). + // + // The square-root curve climbs steeply for the first few rows then + // flattens out — perceptually we're far more sensitive to the + // difference between 0 and 5 rows than between 50 and 100, so the + // ramp matches that. `Math.sqrt(50) ā‰ˆ 7.07`, so the divisor of 7 + // saturates the scale near 50 rows and then plateaus. + // + // The height-tween animation already scales with pixel delta + // (`PX_PER_SEC` / `MIN_MS` / `MAX_MS` above), which is itself a + // proxy for "how many rows just appeared/disappeared", so we don't + // need a separate row-count factor there. + const rowAnimScale = Math.min(1, Math.sqrt(rows.length) / 7); + // 220 ms (empty / 1 row) → 460 ms (50+ rows). The upper bound is + // intentionally compressed: at the previous 700 ms ceiling, big + // tables felt sluggish — a wall of 50+ rows benefits from a + // longer-than-snappy fade, but past ~450 ms the user starts + // *waiting* on the dim instead of perceiving it as stale-state + // feedback. + const dimDurationMs = Math.round(220 + rowAnimScale * 240); + // 180 ms (empty / 1 row) → 360 ms (50+ rows). Still slightly + // faster than the dim so the progress bar leads the dim into / + // out of view, and tightened in lockstep so big tables don't + // sit watching a slow strip of motion above static cells. + const progressDurationMs = Math.round(180 + rowAnimScale * 180); + + const [selected, setSelected] = useState>(new Set()); + // Rebuild the selection so it only ever contains keys that exist on the + // current page (e.g. a row was deleted by another tab). + useEffect(() => { + setSelected((prev) => { + if (prev.size === 0) return prev; + const visible = new Set(rows.map(idOf)); + let changed = false; + const next = new Set(); + for (const k of prev) { + if (visible.has(k)) next.add(k); + else changed = true; + } + return changed ? next : prev; + }); + }, [rows, idOf]); + // Clear selection whenever the user paginates, sorts, or applies a new + // filter — bulk actions should only target rows the user can see right + // now. + useEffect(() => { + setSelected(new Set()); + }, [page, pageSize, sortField, sortDir, appliedFilters]); + // Arm the `paginating` flag the moment the user paginates / sorts / + // changes a filter. We don't clear `rows` here on purpose — clearing + // would collapse the table card from "25 visible rows" down to the + // bare empty-state placeholder height, which reads as a sudden + // layout snap. Instead, the existing rows stay mounted (so the + // card keeps its current height) but get faded to opacity 0 via + // the gate on the desktop TableBody / mobile row-cards Stack + // below — the column headers and mobile toolbar above them stay + // fully visible. When the new data lands, `reload`'s + // `setRows(reconcileRows(...))` swaps the entries in place and + // `setPaginating(false)` lets the body fade back to opacity 1, so + // the new rows appear to cross-fade into the slot the old ones + // were occupying. The empty-state placeholder stays hidden for + // the same window so "No data yet" doesn't flash mid-roundtrip. + // + // Skipped on the very first run (initial mount) — `rows` is already + // `[]` and we haven't even fetched anything, so there's nothing to + // hide and nothing to gate against. Without this guard the initial + // empty-state placeholder would briefly arm `paginating` for the + // duration of the first reload, keeping the "No data yet" copy + // hidden longer than necessary on cold mounts that legitimately + // resolve to an empty list. + const dataDepsFirstRunRef = useRef(true); + useEffect(() => { + if (dataDepsFirstRunRef.current) { + dataDepsFirstRunRef.current = false; + return; + } + setPaginating(true); + }, [page, pageSize, sortField, sortDir, appliedFilters]); + + const allSelected = rows.length > 0 && selected.size === rows.length; + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => { + setSelected(allSelected ? new Set() : new Set(rows.map(idOf))); + }; + const toggleRow = useCallback( + (row: TEntity) => { + const id = idOf(row); + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, + [idOf], + ); + + const handleBulkDelete = () => { + if (selected.size === 0) return; + setPendingDelete({ kind: "bulk", ids: Array.from(selected) }); + }; + + return ( + // Desktop (md+): flex column that fills the page-content slot + // exactly, so the table card below can `flex: 1` and stay inside + // the viewport regardless of how many rows / how tall a filter + // panel is open. Mobile: default block layout — the page + // continues to scroll naturally on phones, where a "fit to + // viewport" table card would just feel cramped. + + + {hasCustomColumnWidths && ( + + + + + + )} + + + { + setRefreshSpinning(true); + void reload(); + }} + size="medium" + sx={{ + width: 40, + height: 40, + borderRadius: 2, + color: "text.primary", + // Mobile browsers leave `:hover` stuck on the + // last-tapped button until the user taps elsewhere, + // which paints a hover tint around Refresh after + // every tap. Gate the hover rule on a real + // hover-capable pointer AND a desktop-width + // viewport so the tint doesn't fire either on real + // touch devices or in Chrome DevTools' mobile + // emulation mode (which keeps `(hover: hover)` + // true because the host machine still has a + // mouse, but matches the narrow viewport of a + // phone). 599.95 px is the upper edge of MUI's + // `xs` breakpoint. + "@media (hover: hover) and (min-width: 600px)": { + "&:hover": { backgroundColor: "action.hover" }, + }, + }} + > + { + if (!loading) setRefreshSpinning(false); + }} + sx={{ + animation: refreshSpinning + ? "sb-refresh-spin 0.35s linear infinite" + : "none", + "@keyframes sb-refresh-spin": { + from: { transform: "rotate(0deg)" }, + to: { transform: "rotate(360deg)" }, + }, + }} + /> + + + + {filterSpecs.length > 0 && ( + 0 + ? `Filters Ā· ${activeFilterCount} active` + : "Filters" + } + > + {/* Filters trigger collapsed to an icon-only button. + The active-count is rendered as a small Badge dot + in the top-right corner so the user still gets a + visual cue when filters are applied without the + "Filters" word taking up toolbar space. */} + + setShowFilters((s) => !s)} + size="medium" + aria-label="Filters" + sx={{ + width: 40, + height: 40, + borderRadius: 2, + // When the panel is open OR there's an active + // filter, paint the button in accent so it + // reads as "engaged"; otherwise it sits + // alongside Refresh as a quiet toolbar action. + color: + showFilters || activeFilterCount > 0 + ? "var(--sb-accent)" + : "text.primary", + // Desktop split: 12 % accent at rest, 20 % under + // a real hover (handled in the desktop branch + // below). The mobile branch promotes the + // engaged tint straight to 20 % so it doesn't + // depend on a hover state the user can't reach. + bgcolor: + showFilters || activeFilterCount > 0 + ? "color-mix(in srgb, var(--sb-accent) 12%, transparent)" + : "transparent", + // "Mobile version" branch — matches both real + // touch devices (`(hover: none)`) AND Chrome + // DevTools' mobile-emulation mode (which keeps + // `(hover: hover)` true because the host + // machine still has a mouse, but does shrink + // the viewport). 599.95 px is the upper edge + // of MUI's `xs` breakpoint, so this catches + // every "phone-shaped" layout regardless of + // input type. + // + // On the phone breakpoint we deliberately drop + // the accent-tinted engaged-state background + // entirely — that backdrop was what the user + // perceived as a halo "appearing on reload" + // (since `usePersistedFiltersOpen` rehydrates + // `showFilters: true` from localStorage on + // remount, the engaged tint would render in + // the very first frame after a refresh, with + // no tap to attribute it to). The Badge in + // the upper-right corner already carries the + // active-filter count, and the icon glyph + // recolours to `var(--sb-accent)` for the + // engaged state, so the indication isn't + // lost — it just stops painting a filled + // background. `transition: color` (no + // background-color) keeps the icon recolour + // smooth without ever needing to animate a + // backdrop that no longer exists. + "@media (hover: none), (max-width: 599.95px)": { + bgcolor: "transparent", + transition: "color 0.14s ease", + }, + // Desktop branch — only fires when the user + // genuinely has a hover-capable pointer AND + // the layout is wide enough to read as + // "desktop". Touch devices and the DevTools + // mobile preset are both excluded so a tap + // there doesn't leave the 20 %-tint hover + // background stuck on the button (which is + // the original "halo remains after tap" bug). + "@media (hover: hover) and (min-width: 600px)": { + "&:hover": { + backgroundColor: + showFilters || activeFilterCount > 0 + ? "color-mix(in srgb, var(--sb-accent) 20%, transparent)" + : "action.hover", + }, + }, + }} + > + + + + + )} + {/* The "create" pill is intentionally smaller (32 Ɨ 32) + than the neutral Refresh / Filters siblings, but it's + wrapped in a 40 Ɨ 40 layout slot so the lower toolbar + stays exactly the same total width as the upper bar's + 3-button row (each is 3 Ɨ 40 px + 2 Ɨ 12 px gap = + 144 px). Without this slot the lower toolbar would + end 8 px earlier and the right edges of the two bars + wouldn't line up. */} + + + + + + + + + } + /> + {filterSpecs.length > 0 && ( + // `flexShrink: 0` keeps the desktop flex column from squeezing + // the Collapse below the height it just measured for its own + // open animation. Without it, when this CrudPage mounts on a + // page where filters were persisted as open, the freshly + // re-rendered Collapse and the `flex: 1` Paper sibling would + // briefly compete for the same vertical pixels — the flex + // algorithm shrinks the Collapse on the same frame MUI is + // tweening its height up, the CSS `transitionend` never + // matches the ever-moving target, and the panel snaps open + // without animation. Forcing `flex-shrink: 0` lets the + // Collapse own its full natural height for the duration of + // its own transition; the Paper's `flex: 1` minus its + // `minHeight: 0` already lets it shrink to make room. + setFiltersFirstPaintReady(true)} + sx={{ flexShrink: 0 }} + > + {/* Pressing Enter anywhere inside the filter panel submits the + current draft — same as clicking Search. Avoids a full
+ wrapper which would conflict with the table form-less buttons. */} + { + if (e.key === "Enter") { + e.preventDefault(); + applyFilters(); + } + }} + sx={{ + mb: 2, + p: 2, + pl: 2.25, + borderRadius: 2.5, + border: "1px solid", + borderColor: "divider", + // Custom request: dedicated surfaces per theme so the filter + // panel reads as a distinct slab — `#f5f7fa` on light, + // `#191919` on dark. + bgcolor: (t) => (t.palette.mode === "light" ? "#f5f7fa" : "#191919"), + display: "flex", + flexDirection: "column", + gap: 1.5, + position: "relative", + overflow: "hidden", + // Subtle accent bar on the left edge — visually couples the + // panel to the toolbar's "Filters" button without shouting. + "&::before": { + content: '""', + position: "absolute", + top: 12, + bottom: 12, + left: 0, + width: 2, + borderRadius: 2, + bgcolor: "primary.main", + opacity: 0.6, + }, + }} + > + + + + + + Filters + + {activeFilterCount > 0 && ( + + )} + + + + + {/* Each filter has a fixed width — datetime-range cells (which + pack two pickers side by side) and any cell explicitly + marked `wide: true` get 1.5Ɨ the slot — and the row simply + flex-wraps when there isn't enough horizontal space, so the + number of filters per row adapts to the viewport without + forcing every cell to share the same fractional column. */} + + {filterSpecs.map((f) => ( + + setFilterValue(f.name, v)} + /> + + ))} + + + + )} + ({ + overflow: "hidden", + position: "relative", + // Desktop: claim the leftover vertical space below the + // PageHeader / filter panel and lay the inner pieces + // (progress bar, TableContainer, TablePagination) out as a + // flex column so the *rows* are the only part that scrolls. + // The card stays pinned inside the browser window — no + // page-level scroll — regardless of row count or page size. + display: { md: "flex" }, + flexDirection: { md: "column" }, + flex: { md: 1 }, + minHeight: { md: 0 }, + // Soft drop shadow gives the table some depth without making + // the rest of the layout look heavy. Tuned per mode so the + // shadow stays visible on white as well as on dark. + boxShadow: + t.palette.mode === "light" + ? "0 1px 0 rgba(15,23,42,0.04), 0 8px 22px rgba(15,23,42,0.10)" + : "0 1px 0 rgba(255,255,255,0.02), 0 12px 28px rgba(0,0,0,0.28)", + })} + > + {/* Thin top progress bar visible during any load (initial or reload). + Gated on `loadingVisible` (loading + ~180 ms) for *refreshes* so + fast roundtrips don't flash a strip of motion at all, but the + very first load bypasses that lag (`!hasFetched`) — the user + sees the progress bar the moment the table mounts, sitting + on top of the empty-state placeholder we now reserve. */} + 0`; clicking Refresh on an empty list now + // also shows the bar, which is the expected feedback for a + // user-initiated action.) + // + // Gating on `loading` (in addition to the lagging + // `loadingVisible`) so the bar disappears the moment the + // fetch finishes — without it, the post-fetch render would + // briefly keep the bar visible while `loadingVisible` waits + // for its useEffect to flip it off, which read as a "double + // render" flicker right when the rows appeared. + opacity: loading && (loadingVisible || !hasFetched) ? 1 : 0, + transition: `opacity ${progressDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`, + pointerEvents: "none", + }} + > + + + {/* Bulk-action toolbar: appears as a thin top strip on the table + card whenever at least one row is selected. Replaces the per- + row delete button for batch operations and gives the user a + one-click way to clear the selection. Rendered as a sibling + *above* the scrolling TableContainer (rather than inside it) + so the strip stays visible while the user scrolls the rows + vertically — otherwise it would scroll out of the viewport + together with the body and the user would lose the bulk- + action affordance the moment they paged past the first + screenful of selected rows. */} + 0} unmountOnExit> + + + {selected.size} selected + + + + + + + 0 ? 0.78 : 1, + transition: `opacity ${dimDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`, + // `pointerEvents` follows the same delayed signal so a + // sub-180 ms refresh never briefly disables hovers / clicks + // (which used to surface as a tiny "dead zone" over the + // table for the duration of a fast roundtrip). + pointerEvents: loading && loadingVisible ? "none" : "auto", + }} + > + {isMobile ? ( + + {rows.length === 0 ? ( + // Mirror of the desktop empty state, just outside any + // table cell so we can drop the colSpan plumbing. + // + // Rendered immediately on mount so the card reserves + // the same vertical space the empty-state CTA will + // eventually occupy. While the very first reload is + // still in flight (`!hasFetched`) the stack is kept + // invisible — the user doesn't see the "No data yet" + // copy or the Create button flash before we know + // whether the API actually has rows for them. + + ) : ( + + {/* Mobile toolbar that replaces the desktop table's + header row: the select-all checkbox sits on the + left, and a compact "sort by + direction" + control sits on the right. The Stack uses + `flexWrap: wrap` so on very narrow phones the + sort group can drop onto its own line instead + of pushing into the checkbox. */} + {rows.length > 0 && ( + + + + Select all + + + + { + const v = e.target.value; + if (v === "") { + setSortField(null); + setSortDir("asc"); + } else if (v !== sortField) { + setSortField(v); + // Default to ascending whenever the + // user picks a new column. The arrow + // button next to the field flips the + // direction without re-opening the + // menu. + setSortDir("asc"); + } + setPage(0); + }} + aria-label="Sort by" + sx={{ + minWidth: 132, + "& .MuiSelect-select": { + py: 0.5, + fontSize: 13, + }, + }} + > + + No sort + + {sortableColumns.map((c) => ( + + {c.label} + + ))} + + + + + setSortDir((d) => + d === "asc" ? "desc" : "asc", + ) + } + aria-label="Toggle sort direction" + sx={{ + width: 32, + height: 32, + color: "text.secondary", + "&:hover": { color: "var(--sb-accent)" }, + }} + > + {sortDir === "asc" ? ( + + ) : ( + + )} + + + + + + )} + {/* Just the row cards live inside this inner Stack + so the `paginating` opacity gate hides the data + cards alone — the select-all + sort toolbar + above (this branch's mobile equivalent of the + desktop column headers) stays fully visible + during the in-flight request. The inner + `spacing={1.25}` matches the outer Stack's + spacing so the visible gap between toolbar and + first card is unchanged. Cards stay mounted + during the fade so the table card height + doesn't snap. */} + + {rows.map((row, i) => { + const key = config.rowKey + ? config.rowKey(row) + : String(row[config.idKey]); + return ( + + ); + })} + + + )} + + ) : ( + + + + + + + {config.columns.map((c, idx) => { + const sortable = c.sortable ?? true; + // The last data column has no resize handle at all. + // Its right edge is the boundary with the trailing + // filler cell, so dragging it would be perceived as + // "resizing the spacer" — every adjustment to this + // column would have to come out of the filler's + // budget. Skipping the handle keeps that boundary + // visually inert; the column's width is governed + // solely by `effectiveWidth` (default + natural + // minimum), and the filler absorbs whatever space + // is left over. + const isLastDataColumn = idx === config.columns.length - 1; + const key = String(c.key); + const isActive = sortable && sortField === key; + const stored = columnWidths[key]; + // Effective width = whatever the user stored, but never + // narrower than the measured natural minimum. This is + // what stops a previously-resized cell (or a fresh + // localStorage value from before the per-column min was + // enforced) from rendering smaller than its header + // content — without it, the label would visually spill + // into the neighbouring column. + // + // For unstored columns we still assign an explicit + // pixel width (DEFAULT_DATA_COL_WIDTH, or a measured + // natural minimum if it's larger). With every data + // column carrying an explicit width, the only + // auto-sized cell in the row is the trailing + //
filler — which is what stops `tableLayout: + // fixed` from rubber-banding the unstored neighbours + // when the user resizes one column. The filler + // soaks up all the leftover horizontal space, and + // shrinks to 0 when the column sum exceeds the + // viewport so the table can overflow horizontally. + const naturalMin = columnNaturalMins[key]; + const effectiveWidth = + stored != null + ? naturalMin != null + ? Math.max(stored, naturalMin) + : stored + : Math.max(naturalMin ?? 0, DEFAULT_DATA_COL_WIDTH); + return ( + { + headerCellRefs.current[key] = el; + }} + sortDirection={isActive ? sortDir : false} + sx={{ + // `position: sticky` (instead of the previous + // `relative`) is required so the data-column + // header tracks `stickyHeader`'s `top: 0` and + // stays glued to the top edge while the body + // rows scroll vertically. Without this override, + // MUI's `stickyHeader` rule would lose against + // the cell's own sx — and only the cells that + // happen to lack an explicit `position` + // (checkbox, filler, Actions) would end up + // sticky, which read as "three random columns + // glued to the top" while the rest of the + // header scrolled away with the rows. + // + // Sticky positioning also creates a containing + // block for the absolute-positioned + // `ResizeHandle` child, exactly the way the + // previous `relative` did, so the resize-grip + // geometry below is unaffected. + position: "sticky", + top: 0, + // Match the explicit Actions header bg so a + // body row scrolling under the sticky data + // headers doesn't show through. The theme's + // `MuiTableCell.head` rule already paints + // `--sb-elevated` here, but listing it + // explicitly keeps this cell's sticky + // background self-contained against any + // future theme override. + backgroundColor: "var(--sb-elevated)", + width: `${effectiveWidth}px`, + // Long header / cell content was overflowing the + // user's chosen column width — clip with ellipsis + // so the resize stays predictable. + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {/* Inline-flex measurement wrapper — the inner span + sizes itself to its natural content (label + + sort caret) regardless of the cell's enforced + pixel width, so its `getBoundingClientRect()` + gives us the smallest cell size that wouldn't + spill into the next column. */} + { + headerLabelRefs.current[key] = el; + }} + sx={{ + // Crucial: do NOT cap this with `maxWidth: 100%`. + // The wrapper has to keep its natural content + // width even when the cell is constrained + // narrower — otherwise `getBoundingClientRect` + // would return the cell's clamped width and + // `naturalMin` would never grow large enough to + // prevent the column from being resized below + // its header content. Visually nothing changes: + // when the cell is wider than the content the + // wrapper just sits left-aligned, when narrower + // the cell's `overflow: hidden` clips the + // overflow exactly as before. + display: "inline-flex", + alignItems: "center", + whiteSpace: "nowrap", + }} + > + {sortable ? ( + handleSort(key)} + // Inactive sortable columns get a symmetric + // up-and-down chevron icon (`UnfoldMore`) so + // it reads as "this column can be sorted in + // either direction" without committing to a + // default arrow. Once a direction is picked + // the column becomes active and MUI's default + // `ArrowDownward` icon takes over (rotated to + // match `sortDir`). + IconComponent={ + isActive ? undefined : UnfoldMoreIcon + } + sx={{ + "& .MuiTableSortLabel-icon": { + opacity: isActive ? 1 : 0.45, + // The inactive icon is symmetric, so + // MUI's direction-based rotation is + // meaningless for it — pin it at 0deg so + // the visual never drifts when MUI flips + // the rotation class on hover / mount. + transform: isActive + ? undefined + : "none !important", + transition: + "opacity 0.14s ease, transform 0.14s ease", + }, + "&:hover .MuiTableSortLabel-icon": { + opacity: isActive ? 1 : 0.75, + }, + }} + > + {c.label} + + ) : ( + c.label + )} + + {!isLastDataColumn && ( + getColumnLiveWidth(key)} + setWidth={setColumnWidth} + getMinWidth={() => getColumnNaturalMin(key)} + getMaxWidth={() => getColumnMaxWidth(key)} + onResizeStart={lockInUnstoredColumnWidths} + /> + )} + + ); + })} + {/* Filler cell — the only auto-width cell in the row. + Under `tableLayout: fixed` it absorbs whatever + horizontal space is left over after the fixed- + width columns (checkbox + each data column + + Actions). When the user resizes a column the + filler shrinks/grows to compensate so the data + columns themselves stay pixel-stable. Once the + sum of the fixed columns exceeds the viewport + the filler collapses to 0 and the surrounding + `TableContainer` provides a horizontal scroll. */} + + + Actions + + + + + {rows.length === 0 && ( + // Empty-state row is rendered the moment the table mounts + // so the card reserves the same vertical space the "No + // data yet" stack will eventually occupy — no collapse- + // to-header-only bounce, and no layout jump when the + // first response lands. + // + // Before the first reload finishes (`!hasFetched`) the + // inner stack is kept invisible: the cell still + // contributes its full height to the row, but the user + // doesn't see the icon / "No data yet" label / Create + // button flash before we even know whether there's + // data. Once `hasFetched` flips to true and `rows` is + // still empty, the stack becomes visible and the user + // gets the real empty-state CTA. + + + + + + + + )} + {rows.map((row, i) => { + const key = config.rowKey + ? config.rowKey(row) + : String(row[config.idKey]); + return ( + + ); + })} + +
+ )} +
+ {config.count !== undefined && ( + // The pagination toolbar is rendered from the first paint + // onwards so the Paper card has a stable total height + // before the first `list()` + `count()` round-trip + // resolves. Without that, the bottom edge of the card + // (and everything below it on the page) visibly jerked + // by ~48 px the moment the toolbar appeared, which read + // as a twitch of the table's bottom bar. + // + // While we're still waiting on count, `total` is `null` + // and `total ?? -1` falls through to -1; the + // `labelDisplayedRows` fallback below converts that into + // a stable "0–0 of 0" placeholder (same fallback also + // covers the edge case where `count` errors out but + // `list()` succeeded). Pre-fetch the toolbar's controls + // are dimmed and pointer-events disabled so the user + // can't accidentally change the page or page size before + // we know what's there. + setPage(p)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(Number(e.target.value)); + setPage(0); + }} + rowsPerPageOptions={[10, 25, 50, 100]} + // Custom range label. MUI's default formatter renders + // "X–Y of more than Y" whenever `count` is negative — + // which is what we pass on the *first* render of the + // page (before /count has resolved, `total` is still + // `null` and `total ?? -1` falls through to -1). The + // resulting "of more than 0" / "of more than 25" + // flashes for a frame on every page reload until the + // count endpoint replies, which reads as a glitch. + // + // While we're waiting on the count we render a stable + // "0–0 of 0" placeholder — same shape as the eventual + // real label, so the toolbar's width / arrow positions + // don't shift the moment the real number arrives. Once + // the count has resolved, this is identical to MUI's + // default formatter ("from–to of count") so the + // user-visible UX is unchanged in the steady state. + labelDisplayedRows={({ from, to, count }) => + count < 0 ? "0–0 of 0" : `${from}–${to} of ${count}` + } + sx={{ + borderTop: "1px solid", + borderColor: "divider", + ".MuiTablePagination-toolbar": { minHeight: 48 }, + // Tabular numerals — every digit advances the same + // width regardless of glyph. Without this the + // "X–Y of Z" range reflows as the user paginates + // (e.g. "1–25 of 100" → "26–50 of 100": the "26" + // is visibly wider than "1", which pushed the + // chevron buttons sideways). The property is + // inherited, so applying it on the root reaches the + // displayed-rows label and the rows-per-page select + // value together. Every other text in the toolbar + // is non-numeric and is unaffected. + fontVariantNumeric: "tabular-nums", + // Pre-fetch: dim the toolbar and disable interaction + // so the placeholder "0–0 of 0" doesn't read as a + // working control. Cross-fades to full opacity once + // the first reload finishes, in lockstep with the + // empty-state CTA / data rows materialising above. + opacity: hasFetched ? 1 : 0.55, + pointerEvents: hasFetched ? "auto" : "none", + transition: + "opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)", + }} + /> + )} +
+ {/* Mount the dialog while EITHER `dialogOpen` is true OR the + underlying creating/editing state still has content — that + second clause keeps the component alive through the MUI leave + transition (open=false → onExited fires → handleDialogExited + clears the state → next render unmounts). Without it the + dialog would yank itself out of the DOM the moment the user + clicks Cancel and the close animation would never play. */} + {(dialogOpen || creating || editing !== null) && ( + )) : undefined} + title={creating ? `New ${config.title.slice(0, -1)}` : `Edit ${config.title.slice(0, -1)}`} + onClose={closeDialog} + onExited={handleDialogExited} + onSubmit={creating ? handleCreate : handleUpdate} + /> + )} + {/* Delete-confirmation dialog: replaces the previous browser-native + window.confirm and serves both per-row and bulk deletes. + Stays a compact pop-over on mobile too (it's tiny), no need for + the full-screen treatment the form dialog gets. */} + { + if (!deleteBusy) setPendingDelete(null); + }} + maxWidth="xs" + fullWidth + > + + {displayPendingDelete?.kind === "bulk" + ? `Delete ${displayPendingDelete.ids.length} ${ + displayPendingDelete.ids.length === 1 + ? config.title.slice(0, -1).toLowerCase() + : config.title.toLowerCase() + }?` + : `Delete ${config.title.slice(0, -1).toLowerCase()}?`} + + + + {displayPendingDelete?.kind === "bulk" + ? `This will permanently remove ${displayPendingDelete.ids.length} ${ + displayPendingDelete.ids.length === 1 + ? config.title.slice(0, -1).toLowerCase() + : config.title.toLowerCase() + } from the database. This action cannot be undone.` + : `${ + displayPendingDelete?.kind === "single" ? displayPendingDelete.label : "" + } will be permanently removed from the database. This action cannot be undone.`} + + + + + + + + {/* Row-action confirmation dialog. Opens whenever a configured + RowActionSpec.confirm() returns a payload — the per-page + replacement for `window.confirm` for non-destructive actions + like "Reset traffic". Same compact size and chrome as the + Delete dialog above; the start icon, primary colour and + button labels come from the action / its `confirm` payload + so the dialog can read as either a primary "Reset" or a + destructive "Wipe" depending on the spec. */} + { + if (!actionBusy) setPendingAction(null); + }} + maxWidth="xs" + fullWidth + > + {displayActionConfirm?.title ?? ""} + + + {displayActionConfirm?.description ?? ""} + + + + + + + + + ); +} + +// CrudRowInner renders a single table row. Wrapped in React.memo with a +// custom equality fn so typing in a filter / opening dialogs / paging +// doesn't re-render every visible row through MUI's emotion CSS-in-JS +// (which was the source of the small <100 ms freezes when pressing +// buttons or typing). Only `row` data, the `selected` boolean, and the +// `columns` reference participate in equality — handler refs and any +// other parent state are intentionally ignored, since the closures +// always do the same thing. +interface CrudRowProps { + row: TEntity; + rowKey: string; + columns: ColumnSpec[]; + selected: boolean; + // Position of the row inside the currently-visible page slice. + // Drives the staggered entrance animation below — the actual prop + // is intentionally NOT part of the memo equality check (see + // `CrudRow` further down) so a row simply changing position + // (resort / page change with overlap) doesn't re-render via + // emotion just because its index moved by one. + index: number; + onToggle: (row: TEntity) => void; + onEdit: (row: TEntity) => void; + onDelete: (row: TEntity) => void; + // Optional extra actions (rendered before Edit / Delete). Stable + // reference from the parent — see the memo equality fn below. + rowActions?: RowActionSpec[]; + onRunAction?: (row: TEntity, action: RowActionSpec) => void; +} +function CrudRowInner(props: CrudRowProps) { + const { + row, + rowKey, + columns, + selected, + index, + onToggle, + onEdit, + onDelete, + rowActions, + onRunAction, + } = props; + // Entrance animation — opacity-only fade so the row reads as + // "just arrived" when the table fills in (initial load, page + // change, filter applied, sort change). Driven by the Web + // Animations API rather than a CSS keyframe rule for the same + // reason DashboardTile uses WAAPI: emotion's class-hash churn + // can re-trigger a CSS keyframe on an already-mounted element, + // which surfaced as a "flash twice on cold load" glitch on the + // dashboard. The `animatedRef` flag guarantees one play per + // row fiber lifetime. + // + // We deliberately animate *opacity* only. `transform: translateY` + // would create a containing block on the ``, which breaks + // `position: sticky` on the Actions cell (the right-pinned + // column would stop sticking to the scroll viewport and start + // sticking to the row instead). Plain opacity has none of + // those side effects. + // + // Stagger delay is `min(index * 22, 220)` ms: the first ~10 + // rows cascade in quick succession, every later row fires at + // the 220 ms cap so a 100-row page doesn't take 2.2 s to + // settle. With a 280 ms duration the last staggered row + // finishes at ~500 ms — under the 600 ms threshold where a + // table tween starts to feel like waiting. + // + // `prefers-reduced-motion` users skip the animation entirely + // and the row mounts at full opacity from the first frame. + const rowRef = useRef(null); + const animatedRef = useRef(false); + useLayoutEffect(() => { + const el = rowRef.current; + if (!el || animatedRef.current) return; + if (typeof el.animate !== "function") return; + animatedRef.current = true; + if ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + el.animate( + [{ opacity: 0 }, { opacity: 1 }], + { + duration: 280, + delay: Math.min(index * 22, 220), + easing: "cubic-bezier(0.22, 0.61, 0.36, 1)", + fill: "backwards", + }, + ); + // Mount-time animation only — `index` deliberately omitted so + // a row whose position shifts later doesn't re-trigger. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + + + onToggle(row)} + inputProps={{ "aria-label": `select row ${rowKey}` }} + /> + + {columns.map((c) => { + const isId = c.key === "id" || c.key === "uuid"; + return ( + + {c.render + ? c.render(row) + : renderDefault((row as Record)[c.key as string])} + + ); + })} + {/* Body filler cell — mirrors the auto-width filler in the + header so each body row has the same column count and the + column widths line up. Empty content; padding stripped so + it can collapse to 0 px under `tableLayout: fixed` when + the fixed columns exceed the viewport. */} + + + + {rowActions?.map((action) => { + if (action.visible && !action.visible(row)) return null; + return ( + + onRunAction?.(row, action)} + sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX} + > + {action.icon} + + + ); + })} + + onEdit(row)} sx={EDIT_BTN_SX}> + + + + + onDelete(row)} + sx={DELETE_BTN_SX} + > + + + + + + + ); +} +const CrudRow = memo(CrudRowInner, (prev, next) => { + return ( + prev.row === next.row && + prev.selected === next.selected && + prev.columns === next.columns && + prev.rowKey === next.rowKey && + prev.rowActions === next.rowActions + ); +}) as typeof CrudRowInner; + +// CrudCardInner — phone-friendly card variant of CrudRow. Same data, +// same handlers (onToggle / onEdit / onDelete), but laid out as a +// vertical label/value list inside a bordered Paper-style Box so the +// row stays fully visible without horizontal scrolling. +// +// Memo'd with the same equality fn as CrudRow for the same reason — +// keep typing in a filter or paging through cheap. +function CrudCardInner(props: CrudRowProps) { + const { + row, + rowKey, + columns, + selected, + index, + onToggle, + onEdit, + onDelete, + rowActions, + onRunAction, + } = props; + // Mirror of the CrudRowInner entrance animation but with the + // upgrade an HTML permits: a small `translateY` slide + // alongside the opacity fade. The Box doesn't host a sticky + // sibling the way a `` does, so the new containing block + // a transform creates is harmless here. + const cardRef = useRef(null); + const animatedRef = useRef(false); + useLayoutEffect(() => { + const el = cardRef.current; + if (!el || animatedRef.current) return; + if (typeof el.animate !== "function") return; + animatedRef.current = true; + if ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + el.animate( + [ + { opacity: 0, transform: "translateY(8px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { + duration: 320, + delay: Math.min(index * 28, 280), + easing: "cubic-bezier(0.22, 0.61, 0.36, 1)", + fill: "backwards", + }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + + {/* Top strip — selection + actions. Compact so the card itself + stays tight on a small screen, but tap targets are still + large enough (Checkbox + IconButtons are 30+ px each). */} + + onToggle(row)} + inputProps={{ "aria-label": `select row ${rowKey}` }} + /> + + {rowActions?.map((action) => { + if (action.visible && !action.visible(row)) return null; + return ( + + onRunAction?.(row, action)} + sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX} + > + {action.icon} + + + ); + })} + + onEdit(row)} sx={EDIT_BTN_SX}> + + + + + onDelete(row)} + sx={DELETE_BTN_SX} + > + + + + + {/* Body — one row per column. Label hugs a fixed-width left + column so values line up vertically; the value column flexes + to the available width and wraps on long content + (uuids, comma-separated IDs, etc.) instead of being clipped + like the desktop table cell would. */} + + {columns.map((c) => { + const isId = c.key === "id" || c.key === "uuid"; + const value = c.render + ? c.render(row) + : renderDefault((row as Record)[c.key as string]); + return ( + + + {c.label} + + + {value} + + + ); + })} + + + ); +} +const CrudCard = memo(CrudCardInner, (prev, next) => { + return ( + prev.row === next.row && + prev.selected === next.selected && + prev.columns === next.columns && + prev.rowKey === next.rowKey && + prev.rowActions === next.rowActions + ); +}) as typeof CrudCardInner; + +// DateCell — pretty two-line date display used by `renderDefault` whenever +// it sees an ISO datetime. Top row is the day in `15 Jan 2025` form (year +// dropped if it's the current year so the cell stays compact); bottom row +// is the time in `HH:mm` form, dimmer and a touch smaller. Hovering shows +// the full localized stamp plus a relative ("2 hours ago"-style) hint; +// for entries too old to express compactly the relative half is dropped +// and only the full stamp is shown. +function DateCell({ value }: { value: string }) { + const d = dayjs(value); + if (!d.isValid()) return <>{value}; + const now = dayjs(); + const sameYear = d.year() === now.year(); + const datePart = sameYear ? d.format("D MMM") : d.format("D MMM YYYY"); + const timePart = d.format("HH:mm"); + const fullStamp = d.format("D MMMM YYYY, HH:mm:ss"); + // Cheap "Xy ago" formatter — avoids pulling in dayjs's relativeTime + // plugin just for one column. For entries older than 30 days we omit + // the relative half and let the tooltip show the full stamp on its own. + const diffMs = now.diff(d); + const sec = Math.round(diffMs / 1000); + const min = Math.round(sec / 60); + const hr = Math.round(min / 60); + const day = Math.round(hr / 24); + let relative = ""; + if (Math.abs(sec) < 60) relative = "just now"; + else if (Math.abs(min) < 60) relative = `${min} min ago`; + else if (Math.abs(hr) < 24) relative = `${hr}h ago`; + else if (Math.abs(day) < 30) relative = `${day}d ago`; + const tooltipTitle = relative ? `${fullStamp} Ā· ${relative}` : fullStamp; + return ( + + + + {datePart} + + + {timePart} + + + + ); +} + +function renderDefault(v: unknown): ReactNode { + if (v === null || v === undefined) return ""; + if (Array.isArray(v)) { + // gap (instead of Stack spacing) gives the chips a real two-axis + // gutter when the column is narrow enough that the chips have to wrap + // onto multiple lines — so e.g. `squad_ids` no longer reads as two + // crammed-together rows of chips when the page is squeezed. + return ( + + {v.map((x, i) => ( + + ))} + + ); + } + if (typeof v === "object") return JSON.stringify(v); + if (typeof v === "string" && /\d{4}-\d{2}-\d{2}T/.test(v)) { + return ; + } + return String(v); +} + +interface DialogProps { + // Controls MUI's `open` directly so the parent can flip it to false to + // trigger the leave transition while the component stays mounted; the + // parent then waits for `onExited` before clearing its own state. + open: boolean; + mode: "create" | "update"; + fields: FieldSpec[]; + initial?: Record; + title: string; + onClose: () => void; + onExited?: () => void; + onSubmit: (form: Record) => Promise; +} + +function CrudDialog({ + open, + mode, + fields, + initial, + title, + onClose, + onExited, + onSubmit, +}: DialogProps) { + // Mirror the mobile detection used in CrudPage so the create / edit + // form dialog goes full-screen on phones + portrait tablets — with a + // narrow viewport a width-sm modal covers most of the screen anyway, + // but full-screen gives the long squad / user forms room to breathe + // and avoids awkward double-scroll (modal scroll + page scroll). + const dialogTheme = useTheme(); + const dialogIsMobile = useMediaQuery(dialogTheme.breakpoints.down("md")); + const [form, setForm] = useState>(() => { + const base = emptyForm(fields, mode); + if (initial) { + // Seed every initial value, including those for fields that are + // currently hidden (e.g. `type` on the user edit form). Hidden values + // are still read by `visibleWhen` predicates and dropped at submit + // time by `normalizeFormForSubmit`. + for (const f of fields) { + const v = initial[f.name]; + if (v === undefined) continue; + if (f.type === "ids" && Array.isArray(v)) base[f.name] = v.join(","); + else if (f.type === "multiselect" && Array.isArray(v)) { + // The multi-select stores values as strings (so `Squad.id` numbers + // become "1", "2"…); coerce here so editing pre-selects the right + // entries and the submit step can re-parse them. + base[f.name] = (v as unknown[]).map(String); + } else base[f.name] = v as unknown; + } + } + return base; + }); + // Re-evaluate visibility every render so dynamic `visibleWhen` predicates + // reflect the latest form values (e.g. switching the user `type`). + const visibleFields = useMemo( + () => fields.filter((f) => fieldVisible(f, mode, form)), + [fields, mode, form], + ); + + // Async option loaders are fired once when the dialog mounts. Each entry + // overrides the field's static `options` while rendering. Loaders are + // skipped for fields hidden in the current mode (e.g. a create-only + // squad picker when the dialog was opened for edit) so the API isn't + // hit for options no one will see. + type LoadedOpts = Record; + const [loadedOptions, setLoadedOptions] = useState({}); + useEffect(() => { + const targets = fields.filter( + (f) => + typeof f.optionsLoader === "function" && (!f.only || f.only === mode), + ); + if (targets.length === 0) return; + let cancelled = false; + (async () => { + try { + const entries = await Promise.all( + targets.map(async (f) => { + const opts = await f.optionsLoader!(); + return [f.name, opts] as const; + }), + ); + if (cancelled) return; + const next: LoadedOpts = {}; + for (const [name, opts] of entries) next[name] = opts; + setLoadedOptions(next); + } catch { + /* loaders are best-effort; fall back to whatever static options exist */ + } + })(); + return () => { + cancelled = true; + }; + // Run only once per dialog instance. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const optionsFor = (f: FieldSpec) => loadedOptions[f.name] ?? f.options ?? []; + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + // Per-field validation messages. A non-empty entry causes the field to + // render in error state with the message as helper text. Cleared as soon + // as the user provides a value for the offending field. + const [fieldErrors, setFieldErrors] = useState>({}); + + // set updates a single field's value. If the field declares `clears`, every + // listed dependent field is reset to its empty default at the same time — + // this is what "switching the user type clears the credential fields" + // relies on. + const set = (name: string, value: unknown) => { + setForm((p) => { + const next: Record = { ...p, [name]: value }; + const source = fields.find((f) => f.name === name); + if (source?.clears && source.clears.length > 0) { + for (const target of source.clears) { + const f = fields.find((x) => x.name === target); + next[target] = f ? emptyValueForField(f) : ""; + } + } + return next; + }); + // Clear the error for the field as soon as the user touches it; also + // clear any errors on dependent fields that just got reset. + setFieldErrors((prev) => { + if (!prev[name] && !fields.find((f) => f.name === name)?.clears) return prev; + const next = { ...prev }; + delete next[name]; + const source = fields.find((f) => f.name === name); + if (source?.clears) for (const t of source.clears) delete next[t]; + return next; + }); + }; + + const submit = async () => { + // Validate visible required fields first. If anything is missing we + // surface it inline (per-field) and via a top-level alert, and skip the + // network round-trip entirely. + const errors = validateRequired(visibleFields, form); + if (Object.keys(errors).length > 0) { + setFieldErrors(errors); + const missing = visibleFields + .filter((f) => errors[f.name]) + .map((f) => f.label); + setErr(`Please fill in the required field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`); + return; + } + setFieldErrors({}); + setBusy(true); + setErr(null); + try { + await onSubmit(normalizeFormForSubmit(fields, mode, form)); + } catch { + // Server-side failures are surfaced via the global toast stack + // by the parent's `notifyApiError` call inside handleCreate / + // handleUpdate. The dialog stays open (we never advance past + // the throw) so the user can retry without re-typing the form. + } finally { + setBusy(false); + } + }; + + // submitOnEnter forwards a top-level Enter key inside the dialog to the + // submit handler. We deliberately ignore Enter coming from elements that + // own their own keyboard semantics: + // + // -