mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-17 16:50:58 +00:00
refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)
* refactor(service): split client.go into focused files
client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):
client.go foundation: ClientService, ClientWithAttachments,
ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
client_locks.go inbound mutation locks, delete tombstones, compactOrphans
client_lookup.go read-only lookups (GetByID, List, EffectiveFlow, ...)
client_link.go inbound association sync (SyncInbound, DetachInbound, ...)
client_crud.go single-client CRUD + validation + protocol defaults
client_inbound_apply.go low-level inbound-settings mutators + by-email setters
client_bulk.go bulk attach/detach/adjust/delete/create + DelDepleted
client_traffic.go traffic-reset paths
client_groups.go client group management
client_paging.go paged listing, filtering, sorting, summary
Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.
* refactor(service): split inbound.go into focused files
inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):
inbound.go core inbound CRUD + InboundService (keeps pkg doc)
inbound_protocol.go protocol / stream capability helpers
inbound_node.go node/runtime/remote coordination + online tracking
inbound_traffic.go traffic accounting, reset, client stats
inbound_client_ips.go per-client IP tracking
inbound_clients.go client lookups within inbounds + copy-clients
inbound_disable.go auto-disable invalid inbounds/clients
inbound_migration.go DB migrations
inbound_sublink.go subscription link providers
inbound_util.go generic slice/string helpers
Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.
* refactor(service): split tgbot.go into focused files
tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):
tgbot.go lifecycle, bot setup, caches, small utils
tgbot_router.go incoming update / command / callback dispatch
tgbot_send.go outbound messaging primitives
tgbot_client.go client views, actions, subscription links
tgbot_inbound.go inbound listing / pickers
tgbot_report.go server usage, exhausted, online, backups, notifications
Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.
* refactor(client): dedupe single-field by-email setters
ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.
Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.
SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.
* refactor(service): extract panel/ subpackage
Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):
user.go UserService (auth / 2FA / LDAP)
panel.go PanelService (restart / self-update) + version helpers
panel_other.go non-unix RestartPanel
panel_unix.go unix RestartPanel
api_token.go ApiTokenService
websocket.go WebSocketService
panel_test.go version/shellQuote unit tests
These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract integration/ subpackage
Move the external-provider integration leaves into web/service/integration/
(package integration):
warp.go WarpService (Cloudflare WARP)
nord.go NordService (NordVPN)
custom_geo.go CustomGeoService (custom geo asset management)
*_test.go custom_geo / panel-proxy tests
These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.
* refactor(service): extract tgbot/ subpackage
Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.
To support the package boundary without changing behavior:
- core exposes XrayProcess() *xray.Process so tgbot keeps calling the
exact same running-process methods it used via the package-level `p`;
- three core methods tgbot calls are exported: ClientService.checkIs-
EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
GetAllEmails (callers updated in-package);
- tgbot's embedded-field types and the few core type refs (Status,
ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.
Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.
* refactor(service): extract outbound/ subpackage
OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.
The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.
* refactor(util): move wireguard into its own subpackage
util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.
* refactor(sub): drop redundant sub prefix from filenames
Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:
subController.go -> controller.go
subService.go -> service.go
subClashService.go -> clash_service.go
subJsonService.go -> json_service.go
(+ matching _test.go files)
* refactor(controller): rename xui.go -> spa.go
XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".
* refactor: move backend packages under internal/
Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.
Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.
Couplings the Go compiler can't see, updated to the new layout:
- frontend i18n imports of web/translation (react.ts, setup.components.ts)
- vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
- Dockerfile COPY paths for web/dist and web/translation
- locale.go os.DirFS("web") disk fallback -> "internal/web"
- .gitignore and ci.yml go:embed stub for internal/web/dist
- api_docs_test.go repo-root relative walk (one level deeper)
- tools/openapigen filesystem package paths; ApiTokenView repointed to the
web/service/panel subpackage and codegen regenerated (clears a stale
type the ci.yml codegen check was failing on)
Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.
* fix(config): keep test runs from writing logs into the source tree
GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).
Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.
* docs: move subscription-template guide out of root into docs/
sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.
Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.
* fix: update stale web/ path references after the internal/ move
The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.
Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.
Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.
* refactor(web): remove the legacy /xui -> /panel redirect middleware
RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.
* build: fix .dockerignore for internal/ layout and exclude runtime dir
- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
so the stale exclude no longer matched and the locally-built dist could be
sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
binaries, certs — ~150MB) was being shipped into the build context for no
reason. Verified the pattern excludes only the directory and still keeps
x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Package common provides common utility functions for error handling, formatting, and multi-error management.
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
// NewErrorf creates a new error with formatted message.
|
||||
func NewErrorf(format string, a ...any) error {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
// NewError creates a new error from the given arguments.
|
||||
func NewError(a ...any) error {
|
||||
msg := fmt.Sprintln(a...)
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
// Recover handles panic recovery and logs the panic error if a message is provided.
|
||||
func Recover(msg string) any {
|
||||
panicErr := recover()
|
||||
if panicErr != nil {
|
||||
if msg != "" {
|
||||
logger.Error(msg, "panic:", panicErr)
|
||||
}
|
||||
}
|
||||
return panicErr
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FormatTraffic formats traffic bytes into human-readable units (B, KB, MB, GB, TB, PB).
|
||||
func FormatTraffic(trafficBytes int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
|
||||
unitIndex := 0
|
||||
size := float64(trafficBytes)
|
||||
|
||||
for size >= 1024 && unitIndex < len(units)-1 {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
return fmt.Sprintf("%.2f%s", size, units[unitIndex])
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatTraffic(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{"zero", 0, "0.00B"},
|
||||
{"under_one_kb", 512, "512.00B"},
|
||||
{"exactly_one_kb", 1024, "1.00KB"},
|
||||
{"one_and_a_half_kb", 1536, "1.50KB"},
|
||||
{"one_mb", 1024 * 1024, "1.00MB"},
|
||||
{"one_gb", 1024 * 1024 * 1024, "1.00GB"},
|
||||
{"one_tb", 1024 * 1024 * 1024 * 1024, "1.00TB"},
|
||||
{"one_pb", 1024 * 1024 * 1024 * 1024 * 1024, "1.00PB"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := FormatTraffic(c.bytes)
|
||||
if got != c.want {
|
||||
t.Fatalf("FormatTraffic(%d) = %q, want %q", c.bytes, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// multiError represents a collection of errors.
|
||||
type multiError []error
|
||||
|
||||
// Error returns a string representation of all errors joined with " | ".
|
||||
func (e multiError) Error() string {
|
||||
var r strings.Builder
|
||||
r.WriteString("multierr: ")
|
||||
for _, err := range e {
|
||||
r.WriteString(err.Error())
|
||||
r.WriteString(" | ")
|
||||
}
|
||||
return r.String()
|
||||
}
|
||||
|
||||
// Combine combines multiple errors into a single error, filtering out nil errors.
|
||||
func Combine(maybeError ...error) error {
|
||||
var errs multiError
|
||||
for _, err := range maybeError {
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCombine_AllNilReturnsNil(t *testing.T) {
|
||||
if err := Combine(); err != nil {
|
||||
t.Fatalf("Combine() with no args = %v, want nil", err)
|
||||
}
|
||||
if err := Combine(nil, nil, nil); err != nil {
|
||||
t.Fatalf("Combine(nil, nil, nil) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombine_SkipsNilErrors(t *testing.T) {
|
||||
e1 := errors.New("boom one")
|
||||
e2 := errors.New("boom two")
|
||||
|
||||
err := Combine(nil, e1, nil, e2, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil combined error")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "boom one") || !strings.Contains(msg, "boom two") {
|
||||
t.Fatalf("combined error %q does not contain both underlying messages", msg)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "multierr: ") {
|
||||
t.Fatalf("combined error %q missing %q prefix", msg, "multierr: ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombine_SingleErrorStillWrapped(t *testing.T) {
|
||||
e := errors.New("only one")
|
||||
err := Combine(e)
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only one") {
|
||||
t.Fatalf("combined error %q missing underlying message", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package crypto provides cryptographic utilities for password hashing and verification.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HashPasswordAsBcrypt generates a bcrypt hash of the given password.
|
||||
func HashPasswordAsBcrypt(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
// CheckPasswordHash verifies if the given password matches the bcrypt hash.
|
||||
func CheckPasswordHash(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func IsHashed(s string) bool {
|
||||
_, err := bcrypt.Cost([]byte(s))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// HashTokenSHA256 returns the hex-encoded SHA-256 digest of token. API tokens
|
||||
// are high-entropy random strings, so a fast unsalted digest is sufficient to
|
||||
// keep them irrecoverable at rest while allowing constant-time verification.
|
||||
func HashTokenSHA256(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// IsSHA256Hex reports whether s looks like a hex-encoded SHA-256 digest
|
||||
// (64 lowercase hex characters), used to skip already-hashed token rows.
|
||||
func IsSHA256Hex(s string) bool {
|
||||
if len(s) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPasswordAsBcrypt_RoundTrip(t *testing.T) {
|
||||
password := "correct horse battery staple"
|
||||
|
||||
hash, err := HashPasswordAsBcrypt(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("expected non-empty hash")
|
||||
}
|
||||
if hash == password {
|
||||
t.Fatal("hash must not equal the plaintext password")
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$2") {
|
||||
t.Fatalf("expected bcrypt prefix $2..., got %q", hash[:min(4, len(hash))])
|
||||
}
|
||||
|
||||
if !CheckPasswordHash(hash, password) {
|
||||
t.Fatal("CheckPasswordHash returned false for the matching password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPasswordHash_WrongPassword(t *testing.T) {
|
||||
hash, err := HashPasswordAsBcrypt("right-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPasswordAsBcrypt returned error: %v", err)
|
||||
}
|
||||
|
||||
if CheckPasswordHash(hash, "wrong-password") {
|
||||
t.Fatal("CheckPasswordHash returned true for a wrong password")
|
||||
}
|
||||
if CheckPasswordHash(hash, "") {
|
||||
t.Fatal("CheckPasswordHash returned true for an empty password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPasswordHash_InvalidHash(t *testing.T) {
|
||||
if CheckPasswordHash("", "anything") {
|
||||
t.Fatal("empty hash must not validate")
|
||||
}
|
||||
if CheckPasswordHash("not-a-bcrypt-hash", "anything") {
|
||||
t.Fatal("malformed hash must not validate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordAsBcrypt_DifferentHashesForSamePassword(t *testing.T) {
|
||||
password := "same-password"
|
||||
h1, err := HashPasswordAsBcrypt(password)
|
||||
if err != nil {
|
||||
t.Fatalf("first hash failed: %v", err)
|
||||
}
|
||||
h2, err := HashPasswordAsBcrypt(password)
|
||||
if err != nil {
|
||||
t.Fatalf("second hash failed: %v", err)
|
||||
}
|
||||
if h1 == h2 {
|
||||
t.Fatal("expected bcrypt to produce different hashes (random salt) for the same password")
|
||||
}
|
||||
if !CheckPasswordHash(h1, password) || !CheckPasswordHash(h2, password) {
|
||||
t.Fatal("both hashes should still validate the original password")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Package json_util provides JSON utilities including a custom RawMessage type.
|
||||
package json_util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// RawMessage is a custom JSON raw message type that marshals empty slices as "null".
|
||||
type RawMessage []byte
|
||||
|
||||
// MarshalJSON customizes the JSON marshaling behavior for RawMessage.
|
||||
// Empty RawMessage values are marshaled as "null" instead of "[]".
|
||||
func (m RawMessage) MarshalJSON() ([]byte, error) {
|
||||
if len(m) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON sets *m to a copy of the JSON data.
|
||||
func (m *RawMessage) UnmarshalJSON(data []byte) error {
|
||||
if m == nil {
|
||||
return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
|
||||
}
|
||||
*m = append((*m)[0:0], data...)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package json_util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRawMessage_MarshalEmptyIsNull(t *testing.T) {
|
||||
var m RawMessage
|
||||
out, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalJSON on empty returned error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out, []byte("null")) {
|
||||
t.Fatalf("empty RawMessage marshaled to %q, want %q", out, "null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawMessage_MarshalPassthrough(t *testing.T) {
|
||||
payload := []byte(`{"a":1}`)
|
||||
m := RawMessage(payload)
|
||||
out, err := m.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalJSON returned error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out, payload) {
|
||||
t.Fatalf("MarshalJSON = %q, want %q", out, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawMessage_UnmarshalCopiesData(t *testing.T) {
|
||||
var m RawMessage
|
||||
src := []byte(`{"k":"v"}`)
|
||||
if err := m.UnmarshalJSON(src); err != nil {
|
||||
t.Fatalf("UnmarshalJSON returned error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(m, src) {
|
||||
t.Fatalf("UnmarshalJSON stored %q, want %q", []byte(m), src)
|
||||
}
|
||||
|
||||
src[0] = 'X'
|
||||
if m[0] == 'X' {
|
||||
t.Fatal("UnmarshalJSON kept a reference to the caller's buffer; expected a copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawMessage_UnmarshalNilReceiverErrors(t *testing.T) {
|
||||
var m *RawMessage
|
||||
if err := m.UnmarshalJSON([]byte("123")); err == nil {
|
||||
t.Fatal("expected error for nil receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawMessage_RoundTripInsideStruct(t *testing.T) {
|
||||
type wrapper struct {
|
||||
Body RawMessage `json:"body"`
|
||||
}
|
||||
in := wrapper{Body: RawMessage(`{"x":42}`)}
|
||||
encoded, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
want := `{"body":{"x":42}}`
|
||||
if string(encoded) != want {
|
||||
t.Fatalf("Marshal = %s, want %s", encoded, want)
|
||||
}
|
||||
|
||||
var out wrapper
|
||||
if err := json.Unmarshal(encoded, &out); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if string(out.Body) != `{"x":42}` {
|
||||
t.Fatalf("round-trip Body = %s, want %s", out.Body, `{"x":42}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package ldaputil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
UseTLS bool
|
||||
BindDN string
|
||||
Password string
|
||||
BaseDN string
|
||||
UserFilter string
|
||||
UserAttr string
|
||||
FlagField string
|
||||
TruthyVals []string
|
||||
Invert bool
|
||||
}
|
||||
|
||||
// FetchVlessFlags returns map[email]enabled
|
||||
func FetchVlessFlags(cfg Config) (map[string]bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
}))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "mail"
|
||||
}
|
||||
// if field not set we fallback to legacy vless_enabled
|
||||
if cfg.FlagField == "" {
|
||||
cfg.FlagField = "vless_enabled"
|
||||
}
|
||||
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
cfg.UserFilter,
|
||||
[]string{cfg.UserAttr, cfg.FlagField},
|
||||
nil,
|
||||
)
|
||||
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]bool, len(res.Entries))
|
||||
for _, e := range res.Entries {
|
||||
user := e.GetAttributeValue(cfg.UserAttr)
|
||||
if user == "" {
|
||||
continue
|
||||
}
|
||||
val := e.GetAttributeValue(cfg.FlagField)
|
||||
enabled := slices.Contains(cfg.TruthyVals, val)
|
||||
if cfg.Invert {
|
||||
enabled = !enabled
|
||||
}
|
||||
result[user] = enabled
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser searches user by cfg.UserAttr and attempts to bind with provided password.
|
||||
func AuthenticateUser(cfg Config, username, password string) (bool, error) {
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
|
||||
scheme := "ldap"
|
||||
if cfg.UseTLS {
|
||||
scheme = "ldaps"
|
||||
}
|
||||
|
||||
ldapURL := fmt.Sprintf("%s://%s", scheme, addr)
|
||||
|
||||
var opts []ldap.DialOpt
|
||||
if cfg.UseTLS {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
}))
|
||||
}
|
||||
|
||||
conn, err := ldap.DialURL(ldapURL, opts...)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Optional initial bind for search
|
||||
if cfg.BindDN != "" {
|
||||
if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.UserFilter == "" {
|
||||
cfg.UserFilter = "(objectClass=person)"
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
|
||||
// Build filter to find specific user
|
||||
filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false,
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
// Try to bind as the user
|
||||
if err := conn.Bind(userDN, password); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
|
||||
// and subscription bodies (typically base64-encoded newline lists of such links).
|
||||
// The output shape matches the wire format used by the panel's Xray template
|
||||
// outbounds array so that parsed objects can be injected directly.
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Outbound is the minimal shape we emit for each parsed link.
|
||||
// Extra fields (mux, etc.) are carried inside settings/streamSettings.
|
||||
type Outbound map[string]any
|
||||
|
||||
// ParseResult holds a parsed outbound together with a stable identity string
|
||||
// that can be used to correlate the same logical server across refreshes
|
||||
// (even if the remark changes).
|
||||
type ParseResult struct {
|
||||
Outbound Outbound
|
||||
Identity string
|
||||
}
|
||||
|
||||
// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
|
||||
// It handles the common case where the body is a base64-encoded blob of
|
||||
// newline-separated links, and also tolerates an already-decoded text body.
|
||||
// It returns the list of successfully parsed outbounds (in order) and their
|
||||
// corresponding identities.
|
||||
func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
||||
text := strings.TrimSpace(string(body))
|
||||
if text == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Try base64 decode first (standard and URL-safe variants).
|
||||
if decoded, ok := tryBase64(text); ok {
|
||||
text = strings.TrimSpace(decoded)
|
||||
}
|
||||
|
||||
lines := splitLines(text)
|
||||
var outbounds []Outbound
|
||||
var identities []string
|
||||
|
||||
for _, ln := range lines {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" || strings.HasPrefix(ln, "#") {
|
||||
continue
|
||||
}
|
||||
res, err := ParseLink(ln)
|
||||
if err != nil || res == nil {
|
||||
// Ignore unparseable lines (comments, unsupported protocols, etc.)
|
||||
continue
|
||||
}
|
||||
outbounds = append(outbounds, res.Outbound)
|
||||
identities = append(identities, res.Identity)
|
||||
}
|
||||
return outbounds, identities, nil
|
||||
}
|
||||
|
||||
func tryBase64(s string) (string, bool) {
|
||||
// Remove whitespace that some providers insert.
|
||||
clean := strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
|
||||
// Common padding fix
|
||||
for len(clean)%4 != 0 {
|
||||
clean += "="
|
||||
}
|
||||
|
||||
// Standard
|
||||
if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
// URL-safe (no padding)
|
||||
if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
// URL-safe with padding
|
||||
if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
|
||||
return string(b), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
// Accept \n, \r\n, and also some providers use literal \n in the text.
|
||||
s = strings.ReplaceAll(s, `\n`, "\n")
|
||||
return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
|
||||
}
|
||||
|
||||
// ParseLink parses a single share link and returns the outbound object plus
|
||||
// a stable identity for tag correlation. Supported schemes:
|
||||
// - vmess://
|
||||
// - vless://
|
||||
// - trojan://
|
||||
// - ss:// (modern and legacy)
|
||||
// - hysteria2:// (also hy2://)
|
||||
// - wireguard:// (also wg://)
|
||||
func ParseLink(link string) (*ParseResult, error) {
|
||||
link = strings.TrimSpace(link)
|
||||
switch {
|
||||
case strings.HasPrefix(link, "vmess://"):
|
||||
return parseVmess(link)
|
||||
case strings.HasPrefix(link, "vless://"):
|
||||
return parseVless(link)
|
||||
case strings.HasPrefix(link, "trojan://"):
|
||||
return parseTrojan(link)
|
||||
case strings.HasPrefix(link, "ss://"):
|
||||
return parseShadowsocks(link)
|
||||
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
|
||||
return parseHysteria2(link)
|
||||
case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
|
||||
return parseWireguard(link)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported link scheme")
|
||||
}
|
||||
}
|
||||
|
||||
// --- vmess ---
|
||||
|
||||
func parseVmess(link string) (*ParseResult, error) {
|
||||
b64 := strings.TrimPrefix(link, "vmess://")
|
||||
// vmess:// base64(json)
|
||||
raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
|
||||
if err != nil {
|
||||
// Some providers use raw URL-safe
|
||||
raw, err = base64.RawURLEncoding.DecodeString(b64)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vmess decode: %w", err)
|
||||
}
|
||||
var j map[string]any
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
return nil, fmt.Errorf("vmess json: %w", err)
|
||||
}
|
||||
|
||||
identity := vmessIdentity(j)
|
||||
|
||||
network := getString(j, "net", "tcp")
|
||||
security := "none"
|
||||
if tls, _ := j["tls"].(string); tls == "tls" {
|
||||
security = "tls"
|
||||
}
|
||||
stream := buildStream(network, security)
|
||||
|
||||
// Map known fields (best effort, matching frontend parser coverage)
|
||||
switch network {
|
||||
case "ws":
|
||||
if host, ok := j["host"].(string); ok {
|
||||
setWS(stream, host, getString(j, "path", "/"))
|
||||
}
|
||||
case "grpc":
|
||||
svc := getString(j, "path", "")
|
||||
if auth, ok := j["authority"].(string); ok && auth != "" {
|
||||
(stream["grpcSettings"].(map[string]any))["authority"] = auth
|
||||
}
|
||||
(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
|
||||
(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
|
||||
case "httpupgrade":
|
||||
setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
|
||||
case "xhttp":
|
||||
xh := stream["xhttpSettings"].(map[string]any)
|
||||
xh["host"] = getString(j, "host", "")
|
||||
xh["path"] = getString(j, "path", "/")
|
||||
if m := getString(j, "mode", ""); m != "" {
|
||||
xh["mode"] = m
|
||||
}
|
||||
// xhttp advanced keys are passed through if present in the json
|
||||
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
|
||||
if v, ok := j[k]; ok {
|
||||
xh[k] = v
|
||||
}
|
||||
}
|
||||
case "tcp":
|
||||
if getString(j, "type", "") == "http" {
|
||||
stream["tcpSettings"] = map[string]any{
|
||||
"header": map[string]any{
|
||||
"type": "http",
|
||||
"request": map[string]any{
|
||||
"version": "1.1",
|
||||
"method": "GET",
|
||||
"path": splitComma(getString(j, "path", "/")),
|
||||
"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if security == "tls" {
|
||||
tls := stream["tlsSettings"].(map[string]any)
|
||||
tls["serverName"] = getString(j, "sni", "")
|
||||
tls["fingerprint"] = getString(j, "fp", "")
|
||||
if alpn := getString(j, "alpn", ""); alpn != "" {
|
||||
tls["alpn"] = splitComma(alpn)
|
||||
}
|
||||
}
|
||||
|
||||
port := num(j["port"])
|
||||
ob := Outbound{
|
||||
"protocol": "vmess",
|
||||
"tag": getString(j, "ps", ""),
|
||||
"settings": map[string]any{
|
||||
"vnext": []any{
|
||||
map[string]any{
|
||||
"address": getString(j, "add", ""),
|
||||
"port": port,
|
||||
"users": []any{
|
||||
map[string]any{
|
||||
"id": getString(j, "id", ""),
|
||||
"security": getString(j, "scy", "auto"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
func vmessIdentity(j map[string]any) string {
|
||||
// Remove ps (remark) for identity
|
||||
core := map[string]any{}
|
||||
for k, v := range j {
|
||||
if k == "ps" {
|
||||
continue
|
||||
}
|
||||
core[k] = v
|
||||
}
|
||||
b, _ := json.Marshal(core)
|
||||
return "vmess:" + string(b)
|
||||
}
|
||||
|
||||
// --- vless / trojan (URL forms) ---
|
||||
|
||||
func parseVless(link string) (*ParseResult, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "vless" {
|
||||
return nil, fmt.Errorf("not vless")
|
||||
}
|
||||
id := u.User.Username()
|
||||
host := u.Hostname()
|
||||
port := defaultPort(u.Port(), 443)
|
||||
params := u.Query()
|
||||
network := params.Get("type")
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
security := params.Get("security")
|
||||
if security == "" {
|
||||
security = "none"
|
||||
}
|
||||
stream := buildStream(network, security)
|
||||
applyTransport(stream, params)
|
||||
applySecurity(stream, params)
|
||||
applyFinalMask(stream, params)
|
||||
|
||||
identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||
|
||||
ob := Outbound{
|
||||
"protocol": "vless",
|
||||
"tag": decodeHash(u.Fragment),
|
||||
"settings": map[string]any{
|
||||
"address": host,
|
||||
"port": port,
|
||||
"id": id,
|
||||
"flow": params.Get("flow"),
|
||||
"encryption": firstNonEmpty(params.Get("encryption"), "none"),
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
func parseTrojan(link string) (*ParseResult, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "trojan" {
|
||||
return nil, fmt.Errorf("not trojan")
|
||||
}
|
||||
pw := u.User.Username()
|
||||
host := u.Hostname()
|
||||
port := defaultPort(u.Port(), 443)
|
||||
params := u.Query()
|
||||
network := params.Get("type")
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
security := params.Get("security")
|
||||
if security == "" {
|
||||
security = "tls"
|
||||
}
|
||||
stream := buildStream(network, security)
|
||||
applyTransport(stream, params)
|
||||
applySecurity(stream, params)
|
||||
applyFinalMask(stream, params)
|
||||
|
||||
identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||
|
||||
ob := Outbound{
|
||||
"protocol": "trojan",
|
||||
"tag": decodeHash(u.Fragment),
|
||||
"settings": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"address": host, "port": port, "password": pw},
|
||||
},
|
||||
},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
// --- shadowsocks ---
|
||||
|
||||
func parseShadowsocks(link string) (*ParseResult, error) {
|
||||
// Two shapes:
|
||||
// ss://base64(method:pass)@host:port#remark
|
||||
// ss://base64(method:pass@host:port)#remark
|
||||
remark := ""
|
||||
if i := strings.Index(link, "#"); i >= 0 {
|
||||
remark, _ = url.QueryUnescape(link[i+1:])
|
||||
link = link[:i]
|
||||
}
|
||||
core := strings.TrimPrefix(link, "ss://")
|
||||
at := strings.Index(core, "@")
|
||||
if at >= 0 {
|
||||
// modern
|
||||
userB64 := core[:at]
|
||||
hp := core[at+1:]
|
||||
userInfo, err := base64DecodeFlexible(userB64)
|
||||
if err != nil {
|
||||
userInfo = userB64 // not b64, rare
|
||||
}
|
||||
colon := strings.LastIndex(hp, ":")
|
||||
if colon < 0 {
|
||||
return nil, fmt.Errorf("bad ss host:port")
|
||||
}
|
||||
host := hp[:colon]
|
||||
port, _ := strconv.Atoi(hp[colon+1:])
|
||||
method, pass := splitMethodPass(userInfo)
|
||||
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||
ob := Outbound{
|
||||
"protocol": "shadowsocks",
|
||||
"tag": remark,
|
||||
"settings": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
// legacy: whole thing b64
|
||||
dec, err := base64DecodeFlexible(core)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at = strings.Index(dec, "@")
|
||||
if at < 0 {
|
||||
return nil, fmt.Errorf("bad legacy ss")
|
||||
}
|
||||
userInfo := dec[:at]
|
||||
hp := dec[at+1:]
|
||||
colon := strings.LastIndex(hp, ":")
|
||||
if colon < 0 {
|
||||
return nil, fmt.Errorf("bad legacy ss hp")
|
||||
}
|
||||
host := hp[:colon]
|
||||
port, _ := strconv.Atoi(hp[colon+1:])
|
||||
method, pass := splitMethodPass(userInfo)
|
||||
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||
ob := Outbound{
|
||||
"protocol": "shadowsocks",
|
||||
"tag": remark,
|
||||
"settings": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
||||
},
|
||||
},
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
func splitMethodPass(userInfo string) (string, string) {
|
||||
colon := strings.Index(userInfo, ":")
|
||||
if colon < 0 {
|
||||
return "2022-blake3-aes-128-gcm", userInfo // guess
|
||||
}
|
||||
return userInfo[:colon], userInfo[colon+1:]
|
||||
}
|
||||
|
||||
// --- hysteria2 ---
|
||||
|
||||
func parseHysteria2(link string) (*ParseResult, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
|
||||
return nil, fmt.Errorf("not hysteria2")
|
||||
}
|
||||
auth := u.User.Username()
|
||||
host := u.Hostname()
|
||||
port := defaultPort(u.Port(), 443)
|
||||
params := u.Query()
|
||||
|
||||
stream := map[string]any{
|
||||
"network": "hysteria",
|
||||
"security": "tls",
|
||||
"hysteriaSettings": map[string]any{
|
||||
"version": 2,
|
||||
"auth": auth,
|
||||
"udpIdleTimeout": 60,
|
||||
},
|
||||
"tlsSettings": map[string]any{
|
||||
"serverName": params.Get("sni"),
|
||||
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
|
||||
"fingerprint": params.Get("fp"),
|
||||
"echConfigList": params.Get("ech"),
|
||||
"verifyPeerCertByName": "",
|
||||
"pinnedPeerCertSha256": params.Get("pinSHA256"),
|
||||
},
|
||||
}
|
||||
applyFinalMask(stream, params)
|
||||
|
||||
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||
|
||||
ob := Outbound{
|
||||
"protocol": "hysteria",
|
||||
"tag": decodeHash(u.Fragment),
|
||||
"settings": map[string]any{"address": host, "port": port, "version": 2},
|
||||
"streamSettings": stream,
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
// --- wireguard ---
|
||||
|
||||
func parseWireguard(link string) (*ParseResult, error) {
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "wireguard" && u.Scheme != "wg" {
|
||||
return nil, fmt.Errorf("not wireguard")
|
||||
}
|
||||
secret, _ := url.QueryUnescape(u.User.Username())
|
||||
params := u.Query()
|
||||
host := u.Hostname()
|
||||
portStr := u.Port()
|
||||
endpoint := host
|
||||
if portStr != "" {
|
||||
endpoint = host + ":" + portStr
|
||||
}
|
||||
|
||||
addrRaw := firstParam(params, "address", "ip")
|
||||
allowedRaw := firstParam(params, "allowedips", "allowed_ips")
|
||||
addrs := splitComma(addrRaw)
|
||||
if len(addrs) == 0 {
|
||||
addrs = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
allowed := splitComma(allowedRaw)
|
||||
if len(allowed) == 0 {
|
||||
allowed = []string{"0.0.0.0/0", "::/0"}
|
||||
}
|
||||
|
||||
peer := map[string]any{
|
||||
"publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
|
||||
"endpoint": endpoint,
|
||||
"allowedIPs": allowed,
|
||||
}
|
||||
if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
|
||||
peer["preSharedKey"] = psk
|
||||
}
|
||||
if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
|
||||
if n, err := strconv.Atoi(ka); err == nil {
|
||||
peer["keepAlive"] = n
|
||||
}
|
||||
}
|
||||
|
||||
settings := map[string]any{
|
||||
"secretKey": secret,
|
||||
"address": addrs,
|
||||
"peers": []any{peer},
|
||||
}
|
||||
if mtu := params.Get("mtu"); mtu != "" {
|
||||
if n, err := strconv.Atoi(mtu); err == nil {
|
||||
settings["mtu"] = n
|
||||
}
|
||||
}
|
||||
if res := params.Get("reserved"); res != "" {
|
||||
parts := splitComma(res)
|
||||
var iv []int
|
||||
for _, p := range parts {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||
iv = append(iv, n)
|
||||
}
|
||||
}
|
||||
if len(iv) > 0 {
|
||||
settings["reserved"] = iv
|
||||
}
|
||||
}
|
||||
|
||||
identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
|
||||
|
||||
ob := Outbound{
|
||||
"protocol": "wireguard",
|
||||
"tag": decodeHash(u.Fragment),
|
||||
"settings": settings,
|
||||
}
|
||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func buildStream(network, security string) map[string]any {
|
||||
stream := map[string]any{"network": network, "security": security}
|
||||
switch network {
|
||||
case "tcp":
|
||||
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
||||
case "kcp":
|
||||
stream["kcpSettings"] = map[string]any{
|
||||
"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
|
||||
"cwndMultiplier": 1, "maxSendingWindow": 2097152,
|
||||
}
|
||||
case "ws":
|
||||
stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
|
||||
case "grpc":
|
||||
stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
|
||||
case "httpupgrade":
|
||||
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
|
||||
case "xhttp":
|
||||
stream["xhttpSettings"] = map[string]any{
|
||||
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
|
||||
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
|
||||
}
|
||||
default:
|
||||
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
||||
}
|
||||
switch security {
|
||||
case "tls":
|
||||
stream["tlsSettings"] = map[string]any{
|
||||
"serverName": "", "alpn": []any{}, "fingerprint": "",
|
||||
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
|
||||
}
|
||||
case "reality":
|
||||
stream["realitySettings"] = map[string]any{
|
||||
"publicKey": "", "fingerprint": "chrome", "serverName": "",
|
||||
"shortId": "", "spiderX": "", "mldsa65Verify": "",
|
||||
}
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
func setWS(stream map[string]any, host, path string) {
|
||||
ws := stream["wsSettings"].(map[string]any)
|
||||
ws["host"] = host
|
||||
ws["path"] = path
|
||||
}
|
||||
|
||||
func setHTTPUpgrade(stream map[string]any, host, path string) {
|
||||
h := stream["httpupgradeSettings"].(map[string]any)
|
||||
h["host"] = host
|
||||
h["path"] = path
|
||||
}
|
||||
|
||||
func applyTransport(stream map[string]any, p url.Values) {
|
||||
net := stream["network"].(string)
|
||||
host := p.Get("host")
|
||||
path := firstNonEmpty(p.Get("path"), "/")
|
||||
switch net {
|
||||
case "ws":
|
||||
setWS(stream, host, path)
|
||||
case "grpc":
|
||||
gs := stream["grpcSettings"].(map[string]any)
|
||||
gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
|
||||
gs["authority"] = p.Get("authority")
|
||||
gs["multiMode"] = p.Get("mode") == "multi"
|
||||
case "httpupgrade":
|
||||
setHTTPUpgrade(stream, host, path)
|
||||
case "xhttp":
|
||||
xh := stream["xhttpSettings"].(map[string]any)
|
||||
xh["host"] = host
|
||||
xh["path"] = path
|
||||
if m := p.Get("mode"); m != "" {
|
||||
xh["mode"] = m
|
||||
}
|
||||
// A few advanced xhttp fields that are commonly carried
|
||||
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
|
||||
if v := p.Get(k); v != "" {
|
||||
xh[k] = v
|
||||
}
|
||||
}
|
||||
case "tcp":
|
||||
if p.Get("headerType") == "http" || p.Get("type") == "http" {
|
||||
stream["tcpSettings"] = map[string]any{
|
||||
"header": map[string]any{
|
||||
"type": "http",
|
||||
"request": map[string]any{
|
||||
"version": "1.1",
|
||||
"method": "GET",
|
||||
"path": splitComma(path),
|
||||
"headers": map[string]any{"Host": splitComma(host)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applySecurity(stream map[string]any, p url.Values) {
|
||||
sec := stream["security"].(string)
|
||||
switch sec {
|
||||
case "tls":
|
||||
tls := stream["tlsSettings"].(map[string]any)
|
||||
tls["serverName"] = p.Get("sni")
|
||||
tls["fingerprint"] = p.Get("fp")
|
||||
if alpn := p.Get("alpn"); alpn != "" {
|
||||
tls["alpn"] = splitComma(alpn)
|
||||
}
|
||||
tls["echConfigList"] = p.Get("ech")
|
||||
tls["pinnedPeerCertSha256"] = p.Get("pcs")
|
||||
case "reality":
|
||||
re := stream["realitySettings"].(map[string]any)
|
||||
re["serverName"] = p.Get("sni")
|
||||
re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
|
||||
re["publicKey"] = p.Get("pbk")
|
||||
re["shortId"] = p.Get("sid")
|
||||
re["spiderX"] = p.Get("spx")
|
||||
re["mldsa65Verify"] = p.Get("pqv")
|
||||
}
|
||||
}
|
||||
|
||||
func applyFinalMask(stream map[string]any, p url.Values) {
|
||||
if fm := p.Get("fm"); fm != "" {
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(fm), &parsed) == nil {
|
||||
stream["finalmask"] = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func firstParam(p url.Values, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := p.Get(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func canonicalQuery(p url.Values) string {
|
||||
// Sort keys for stable identity
|
||||
keys := make([]string, 0, len(p))
|
||||
for k := range p {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
// simple sort
|
||||
for i := 0; i < len(keys); i++ {
|
||||
for j := i + 1; j < len(keys); j++ {
|
||||
if keys[j] < keys[i] {
|
||||
keys[i], keys[j] = keys[j], keys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
for _, v := range p[k] {
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func decodeHash(h string) string {
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if dec, err := url.QueryUnescape(h); err == nil {
|
||||
return dec
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func defaultPort(p string, def int) int {
|
||||
if p == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func num(v any) int {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getString(m map[string]any, key, def string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func splitComma(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCommaOrDefault(s string, def []string) []string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return splitComma(s)
|
||||
}
|
||||
|
||||
func padBase64(s string) string {
|
||||
for len(s)%4 != 0 {
|
||||
s += "="
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func base64DecodeFlexible(s string) (string, error) {
|
||||
s = padBase64(s)
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
return string(b), nil
|
||||
}
|
||||
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
|
||||
return string(b), nil
|
||||
}
|
||||
return "", fmt.Errorf("base64 decode failed")
|
||||
}
|
||||
|
||||
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
|
||||
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func SlugRemark(remark string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(remark))
|
||||
s = slugRe.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
// collapse runs of dashes
|
||||
for strings.Contains(s, "--") {
|
||||
s = strings.ReplaceAll(s, "--", "-")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SuggestTag builds a tag from a prefix and a remark (or index fallback).
|
||||
// It is intended for initial assignment; stability is handled by the service layer.
|
||||
func SuggestTag(prefix, remark string, idx int) string {
|
||||
base := SlugRemark(remark)
|
||||
if base == "" {
|
||||
base = fmt.Sprintf("%d", idx)
|
||||
}
|
||||
p := strings.TrimSuffix(prefix, "-")
|
||||
if p != "" {
|
||||
return p + "-" + base
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseVmessLink(t *testing.T) {
|
||||
// vmess:// + base64 of:
|
||||
// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
|
||||
link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
|
||||
res, err := ParseLink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse vmess: %v", err)
|
||||
}
|
||||
if res.Outbound["protocol"] != "vmess" {
|
||||
t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
|
||||
}
|
||||
if res.Outbound["tag"] != "test" {
|
||||
t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVlessLink(t *testing.T) {
|
||||
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
||||
res, err := ParseLink(link)
|
||||
if err != nil {
|
||||
t.Fatalf("parse vless: %v", err)
|
||||
}
|
||||
if res.Outbound["protocol"] != "vless" {
|
||||
t.Fatalf("bad protocol")
|
||||
}
|
||||
if res.Outbound["tag"] != "node1" {
|
||||
t.Errorf("tag mismatch: %v", res.Outbound["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubscriptionBody_Base64(t *testing.T) {
|
||||
// base64 of the two joined links:
|
||||
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
|
||||
b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
|
||||
obs, ids, err := ParseSubscriptionBody([]byte(b64))
|
||||
if err != nil {
|
||||
t.Fatalf("parse sub body: %v", err)
|
||||
}
|
||||
if len(obs) != 2 {
|
||||
t.Fatalf("expected 2 outbounds, got %d", len(obs))
|
||||
}
|
||||
if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
|
||||
t.Errorf("bad identities: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugAndSuggest(t *testing.T) {
|
||||
if SlugRemark("Hello World!") != "hello-world" {
|
||||
t.Errorf("slug failed")
|
||||
}
|
||||
tag := SuggestTag("hk-", " SG 01 !! ", 0)
|
||||
if tag != "hk-sg-01" {
|
||||
t.Errorf("suggest tag got %q", tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package netproxy builds HTTP clients that route the panel's own outbound
|
||||
// requests through an admin-configured proxy, used to reach GitHub and Telegram
|
||||
// from servers where those services are filtered.
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose transport honors proxyURL.
|
||||
//
|
||||
// An empty proxyURL yields a plain client (unchanged behavior). socks5/socks5h
|
||||
// URLs are dialed through golang.org/x/net/proxy; http/https URLs use the
|
||||
// standard library proxy support. Any other scheme returns an error so callers
|
||||
// can log it and fall back to a direct connection.
|
||||
//
|
||||
// The proxy address is intentionally not subjected to SSRF filtering: it is
|
||||
// admin-configured and is commonly a loopback/private address (for example a
|
||||
// local Xray SOCKS inbound).
|
||||
func NewHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
|
||||
if proxyURL == "" {
|
||||
return &http.Client{Timeout: timeout}, nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse proxy url: %w", err)
|
||||
}
|
||||
|
||||
transport := baseTransport()
|
||||
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "socks5", "socks5h":
|
||||
var auth *proxy.Auth
|
||||
if parsed.User != nil {
|
||||
password, _ := parsed.User.Password()
|
||||
auth = &proxy.Auth{User: parsed.User.Username(), Password: password}
|
||||
}
|
||||
dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
transport.DialContext = contextDialer.DialContext
|
||||
} else {
|
||||
transport.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme %q", parsed.Scheme)
|
||||
}
|
||||
|
||||
return &http.Client{Timeout: timeout, Transport: transport}, nil
|
||||
}
|
||||
|
||||
func baseTransport() *http.Transport {
|
||||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
return base.Clone()
|
||||
}
|
||||
return &http.Transport{}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package netproxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
proxyURL string
|
||||
wantErr bool
|
||||
wantProxy bool
|
||||
wantDial bool
|
||||
}{
|
||||
{name: "empty returns direct client", proxyURL: ""},
|
||||
{name: "socks5 sets custom dialer", proxyURL: "socks5://127.0.0.1:1080", wantDial: true},
|
||||
{name: "socks5 with auth", proxyURL: "socks5://user:pass@127.0.0.1:1080", wantDial: true},
|
||||
{name: "http sets transport proxy", proxyURL: "http://127.0.0.1:8080", wantProxy: true},
|
||||
{name: "https sets transport proxy", proxyURL: "https://127.0.0.1:8080", wantProxy: true},
|
||||
{name: "unsupported scheme errors", proxyURL: "ftp://127.0.0.1:21", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, err := NewHTTPClient(tc.proxyURL, 5*time.Second)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.proxyURL)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for %q: %v", tc.proxyURL, err)
|
||||
}
|
||||
if client.Timeout != 5*time.Second {
|
||||
t.Errorf("timeout = %v, want 5s", client.Timeout)
|
||||
}
|
||||
if tc.wantProxy {
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok || transport.Proxy == nil {
|
||||
t.Errorf("expected transport with Proxy set for %q", tc.proxyURL)
|
||||
}
|
||||
}
|
||||
if tc.wantDial {
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok || transport.DialContext == nil {
|
||||
t.Errorf("expected transport with DialContext set for %q", tc.proxyURL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package netsafe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func IsBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
type allowPrivateCtxKey struct{}
|
||||
|
||||
func ContextWithAllowPrivate(ctx context.Context, allow bool) context.Context {
|
||||
return context.WithValue(ctx, allowPrivateCtxKey{}, allow)
|
||||
}
|
||||
|
||||
func AllowPrivateFromContext(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(allowPrivateCtxKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
var defaultDialer = &net.Dialer{Timeout: 10 * time.Second}
|
||||
|
||||
func SSRFGuardedDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowPrivate := AllowPrivateFromContext(ctx)
|
||||
var ips []net.IPAddr
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
ips = []net.IPAddr{{IP: ip}}
|
||||
} else {
|
||||
ips, err = net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var lastErr error
|
||||
for _, ipAddr := range ips {
|
||||
if !allowPrivate && IsBlockedIP(ipAddr.IP) {
|
||||
lastErr = fmt.Errorf("blocked private/internal address %s", ipAddr.IP)
|
||||
continue
|
||||
}
|
||||
conn, derr := defaultDialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
|
||||
if derr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = derr
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no usable address for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
var hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$`)
|
||||
|
||||
func NormalizeHost(addr string) (string, error) {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return "", fmt.Errorf("address is required")
|
||||
}
|
||||
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
|
||||
addr = addr[1 : len(addr)-1]
|
||||
}
|
||||
if ip := net.ParseIP(addr); ip != nil {
|
||||
return ip.String(), nil
|
||||
}
|
||||
if len(addr) > 253 || !hostnamePattern.MatchString(addr) {
|
||||
return "", fmt.Errorf("invalid host %q", addr)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package netsafe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsBlockedIP(t *testing.T) {
|
||||
cases := []struct {
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"::1", true},
|
||||
{"10.0.0.5", true},
|
||||
{"172.16.0.1", true},
|
||||
{"192.168.1.1", true},
|
||||
{"169.254.0.1", true},
|
||||
{"0.0.0.0", true},
|
||||
{"::", true},
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"2606:4700:4700::1111", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.ip, func(t *testing.T) {
|
||||
ip := net.ParseIP(c.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("could not parse %q", c.ip)
|
||||
}
|
||||
if got := IsBlockedIP(ip); got != c.want {
|
||||
t.Fatalf("IsBlockedIP(%s) = %v, want %v", c.ip, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateFromContext_Default(t *testing.T) {
|
||||
if AllowPrivateFromContext(context.Background()) {
|
||||
t.Fatal("default context should report AllowPrivate=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowPrivateFromContext_RoundTrip(t *testing.T) {
|
||||
ctx := ContextWithAllowPrivate(context.Background(), true)
|
||||
if !AllowPrivateFromContext(ctx) {
|
||||
t.Fatal("expected AllowPrivate=true after ContextWithAllowPrivate(true)")
|
||||
}
|
||||
ctx = ContextWithAllowPrivate(ctx, false)
|
||||
if AllowPrivateFromContext(ctx) {
|
||||
t.Fatal("expected AllowPrivate=false after overriding with false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHost_Valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"example.com", "example.com"},
|
||||
{" example.com ", "example.com"},
|
||||
{"a.b.c.example.com", "a.b.c.example.com"},
|
||||
{"10.0.0.1", "10.0.0.1"},
|
||||
{"[2606:4700:4700::1111]", "2606:4700:4700::1111"},
|
||||
{"2606:4700:4700::1111", "2606:4700:4700::1111"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got, err := NormalizeHost(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeHost(%q) returned error: %v", c.in, err)
|
||||
}
|
||||
if !strings.EqualFold(got, c.want) {
|
||||
t.Fatalf("NormalizeHost(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHost_Invalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
" ",
|
||||
"-leading-dash.com",
|
||||
"trailing-dash-.com",
|
||||
"bad host with spaces",
|
||||
"under_score.example.com",
|
||||
"exa$mple.com",
|
||||
strings.Repeat("a", 254),
|
||||
}
|
||||
for _, in := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
if _, err := NormalizeHost(in); err == nil {
|
||||
t.Fatalf("NormalizeHost(%q) expected error, got nil", in)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRFGuardedDialContext_BlocksLiteralPrivateIP(t *testing.T) {
|
||||
_, err := SSRFGuardedDialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("expected dial to 127.0.0.1 to be blocked")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blocked") {
|
||||
t.Fatalf("expected 'blocked' in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRFGuardedDialContext_AllowPrivateBypassesGuard(t *testing.T) {
|
||||
ctx := ContextWithAllowPrivate(context.Background(), true)
|
||||
_, err := SSRFGuardedDialContext(ctx, "tcp", "127.0.0.1:1")
|
||||
if err == nil {
|
||||
t.Fatal("dial to a closed loopback port should still fail at the connect step")
|
||||
}
|
||||
if strings.Contains(err.Error(), "blocked private/internal address") {
|
||||
t.Fatalf("expected guard to be bypassed when AllowPrivate=true, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRFGuardedDialContext_BadAddress(t *testing.T) {
|
||||
if _, err := SSRFGuardedDialContext(context.Background(), "tcp", "no-port"); err == nil {
|
||||
t.Fatal("expected error for address without port")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package random provides utilities for generating random strings and numbers.
|
||||
package random
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
var (
|
||||
numSeq [10]rune
|
||||
lowerSeq [26]rune
|
||||
upperSeq [26]rune
|
||||
numLowerSeq [36]rune
|
||||
numUpperSeq [36]rune
|
||||
allSeq [62]rune
|
||||
)
|
||||
|
||||
// init initializes the character sequences used for random string generation.
|
||||
// It sets up arrays for numbers, lowercase letters, uppercase letters, and combinations.
|
||||
func init() {
|
||||
for i := range 10 {
|
||||
numSeq[i] = rune('0' + i)
|
||||
}
|
||||
for i := range 26 {
|
||||
lowerSeq[i] = rune('a' + i)
|
||||
upperSeq[i] = rune('A' + i)
|
||||
}
|
||||
|
||||
copy(numLowerSeq[:], numSeq[:])
|
||||
copy(numLowerSeq[len(numSeq):], lowerSeq[:])
|
||||
|
||||
copy(numUpperSeq[:], numSeq[:])
|
||||
copy(numUpperSeq[len(numSeq):], upperSeq[:])
|
||||
|
||||
copy(allSeq[:], numSeq[:])
|
||||
copy(allSeq[len(numSeq):], lowerSeq[:])
|
||||
copy(allSeq[len(numSeq)+len(lowerSeq):], upperSeq[:])
|
||||
}
|
||||
|
||||
// Seq generates a random string of length n containing alphanumeric characters (numbers, lowercase and uppercase letters).
|
||||
func Seq(n int) string {
|
||||
runes := make([]rune, n)
|
||||
for i := range n {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(allSeq))))
|
||||
if err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
runes[i] = allSeq[idx.Int64()]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// NumLower generates a random string of length n containing digits and lowercase letters only.
|
||||
func NumLower(n int) string {
|
||||
runes := make([]rune, n)
|
||||
for i := range n {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(numLowerSeq))))
|
||||
if err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
runes[i] = numLowerSeq[idx.Int64()]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// Num generates a random integer between 0 and n-1.
|
||||
func Num(n int) int {
|
||||
bn := big.NewInt(int64(n))
|
||||
r, err := rand.Int(rand.Reader, bn)
|
||||
if err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return int(r.Int64())
|
||||
}
|
||||
|
||||
// Base64Bytes returns n cryptographically-random bytes encoded as standard
|
||||
// base64 (with padding). Used for ss2022 keys, which xray expects as a
|
||||
// base64-encoded key of a specific byte length per cipher.
|
||||
func Base64Bytes(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package random
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeq_LengthAndAlphabet(t *testing.T) {
|
||||
for _, n := range []int{0, 1, 8, 64, 256} {
|
||||
s := Seq(n)
|
||||
if len(s) != n {
|
||||
t.Fatalf("Seq(%d) returned length %d", n, len(s))
|
||||
}
|
||||
for i, r := range s {
|
||||
isDigit := r >= '0' && r <= '9'
|
||||
isLower := r >= 'a' && r <= 'z'
|
||||
isUpper := r >= 'A' && r <= 'Z'
|
||||
if !(isDigit || isLower || isUpper) {
|
||||
t.Fatalf("Seq(%d) byte %d = %q is not alphanumeric", n, i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeq_NotConstant(t *testing.T) {
|
||||
a := Seq(32)
|
||||
b := Seq(32)
|
||||
if a == b {
|
||||
t.Fatalf("two consecutive Seq(32) calls produced identical output: %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNum_InRange(t *testing.T) {
|
||||
for _, upper := range []int{1, 2, 10, 1000} {
|
||||
for range 200 {
|
||||
v := Num(upper)
|
||||
if v < 0 || v >= upper {
|
||||
t.Fatalf("Num(%d) returned %d, out of [0, %d)", upper, v, upper)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64Bytes_DecodesToRequestedSize(t *testing.T) {
|
||||
for _, n := range []int{1, 16, 32, 64} {
|
||||
out := Base64Bytes(n)
|
||||
decoded, err := base64.StdEncoding.DecodeString(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Base64Bytes(%d) produced invalid base64 %q: %v", n, out, err)
|
||||
}
|
||||
if len(decoded) != n {
|
||||
t.Fatalf("Base64Bytes(%d) decoded to %d bytes", n, len(decoded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64Bytes_Random(t *testing.T) {
|
||||
a := Base64Bytes(32)
|
||||
b := Base64Bytes(32)
|
||||
if a == b {
|
||||
t.Fatalf("two consecutive Base64Bytes(32) calls produced identical output: %q", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package reflect_util provides reflection utilities for working with struct fields and values.
|
||||
package reflect_util
|
||||
|
||||
import "reflect"
|
||||
|
||||
// GetFields returns all struct fields of the given reflect.Type.
|
||||
func GetFields(t reflect.Type) []reflect.StructField {
|
||||
num := t.NumField()
|
||||
fields := make([]reflect.StructField, 0, num)
|
||||
for i := range num {
|
||||
fields = append(fields, t.Field(i))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// GetFieldValues returns all field values of the given reflect.Value.
|
||||
func GetFieldValues(v reflect.Value) []reflect.Value {
|
||||
num := v.NumField()
|
||||
fields := make([]reflect.Value, 0, num)
|
||||
for i := range num {
|
||||
fields = append(fields, v.Field(i))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package sys provides system utilities for monitoring network connections and CPU usage.
|
||||
// Platform-specific implementations are provided for Windows, Linux, and macOS.
|
||||
package sys
|
||||
|
||||
import (
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
//go:linkname HostProc github.com/shirou/gopsutil/v4/internal/common.HostProc
|
||||
func HostProc(combineWith ...string) string
|
||||
@@ -0,0 +1,91 @@
|
||||
//go:build darwin
|
||||
|
||||
package sys
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var SIGUSR1 = syscall.SIGUSR1
|
||||
|
||||
func GetTCPCount() (int, error) {
|
||||
stats, err := net.Connections("tcp")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(stats), nil
|
||||
}
|
||||
|
||||
func GetUDPCount() (int, error) {
|
||||
stats, err := net.Connections("udp")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(stats), nil
|
||||
}
|
||||
|
||||
// --- CPU Utilization (macOS native) ---
|
||||
|
||||
// sysctl kern.cp_time returns 5 longs in the BSD CPUSTATES order:
|
||||
// user, nice, sys, intr, idle (CP_INTR=3, CP_IDLE=4). gopsutil reads the
|
||||
// same layout in cpu_darwin_nocgo.go.
|
||||
var (
|
||||
cpuMu sync.Mutex
|
||||
lastTotals [5]uint64
|
||||
hasLastCPUT bool
|
||||
)
|
||||
|
||||
func CPUPercentRaw() (float64, error) {
|
||||
raw, err := unix.SysctlRaw("kern.cp_time")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Expect either 5*8 bytes (uint64) or 5*4 bytes (uint32)
|
||||
var out [5]uint64
|
||||
switch len(raw) {
|
||||
case 5 * 8:
|
||||
for i := range 5 {
|
||||
out[i] = binary.LittleEndian.Uint64(raw[i*8 : (i+1)*8])
|
||||
}
|
||||
case 5 * 4:
|
||||
for i := range 5 {
|
||||
out[i] = uint64(binary.LittleEndian.Uint32(raw[i*4 : (i+1)*4]))
|
||||
}
|
||||
default:
|
||||
return 0, fmt.Errorf("unexpected kern.cp_time size: %d", len(raw))
|
||||
}
|
||||
|
||||
cpuMu.Lock()
|
||||
defer cpuMu.Unlock()
|
||||
|
||||
if !hasLastCPUT {
|
||||
lastTotals = out
|
||||
hasLastCPUT = true
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var deltas [5]uint64
|
||||
var totald uint64
|
||||
for i := range 5 {
|
||||
deltas[i] = out[i] - lastTotals[i]
|
||||
totald += deltas[i]
|
||||
}
|
||||
lastTotals = out
|
||||
|
||||
if totald == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
idleDelta := deltas[4]
|
||||
busy := totald - idleDelta
|
||||
pct := float64(busy) / float64(totald) * 100.0
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//go:build linux
|
||||
|
||||
package sys
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var SIGUSR1 = syscall.SIGUSR1
|
||||
|
||||
// countConnections returns the number of entries in a /proc/net/{tcp,udp}[6]
|
||||
// file. Returns 0 if the file is absent (e.g. /proc/net/tcp6 when IPv6 is
|
||||
// disabled) and excludes the column header line.
|
||||
func countConnections(path string) (int, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
n++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
n-- // first line is the column header
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// GetTCPCount returns the number of active TCP connections by reading
|
||||
// /proc/net/tcp and /proc/net/tcp6 when available.
|
||||
func GetTCPCount() (int, error) {
|
||||
root := HostProc()
|
||||
tcp4, err := countConnections(root + "/net/tcp")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tcp6, err := countConnections(root + "/net/tcp6")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tcp4 + tcp6, nil
|
||||
}
|
||||
|
||||
// GetUDPCount returns the number of active UDP connections by reading
|
||||
// /proc/net/udp and /proc/net/udp6 when available.
|
||||
func GetUDPCount() (int, error) {
|
||||
root := HostProc()
|
||||
udp4, err := countConnections(root + "/net/udp")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
udp6, err := countConnections(root + "/net/udp6")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return udp4 + udp6, nil
|
||||
}
|
||||
|
||||
// --- CPU Utilization (Linux native) ---
|
||||
|
||||
var (
|
||||
cpuMu sync.Mutex
|
||||
lastTotal uint64
|
||||
lastIdleAll uint64
|
||||
hasLast bool
|
||||
)
|
||||
|
||||
// CPUPercentRaw returns instantaneous total CPU utilization by reading
|
||||
// /proc/stat. First call initializes and returns 0; subsequent calls return
|
||||
// busy/total * 100. Uses HostProc so HOST_PROC overrides (containers) apply.
|
||||
func CPUPercentRaw() (float64, error) {
|
||||
f, err := os.Open(HostProc("stat"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
rd := bufio.NewReader(f)
|
||||
line, err := rd.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return 0, err
|
||||
}
|
||||
// Expect: cpu user nice system idle iowait irq softirq steal guest guest_nice
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, fmt.Errorf("unexpected /proc/stat format")
|
||||
}
|
||||
|
||||
nums := make([]uint64, 0, len(fields)-1)
|
||||
for i := 1; i < len(fields); i++ {
|
||||
v, err := strconv.ParseUint(fields[i], 10, 64)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
nums = append(nums, v)
|
||||
}
|
||||
if len(nums) < 4 {
|
||||
return 0, fmt.Errorf("insufficient cpu fields")
|
||||
}
|
||||
for len(nums) < 8 {
|
||||
nums = append(nums, 0)
|
||||
}
|
||||
|
||||
user, nice, system, idle := nums[0], nums[1], nums[2], nums[3]
|
||||
iowait, irq, softirq, steal := nums[4], nums[5], nums[6], nums[7]
|
||||
|
||||
idleAll := idle + iowait
|
||||
nonIdle := user + nice + system + irq + softirq + steal
|
||||
total := idleAll + nonIdle
|
||||
|
||||
cpuMu.Lock()
|
||||
defer cpuMu.Unlock()
|
||||
|
||||
if !hasLast {
|
||||
lastTotal = total
|
||||
lastIdleAll = idleAll
|
||||
hasLast = true
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
totald := total - lastTotal
|
||||
idled := idleAll - lastIdleAll
|
||||
lastTotal = total
|
||||
lastIdleAll = idleAll
|
||||
|
||||
if totald == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
busy := totald - idled
|
||||
pct := float64(busy) / float64(totald) * 100.0
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build windows
|
||||
|
||||
package sys
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/shirou/gopsutil/v4/net"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var SIGUSR1 = syscall.Signal(0)
|
||||
|
||||
// GetConnectionCount returns the number of active connections for the specified protocol ("tcp" or "udp").
|
||||
func GetConnectionCount(proto string) (int, error) {
|
||||
if proto != "tcp" && proto != "udp" {
|
||||
return 0, errors.New("invalid protocol")
|
||||
}
|
||||
stats, err := net.Connections(proto)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(stats), nil
|
||||
}
|
||||
|
||||
// GetTCPCount returns the number of active TCP connections.
|
||||
func GetTCPCount() (int, error) {
|
||||
return GetConnectionCount("tcp")
|
||||
}
|
||||
|
||||
// GetUDPCount returns the number of active UDP connections.
|
||||
func GetUDPCount() (int, error) {
|
||||
return GetConnectionCount("udp")
|
||||
}
|
||||
|
||||
// --- CPU Utilization (Windows native) ---
|
||||
|
||||
var (
|
||||
// NewLazySystemDLL forces the load from %SystemRoot%\System32 so a
|
||||
// kernel32.dll planted next to the binary can't hijack the call.
|
||||
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
procGetSystemTimes = modKernel32.NewProc("GetSystemTimes")
|
||||
|
||||
cpuMu sync.Mutex
|
||||
lastIdle uint64
|
||||
lastKernel uint64
|
||||
lastUser uint64
|
||||
hasLast bool
|
||||
)
|
||||
|
||||
func ftToUint64(ft windows.Filetime) uint64 {
|
||||
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
// CPUPercentRaw returns instantaneous total CPU utilization across all
|
||||
// logical processors via Windows GetSystemTimes. The first call returns 0
|
||||
// while it initializes the baseline; subsequent calls compute deltas.
|
||||
func CPUPercentRaw() (float64, error) {
|
||||
var idleFT, kernelFT, userFT windows.Filetime
|
||||
r1, _, e1 := procGetSystemTimes.Call(
|
||||
uintptr(unsafe.Pointer(&idleFT)),
|
||||
uintptr(unsafe.Pointer(&kernelFT)),
|
||||
uintptr(unsafe.Pointer(&userFT)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
if errno, _ := e1.(syscall.Errno); errno != 0 {
|
||||
return 0, errno
|
||||
}
|
||||
return 0, errors.New("GetSystemTimes failed")
|
||||
}
|
||||
|
||||
idle := ftToUint64(idleFT)
|
||||
kernel := ftToUint64(kernelFT)
|
||||
user := ftToUint64(userFT)
|
||||
|
||||
cpuMu.Lock()
|
||||
defer cpuMu.Unlock()
|
||||
|
||||
if !hasLast {
|
||||
lastIdle = idle
|
||||
lastKernel = kernel
|
||||
lastUser = user
|
||||
hasLast = true
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
idleDelta := idle - lastIdle
|
||||
kernelDelta := kernel - lastKernel
|
||||
userDelta := user - lastUser
|
||||
|
||||
lastIdle = idle
|
||||
lastKernel = kernel
|
||||
lastUser = user
|
||||
|
||||
total := kernelDelta + userDelta
|
||||
if total == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// kernel time includes idle on Windows; busy = total - idle
|
||||
busy := total - idleDelta
|
||||
|
||||
pct := float64(busy) / float64(total) * 100.0
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return pct, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
// GenerateWireguardKeypair generates a base64 encoded private and public key pair for Wireguard.
|
||||
func GenerateWireguardKeypair() (privateKey string, publicKey string, err error) {
|
||||
var priv [32]byte
|
||||
if _, err := rand.Read(priv[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
priv[0] &= 248
|
||||
priv[31] &= 127
|
||||
priv[31] |= 64
|
||||
|
||||
var pub [32]byte
|
||||
curve25519.ScalarBaseMult(&pub, &priv)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||
}
|
||||
Reference in New Issue
Block a user