Merge branch 'main' into opencode/lucky-otter

This commit is contained in:
sagit
2026-02-11 15:07:07 +08:00
committed by GitHub
9 changed files with 419 additions and 30 deletions
+35 -28
View File
@@ -2,35 +2,7 @@
> **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## Original Project
- **Name**: flux-panel
- **Source**: https://github.com/bqlpfy/flux-panel
- **License**: Apache License 2.0
## Modifications
The following major changes and additions have been made in this fork (FLVX):
### 1. Backend Architecture (Replaced)
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
### 2. Forwarding Agent (Modified)
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
### 3. Frontend (Modified)
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
### 4. Mobile Applications (Removed)
- **Removed**: `android-app/` - Source code for the Android client.
- **Removed**: `ios-app/` - Source code for the iOS client.
### 5. Infrastructure & Scripts
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
- **Added**: `AGENTS.md` (Project documentation).
---
## 特性
- 支持按 **隧道账号级别** 管理流量转发数量,可用于用户/隧道配额控制
@@ -39,6 +11,10 @@ The following major changes and additions have been made in this fork (FLVX):
- 可针对 **指定用户的指定隧道进行限速** 设置
- 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型
- 提供灵活的转发策略配置,适用于多种网络场景
- 面板分享,支持将节点分享给其他人,面板对接面板
- 支持分组权限管理,隧道分组、用户分组
- 支持批量功能,可以批量下发配置,启停等
- 支持隧道修改配置、转发修改隧道
## 部署流程
@@ -73,6 +49,37 @@ curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.
> ⚠️ 首次登录后请立即修改默认密码!
---
## Original Project
- **Name**: flux-panel
- **Source**: https://github.com/bqlpfy/flux-panel
- **License**: Apache License 2.0
## Modifications
The following major changes and additions have been made in this fork (FLVX):
### 1. Backend Architecture (Replaced)
- **Removed**: The original `springboot-backend/` (Java/Spring Boot) has been entirely removed.
- **Added**: A new `go-backend/` (Go/SQLite) implementation replaces the original backend.
### 2. Forwarding Agent (Modified)
- **Modified**: `go-gost/` - Modified forwarding agent wrapper.
- **Modified**: `go-gost/x/` - Modified local fork of the `gost` extensions library.
### 3. Frontend (Modified)
- **Modified**: `vite-frontend/` - Significant updates to the React/Vite dashboard to compatible with the new Go backend, including UI/UX improvements (HeroUI + Tailwind).
### 4. Mobile Applications (Removed)
- **Removed**: `android-app/` - Source code for the Android client.
- **Removed**: `ios-app/` - Source code for the iOS client.
### 5. Infrastructure & Scripts
- **Modified**: `docker-compose-v4.yml`, `docker-compose-v6.yml` (Updated for Go backend).
- **Modified**: `install.sh`, `panel_install.sh` (Updated installation logic).
- **Added**: `AGENTS.md` (Project documentation).
---
## 免责声明
+54
View File
@@ -77,3 +77,57 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
---
## 三、Caddy 反向代理(可选)
如果需要通过域名访问面板并自动获取 HTTPS 证书,可以使用 Caddy 作为反向代理。
### 1. 安装 Caddy
```bash
# Debian / Ubuntu
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudflare.com/content/v1/e2qwFJ2fRP2b2q/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
```
其他系统请参考 [Caddy 官方安装文档](https://caddyserver.com/docs/install)。
### 2. 配置 Caddyfile
编辑 Caddy 配置文件:
```bash
sudo nano /etc/caddy/Caddyfile
```
#### 面板域名配置
将 `panel.example.com` 替换为你自己的域名:
```caddyfile
panel.example.com {
reverse_proxy localhost:6366
}
```
Caddy 会自动为域名申请和续期 HTTPS 证书,无需额外配置。
### 3. 重启 Caddy
```bash
sudo systemctl restart caddy
```
### 4. 注意事项
- 确保域名已正确解析到服务器 IP。
- 确保服务器防火墙放行了 **80** 和 **443** 端口(Caddy 自动申请证书需要)。
- 使用 Caddy 反向代理后,可以在 `.env` 中将前端端口改为仅监听本地,避免直接暴露:
```
FRONTEND_PORT=127.0.0.1:6366
```
@@ -85,6 +85,14 @@ func NewFederationClient() *FederationClient {
}
}
func NewFederationClientWithTimeout(timeout time.Duration) *FederationClient {
return &FederationClient{
client: &http.Client{
Timeout: timeout,
},
}
}
func (c *FederationClient) Connect(url, token, localDomain string) (*RemoteNodeInfo, error) {
url = strings.TrimSuffix(url, "/")
req, err := http.NewRequest("POST", url+"/api/v1/federation/connect", nil)
@@ -8,6 +8,7 @@ import (
"net/http"
"sort"
"strings"
"sync"
"time"
"go-backend/internal/http/client"
@@ -40,6 +41,17 @@ type resetPeerShareFlowRequest struct {
ID int64 `json:"id"`
}
type updatePeerShareRequest struct {
ID int64 `json:"id"`
Name string `json:"name"`
MaxBandwidth int64 `json:"maxBandwidth"`
ExpiryTime int64 `json:"expiryTime"`
PortRangeStart int `json:"portRangeStart"`
PortRangeEnd int `json:"portRangeEnd"`
AllowedDomains string `json:"allowedDomains"`
AllowedIPs string `json:"allowedIps"`
}
type nodeImportRequest struct {
RemoteURL string `json:"remoteUrl"`
Token string `json:"token"`
@@ -325,6 +337,80 @@ func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Reques
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationShareUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
return
}
var req updatePeerShareRequest
if err := decodeJSON(r.Body, &req); err != nil {
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
return
}
if req.ID <= 0 {
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
return
}
share, err := h.repo.GetPeerShare(req.ID)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if share == nil {
response.WriteJSON(w, response.ErrDefault("Share not found"))
return
}
if req.Name == "" {
response.WriteJSON(w, response.ErrDefault("Name is required"))
return
}
if req.MaxBandwidth < 0 {
response.WriteJSON(w, response.ErrDefault("Max bandwidth cannot be negative"))
return
}
if req.ExpiryTime < 0 {
response.WriteJSON(w, response.ErrDefault("Expiry time cannot be negative"))
return
}
if req.PortRangeStart < 0 || req.PortRangeStart > 65535 || req.PortRangeEnd < 0 || req.PortRangeEnd > 65535 {
response.WriteJSON(w, response.ErrDefault("Invalid port range"))
return
}
if req.PortRangeStart > req.PortRangeEnd {
response.WriteJSON(w, response.ErrDefault("Port range start cannot be greater than end"))
return
}
allowedIPs, err := normalizePeerShareAllowedIPs(req.AllowedIPs)
if err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
share.Name = req.Name
share.MaxBandwidth = req.MaxBandwidth
share.ExpiryTime = req.ExpiryTime
share.PortRangeStart = req.PortRangeStart
share.PortRangeEnd = req.PortRangeEnd
share.AllowedDomains = req.AllowedDomains
share.AllowedIPs = allowedIPs
share.UpdatedTime = time.Now().UnixMilli()
if err := h.repo.UpdatePeerShare(share); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OKEmpty())
}
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("Invalid method"))
@@ -1317,6 +1403,74 @@ func isPeerIPAllowed(clientIP net.IP, whitelist string) bool {
return false
}
func (h *Handler) syncRemoteNodeStatuses(items []map[string]interface{}) {
type remoteEntry struct {
index int
remoteURL string
remoteToken string
}
var remotes []remoteEntry
for i, item := range items {
isRemote, _ := item["isRemote"].(int)
if isRemote != 1 {
continue
}
url, _ := item["remoteUrl"].(string)
token, _ := item["remoteToken"].(string)
url = strings.TrimSpace(url)
token = strings.TrimSpace(token)
if url == "" || token == "" {
continue
}
remotes = append(remotes, remoteEntry{index: i, remoteURL: url, remoteToken: token})
}
if len(remotes) == 0 {
return
}
localDomain := h.federationLocalDomain()
fc := client.NewFederationClientWithTimeout(5 * time.Second)
type syncResult struct {
index int
status int
syncError string
}
results := make([]syncResult, len(remotes))
var wg sync.WaitGroup
for i, entry := range remotes {
wg.Add(1)
go func(idx int, e remoteEntry) {
defer wg.Done()
info, err := fc.Connect(e.remoteURL, e.remoteToken, localDomain)
if err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Invalid token") || strings.Contains(errMsg, "Unauthorized") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_deleted"}
} else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Share is disabled") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_disabled"}
} else if strings.Contains(errMsg, "Share expired") {
results[idx] = syncResult{index: e.index, status: 0, syncError: "provider_share_expired"}
} else {
results[idx] = syncResult{index: e.index, status: 0, syncError: errMsg}
}
} else {
results[idx] = syncResult{index: e.index, status: info.Status, syncError: ""}
}
}(i, entry)
}
wg.Wait()
for _, r := range results {
items[r.index]["status"] = r.status
if r.syncError != "" {
items[r.index]["syncError"] = r.syncError
}
}
}
func (h *Handler) cleanupPeerShareRuntimes(shareID int64) {
if h == nil || h.repo == nil || shareID <= 0 {
return
@@ -159,6 +159,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/open_api/sub_store", h.openAPISubStore)
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
mux.HandleFunc("/api/v1/federation/share/update", h.federationShareUpdate)
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
@@ -324,6 +325,9 @@ func (h *Handler) nodeList(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
h.syncRemoteNodeStatuses(items)
response.WriteJSON(w, response.OK(items))
}
@@ -413,7 +413,7 @@ func (h *Handler) nodeInstall(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
cmd := fmt.Sprintf("curl -L https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
cmd := fmt.Sprintf("curl -L https://gcode.hostcentral.cc/https://github.com/Sagit-chu/flux-panel/releases/latest/download/install.sh -o ./install.sh && chmod +x ./install.sh && ./install.sh -a %s -s %s", processServerAddress(panelAddr), secret)
response.WriteJSON(w, response.OK(cmd))
}
+10
View File
@@ -208,6 +208,16 @@ export const createPeerShare = (data: {
allowedDomains?: string;
allowedIps?: string;
}) => Network.post("/federation/share/create", data);
export const updatePeerShare = (data: {
id: number;
name: string;
maxBandwidth: number;
expiryTime: number;
portRangeStart: number;
portRangeEnd: number;
allowedDomains: string;
allowedIps: string;
}) => Network.post("/federation/share/update", data);
export const deletePeerShare = (id: number) =>
Network.post("/federation/share/delete", { id });
export const resetPeerShareFlow = (id: number) =>
+14 -1
View File
@@ -70,6 +70,7 @@ interface Node {
status: number;
isRemote?: number;
remoteUrl?: string;
syncError?: string;
connectionStatus: "online" | "offline";
systemInfo?: {
cpuUsage: number;
@@ -236,7 +237,8 @@ export default function NodePage() {
const nodesData: Node[] = (res.data || []).map((node: any) => ({
...node,
inx: node.inx ?? 0,
connectionStatus: node.status === 1 ? "online" : "offline",
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
syncError: node.syncError || undefined,
systemInfo: null,
copyLoading: false,
}));
@@ -1310,6 +1312,17 @@ export default function NodePage() {
</CardHeader>
<CardBody className="pt-0 pb-3">
{isRemoteNode && node.syncError && (
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{/* 基础信息 */}
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center text-sm min-w-0">
+139
View File
@@ -20,6 +20,7 @@ import {
resetPeerShareFlow,
getPeerRemoteUsageList,
importRemoteNode,
updatePeerShare,
} from "@/api";
interface Node {
@@ -77,6 +78,7 @@ interface RemoteUsageNode {
usedPorts: number[];
bindings: RemoteUsageBinding[];
activeBindingNum: number;
syncError?: string;
}
export default function PanelSharingPage() {
@@ -91,6 +93,7 @@ export default function PanelSharingPage() {
// Modals
const [createShareOpen, setCreateShareOpen] = useState(false);
const [editShareOpen, setEditShareOpen] = useState(false);
const [importNodeOpen, setImportNodeOpen] = useState(false);
// Forms
@@ -110,6 +113,17 @@ export default function PanelSharingPage() {
token: "",
});
const [editForm, setEditForm] = useState({
id: 0,
name: "",
maxBandwidth: 0,
expiryTime: 0,
portRangeStart: 10000,
portRangeEnd: 20000,
allowedDomains: "",
allowedIps: "",
});
const loadShares = useCallback(async () => {
setLoading(true);
try {
@@ -239,6 +253,52 @@ export default function PanelSharingPage() {
}
};
const openEditShare = (share: PeerShare) => {
setEditForm({
id: share.id,
name: share.name,
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
expiryTime: share.expiryTime,
portRangeStart: share.portRangeStart,
portRangeEnd: share.portRangeEnd,
allowedDomains: share.allowedDomains || "",
allowedIps: share.allowedIps || "",
});
setEditShareOpen(true);
};
const handleEditShare = async () => {
if (!editForm.name) {
toast.error("名称不能为空");
return;
}
if (editForm.maxBandwidth < 0) {
toast.error("流量上限不能为负数");
return;
}
try {
const res = await updatePeerShare({
id: editForm.id,
name: editForm.name,
maxBandwidth: Math.max(0, editForm.maxBandwidth) * 1024 * 1024 * 1024,
expiryTime: editForm.expiryTime,
portRangeStart: editForm.portRangeStart,
portRangeEnd: editForm.portRangeEnd,
allowedDomains: editForm.allowedDomains,
allowedIps: editForm.allowedIps,
});
if (res.code === 0) {
toast.success("编辑成功");
setEditShareOpen(false);
loadShares();
} else {
toast.error(res.msg || "编辑失败");
}
} catch {
toast.error("网络错误");
}
};
const handleImportNode = async () => {
if (!importForm.remoteUrl || !importForm.token) {
toast.error("请填写完整信息");
@@ -326,6 +386,13 @@ export default function PanelSharingPage() {
<CardHeader className="flex justify-between">
<h3 className="font-bold">{share.name}</h3>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openEditShare(share)}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
@@ -390,6 +457,17 @@ export default function PanelSharingPage() {
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
</CardHeader>
<CardBody className="text-sm space-y-2">
{node.syncError && (
<div className="px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
{node.syncError === "provider_share_deleted"
? "提供方已删除该分享"
: node.syncError === "provider_share_disabled"
? "提供方已禁用该分享"
: node.syncError === "provider_share_expired"
? "提供方分享已过期"
: `远程同步失败: ${node.syncError}`}
</div>
)}
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
<p>共享ID: {node.shareId || "-"}</p>
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
@@ -491,6 +569,67 @@ export default function PanelSharingPage() {
</ModalContent>
</Modal>
{/* Edit Share Modal */}
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
<ModalContent>
<ModalHeader>编辑分享</ModalHeader>
<ModalBody>
<Input
label="名称"
placeholder="备注名称"
value={editForm.name}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
<div className="flex gap-4">
<Input
label="起始端口"
type="number"
value={editForm.portRangeStart.toString()}
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
/>
<Input
label="结束端口"
type="number"
value={editForm.portRangeEnd.toString()}
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
/>
</div>
<Input
label="流量上限 (GB)"
type="number"
description="0 表示不限流量"
value={editForm.maxBandwidth.toString()}
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
/>
<Input
label="过期时间"
type="datetime-local"
description="留空或清除表示永久有效"
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
/>
<Input
label="允许的域名 (可选)"
placeholder="example.com, panel.test.com"
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
value={editForm.allowedDomains}
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
/>
<Input
label="允许的API IP (可选)"
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
value={editForm.allowedIps}
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
/>
</ModalBody>
<ModalFooter>
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
<Button color="primary" onPress={handleEditShare}>保存</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Import Node Modal */}
<Modal isOpen={importNodeOpen} onClose={() => setImportNodeOpen(false)}>
<ModalContent>