mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-26 17:36:15 +00:00
Compare commits
19 commits
4a6ae0325e
...
ef43432d39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef43432d39 | ||
|
|
8bb707776b | ||
|
|
554981d9d3 | ||
|
|
a08f1c6c13 | ||
|
|
7f7ae0c547 | ||
|
|
60abeaad66 | ||
|
|
a6d0100381 | ||
|
|
6767f76ccf | ||
|
|
e4add73c9e | ||
|
|
ff72090e1a | ||
|
|
a3e1bd59df | ||
|
|
5bbb48a8fd | ||
|
|
ee84d585f9 | ||
|
|
7b03346cfc | ||
|
|
7d1f28a6c9 | ||
|
|
68e37604e2 | ||
|
|
c0821672c2 | ||
|
|
791ca3cf8d | ||
|
|
1ae1c16132 |
20 changed files with 679 additions and 156 deletions
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
58
.github/workflows/code-analyzer.yml
vendored
Normal file
58
.github/workflows/code-analyzer.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: Go Code Analyzer
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
- ".github/workflows/code-analyzer.yml"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "**.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
- ".github/workflows/code-analyzer.yml"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze Go code
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
unformatted=$(gofmt -l .)
|
||||||
|
if [ -n "$unformatted" ]; then
|
||||||
|
echo "These files are not gofmt-formatted:"
|
||||||
|
echo "$unformatted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Run staticcheck
|
||||||
|
uses: dominikh/staticcheck-action@v1
|
||||||
|
with:
|
||||||
|
version: "latest"
|
||||||
|
install-go: false
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: go test -race -shuffle=on ./...
|
||||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
|
|
@ -15,13 +15,13 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
hsanaeii/3x-ui
|
hsanaeii/3x-ui
|
||||||
|
|
@ -32,28 +32,28 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
with:
|
with:
|
||||||
install: true
|
install: true
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -133,7 +133,7 @@ jobs:
|
||||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-linux-${{ matrix.platform }}
|
name: x-ui-linux-${{ matrix.platform }}
|
||||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||||
|
|
@ -165,7 +165,7 @@ jobs:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
|
|
@ -230,7 +230,7 @@ jobs:
|
||||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||||
|
|
||||||
- name: Upload files to Artifacts
|
- name: Upload files to Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: x-ui-windows-amd64
|
name: x-ui-windows-amd64
|
||||||
path: ./x-ui-windows-amd64.zip
|
path: ./x-ui-windows-amd64.zip
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
|
|
@ -79,12 +79,12 @@ func (a *IndexController) login(c *gin.Context) {
|
||||||
|
|
||||||
if user == nil {
|
if user == nil {
|
||||||
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
|
||||||
|
|
||||||
notifyPass := safePass
|
notifyPass := safePass
|
||||||
|
|
||||||
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
|
if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
|
||||||
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
|
translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
|
||||||
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
|
notifyPass = fmt.Sprintf("*** (%s)", translatedError)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
|
a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type XraySettingController struct {
|
||||||
OutboundService service.OutboundService
|
OutboundService service.OutboundService
|
||||||
XrayService service.XrayService
|
XrayService service.XrayService
|
||||||
WarpService service.WarpService
|
WarpService service.WarpService
|
||||||
|
NordService service.NordService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
// NewXraySettingController creates a new XraySettingController and initializes its routes.
|
||||||
|
|
@ -35,6 +36,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.POST("/", a.getXraySetting)
|
g.POST("/", a.getXraySetting)
|
||||||
g.POST("/warp/:action", a.warp)
|
g.POST("/warp/:action", a.warp)
|
||||||
|
g.POST("/nord/:action", a.nord)
|
||||||
g.POST("/update", a.updateSetting)
|
g.POST("/update", a.updateSetting)
|
||||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||||
g.POST("/testOutbound", a.testOutbound)
|
g.POST("/testOutbound", a.testOutbound)
|
||||||
|
|
@ -123,6 +125,32 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
||||||
jsonObj(c, resp, err)
|
jsonObj(c, resp, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nord handles NordVPN-related operations based on the action parameter.
|
||||||
|
func (a *XraySettingController) nord(c *gin.Context) {
|
||||||
|
action := c.Param("action")
|
||||||
|
var resp string
|
||||||
|
var err error
|
||||||
|
switch action {
|
||||||
|
case "countries":
|
||||||
|
resp, err = a.NordService.GetCountries()
|
||||||
|
case "servers":
|
||||||
|
countryId := c.PostForm("countryId")
|
||||||
|
resp, err = a.NordService.GetServers(countryId)
|
||||||
|
case "reg":
|
||||||
|
token := c.PostForm("token")
|
||||||
|
resp, err = a.NordService.GetCredentials(token)
|
||||||
|
case "setKey":
|
||||||
|
key := c.PostForm("key")
|
||||||
|
resp, err = a.NordService.SetKey(key)
|
||||||
|
case "data":
|
||||||
|
resp, err = a.NordService.GetNordData()
|
||||||
|
case "del":
|
||||||
|
err = a.NordService.DelNordData()
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonObj(c, resp, err)
|
||||||
|
}
|
||||||
|
|
||||||
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
|
||||||
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
|
||||||
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
|
||||||
|
|
|
||||||
306
web/html/modals/nord_modal.html
Normal file
306
web/html/modals/nord_modal.html
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
{{define "modals/nordModal"}}
|
||||||
|
<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
|
||||||
|
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
|
||||||
|
:footer="null" :class="themeSwitcher.currentTheme">
|
||||||
|
<template v-if="nordModal.nordData == null">
|
||||||
|
<a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
|
||||||
|
<a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
|
||||||
|
<a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
|
||||||
|
<div :style="{ marginTop: '10px' }">
|
||||||
|
<a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-tab-pane>
|
||||||
|
<a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
|
||||||
|
<a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
|
||||||
|
<div :style="{ marginTop: '10px' }">
|
||||||
|
<a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<table :style="{ margin: '5px 0', width: '100%' }">
|
||||||
|
<tr class="client-table-odd-row" v-if="nordModal.nordData.token">
|
||||||
|
<td>{{ i18n "pages.xray.outbound.accessToken" }}</td>
|
||||||
|
<td>[[ nordModal.nordData.token ]]</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{{ i18n "pages.xray.outbound.privateKey" }}</td>
|
||||||
|
<td>[[ nordModal.nordData.private_key ]]</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
|
||||||
|
<a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
|
||||||
|
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
|
||||||
|
<a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
|
||||||
|
<a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
|
||||||
|
[[ c.name ]] ([[ c.code ]])
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.city" }}' v-if="nordModal.cities.length > 0">
|
||||||
|
<a-select v-model="nordModal.cityId" @change="onCityChange" show-search option-filter-prop="label">
|
||||||
|
<a-select-option :key="0" :value="null" label='{{ i18n "pages.xray.outbound.allCities" }}'>
|
||||||
|
{{ i18n "pages.xray.outbound.allCities" }}
|
||||||
|
</a-select-option>
|
||||||
|
<a-select-option v-for="c in nordModal.cities" :key="c.id" :value="c.id" :label="c.name">
|
||||||
|
[[ c.name ]]
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label='{{ i18n "pages.xray.outbound.server" }}' v-if="filteredServers.length > 0">
|
||||||
|
<a-select v-model="nordModal.serverId">
|
||||||
|
<a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
|
||||||
|
[[ s.cityName ]] - [[ s.name ]] ({{ i18n "pages.xray.outbound.load" }}: [[ s.load ]]%)
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
<a-divider :style="{ margin: '10px 0' }">{{ i18n "pages.xray.outbound.outboundStatus" }}</a-divider>
|
||||||
|
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||||
|
<template v-if="nordOutboundIndex>=0">
|
||||||
|
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
|
||||||
|
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
|
||||||
|
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
|
||||||
|
</template>
|
||||||
|
</a-form>
|
||||||
|
</template>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const nordModal = {
|
||||||
|
visible: false,
|
||||||
|
confirmLoading: false,
|
||||||
|
nordData: null,
|
||||||
|
token: '',
|
||||||
|
manualKey: '',
|
||||||
|
countries: [],
|
||||||
|
countryId: null,
|
||||||
|
cities: [],
|
||||||
|
cityId: null,
|
||||||
|
servers: [],
|
||||||
|
serverId: null,
|
||||||
|
show() {
|
||||||
|
this.visible = true;
|
||||||
|
this.getData();
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.visible = false;
|
||||||
|
},
|
||||||
|
loading(loading = true) {
|
||||||
|
this.confirmLoading = loading;
|
||||||
|
},
|
||||||
|
async getData() {
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/data');
|
||||||
|
if (msg.success) {
|
||||||
|
this.nordData = msg.obj ? JSON.parse(msg.obj) : null;
|
||||||
|
if (this.nordData) {
|
||||||
|
await this.fetchCountries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
async login() {
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
|
||||||
|
if (msg.success) {
|
||||||
|
this.nordData = JSON.parse(msg.obj);
|
||||||
|
await this.fetchCountries();
|
||||||
|
}
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
async saveKey() {
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
|
||||||
|
if (msg.success) {
|
||||||
|
this.nordData = JSON.parse(msg.obj);
|
||||||
|
await this.fetchCountries();
|
||||||
|
}
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
async logout(index) {
|
||||||
|
this.loading(true);
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
||||||
|
if (msg.success) {
|
||||||
|
this.delOutbound(index);
|
||||||
|
this.delRouting();
|
||||||
|
this.nordData = null;
|
||||||
|
this.token = '';
|
||||||
|
this.manualKey = '';
|
||||||
|
this.countries = [];
|
||||||
|
this.cities = [];
|
||||||
|
this.servers = [];
|
||||||
|
this.countryId = null;
|
||||||
|
this.cityId = null;
|
||||||
|
}
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
async fetchCountries() {
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/countries');
|
||||||
|
if (msg.success) {
|
||||||
|
this.countries = JSON.parse(msg.obj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async fetchServers() {
|
||||||
|
this.loading(true);
|
||||||
|
this.servers = [];
|
||||||
|
this.cities = [];
|
||||||
|
this.serverId = null;
|
||||||
|
this.cityId = null;
|
||||||
|
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
|
||||||
|
if (msg.success) {
|
||||||
|
const data = JSON.parse(msg.obj);
|
||||||
|
const locations = data.locations || [];
|
||||||
|
const locToCity = {};
|
||||||
|
const citiesMap = new Map();
|
||||||
|
locations.forEach(loc => {
|
||||||
|
if (loc.country && loc.country.city) {
|
||||||
|
citiesMap.set(loc.country.city.id, loc.country.city);
|
||||||
|
locToCity[loc.id] = loc.country.city;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
this.servers = (data.servers || []).map(s => {
|
||||||
|
const firstLocId = (s.location_ids || [])[0];
|
||||||
|
const city = locToCity[firstLocId];
|
||||||
|
s.cityId = city ? city.id : null;
|
||||||
|
s.cityName = city ? city.name : 'Unknown';
|
||||||
|
return s;
|
||||||
|
}).sort((a, b) => a.load - b.load);
|
||||||
|
|
||||||
|
if (this.servers.length > 0) {
|
||||||
|
this.serverId = this.servers[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.servers.length === 0) {
|
||||||
|
app.$message.warning('No servers found for the selected country');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.loading(false);
|
||||||
|
},
|
||||||
|
addOutbound() {
|
||||||
|
const server = this.servers.find(s => s.id === this.serverId);
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
const tech = server.technologies.find(t => t.id === 35);
|
||||||
|
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||||
|
|
||||||
|
const outbound = {
|
||||||
|
tag: `nord-${server.hostname}`,
|
||||||
|
protocol: 'wireguard',
|
||||||
|
settings: {
|
||||||
|
secretKey: this.nordData.private_key,
|
||||||
|
address: ['10.5.0.2/32'],
|
||||||
|
peers: [{
|
||||||
|
publicKey: publicKey,
|
||||||
|
endpoint: server.station + ':51820'
|
||||||
|
}],
|
||||||
|
noKernelTun: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.templateSettings.outbounds.push(outbound);
|
||||||
|
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||||
|
this.close();
|
||||||
|
app.$message.success('NordVPN outbound added');
|
||||||
|
},
|
||||||
|
resetOutbound(index) {
|
||||||
|
const server = this.servers.find(s => s.id === this.serverId);
|
||||||
|
if (!server || index === -1) return;
|
||||||
|
|
||||||
|
const tech = server.technologies.find(t => t.id === 35);
|
||||||
|
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
|
||||||
|
|
||||||
|
const oldTag = app.templateSettings.outbounds[index].tag;
|
||||||
|
const newTag = `nord-${server.hostname}`;
|
||||||
|
|
||||||
|
const outbound = {
|
||||||
|
tag: newTag,
|
||||||
|
protocol: 'wireguard',
|
||||||
|
settings: {
|
||||||
|
secretKey: this.nordData.private_key,
|
||||||
|
address: ['10.5.0.2/32'],
|
||||||
|
peers: [{
|
||||||
|
publicKey: publicKey,
|
||||||
|
endpoint: server.station + ':51820'
|
||||||
|
}],
|
||||||
|
noKernelTun: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
app.templateSettings.outbounds[index] = outbound;
|
||||||
|
|
||||||
|
// Sync routing rules
|
||||||
|
app.templateSettings.routing.rules.forEach(r => {
|
||||||
|
if (r.outboundTag === oldTag) {
|
||||||
|
r.outboundTag = newTag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||||
|
this.close();
|
||||||
|
app.$message.success('NordVPN outbound updated');
|
||||||
|
},
|
||||||
|
delOutbound(index) {
|
||||||
|
if (index !== -1) {
|
||||||
|
app.templateSettings.outbounds.splice(index, 1);
|
||||||
|
app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
delRouting() {
|
||||||
|
if (app.templateSettings && app.templateSettings.routing) {
|
||||||
|
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
el: '#nord-modal',
|
||||||
|
data: {
|
||||||
|
nordModal: nordModal,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
login: () => nordModal.login(),
|
||||||
|
saveKey: () => nordModal.saveKey(),
|
||||||
|
logout() { nordModal.logout(this.nordOutboundIndex) },
|
||||||
|
fetchServers: () => nordModal.fetchServers(),
|
||||||
|
addOutbound: () => nordModal.addOutbound(),
|
||||||
|
resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
|
||||||
|
onCityChange() {
|
||||||
|
if (this.filteredServers.length > 0) {
|
||||||
|
this.nordModal.serverId = this.filteredServers[0].id;
|
||||||
|
} else {
|
||||||
|
this.nordModal.serverId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
nordOutboundIndex: {
|
||||||
|
get: function () {
|
||||||
|
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filteredServers: function() {
|
||||||
|
if (!this.nordModal.cityId) {
|
||||||
|
return this.nordModal.servers;
|
||||||
|
}
|
||||||
|
return this.nordModal.servers.filter(s => s.cityId === this.nordModal.cityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
@ -313,6 +313,25 @@
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
|
<a-setting-list-item paddings="small">
|
||||||
|
<template #title>{{ i18n "pages.xray.nordRouting" }}</template>
|
||||||
|
<template #control>
|
||||||
|
<template v-if="NordExist">
|
||||||
|
<a-select mode="tags" :style="{ width: '100%' }"
|
||||||
|
v-model="nordDomains"
|
||||||
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option :value="p.value" :label="p.label"
|
||||||
|
v-for="p in settingsData.ServicesOptions">
|
||||||
|
<span>[[ p.label ]]</span>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-button type="primary" icon="api"
|
||||||
|
@click="showNord()">{{ i18n "pages.xray.outbound.nordvpn" }}</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</a-collapse-panel>
|
||||||
<a-collapse-panel key="6"
|
<a-collapse-panel key="6"
|
||||||
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" icon="cloud"
|
<a-button type="primary" icon="cloud"
|
||||||
@click="showWarp()">WARP</a-button>
|
@click="showWarp()">WARP</a-button>
|
||||||
|
<a-button type="primary" icon="api"
|
||||||
|
@click="showNord()">NordVPN</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@
|
||||||
{{template "modals/dnsPresetsModal"}}
|
{{template "modals/dnsPresetsModal"}}
|
||||||
{{template "modals/fakednsModal"}}
|
{{template "modals/fakednsModal"}}
|
||||||
{{template "modals/warpModal"}}
|
{{template "modals/warpModal"}}
|
||||||
|
{{template "modals/nordModal"}}
|
||||||
<script>
|
<script>
|
||||||
const rulesColumns = [
|
const rulesColumns = [
|
||||||
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
{ title: "#", align: 'center', width: 15, scopedSlots: { customRender: 'action' } },
|
||||||
|
|
@ -1057,6 +1058,9 @@
|
||||||
},
|
},
|
||||||
showWarp() {
|
showWarp() {
|
||||||
warpModal.show();
|
warpModal.show();
|
||||||
|
},
|
||||||
|
showNord() {
|
||||||
|
nordModal.show();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
@ -1397,6 +1401,19 @@
|
||||||
this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
|
this.templateRuleSetter({ outboundTag: "warp", property: "domain", data: newValue });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
nordTag: {
|
||||||
|
get: function () {
|
||||||
|
return this.templateSettings ? (this.templateSettings.outbounds.find((o) => o.tag.startsWith("nord-")) || { tag: "nord" }).tag : "nord";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nordDomains: {
|
||||||
|
get: function () {
|
||||||
|
return this.templateRuleGetter({ outboundTag: this.nordTag, property: "domain" });
|
||||||
|
},
|
||||||
|
set: function (newValue) {
|
||||||
|
this.templateRuleSetter({ outboundTag: this.nordTag, property: "domain", data: newValue });
|
||||||
|
}
|
||||||
|
},
|
||||||
torrentSettings: {
|
torrentSettings: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
|
return ArrayUtils.doAllItemsExist(this.settingsData.protocols.bittorrent, this.blockedProtocols);
|
||||||
|
|
@ -1414,6 +1431,11 @@
|
||||||
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
|
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag == "warp") >= 0 : false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
NordExist: {
|
||||||
|
get: function () {
|
||||||
|
return this.templateSettings ? this.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) >= 0 : false;
|
||||||
|
},
|
||||||
|
},
|
||||||
enableDNS: {
|
enableDNS: {
|
||||||
get: function () {
|
get: function () {
|
||||||
return this.templateSettings ? this.templateSettings.dns != null : false;
|
return this.templateSettings ? this.templateSettings.dns != null : false;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database"
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
|
@ -319,13 +318,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert back to slice and sort by timestamp (newest first)
|
// Convert back to slice and sort by timestamp (oldest first)
|
||||||
|
// This ensures we always protect the original/current connections and ban new excess ones.
|
||||||
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
allIps := make([]IPWithTimestamp, 0, len(ipMap))
|
||||||
for ip, timestamp := range ipMap {
|
for ip, timestamp := range ipMap {
|
||||||
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
||||||
}
|
}
|
||||||
sort.Slice(allIps, func(i, j int) bool {
|
sort.Slice(allIps, func(i, j int) bool {
|
||||||
return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
|
return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
|
||||||
})
|
})
|
||||||
|
|
||||||
shouldCleanLog := false
|
shouldCleanLog := false
|
||||||
|
|
@ -345,23 +345,17 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
if len(allIps) > limitIp {
|
if len(allIps) > limitIp {
|
||||||
shouldCleanLog = true
|
shouldCleanLog = true
|
||||||
|
|
||||||
// Keep only the newest IPs (up to limitIp)
|
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
|
||||||
keptIps := allIps[:limitIp]
|
keptIps := allIps[:limitIp]
|
||||||
disconnectedIps := allIps[limitIp:]
|
bannedIps := allIps[limitIp:]
|
||||||
|
|
||||||
// Log the disconnected IPs (old ones)
|
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
|
||||||
for _, ipTime := range disconnectedIps {
|
for _, ipTime := range bannedIps {
|
||||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actually disconnect old IPs by temporarily removing and re-adding user
|
// Update database with only the currently active (kept) IPs
|
||||||
// This forces Xray to drop existing connections from old IPs
|
|
||||||
if len(disconnectedIps) > 0 {
|
|
||||||
j.disconnectClientTemporarily(inbound, clientEmail, clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update database with only the newest IPs
|
|
||||||
jsonIps, _ := json.Marshal(keptIps)
|
jsonIps, _ := json.Marshal(keptIps)
|
||||||
inboundClientIps.Ips = string(jsonIps)
|
inboundClientIps.Ips = string(jsonIps)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -378,67 +372,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(j.disAllowedIps) > 0 {
|
if len(j.disAllowedIps) > 0 {
|
||||||
logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
|
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldCleanLog
|
return shouldCleanLog
|
||||||
}
|
}
|
||||||
|
|
||||||
// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
|
|
||||||
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
|
|
||||||
var xrayAPI xray.XrayAPI
|
|
||||||
|
|
||||||
// Get panel settings for API port
|
|
||||||
db := database.GetDB()
|
|
||||||
var apiPort int
|
|
||||||
var apiPortSetting model.Setting
|
|
||||||
if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
|
|
||||||
apiPort, _ = strconv.Atoi(apiPortSetting.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if apiPort == 0 {
|
|
||||||
apiPort = 10085 // Default API port
|
|
||||||
}
|
|
||||||
|
|
||||||
err := xrayAPI.Init(apiPort)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer xrayAPI.Close()
|
|
||||||
|
|
||||||
// Find the client config
|
|
||||||
var clientConfig map[string]any
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.Email == clientEmail {
|
|
||||||
// Convert client to map for API
|
|
||||||
clientBytes, _ := json.Marshal(client)
|
|
||||||
json.Unmarshal(clientBytes, &clientConfig)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientConfig == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove user to disconnect all connections
|
|
||||||
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a moment for disconnection to take effect
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
// Re-add user to allow new connections
|
|
||||||
err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
inbound := &model.Inbound{}
|
inbound := &model.Inbound{}
|
||||||
|
|
|
||||||
|
|
@ -2032,7 +2032,6 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if t != nil && client != nil {
|
if t != nil && client != nil {
|
||||||
t.Enable = client.Enable
|
|
||||||
t.UUID = client.ID
|
t.UUID = client.ID
|
||||||
t.SubId = client.SubID
|
t.SubId = client.SubID
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|
|
||||||
125
web/service/nord.go
Normal file
125
web/service/nord.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/util/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NordService struct {
|
||||||
|
SettingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) GetCountries() (string, error) {
|
||||||
|
resp, err := http.Get("https://api.nordvpn.com/v1/countries")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) GetServers(countryId string) (string, error) {
|
||||||
|
url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
servers, ok := data["servers"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []any
|
||||||
|
for _, s := range servers {
|
||||||
|
if server, ok := s.(map[string]any); ok {
|
||||||
|
if load, ok := server["load"].(float64); ok && load > 7 {
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["servers"] = filtered
|
||||||
|
|
||||||
|
result, _ := json.Marshal(data)
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) SetKey(privateKey string) (string, error) {
|
||||||
|
nordData := map[string]string{
|
||||||
|
"private_key": privateKey,
|
||||||
|
"token": "", // No token for manual key
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(nordData)
|
||||||
|
s.SettingService.SetNord(string(data))
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) GetCredentials(token string) (string, error) {
|
||||||
|
url := "https://api.nordvpn.com/v1/users/services/credentials"
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth("token", token)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds map[string]any
|
||||||
|
if err := json.Unmarshal(body, &creds); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, ok := creds["nordlynx_private_key"].(string)
|
||||||
|
if !ok || privateKey == "" {
|
||||||
|
return "", common.NewError("failed to retrieve NordLynx private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
nordData := map[string]string{
|
||||||
|
"private_key": privateKey,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(nordData)
|
||||||
|
s.SettingService.SetNord(string(data))
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) GetNordData() (string, error) {
|
||||||
|
return s.SettingService.GetNord()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NordService) DelNordData() error {
|
||||||
|
return s.SettingService.SetNord("")
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,7 @@ var defaultValueMap = map[string]string{
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
|
"nord": "",
|
||||||
"externalTrafficInformEnable": "false",
|
"externalTrafficInformEnable": "false",
|
||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||||
|
|
@ -583,6 +584,14 @@ func (s *SettingService) SetWarp(data string) error {
|
||||||
return s.setString("warp", data)
|
return s.setString("warp", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetNord() (string, error) {
|
||||||
|
return s.getString("nord")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetNord(data string) error {
|
||||||
|
return s.setString("nord", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
func (s *SettingService) GetExternalTrafficInformEnable() (bool, error) {
|
||||||
return s.getBool("externalTrafficInformEnable")
|
return s.getBool("externalTrafficInformEnable")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1926,6 +1926,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "add_client_submit_enable":
|
case "add_client_submit_enable":
|
||||||
client_Enable = true
|
client_Enable = true
|
||||||
|
|
@ -1936,6 +1938,8 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
|
||||||
} else {
|
} else {
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.successfulOperation"), tu.ReplyKeyboardRemove())
|
||||||
|
t.sendClientIndividualLinks(chatId, client_Email)
|
||||||
|
t.sendClientQRLinks(chatId, client_Email)
|
||||||
}
|
}
|
||||||
case "reset_all_traffics_cancel":
|
case "reset_all_traffics_cancel":
|
||||||
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
|
||||||
|
|
@ -3302,6 +3306,27 @@ func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getCommonClientButtons returns the shared inline keyboard rows for client configuration
|
||||||
|
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
||||||
|
return [][]telego.InlineKeyboardButton{
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
||||||
|
),
|
||||||
|
tu.InlineKeyboardRow(
|
||||||
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// addClient handles the process of adding a new client to an inbound.
|
// addClient handles the process of adding a new client to an inbound.
|
||||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||||
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
|
||||||
|
|
@ -3312,91 +3337,40 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||||
|
|
||||||
protocol := inbound.Protocol
|
protocol := inbound.Protocol
|
||||||
|
|
||||||
|
var protocolRows [][]telego.InlineKeyboardButton
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case model.VMESS, model.VLESS:
|
case model.VMESS, model.VLESS:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
case model.Trojan:
|
case model.Trojan:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_tr"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
case model.Shadowsocks:
|
case model.Shadowsocks:
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
protocolRows = [][]telego.InlineKeyboardButton{
|
||||||
tu.InlineKeyboardRow(
|
tu.InlineKeyboardRow(
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_password")).WithCallbackData("add_client_ch_default_pass_sh"),
|
||||||
),
|
),
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
tu.InlineKeyboardButton("ip limit").WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonRows := t.getCommonClientButtons()
|
||||||
|
inlineKeyboard := tu.InlineKeyboard(append(protocolRows, commonRows...)...)
|
||||||
|
|
||||||
|
if len(messageID) > 0 {
|
||||||
|
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||||
|
} else {
|
||||||
|
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// searchInbound searches for inbounds by remark and sends the results.
|
// searchInbound searches for inbounds by remark and sends the results.
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
||||||
return nil, errors.New("invalid 2fa code")
|
return nil, errors.New("invalid 2fa code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"confirm" = "Confirm"
|
"confirm" = "Confirm"
|
||||||
"cancel" = "Cancel"
|
"cancel" = "Cancel"
|
||||||
"close" = "Close"
|
"close" = "Close"
|
||||||
|
"save" = "Save"
|
||||||
|
"logout" = "Log Out"
|
||||||
"create" = "Create"
|
"create" = "Create"
|
||||||
"update" = "Update"
|
"update" = "Update"
|
||||||
"copy" = "Copy"
|
"copy" = "Copy"
|
||||||
|
|
@ -454,6 +456,8 @@
|
||||||
"ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
|
"ipv4RoutingDesc" = "These options will route traffic based on a specific destination via IPv4."
|
||||||
"warpRouting" = "WARP Routing"
|
"warpRouting" = "WARP Routing"
|
||||||
"warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
|
"warpRoutingDesc" = "These options will route traffic based on a specific destination via WARP."
|
||||||
|
"nordRouting" = "NordVPN Routing"
|
||||||
|
"nordRoutingDesc" = "These options will route traffic based on a specific destination via NordVPN."
|
||||||
"Template" = "Advanced Xray Configuration Template"
|
"Template" = "Advanced Xray Configuration Template"
|
||||||
"TemplateDesc" = "The final Xray config file will be generated based on this template."
|
"TemplateDesc" = "The final Xray config file will be generated based on this template."
|
||||||
"FreedomStrategy" = "Freedom Protocol Strategy"
|
"FreedomStrategy" = "Freedom Protocol Strategy"
|
||||||
|
|
@ -531,6 +535,14 @@
|
||||||
"testSuccess" = "Test successful"
|
"testSuccess" = "Test successful"
|
||||||
"testFailed" = "Test failed"
|
"testFailed" = "Test failed"
|
||||||
"testError" = "Failed to test outbound"
|
"testError" = "Failed to test outbound"
|
||||||
|
"nordvpn" = "NordVPN"
|
||||||
|
"accessToken" = "Access Token"
|
||||||
|
"country" = "Country"
|
||||||
|
"server" = "Server"
|
||||||
|
"city" = "City"
|
||||||
|
"allCities" = "All Cities"
|
||||||
|
"privateKey" = "Private Key"
|
||||||
|
"load" = "Load"
|
||||||
|
|
||||||
[pages.xray.balancer]
|
[pages.xray.balancer]
|
||||||
"addBalancer" = "Add Balancer"
|
"addBalancer" = "Add Balancer"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"confirm" = "تایید"
|
"confirm" = "تایید"
|
||||||
"cancel" = "انصراف"
|
"cancel" = "انصراف"
|
||||||
"close" = "بستن"
|
"close" = "بستن"
|
||||||
|
"save" = "ذخیره"
|
||||||
|
"logout" = "خروج"
|
||||||
"create" = "ایجاد"
|
"create" = "ایجاد"
|
||||||
"update" = "بهروزرسانی"
|
"update" = "بهروزرسانی"
|
||||||
"copy" = "کپی"
|
"copy" = "کپی"
|
||||||
|
|
@ -454,6 +456,8 @@
|
||||||
"ipv4RoutingDesc" = "این گزینهها ترافیک را از طریق آیپی نسخه4 سرور، به مقصد هدایت میکند"
|
"ipv4RoutingDesc" = "این گزینهها ترافیک را از طریق آیپی نسخه4 سرور، به مقصد هدایت میکند"
|
||||||
"warpRouting" = "WARP مسیریابی"
|
"warpRouting" = "WARP مسیریابی"
|
||||||
"warpRoutingDesc" = "این گزینهها ترافیک را از طریق وارپ کلادفلر به مقصد هدایت میکند"
|
"warpRoutingDesc" = "این گزینهها ترافیک را از طریق وارپ کلادفلر به مقصد هدایت میکند"
|
||||||
|
"nordRouting" = "مسیریابی NordVPN"
|
||||||
|
"nordRoutingDesc" = "این گزینهها ترافیک را بر اساس مقصد خاص از طریق NordVPN مسیریابی میکنند."
|
||||||
"Template" = "پیکربندی پیشرفته الگو ایکسری"
|
"Template" = "پیکربندی پیشرفته الگو ایکسری"
|
||||||
"TemplateDesc" = "فایل پیکربندی نهایی ایکسری بر اساس این الگو ایجاد میشود"
|
"TemplateDesc" = "فایل پیکربندی نهایی ایکسری بر اساس این الگو ایجاد میشود"
|
||||||
"FreedomStrategy" = "Freedom استراتژی پروتکل"
|
"FreedomStrategy" = "Freedom استراتژی پروتکل"
|
||||||
|
|
@ -531,6 +535,12 @@
|
||||||
"testSuccess" = "تست موفقیتآمیز"
|
"testSuccess" = "تست موفقیتآمیز"
|
||||||
"testFailed" = "تست ناموفق"
|
"testFailed" = "تست ناموفق"
|
||||||
"testError" = "خطا در تست خروجی"
|
"testError" = "خطا در تست خروجی"
|
||||||
|
"nordvpn" = "NordVPN"
|
||||||
|
"accessToken" = "توکن دسترسی"
|
||||||
|
"country" = "کشور"
|
||||||
|
"server" = "سرور"
|
||||||
|
"privateKey" = "کلید خصوصی"
|
||||||
|
"load" = "فشار سرور"
|
||||||
|
|
||||||
[pages.xray.balancer]
|
[pages.xray.balancer]
|
||||||
"addBalancer" = "افزودن بالانسر"
|
"addBalancer" = "افزودن بالانسر"
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
|
"geofileUpdateDialogDesc" = "Это обновит файл #filename#."
|
||||||
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
"geofilesUpdateDialogDesc" = "Это обновит все геофайлы."
|
||||||
"geofilesUpdateAll" = "Обновить все"
|
"geofilesUpdateAll" = "Обновить все"
|
||||||
"geofileUpdatePopover" = "Геофайл успешно обновлён"
|
"geofileUpdatePopover" = "Геофайлы успешно обновлены"
|
||||||
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
|
"dontRefresh" = "Установка в процессе. Не обновляйте страницу"
|
||||||
"logs" = "Журнал"
|
"logs" = "Журнал"
|
||||||
"config" = "Конфигурация"
|
"config" = "Конфигурация"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"confirm" = "确定"
|
"confirm" = "确定"
|
||||||
"cancel" = "取消"
|
"cancel" = "取消"
|
||||||
"close" = "关闭"
|
"close" = "关闭"
|
||||||
|
"save" = "保存"
|
||||||
|
"logout" = "登出"
|
||||||
"create" = "创建"
|
"create" = "创建"
|
||||||
"update" = "更新"
|
"update" = "更新"
|
||||||
"copy" = "复制"
|
"copy" = "复制"
|
||||||
|
|
@ -454,6 +456,8 @@
|
||||||
"ipv4RoutingDesc" = "此选项将仅通过 IPv4 路由到目标域"
|
"ipv4RoutingDesc" = "此选项将仅通过 IPv4 路由到目标域"
|
||||||
"warpRouting" = "WARP 路由"
|
"warpRouting" = "WARP 路由"
|
||||||
"warpRoutingDesc" = "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。"
|
"warpRoutingDesc" = "注意:在使用这些选项之前,请按照面板 GitHub 上的步骤在你的服务器上以 socks5 代理模式安装 WARP。WARP 将通过 Cloudflare 服务器将流量路由到网站。"
|
||||||
|
"nordRouting" = "NordVPN 路由"
|
||||||
|
"nordRoutingDesc" = "这些选项将根据特定目的地通过 NordVPN 路由流量。"
|
||||||
"Template" = "高级 Xray 配置模板"
|
"Template" = "高级 Xray 配置模板"
|
||||||
"TemplateDesc" = "最终的 Xray 配置文件将基于此模板生成"
|
"TemplateDesc" = "最终的 Xray 配置文件将基于此模板生成"
|
||||||
"FreedomStrategy" = "Freedom 协议策略"
|
"FreedomStrategy" = "Freedom 协议策略"
|
||||||
|
|
@ -528,9 +532,14 @@
|
||||||
"test" = "测试"
|
"test" = "测试"
|
||||||
"testResult" = "测试结果"
|
"testResult" = "测试结果"
|
||||||
"testing" = "正在测试连接..."
|
"testing" = "正在测试连接..."
|
||||||
"testSuccess" = "测试成功"
|
"nordvpn" = "NordVPN"
|
||||||
"testFailed" = "测试失败"
|
"accessToken" = "访问令牌"
|
||||||
"testError" = "测试出站失败"
|
"country" = "国家"
|
||||||
|
"server" = "服务器"
|
||||||
|
"city" = "城市"
|
||||||
|
"allCities" = "所有城市"
|
||||||
|
"privateKey" = "私钥"
|
||||||
|
"load" = "负载"
|
||||||
|
|
||||||
[pages.xray.balancer]
|
[pages.xray.balancer]
|
||||||
"addBalancer" = "添加负载均衡"
|
"addBalancer" = "添加负载均衡"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue