mirror of
https://github.com/remnawave/migrate.git
synced 2026-05-13 12:16:40 +00:00
feat: add 3xui support
Co-authored-by: Михаил Проказин <bearpro@outlook.com>
This commit is contained in:
parent
6844e48b92
commit
9e8c5119b7
3 changed files with 321 additions and 2 deletions
|
|
@ -3,7 +3,7 @@ package config
|
|||
import "github.com/alecthomas/kong"
|
||||
|
||||
type Config struct {
|
||||
PanelType string `name:"panel-type" help:"Source panel type (e.g., marzban, marzneshin)" required:"true" default:"marzban" enum:"marzban,marzneshin" env:"PANEL_TYPE"`
|
||||
PanelType string `name:"panel-type" help:"Source panel type (e.g., marzban, marzneshin, 3xui)" required:"true" default:"marzban" enum:"marzban,marzneshin,3xui" env:"PANEL_TYPE"`
|
||||
PanelURL string `name:"panel-url" help:"Source panel URL" required:"true" env:"PANEL_URL"`
|
||||
PanelUsername string `name:"panel-username" help:"Source panel admin username" required:"true" env:"PANEL_USERNAME"`
|
||||
PanelPassword string `name:"panel-password" help:"Source panel admin password" required:"true" env:"PANEL_PASSWORD"`
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ func Factory(panelType, baseURL string, headers map[string]string) (SourcePanel,
|
|||
return NewMarzbanPanel(baseURL, headers), nil
|
||||
case "marzneshin":
|
||||
return NewMarzneshinPanel(baseURL, headers), nil
|
||||
case "3xui":
|
||||
return NewThreeXUIPanel(baseURL, headers), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported panel type: %s", panelType)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
318
source/threexui.go
Normal file
318
source/threexui.go
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
package source
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"remnawave-migrate/models"
|
||||
)
|
||||
|
||||
type ThreeXUIPanel struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
headers map[string]string
|
||||
cookies []*http.Cookie
|
||||
allUsersCache []models.User
|
||||
cacheLoaded bool
|
||||
}
|
||||
|
||||
type threeXUIClient struct {
|
||||
Email string `json:"email"`
|
||||
ID string `json:"id"`
|
||||
Password string `json:"password"`
|
||||
Enable bool `json:"enable"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Comment string `json:"comment"`
|
||||
LimitIp int `json:"limitIp"`
|
||||
TotalBytes int64 `json:"totalGB"` // Despite field called GB, the value is actually byte count
|
||||
Reset int `json:"reset"`
|
||||
}
|
||||
|
||||
type threeXUIClientStatsItem struct {
|
||||
ID int `json:"id"`
|
||||
InboundID int `json:"inboundId"`
|
||||
Enable bool `json:"enable"`
|
||||
Email string `json:"email"`
|
||||
UUID string `json:"uuid"`
|
||||
SubID string `json:"subId"`
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
AllTime int64 `json:"allTime"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
Total int64 `json:"total"`
|
||||
Reset int `json:"reset"`
|
||||
LastOnline int64 `json:"lastOnline"`
|
||||
}
|
||||
|
||||
// Combines two elements of the user ("client" in terms of 3x-ui) description
|
||||
type user struct {
|
||||
client threeXUIClient
|
||||
clientStatsItem threeXUIClientStatsItem
|
||||
}
|
||||
|
||||
type inbound struct {
|
||||
ID int `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Tag string `json:"tag"`
|
||||
TrafficReset string `json:"trafficReset"`
|
||||
Settings string `json:"settings"`
|
||||
ClientStats []threeXUIClientStatsItem `json:"clientStats"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj []inbound `json:"obj"`
|
||||
}
|
||||
|
||||
func NewThreeXUIPanel(baseURL string, headers map[string]string) *ThreeXUIPanel {
|
||||
return &ThreeXUIPanel{
|
||||
client: &http.Client{},
|
||||
baseURL: baseURL,
|
||||
headers: headers,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ThreeXUIPanel) Login(username, password string) error {
|
||||
loginData := url.Values{}
|
||||
loginData.Set("username", username)
|
||||
loginData.Set("password", password)
|
||||
|
||||
req, err := http.NewRequest("POST",
|
||||
fmt.Sprintf("%s/login", p.baseURL),
|
||||
strings.NewReader(loginData.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating login request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for k, v := range p.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending login request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("login failed: status %d, body: %s",
|
||||
resp.StatusCode, body)
|
||||
}
|
||||
|
||||
p.cookies = resp.Cookies()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func processUser(client threeXUIClient, clientStatsItem threeXUIClientStatsItem, inboundTrafficReset string) models.ProcessedUser {
|
||||
|
||||
var expireTime time.Time
|
||||
if client.ExpiryTime > 0 {
|
||||
expireTime = time.Unix(client.ExpiryTime/1000, 0).UTC()
|
||||
} else {
|
||||
expireTime = time.Date(2099, 12, 31, 15, 13, 22, 214000000, time.UTC)
|
||||
}
|
||||
|
||||
var status string
|
||||
if !client.Enable {
|
||||
status = "DISABLED"
|
||||
} else if expireTime.Before(time.Now().UTC()) {
|
||||
status = "EXPIRED"
|
||||
} else if clientStatsItem.LastOnline == 0 {
|
||||
status = "INACTIVE"
|
||||
} else {
|
||||
status = "ACTIVE"
|
||||
}
|
||||
|
||||
var trafficResetStrategy string
|
||||
switch inboundTrafficReset {
|
||||
case "daily":
|
||||
trafficResetStrategy = "DAY"
|
||||
case "weekly":
|
||||
trafficResetStrategy = "WEEK"
|
||||
case "monthly":
|
||||
trafficResetStrategy = "MONTH"
|
||||
case "never":
|
||||
default:
|
||||
trafficResetStrategy = "NO_RESET"
|
||||
}
|
||||
|
||||
var createdAtTime time.Time
|
||||
if client.CreatedAt > 0 {
|
||||
createdAtTime = time.Unix(client.CreatedAt/1000, 0).UTC()
|
||||
} else {
|
||||
createdAtTime = time.Now().UTC()
|
||||
}
|
||||
|
||||
return models.ProcessedUser{
|
||||
Username: client.Email,
|
||||
Status: status,
|
||||
VlessID: "", //Protocol specific fields are set later
|
||||
TrojanPassword: "",
|
||||
ShadowsocksPassword: "",
|
||||
SubscriptionHash: "", // Will be generated
|
||||
DataLimit: client.TotalBytes,
|
||||
DataLimitResetStrategy: trafficResetStrategy,
|
||||
Note: client.Comment,
|
||||
Expire: expireTime.Format("2006-01-02T15:04:05.000Z"),
|
||||
CreatedAt: createdAtTime.Format("2006-01-02T15:04:05.000Z"),
|
||||
}
|
||||
}
|
||||
|
||||
func joinClientStats(clients []threeXUIClient, clientStats []threeXUIClientStatsItem) []user {
|
||||
clientsMap := make(map[string]threeXUIClient)
|
||||
for _, client := range clients {
|
||||
clientsMap[client.Email] = client
|
||||
}
|
||||
|
||||
var users []user
|
||||
for _, stat := range clientStats {
|
||||
if client, ok := clientsMap[stat.Email]; ok {
|
||||
users = append(users, user{
|
||||
client: client,
|
||||
clientStatsItem: stat,
|
||||
})
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func getInboundUsers(inbound inbound) ([]models.ProcessedUser, error) {
|
||||
var settingsObj struct {
|
||||
Clients []threeXUIClient `json:"clients"`
|
||||
Decryption string `json:"decryption"`
|
||||
Encryption string `json:"encryption"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(inbound.Settings), &settingsObj); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse settings for inbound %d: %w", inbound.ID, err)
|
||||
}
|
||||
|
||||
users := joinClientStats(settingsObj.Clients, inbound.ClientStats)
|
||||
|
||||
var processedUsers []models.ProcessedUser
|
||||
for _, user := range users {
|
||||
processedUser := processUser(user.client, user.clientStatsItem, inbound.TrafficReset)
|
||||
|
||||
switch inbound.Protocol {
|
||||
case "shadowsocks":
|
||||
processedUser.ShadowsocksPassword = user.client.Password
|
||||
case "trojan":
|
||||
processedUser.TrojanPassword = user.client.Password
|
||||
case "vless":
|
||||
default:
|
||||
processedUser.VlessID = user.client.ID
|
||||
}
|
||||
|
||||
processedUsers = append(processedUsers, processedUser)
|
||||
}
|
||||
return processedUsers, nil
|
||||
}
|
||||
|
||||
func fetchInbounds(p *ThreeXUIPanel) (*apiResponse, error) {
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("%s/panel/api/inbounds/list", p.baseURL),
|
||||
nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
for _, cookie := range p.cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
|
||||
for k, v := range p.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("getting inbounds failed: status %d, body: %s",
|
||||
resp.StatusCode, body)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
var apiResponse apiResponse
|
||||
|
||||
if err := json.Unmarshal(body, &apiResponse); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !apiResponse.Success {
|
||||
return nil, fmt.Errorf("API error: %s", apiResponse.Msg)
|
||||
}
|
||||
|
||||
return &apiResponse, nil
|
||||
}
|
||||
|
||||
func loadAllUsers(p *ThreeXUIPanel) ([]models.User, error) {
|
||||
if p.cacheLoaded {
|
||||
return p.allUsersCache, nil
|
||||
}
|
||||
|
||||
apiResponse, err := fetchInbounds(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allUsers []models.ProcessedUser
|
||||
|
||||
for _, inbound := range apiResponse.Obj {
|
||||
inboundUsers, err := getInboundUsers(inbound)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allUsers = append(allUsers, inboundUsers...)
|
||||
}
|
||||
|
||||
p.allUsersCache = make([]models.User, len(allUsers))
|
||||
for i, processedUser := range allUsers {
|
||||
p.allUsersCache[i] = models.User{
|
||||
ProcessedUser: processedUser,
|
||||
}
|
||||
}
|
||||
|
||||
p.cacheLoaded = true
|
||||
return p.allUsersCache, nil
|
||||
}
|
||||
|
||||
func (p *ThreeXUIPanel) GetUsers(offset, limit int) (*models.UsersResponse, error) {
|
||||
allUsers, err := loadAllUsers(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total := len(allUsers)
|
||||
start := min(offset, total)
|
||||
end := min(offset+limit, total)
|
||||
|
||||
if limit == 0 {
|
||||
return &models.UsersResponse{
|
||||
Users: allUsers,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &models.UsersResponse{
|
||||
Users: allUsers[start:end],
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue