342 lines
8.4 KiB
Go
342 lines
8.4 KiB
Go
package panel
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var tokenRe = regexp.MustCompile(`^awp_[A-Za-z0-9_-]+$`)
|
|
|
|
type Client struct {
|
|
HTTPBudget time.Duration
|
|
httpClient *http.Client
|
|
cacheMu sync.Mutex
|
|
serverCache map[string]serverCacheEntry
|
|
}
|
|
|
|
type serverCacheEntry struct {
|
|
servers []Server
|
|
until time.Time
|
|
}
|
|
|
|
type Server struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
}
|
|
|
|
type Response struct {
|
|
Code int
|
|
Body []byte
|
|
Err error
|
|
}
|
|
|
|
type AddResult struct {
|
|
ClientID string
|
|
RawJSON string
|
|
}
|
|
|
|
func New(budget time.Duration) *Client {
|
|
return &Client{
|
|
HTTPBudget: budget,
|
|
httpClient: &http.Client{Timeout: 120 * time.Second},
|
|
serverCache: map[string]serverCacheEntry{},
|
|
}
|
|
}
|
|
|
|
func NormalizeToken(raw string) string {
|
|
t := strings.TrimSpace(raw)
|
|
t = strings.TrimPrefix(t, "\ufeff")
|
|
t = strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '\u200b', '\u200c', '\u200d', '\ufeff', '\u2060',
|
|
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e':
|
|
return -1
|
|
default:
|
|
return r
|
|
}
|
|
}, t)
|
|
t = strings.TrimSpace(t)
|
|
if strings.HasPrefix(strings.ToLower(t), "bearer ") {
|
|
t = strings.TrimSpace(t[7:])
|
|
}
|
|
return strings.Trim(t, " \t\n\r\"'")
|
|
}
|
|
|
|
func TokenOK(t string) bool {
|
|
return len(t) >= 24 && tokenRe.MatchString(t)
|
|
}
|
|
|
|
func APIProtocol(protocol string) string {
|
|
if protocol == "vless" {
|
|
return "xray"
|
|
}
|
|
return protocol
|
|
}
|
|
|
|
func CreateTimeout(protocol string, budget time.Duration) time.Duration {
|
|
var d time.Duration
|
|
switch protocol {
|
|
case "vless", "xray":
|
|
d = 100 * time.Second
|
|
default:
|
|
d = 75 * time.Second
|
|
}
|
|
if budget > 0 && d > budget {
|
|
d = budget
|
|
}
|
|
if d < 5*time.Second {
|
|
d = 5 * time.Second
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (c *Client) Request(ctx context.Context, method, baseURL, path, token string, jsonBody []byte, timeout time.Duration) Response {
|
|
if timeout <= 0 {
|
|
timeout = 60 * time.Second
|
|
}
|
|
if c.HTTPBudget > 0 && timeout > c.HTTPBudget {
|
|
timeout = c.HTTPBudget
|
|
}
|
|
url := strings.TrimRight(baseURL, "/") + path
|
|
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
var body io.Reader
|
|
if jsonBody != nil {
|
|
body = bytes.NewReader(jsonBody)
|
|
}
|
|
req, err := http.NewRequestWithContext(reqCtx, method, url, body)
|
|
if err != nil {
|
|
return Response{Err: err}
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "AmneziaSiteBridge/1.0 (+Go; Dokploy)")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
if jsonBody != nil {
|
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return Response{Err: err}
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
return Response{Code: resp.StatusCode, Body: b}
|
|
}
|
|
|
|
func (c *Client) AddConnection(ctx context.Context, baseURL, token string, serverID int, protocol, name string) (AddResult, error) {
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"protocol": APIProtocol(protocol),
|
|
"name": name,
|
|
})
|
|
timeout := CreateTimeout(protocol, c.HTTPBudget)
|
|
resp := c.Request(ctx, http.MethodPost, baseURL, fmt.Sprintf("/api/servers/%d/connections/add", serverID), token, payload, timeout)
|
|
if resp.Err != nil {
|
|
if strings.Contains(strings.ToLower(resp.Err.Error()), "timeout") || strings.Contains(strings.ToLower(resp.Err.Error()), "deadline") {
|
|
return AddResult{}, fmt.Errorf("Сервер создаёт ключ слишком долго. Подождите минуту и попробуйте снова.")
|
|
}
|
|
return AddResult{}, fmt.Errorf("Не удалось создать подключение.")
|
|
}
|
|
if resp.Code < 200 || resp.Code >= 300 {
|
|
return AddResult{}, fmt.Errorf("Не удалось создать подключение. Выберите другой пункт или тип VPN.")
|
|
}
|
|
var dec map[string]any
|
|
_ = json.Unmarshal(resp.Body, &dec)
|
|
clientID := ""
|
|
if v, ok := dec["client_id"].(string); ok {
|
|
clientID = strings.TrimSpace(v)
|
|
}
|
|
if clientID == "" {
|
|
if v, ok := dec["clientId"].(string); ok {
|
|
clientID = strings.TrimSpace(v)
|
|
}
|
|
}
|
|
if clientID == "" {
|
|
return AddResult{}, fmt.Errorf("Сервис не выдал ключ подключения.")
|
|
}
|
|
return AddResult{ClientID: clientID, RawJSON: string(resp.Body)}, nil
|
|
}
|
|
|
|
func (c *Client) RemoveConnection(ctx context.Context, baseURL, token string, serverID int, protocol, clientID string, treat404 bool) error {
|
|
clientID = strings.TrimSpace(clientID)
|
|
protocol = strings.TrimSpace(protocol)
|
|
if clientID == "" || protocol == "" {
|
|
return fmt.Errorf("неполные параметры удаления")
|
|
}
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"protocol": APIProtocol(protocol),
|
|
"client_id": clientID,
|
|
})
|
|
resp := c.Request(ctx, http.MethodPost, baseURL, fmt.Sprintf("/api/servers/%d/connections/remove", serverID), token, payload, 45*time.Second)
|
|
if resp.Err != nil {
|
|
return resp.Err
|
|
}
|
|
if resp.Code >= 200 && resp.Code < 300 {
|
|
return nil
|
|
}
|
|
if resp.Code == 404 && treat404 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("HTTP %d", resp.Code)
|
|
}
|
|
|
|
func (c *Client) RemoveClientSmart(ctx context.Context, baseURL, token string, serverID int, protocol, clientID string) error {
|
|
protos := uniqueStrings([]string{APIProtocol(protocol), protocol, "vless", "xray", "awg2", "wireguard", "awg", "awg_legacy"})
|
|
var last error
|
|
for _, p := range protos {
|
|
if err := c.RemoveConnection(ctx, baseURL, token, serverID, p, clientID, true); err == nil {
|
|
return nil
|
|
} else {
|
|
last = err
|
|
}
|
|
}
|
|
if last == nil {
|
|
last = fmt.Errorf("не удалось удалить")
|
|
}
|
|
return last
|
|
}
|
|
|
|
func (c *Client) ListServers(ctx context.Context, baseURL, token string) ([]Server, error) {
|
|
key := strings.TrimRight(baseURL, "/")
|
|
c.cacheMu.Lock()
|
|
if e, ok := c.serverCache[key]; ok && time.Now().Before(e.until) {
|
|
out := append([]Server(nil), e.servers...)
|
|
c.cacheMu.Unlock()
|
|
return out, nil
|
|
}
|
|
c.cacheMu.Unlock()
|
|
|
|
paths := []string{"/api/servers", "/api/servers/", "/api/v1/servers"}
|
|
var servers []Server
|
|
for _, p := range paths {
|
|
resp := c.Request(ctx, http.MethodGet, baseURL, p, token, nil, 20*time.Second)
|
|
if resp.Err != nil || resp.Code < 200 || resp.Code >= 300 {
|
|
continue
|
|
}
|
|
servers = parseServers(resp.Body)
|
|
if len(servers) > 0 {
|
|
break
|
|
}
|
|
}
|
|
c.cacheMu.Lock()
|
|
c.serverCache[key] = serverCacheEntry{servers: servers, until: time.Now().Add(2 * time.Minute)}
|
|
c.cacheMu.Unlock()
|
|
return servers, nil
|
|
}
|
|
|
|
func (c *Client) ClearCache() {
|
|
c.cacheMu.Lock()
|
|
c.serverCache = map[string]serverCacheEntry{}
|
|
c.cacheMu.Unlock()
|
|
}
|
|
|
|
func (c *Client) Ping(ctx context.Context, baseURL, token string, serverID int) (alive bool, ms int, errMsg string) {
|
|
resp := c.Request(ctx, http.MethodGet, baseURL, fmt.Sprintf("/api/servers/%d/ping", serverID), token, nil, 15*time.Second)
|
|
if resp.Err != nil {
|
|
return false, 0, resp.Err.Error()
|
|
}
|
|
var dec map[string]any
|
|
_ = json.Unmarshal(resp.Body, &dec)
|
|
if v, ok := dec["alive"].(bool); ok {
|
|
alive = v
|
|
}
|
|
switch v := dec["ms"].(type) {
|
|
case float64:
|
|
ms = int(v)
|
|
case int:
|
|
ms = v
|
|
}
|
|
if e, ok := dec["error"].(string); ok {
|
|
errMsg = e
|
|
}
|
|
return
|
|
}
|
|
|
|
func parseServers(body []byte) []Server {
|
|
var root any
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil
|
|
}
|
|
var list []any
|
|
switch t := root.(type) {
|
|
case []any:
|
|
list = t
|
|
case map[string]any:
|
|
for _, k := range []string{"servers", "items", "results", "list"} {
|
|
if arr, ok := t[k].([]any); ok {
|
|
list = arr
|
|
break
|
|
}
|
|
}
|
|
if list == nil {
|
|
if data, ok := t["data"].(map[string]any); ok {
|
|
if arr, ok := data["servers"].([]any); ok {
|
|
list = arr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out := make([]Server, 0, len(list))
|
|
for _, item := range list {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id := anyInt(m["id"])
|
|
name, _ := m["name"].(string)
|
|
host, _ := m["host"].(string)
|
|
if host == "" {
|
|
host, _ = m["ip"].(string)
|
|
}
|
|
out = append(out, Server{ID: id, Name: name, Host: host})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func anyInt(v any) int {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return int(t)
|
|
case int:
|
|
return t
|
|
case json.Number:
|
|
n, _ := t.Int64()
|
|
return int(n)
|
|
case string:
|
|
var n int
|
|
fmt.Sscanf(t, "%d", &n)
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func uniqueStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
var out []string
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|