mirror of
https://github.com/remnawave/migrate.git
synced 2026-05-13 04:09:04 +00:00
Refactor: Implement universal panel migration architecture
- Created modular source panel interface for extensibility - Renamed arguments from marzban-* to panel-* - Added panel-type argument with support for "marzban" and "marzneshin" - Implemented Marzneshin panel support with unique proxy extraction logic - Restructured models to support different panel types - Updated README with new options and instructions - Renamed app to "remnawave-migrate" to reflect universal nature This refactoring makes the migration tool more flexible and allows easier addition of new panel types in the future. Co-authored-by: Yury Kastov <kastov@gog.sh>
This commit is contained in:
parent
bceb7f6f24
commit
faf2786d01
17 changed files with 471 additions and 198 deletions
3
.github/workflows/release.yaml
vendored
3
.github/workflows/release.yaml
vendored
|
|
@ -36,13 +36,12 @@ jobs:
|
|||
- name: Setup Go Environment
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: marzban/go.mod
|
||||
go-version-file: go.mod
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
workdir: marzban
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
version: 2
|
||||
|
||||
project_name: marzban-migration-tool
|
||||
project_name: remnawave-migrate
|
||||
|
||||
before:
|
||||
hooks:
|
||||
|
|
@ -13,7 +13,7 @@ checksum:
|
|||
|
||||
builds:
|
||||
- id: build
|
||||
binary: marzban-migration-tool
|
||||
binary: remnawave-migrate
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
|
|
@ -28,7 +28,7 @@ builds:
|
|||
- -X main.version={{ .Tag }}
|
||||
|
||||
archives:
|
||||
- id: marzban-migration-tool
|
||||
- id: remnawave-migrate
|
||||
format: tar.gz
|
||||
name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
format_overrides:
|
||||
135
README.md
135
README.md
|
|
@ -1,42 +1,131 @@
|
|||
# Remnawave Migration Tools
|
||||
# Remnawave Migration Tool
|
||||
|
||||
This repository contains a collection of tools for migrating users from various VPN panels to Remnawave panel.
|
||||
A command-line tool for migrating users from various VPN management panels to Remnawave.
|
||||
|
||||
## Available Migration Tools
|
||||
## Supported Source Panels
|
||||
|
||||
### [Marzban Migration Tool](./marzban)
|
||||
- Marzban
|
||||
- Marzneshin
|
||||
|
||||
Migrate users from Marzban panel to Remnawave panel. Supports batch processing, selective migration of recent users, and custom traffic reset strategies.
|
||||
## Overview
|
||||
|
||||
Features:
|
||||
This tool helps you migrate user accounts from various VPN management panels to a Remnawave. It supports batch processing, selective migration of recent users, and customization of traffic reset strategies.
|
||||
|
||||
- Migrate user credentials and settings
|
||||
- Batch processing
|
||||
- Selective migration of recent users
|
||||
Key features:
|
||||
|
||||
- Batch processing with configurable batch size
|
||||
- Migration of selected number of most recent users
|
||||
- Automatic handling of existing users
|
||||
- Support for environment variables
|
||||
- Customizable traffic reset strategy
|
||||
- Environment variables support
|
||||
- Flexible status handling
|
||||
|
||||
[Learn more about Marzban Migration →](./marzban)
|
||||
### Migrated User Fields
|
||||
|
||||
## General Information
|
||||
The following user fields are migrated to Remnawave:
|
||||
|
||||
All migration tools in this repository follow these principles:
|
||||
| Field | Description |
|
||||
| -------------------- | ------------------------------------------------- |
|
||||
| Username | User's unique identifier |
|
||||
| Status | User's status (can be preserved or set to ACTIVE) |
|
||||
| ShortUUID | Generated from subscription URL hash |
|
||||
| TrojanPassword | Password for Trojan protocol |
|
||||
| VlessUUID | UUID for VLESS protocol |
|
||||
| SsPassword | Password for Shadowsocks protocol |
|
||||
| TrafficLimitBytes | Traffic limit in bytes |
|
||||
| TrafficLimitStrategy | Traffic reset strategy (can be customized) |
|
||||
| ExpireAt | Account expiration date (UTC) |
|
||||
| Description | User notes/description |
|
||||
|
||||
- Safe and non-destructive migration
|
||||
- Configurable through CLI flags and environment variables
|
||||
- Detailed logging and error handling
|
||||
- Respect for existing users and data
|
||||
- Clear documentation and usage examples
|
||||
## Configuration
|
||||
|
||||
## Contributing
|
||||
The tool can be configured using command-line flags or environment variables:
|
||||
|
||||
We welcome contributions for new migration tools or improvements to existing ones. If you'd like to add support for migrating from another panel:
|
||||
| Flag | Environment Variable | Description | Default |
|
||||
| ---------------------- | -------------------- | ---------------------------------------------- | --------- |
|
||||
| `--panel-type` | `PANEL_TYPE` | Source panel type (`marzban` or `marzneshin`) | `marzban` |
|
||||
| `--panel-url` | `PANEL_URL` | Source panel URL | |
|
||||
| `--panel-username` | `PANEL_USERNAME` | Source panel admin username | |
|
||||
| `--panel-password` | `PANEL_PASSWORD` | Source panel admin password | |
|
||||
| `--remnawave-url` | `REMNAWAVE_URL` | Destination panel URL | |
|
||||
| `--remnawave-token` | `REMNAWAVE_TOKEN` | Destination panel API token | |
|
||||
| `--batch-size` | `BATCH_SIZE` | Number of users to process in one batch | 100 |
|
||||
| `--last-users` | `LAST_USERS` | Only migrate last N users (0 means all users) | 0 |
|
||||
| `--preferred-strategy` | `PREFERRED_STRATEGY` | Preferred traffic reset strategy for all users | |
|
||||
| `--preserve-status` | `PRESERVE_STATUS` | Preserve user status from source panel | false |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Migrate all users (sets all users to ACTIVE status)
|
||||
./migration-tool \
|
||||
--panel-type=marzban \
|
||||
--panel-url="http://marzban.example.com" \
|
||||
--panel-username="admin" \
|
||||
--panel-password="password" \
|
||||
--remnawave-url="http://remnawave.example.com" \
|
||||
--remnawave-token="your-token"
|
||||
```
|
||||
|
||||
### Preserve User Status
|
||||
|
||||
```bash
|
||||
# Migrate users preserving their original status
|
||||
./migration-tool \
|
||||
[other flags...] \
|
||||
--preserve-status
|
||||
```
|
||||
|
||||
### Migrate Last N Users
|
||||
|
||||
```bash
|
||||
# Migrate only the last 50 users
|
||||
./migration-tool \
|
||||
[other flags...] \
|
||||
--last-users=50
|
||||
```
|
||||
|
||||
### Set Preferred Traffic Reset Strategy
|
||||
|
||||
```bash
|
||||
# Migrate users with a specific reset strategy
|
||||
./migration-tool \
|
||||
[other flags...] \
|
||||
--preferred-strategy=MONTH
|
||||
```
|
||||
|
||||
Available strategy values:
|
||||
|
||||
- `NO_RESET` - No traffic limit reset
|
||||
- `DAY` - Reset daily
|
||||
- `WEEK` - Reset weekly
|
||||
- `MONTH` - Reset monthly
|
||||
|
||||
**Note:** If not specified, the original strategy from Marzban will be used (with YEAR strategy converted to NO_RESET as Remnawave doesn't support yearly resets).
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
```bash
|
||||
export PANEL_TYPE="marzban"
|
||||
export PANEL_URL="http://marzban.example.com"
|
||||
export PANEL_USERNAME="admin"
|
||||
export PANEL_PASSWORD="password"
|
||||
export REMNAWAVE_URL="http://remnawave.example.com"
|
||||
export REMNAWAVE_TOKEN="your-token"
|
||||
export BATCH_SIZE="200"
|
||||
export LAST_USERS="50"
|
||||
export PREFERRED_STRATEGY="MONTH"
|
||||
export PRESERVE_STATUS="true"
|
||||
|
||||
./migration-tool
|
||||
```
|
||||
|
||||
## Contribute
|
||||
|
||||
1. **Fork & Branch**: Fork this repository and create a branch for your work.
|
||||
2. **Create a new directory** for your panel tool (e.g., 3xui for 3X-UI migration)
|
||||
3. **Follow the existing code structure** and documentation patterns
|
||||
4. **Submit a pull request** with your changes
|
||||
2. **Implement Changes**: Work on your feature or fix, keeping code clean and well-documented.
|
||||
3. **Test**: Ensure your changes maintain or improve current functionality, adding tests for new features.
|
||||
4. **Commit & PR**: Commit your changes with clear messages, then open a pull request detailing your work.
|
||||
5. **Feedback**: Be prepared to engage with feedback and further refine your contribution.
|
||||
|
|
|
|||
|
|
@ -3,22 +3,23 @@ package config
|
|||
import "github.com/alecthomas/kong"
|
||||
|
||||
type Config struct {
|
||||
MarzbanURL string `name:"marzban-url" help:"Marzban panel URL" required:"true" env:"MARZBAN_URL"`
|
||||
MarzbanUsername string `name:"marzban-username" help:"Marzban admin username" required:"true" env:"MARZBAN_USERNAME"`
|
||||
MarzbanPassword string `name:"marzban-password" help:"Marzban admin password" required:"true" env:"MARZBAN_PASSWORD"`
|
||||
PanelType string `name:"panel-type" help:"Source panel type (e.g., marzban, marzneshin)" required:"true" default:"marzban" enum:"marzban,marzneshin" 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"`
|
||||
RemnawaveURL string `name:"remnawave-url" help:"Destination panel URL" env:"REMNAWAVE_URL"`
|
||||
RemnawaveToken string `name:"remnawave-token" help:"Destination panel API token" env:"REMNAWAVE_TOKEN"`
|
||||
BatchSize int `name:"batch-size" help:"Number of users to process in one batch" default:"100" env:"BATCH_SIZE"`
|
||||
LastUsers int `name:"last-users" help:"Only migrate last N users (0 means all users)" default:"0" env:"LAST_USERS"`
|
||||
PreferredStrategy string `name:"preferred-strategy" help:"Preferred traffic reset strategy for all users (NO_RESET, DAY, WEEK, MONTH). If set, overrides the user's original strategy" default:"" env:"PREFERRED_STRATEGY"`
|
||||
PreserveStatus bool `name:"preserve-status" help:"Preserve user status from Marzban (if false, sets all users to ACTIVE)" default:"false" env:"PRESERVE_STATUS"`
|
||||
PreserveStatus bool `name:"preserve-status" help:"Preserve user status from source panel (if false, sets all users to ACTIVE)" default:"false" env:"PRESERVE_STATUS"`
|
||||
}
|
||||
|
||||
func Parse(version string) *Config {
|
||||
var cfg Config
|
||||
kong.Parse(&cfg,
|
||||
kong.Name("marzban-migration-tool"),
|
||||
kong.Description("Migrate users from Marzban panel to Remnawave panel"),
|
||||
kong.Name("remnawave-migrate"),
|
||||
kong.Description("Migrate users from various panels to Remnawave panel"),
|
||||
kong.Vars{"version": version},
|
||||
)
|
||||
return &cfg
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
module marzban-migration-tool
|
||||
module remnawave-migrate
|
||||
|
||||
go 1.23.5
|
||||
|
||||
|
|
@ -4,10 +4,10 @@ import (
|
|||
"log"
|
||||
"strings"
|
||||
|
||||
"marzban-migration-tool/config"
|
||||
"marzban-migration-tool/marzban"
|
||||
"marzban-migration-tool/migrator"
|
||||
"marzban-migration-tool/remnawave"
|
||||
"remnawave-migrate/config"
|
||||
"remnawave-migrate/migrator"
|
||||
"remnawave-migrate/remnawave"
|
||||
"remnawave-migrate/source"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -17,8 +17,8 @@ var (
|
|||
func main() {
|
||||
cfg := config.Parse(version)
|
||||
|
||||
if cfg.MarzbanPassword == "" {
|
||||
log.Fatal("Marzban password is required")
|
||||
if cfg.PanelPassword == "" {
|
||||
log.Fatal("Panel password is required")
|
||||
}
|
||||
if cfg.RemnawaveToken == "" {
|
||||
log.Fatal("Remnawave token is required")
|
||||
|
|
@ -39,14 +39,20 @@ func main() {
|
|||
cfg.PreferredStrategy = strategy
|
||||
}
|
||||
|
||||
marzbanPanel := marzban.NewPanel(cfg.MarzbanURL)
|
||||
if err := marzbanPanel.Login(cfg.MarzbanUsername, cfg.MarzbanPassword); err != nil {
|
||||
log.Printf("Starting migration from %s panel...", cfg.PanelType)
|
||||
|
||||
sourcePanel, err := source.Factory(cfg.PanelType, cfg.PanelURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create source panel: %v", err)
|
||||
}
|
||||
|
||||
if err := sourcePanel.Login(cfg.PanelUsername, cfg.PanelPassword); err != nil {
|
||||
log.Fatalf("Login failed: %v", err)
|
||||
}
|
||||
|
||||
remnaPanel := remnawave.NewPanel(cfg.RemnawaveURL, cfg.RemnawaveToken)
|
||||
|
||||
m := migrator.New(marzbanPanel, remnaPanel, cfg.PreferredStrategy, cfg.PreserveStatus)
|
||||
m := migrator.New(sourcePanel, remnaPanel, cfg.PreferredStrategy, cfg.PreserveStatus)
|
||||
if err := m.MigrateUsers(cfg.BatchSize, cfg.LastUsers); err != nil {
|
||||
log.Fatalf("Migration failed: %v", err)
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
# Marzban Migration Tool
|
||||
|
||||
A command-line tool for migrating users from Marzban panel to Remnawave panel.
|
||||
|
||||
## Overview
|
||||
|
||||
This tool helps you migrate user accounts from a Marzban VPN panel to a Remnawave panel. It supports batch processing, selective migration of recent users, and customization of traffic reset strategies.
|
||||
|
||||
Key features:
|
||||
|
||||
- Batch processing with configurable batch size
|
||||
- Migration of selected number of most recent users
|
||||
- Automatic handling of existing users
|
||||
- Support for environment variables
|
||||
- Customizable traffic reset strategy
|
||||
- Flexible status handling
|
||||
|
||||
### Migrated User Fields
|
||||
|
||||
The following user fields are migrated from Marzban to Remnawave:
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | ------------------------------------------------- |
|
||||
| Username | User's unique identifier |
|
||||
| Status | User's status (can be preserved or set to ACTIVE) |
|
||||
| ShortUUID | Generated from subscription URL hash |
|
||||
| TrojanPassword | Password for Trojan protocol |
|
||||
| VlessUUID | UUID for VLESS protocol |
|
||||
| SsPassword | Password for Shadowsocks protocol |
|
||||
| TrafficLimitBytes | Traffic limit in bytes |
|
||||
| TrafficLimitStrategy | Traffic reset strategy (can be customized) |
|
||||
| ExpireAt | Account expiration date (UTC) |
|
||||
| Description | User notes/description |
|
||||
|
||||
## Configuration
|
||||
|
||||
The tool can be configured using command-line flags or environment variables:
|
||||
|
||||
| Flag | Environment Variable | Description | Default |
|
||||
| ---------------------- | -------------------- | --------------------------------------- | -------- |
|
||||
| `--marzban-url` | `MARZBAN_URL` | Source Marzban panel URL | Required |
|
||||
| `--marzban-username` | `MARZBAN_USERNAME` | Marzban admin username | Required |
|
||||
| `--marzban-password` | `MARZBAN_PASSWORD` | Marzban admin password | Required |
|
||||
| `--remnawave-url` | `REMNAWAVE_URL` | Destination Remnawave panel URL | Required |
|
||||
| `--remnawave-token` | `REMNAWAVE_TOKEN` | Remnawave API token | Required |
|
||||
| `--batch-size` | `BATCH_SIZE` | Number of users to process in one batch | 100 |
|
||||
| `--last-users` | `LAST_USERS` | Only migrate last N users | 0 (all) |
|
||||
| `--preferred-strategy` | `PREFERRED_STRATEGY` | Preferred traffic reset strategy | (empty) |
|
||||
| `--preserve-status` | `PRESERVE_STATUS` | Preserve user status from Marzban | false |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Migrate all users (sets all users to ACTIVE status)
|
||||
./marzban-migration-tool \
|
||||
--marzban-url="http://marzban.example.com" \
|
||||
--marzban-username="admin" \
|
||||
--marzban-password="password" \
|
||||
--remnawave-url="http://remnawave.example.com" \
|
||||
--remnawave-token="your-token"
|
||||
```
|
||||
|
||||
### Preserve User Status
|
||||
|
||||
```bash
|
||||
# Migrate users preserving their original status
|
||||
./marzban-migration-tool \
|
||||
[other flags...] \
|
||||
--preserve-status
|
||||
```
|
||||
|
||||
### Migrate Last N Users
|
||||
|
||||
```bash
|
||||
# Migrate only the last 50 users
|
||||
./marzban-migration-tool \
|
||||
[other flags...] \
|
||||
--last-users=50
|
||||
```
|
||||
|
||||
### Set Preferred Traffic Reset Strategy
|
||||
|
||||
```bash
|
||||
# Migrate users with a specific reset strategy
|
||||
./marzban-migration-tool \
|
||||
[other flags...] \
|
||||
--preferred-strategy=MONTH
|
||||
```
|
||||
|
||||
Available strategy values:
|
||||
|
||||
- `NO_RESET` - No traffic limit reset
|
||||
- `DAY` - Reset daily
|
||||
- `WEEK` - Reset weekly
|
||||
- `MONTH` - Reset monthly
|
||||
|
||||
**Note:** If not specified, the original strategy from Marzban will be used (with YEAR strategy converted to NO_RESET as Remnawave doesn't support yearly resets).
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
```bash
|
||||
export MARZBAN_URL="http://marzban.example.com"
|
||||
export MARZBAN_USERNAME="admin"
|
||||
export MARZBAN_PASSWORD="password"
|
||||
export REMNAWAVE_URL="http://remnawave.example.com"
|
||||
export REMNAWAVE_TOKEN="your-token"
|
||||
export BATCH_SIZE="200"
|
||||
export LAST_USERS="50"
|
||||
export PREFERRED_STRATEGY="MONTH"
|
||||
export PRESERVE_STATUS="true"
|
||||
|
||||
./marzban-migration-tool
|
||||
```
|
||||
|
||||
## Contribute
|
||||
|
||||
1. **Fork & Branch**: Fork this repository and create a branch for your work.
|
||||
2. **Implement Changes**: Work on your feature or fix, keeping code clean and well-documented.
|
||||
3. **Test**: Ensure your changes maintain or improve current functionality, adding tests for new features.
|
||||
4. **Commit & PR**: Commit your changes with clear messages, then open a pull request detailing your work.
|
||||
5. **Feedback**: Be prepared to engage with feedback and further refine your contribution.
|
||||
|
|
@ -4,18 +4,18 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
|
||||
"marzban-migration-tool/marzban"
|
||||
"marzban-migration-tool/remnawave"
|
||||
"remnawave-migrate/remnawave"
|
||||
"remnawave-migrate/source"
|
||||
)
|
||||
|
||||
type Migrator struct {
|
||||
source *marzban.Panel
|
||||
source source.SourcePanel
|
||||
destination *remnawave.Panel
|
||||
PreferredStrategy string
|
||||
PreserveStatus bool
|
||||
}
|
||||
|
||||
func New(source *marzban.Panel, destination *remnawave.Panel, preferredStrategy string, preserveStatus bool) *Migrator {
|
||||
func New(source source.SourcePanel, destination *remnawave.Panel, preferredStrategy string, preserveStatus bool) *Migrator {
|
||||
return &Migrator{
|
||||
source: source,
|
||||
destination: destination,
|
||||
|
|
@ -1,11 +1,29 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"marzban-migration-tool/util"
|
||||
"remnawave-migrate/util"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UsersResponse struct {
|
||||
Users []User `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
MarzbanUser MarzbanUser
|
||||
ProcessedUser ProcessedUser
|
||||
}
|
||||
|
||||
func (u *User) Process() ProcessedUser {
|
||||
if u.ProcessedUser.Username != "" {
|
||||
return u.ProcessedUser
|
||||
}
|
||||
|
||||
return u.MarzbanUser.Process()
|
||||
}
|
||||
|
||||
type MarzbanProxies struct {
|
||||
Vless struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -32,6 +50,11 @@ type MarzbanUser struct {
|
|||
SubscriptionURL string `json:"subscription_url"`
|
||||
}
|
||||
|
||||
type MarzbanUsersResponse struct {
|
||||
Users []MarzbanUser `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type ProcessedUser struct {
|
||||
Expire string `json:"expire"`
|
||||
DataLimit int64 `json:"data_limit"`
|
||||
|
|
@ -133,11 +156,6 @@ func (p *ProcessedUser) ToCreateUserRequest(preferredStrategy string, preserveSt
|
|||
return req
|
||||
}
|
||||
|
||||
type MarzbanUsersResponse struct {
|
||||
Users []MarzbanUser `json:"users"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"marzban-migration-tool/models"
|
||||
"remnawave-migrate/models"
|
||||
)
|
||||
|
||||
type Panel struct {
|
||||
|
|
@ -45,14 +45,14 @@ func (p *Panel) CreateUser(req models.CreateUserRequest) error {
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusInternalServerError {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var apiErr ApiError
|
||||
if err := json.Unmarshal(body, &apiErr); err != nil {
|
||||
return fmt.Errorf("failed to parse error response: %w", err)
|
||||
}
|
||||
|
||||
if apiErr.ErrorCode == "A019" {
|
||||
if apiErr.ErrorCode == "A019" || apiErr.ErrorCode == "A020" || apiErr.ErrorCode == "A021" || apiErr.ErrorCode == "A032" {
|
||||
return &UserExistsError{
|
||||
Username: req.Username,
|
||||
ApiError: apiErr,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package marzban
|
||||
package source
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -8,23 +8,23 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
"marzban-migration-tool/models"
|
||||
"remnawave-migrate/models"
|
||||
)
|
||||
|
||||
type Panel struct {
|
||||
type MarzbanPanel struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
authToken string
|
||||
}
|
||||
|
||||
func NewPanel(baseURL string) *Panel {
|
||||
return &Panel{
|
||||
func NewMarzbanPanel(baseURL string) *MarzbanPanel {
|
||||
return &MarzbanPanel{
|
||||
client: &http.Client{},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Panel) Login(username, password string) error {
|
||||
func (p *MarzbanPanel) Login(username, password string) error {
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
|
|
@ -61,7 +61,7 @@ func (p *Panel) Login(username, password string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Panel) GetUsers(offset, limit int) (*models.MarzbanUsersResponse, error) {
|
||||
func (p *MarzbanPanel) GetUsers(offset, limit int) (*models.UsersResponse, error) {
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("%s/api/users?offset=%d&limit=%d", p.baseURL, offset, limit),
|
||||
nil)
|
||||
|
|
@ -83,10 +83,21 @@ func (p *Panel) GetUsers(offset, limit int) (*models.MarzbanUsersResponse, error
|
|||
resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var users models.MarzbanUsersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
|
||||
var marzbanResp models.MarzbanUsersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&marzbanResp); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
return &users, nil
|
||||
users := &models.UsersResponse{
|
||||
Users: make([]models.User, len(marzbanResp.Users)),
|
||||
Total: marzbanResp.Total,
|
||||
}
|
||||
|
||||
for i, user := range marzbanResp.Users {
|
||||
users.Users[i] = models.User{
|
||||
MarzbanUser: user,
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
256
source/marzneshin.go
Normal file
256
source/marzneshin.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package source
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"remnawave-migrate/models"
|
||||
)
|
||||
|
||||
type MarzneshinPanel struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
authToken string
|
||||
}
|
||||
|
||||
type MarzneshinUser struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
ExpireStrategy string `json:"expire_strategy"`
|
||||
ExpireDate *string `json:"expire_date"`
|
||||
DataLimit *int64 `json:"data_limit"`
|
||||
DataLimitResetStrategy string `json:"data_limit_reset_strategy"`
|
||||
Note *string `json:"note"`
|
||||
Key string `json:"key"`
|
||||
Activated bool `json:"activated"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Expired bool `json:"expired"`
|
||||
DataLimitReached bool `json:"data_limit_reached"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SubscriptionURL string `json:"subscription_url"`
|
||||
}
|
||||
|
||||
type MarzneshinUsersResponse struct {
|
||||
Items []MarzneshinUser `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Pages int `json:"pages"`
|
||||
}
|
||||
|
||||
func NewMarzneshinPanel(baseURL string) *MarzneshinPanel {
|
||||
return &MarzneshinPanel{
|
||||
client: &http.Client{},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MarzneshinPanel) Login(username, password string) error {
|
||||
data := url.Values{}
|
||||
data.Set("username", username)
|
||||
data.Set("password", password)
|
||||
|
||||
req, err := http.NewRequest("POST", p.baseURL+"/api/admins/token",
|
||||
strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sending 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)
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
p.authToken = tokenResp.AccessToken
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MarzneshinPanel) GetUsers(offset, limit int) (*models.UsersResponse, error) {
|
||||
page := (offset / limit) + 1
|
||||
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("%s/api/users?page=%d&size=%d", p.baseURL, page, limit),
|
||||
nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+p.authToken)
|
||||
|
||||
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 users failed: status %d, body: %s",
|
||||
resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var marzneshinResp MarzneshinUsersResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&marzneshinResp); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
|
||||
users := &models.UsersResponse{
|
||||
Users: make([]models.User, len(marzneshinResp.Items)),
|
||||
Total: marzneshinResp.Total,
|
||||
}
|
||||
|
||||
for i, user := range marzneshinResp.Items {
|
||||
vlessID, trojanPassword, ssPassword, err := p.fetchUserProxies(user.Username, user.Key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching proxies for user %s: %w", user.Username, err)
|
||||
}
|
||||
|
||||
processedUser := models.ProcessedUser{
|
||||
Username: user.Username,
|
||||
VlessID: vlessID,
|
||||
TrojanPassword: trojanPassword,
|
||||
ShadowsocksPassword: ssPassword,
|
||||
SubscriptionHash: user.Key,
|
||||
DataLimitResetStrategy: strings.ToUpper(user.DataLimitResetStrategy),
|
||||
Note: getStringValue(user.Note),
|
||||
}
|
||||
|
||||
if user.ExpireDate != nil {
|
||||
processedUser.Expire = *user.ExpireDate
|
||||
} else {
|
||||
farFuture := time.Date(2099, 12, 31, 15, 13, 22, 214000000, time.UTC).Format("2006-01-02T15:04:05.000Z")
|
||||
processedUser.Expire = farFuture
|
||||
}
|
||||
|
||||
if user.DataLimit != nil {
|
||||
processedUser.DataLimit = *user.DataLimit
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
processedUser.Status = "DISABLED"
|
||||
} else if user.Expired || user.DataLimitReached {
|
||||
processedUser.Status = "EXPIRED"
|
||||
} else if user.Activated && user.IsActive {
|
||||
processedUser.Status = "ACTIVE"
|
||||
} else {
|
||||
processedUser.Status = "INACTIVE"
|
||||
}
|
||||
|
||||
users.Users[i] = models.User{
|
||||
ProcessedUser: processedUser,
|
||||
}
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (p *MarzneshinPanel) fetchUserProxies(username, key string) (string, string, string, error) {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/sub/%s/%s", p.baseURL, username, key), nil)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("creating subscription request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("fetching subscription: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", "", "", fmt.Errorf("subscription request failed: status %d, body: %s",
|
||||
resp.StatusCode, body)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("reading subscription body: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(body))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("decoding subscription content: %w", err)
|
||||
}
|
||||
|
||||
configs := strings.Split(string(decoded), "\n")
|
||||
|
||||
var vlessID, trojanPassword, ssPassword string
|
||||
|
||||
for _, config := range configs {
|
||||
if strings.HasPrefix(config, "vless://") {
|
||||
vlessID = extractVlessID(config)
|
||||
} else if strings.HasPrefix(config, "trojan://") {
|
||||
trojanPassword = extractTrojanPassword(config)
|
||||
} else if strings.HasPrefix(config, "ss://") {
|
||||
ssPassword = extractShadowsocksPassword(config)
|
||||
}
|
||||
}
|
||||
|
||||
return vlessID, trojanPassword, ssPassword, nil
|
||||
}
|
||||
|
||||
func extractVlessID(config string) string {
|
||||
re := regexp.MustCompile(`vless://([^@]+)@`)
|
||||
matches := re.FindStringSubmatch(config)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractTrojanPassword(config string) string {
|
||||
re := regexp.MustCompile(`trojan://([^@]+)@`)
|
||||
matches := re.FindStringSubmatch(config)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractShadowsocksPassword(config string) string {
|
||||
re := regexp.MustCompile(`ss://([^#@]+)`)
|
||||
matches := re.FindStringSubmatch(config)
|
||||
if len(matches) > 1 {
|
||||
decoded, err := base64.StdEncoding.DecodeString(matches[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(string(decoded), ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getStringValue(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
24
source/source.go
Normal file
24
source/source.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package source
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"remnawave-migrate/models"
|
||||
)
|
||||
|
||||
type SourcePanel interface {
|
||||
Login(username, password string) error
|
||||
|
||||
GetUsers(offset, limit int) (*models.UsersResponse, error)
|
||||
}
|
||||
|
||||
func Factory(panelType, baseURL string) (SourcePanel, error) {
|
||||
switch panelType {
|
||||
case "marzban":
|
||||
return NewMarzbanPanel(baseURL), nil
|
||||
case "marzneshin":
|
||||
return NewMarzneshinPanel(baseURL), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported panel type: %s", panelType)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,26 +6,20 @@ import (
|
|||
)
|
||||
|
||||
func SanitizeUsername(username string) string {
|
||||
// Define regex pattern for valid characters
|
||||
validPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// Create a new string builder
|
||||
var sanitized strings.Builder
|
||||
|
||||
// Keep only valid characters
|
||||
for _, char := range username {
|
||||
if validPattern.MatchString(string(char)) {
|
||||
sanitized.WriteRune(char)
|
||||
} else {
|
||||
// Replace invalid characters with underscore
|
||||
sanitized.WriteRune('_')
|
||||
}
|
||||
}
|
||||
|
||||
// Get the sanitized username
|
||||
result := sanitized.String()
|
||||
|
||||
// Ensure minimum length of 6 characters
|
||||
if len(result) < 6 {
|
||||
result = result + strings.Repeat("_", 6-len(result))
|
||||
}
|
||||
|
|
@ -74,7 +74,6 @@ func TestEnsureValidUsername(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestUsernameLength ensures the function handles various length scenarios correctly
|
||||
func TestUsernameLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -111,7 +110,6 @@ func TestUsernameLength(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestRegexCompliance ensures all characters in the output comply with the regex pattern
|
||||
func TestRegexCompliance(t *testing.T) {
|
||||
validPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
tests := []string{
|
||||
Loading…
Add table
Add a link
Reference in a new issue