Compare commits

...

35 Commits

Author SHA1 Message Date
sagit abb8591b11 fix(backend): backfill legacy node dual-stack columns in sqlite migration 2026-02-10 04:06:00 +00:00
sagit 2acee481f0 fix(backend): backfill legacy inx columns during sqlite migration 2026-02-10 03:14:08 +00:00
sagit 76f443f900 Merge pull request #62 from Sagit-chu/update-tz-mirror
feat: add Shanghai timezone to docker-compose and update github mirror
2026-02-09 18:52:59 +08:00
sagit 47b1663938 feat: add Shanghai timezone to docker-compose and update github mirror 2026-02-09 10:47:13 +00:00
sagit 406f5bb380 Merge pull request #61 from Sagit-chu/opencode/calm-orchid
fix: limit speed
2026-02-09 17:13:40 +08:00
sagit 85ea6c17a4 Merge branch 'main' into opencode/calm-orchid 2026-02-09 17:12:38 +08:00
sagit 3420dc5460 fix(backend): sync limiter on association instead of connection
Reverted the full sync on connection hook. Instead, ensureLimiterOnNode is called within syncForwardServices to push limiter configuration immediately before pushing the service configuration that references it.
2026-02-09 09:06:59 +00:00
sagit 3d7a0b697d feat(backend): sync limiters on agent connect
Implemented full sync of speed limit configurations when an Agent connects via WebSocket. This ensures that even fresh or restarted agents receive the necessary limiter configurations.
2026-02-09 08:49:47 +00:00
sagit 065b23d9c3 Merge pull request #59 from Sagit-chu/opencode/calm-orchid
refactor(backend): reimplement speed limit logic
2026-02-09 16:15:59 +08:00
sagit 7919dfde59 Merge branch 'main' into opencode/calm-orchid 2026-02-09 16:13:40 +08:00
sagit 565d732967 refactor(backend): reimplement speed limit logic
1. Refactor speed limit CRUD to sync with agents immediately via WebSocket (AddLimiters/DeleteLimiters).
2. Update unit conversion to match GOST v3 requirements (Mbps -> MB/s).
3. Update service config generation to reference Limiter IDs instead of hardcoded values.
2026-02-09 08:12:10 +00:00
sagit 20dc151aec Merge pull request #58 from Sagit-chu/opencode/calm-orchid
fix(backend): fix tunnel batch redeploy logic for type 2 tunnels and speed limit
2026-02-09 14:28:01 +08:00
sagit 630ed969d3 Merge branch 'main' into opencode/calm-orchid 2026-02-09 14:23:20 +08:00
sagit e94aa01213 fix(gost): append 'B' suffix to speed limit values for correct unit parsing 2026-02-09 06:22:47 +00:00
sagit 67d8f7a381 fix(backend): correct speed limit unit conversion from Mbps to Bytes/s 2026-02-09 06:13:56 +00:00
sagit 0c7b7deaf5 fix(backend): fix tunnel batch redeploy logic for type 2 tunnels 2026-02-09 05:17:58 +00:00
sagit a4def9c5f3 Merge pull request #57 from Sagit-chu/opencode/calm-orchid
fix: prevent nil pointer dereference in listener config parsing
2026-02-09 12:42:16 +08:00
sagit 6582348da2 Merge branch 'main' into opencode/calm-orchid 2026-02-09 12:40:57 +08:00
sagit 3a14b22ebc fix: prevent nil pointer dereference in listener config parsing 2026-02-09 04:39:27 +00:00
sagit d7b44916bf Merge pull request #56 from Sagit-chu/opencode/calm-orchid
fix(limiter): fix traffic limiter ScopeClient behavior to allow per-u…
2026-02-09 11:39:23 +08:00
sagit f8a0bda3fd Merge branch 'main' into opencode/calm-orchid 2026-02-09 11:37:49 +08:00
sagit 634562e56d fix(config): support raw number string for limiter configuration 2026-02-09 03:17:02 +00:00
sagit d06e02998b fix(limiter): fix traffic limiter ScopeClient behavior to allow per-user limits 2026-02-09 03:12:33 +00:00
sagit bbffe5872c Merge pull request #54 from Sagit-chu/opencode/clever-eagle
feat(backend): ensure all tables are created at startup
2026-02-09 09:42:48 +08:00
sagit 98db0e6a5e Merge branch 'main' into opencode/clever-eagle 2026-02-09 09:41:29 +08:00
sagit 30591a008a feat(backend): ensure all tables are created at startup 2026-02-09 01:29:42 +00:00
sagit 36ba2f95ef Merge pull request #53 from Sagit-chu/opencode/proud-knight
fix(docker): fix go-backend docker build syntax error
2026-02-08 20:12:44 +08:00
sagit 858f9ef1d5 fix(docker): fix go-backend docker build syntax error 2026-02-08 12:11:02 +00:00
sagit 218084b542 Merge pull request #51 from Sagit-chu/opencode/brave-meadow
feat: update doc
2026-02-08 19:57:42 +08:00
sagit a9c304546e Merge branch 'main' into opencode/brave-meadow 2026-02-08 19:55:57 +08:00
sagit 85250aa2d3 chore: fix remaining rebranding and frontend updates 2026-02-08 11:50:06 +00:00
sagit aeeb57b89d chore: rebrand to FLVX and fix docker multi-arch build 2026-02-08 11:50:06 +00:00
sagit 4e163163cb Merge pull request #50 from Sagit-chu/opencode/brave-meadow
docs: add usage documentation and github pages workflow
2026-02-08 18:54:50 +08:00
sagit eb5464511a Merge branch 'main' into opencode/brave-meadow 2026-02-08 18:53:05 +08:00
sagit efa6c0e322 docs: add usage documentation and github pages workflow 2026-02-08 10:51:18 +00:00
29 changed files with 734 additions and 65 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Deploy Docs
on:
push:
branches:
- main
- master
- beta
paths:
- 'doc/**'
- 'mkdocs.yml'
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure Git Credentials
run: |
git config user.name github-actions[bot]
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
- uses: actions/setup-python@v5
with:
python-version: 3.x
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v4
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
restore-keys: |
mkdocs-material-
- name: Install MkDocs and Material Theme
run: pip install mkdocs-material
- name: Build and Deploy
run: mkdocs gh-deploy --force
+15 -11
View File
@@ -5,17 +5,15 @@
**Branch:** beta
## OVERVIEW
Flux Panel is a traffic forwarding management system built on a forked GOST v3 stack. It ships as Dockerized Spring Boot (admin API) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
## STRUCTURE
```
./
├── go-gost/ # Go forwarding agent (forked gost + local x/)
│ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
├── springboot-backend/ # Java/Spring Boot admin API (SQLite/MyBatis)
├── go-backend/ # Go Admin API (SQLite, net/http)
├── vite-frontend/ # React/Vite dashboard (HeroUI + Tailwind)
├── android-app/ # Android WebView wrapper (optional)
├── ios-app/ # iOS WebView wrapper (optional)
├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
├── panel_install.sh # Panel installer/upgrader (downloads compose)
@@ -30,13 +28,19 @@ Flux Panel is a traffic forwarding management system built on a forked GOST v3 s
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API entry** | `springboot-backend/src/main/java/com/admin/AdminApplication.java` | Spring Boot app |
| **Admin API routes** | `springboot-backend/src/main/java/com/admin/controller/` | Mostly `/api/v1/*` controllers |
| **Admin auth** | `springboot-backend/src/main/java/com/admin/common/interceptor/JwtInterceptor.java` | Checks `Authorization` header |
| **Web UI routing** | `vite-frontend/src/App.tsx` | React Router v6 + ProtectedRoute/H5 layouts |
| **Web UI API client** | `vite-frontend/src/api/network.ts` | Axios `baseURL` + `Authorization` header |
| **Go agent entry** | `go-gost/main.go` | Reads panel `config.json` + starts gost services |
| **Go x fork** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Admin API** | `go-backend/` | Go Admin API (SQLite) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (HeroUI + Tailwind) |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `flvx` | Project | `.` | Root directory |
| `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
| `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
| `main` | Func | `go-gost/main.go` | Agent Entry |
## CONVENTIONS
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
+3 -1
View File
@@ -1,4 +1,6 @@
# flux-panel转发面板 哆啦A梦转发面板
# FLVX
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
本项目基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 两个开源库,实现了转发面板。
---
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+24
View File
@@ -0,0 +1,24 @@
# 常见问题 (FAQ)
### Q1: 安装脚本提示 "Docker command not found"?
**A**: 请确保您的系统已安装 Docker 和 Docker Compose。
- Ubuntu/Debian 安装 Docker: `curl -fsSL https://get.docker.com | bash`
### Q2: 面板无法访问 (Connection Refused)?
**A**:
1. 检查防火墙是否放行了前端端口(默认 `6366`)。
2. 检查容器是否正常运行: `docker ps`。
3. 查看容器日志: `docker logs flux-panel-backend` 或 `docker logs vite-frontend`。
### Q3: 节点显示离线?
**A**:
1. 检查节点服务器与面板服务器之间的网络连通性。
2. 确认在节点端安装时输入的 **面板地址** 和 **密钥** 是否正确。
3. 检查节点端服务状态: `systemctl status flux_agent`。
4. 查看节点端日志: `journalctl -u flux_agent -f`。
### Q4: 只有 TCP 能通,UDP 不通?
**A**: 请检查服务器防火墙和安全组(AWS/阿里云/腾讯云等)是否同时放行了对应端口的 **TCP 和 UDP** 协议。
### Q5: IPv6 无法使用?
**A**: 面板安装脚本会自动尝试配置 Docker 的 IPv6。如果失败,请手动检查 `/etc/docker/daemon.json` 配置,确保 `ipv6: true` 且分配了正确的 `fixed-cidr-v6` 子网。
+37
View File
@@ -0,0 +1,37 @@
# FLVX 官方文档
**FLVX** 是一个基于 [go-gost/gost](https://github.com/go-gost/gost) 和 [go-gost/x](https://github.com/go-gost/x) 开发的高性能流量转发管理系统。
> 📞 **联系我们**: [Telegram群组](https://t.me/flvxpanel)
## 核心特性
- **多协议支持**: 完美支持 TCP 和 UDP 协议转发。
- **灵活转发**: 支持 **端口转发** 与 **隧道转发** 两种模式。
- **流量控制**: 支持按 **隧道账号级别** 管理流量转发数量,用于用户/隧道配额控制。
- **限速管理**: 可针对 **指定用户的指定隧道进行限速** 设置。
- **计费策略**: 支持配置 **单向或双向流量计费方式**,灵活适配不同计费模型。
- **策略配置**: 提供灵活的转发策略配置,适用于多种网络场景。
## 快速开始
- [安装部署](./install.md)
- [使用指南](./usage.md)
- [常见问题](./faq.md)
## 免责声明
本项目仅供个人学习与研究使用,基于开源项目进行二次开发。
使用本项目所带来的任何风险均由使用者自行承担。本项目为开源的流量转发工具,仅限合法、合规用途。
**禁止将本项目用于任何违法或未经授权的行为,包括但不限于网络攻击、数据窃取、非法访问等。**
## 捐赠支持
如果您觉得本项目对您有帮助,欢迎请作者喝杯咖啡!
| 网络 | 地址 |
|------------|----------------------------------------------------------------------|
| BNB(BEP20) | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
| TRC20 | `TM8VYdU3s3gSX5PC8swjAJrAzZFCHKqG2k` |
| Aptos | `0x49427bfcba1006a346447430689b2307ac156316bb34850d1d3029ff9d118da5` |
| Polygon | `0xa608708fdc6279a2433fd4b82f0b72b8cbe97ed5` |
+79
View File
@@ -0,0 +1,79 @@
# 安装部署指南
本文档介绍如何部署 FLVX 面板端及节点端。
## 一、面板端部署
面板端负责管理用户、节点和转发规则。
### 1. 环境要求
- 操作系统:Linux (推荐 Debian 10+ / Ubuntu 20.04+)
- 必须安装 Docker 和 Docker Compose
### 2. 一键安装脚本
使用以下命令即可快速安装面板:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
**安装过程中会提示输入以下信息:**
- **前端端口**: 默认为 `6366`
- **后端端口**: 默认为 `6365`
脚本会自动检测系统是否支持 IPv6,并自动配置 Docker 的 IPv6 支持。
### 3. 访问面板
安装完成后,访问:
`http://<服务器IP>:<前端端口>` (默认: `http://<服务器IP>:6366`)
**默认管理员账号:**
- 用户名: `admin_user`
- 密码: `admin_user`
> ⚠️ **注意**: 首次登录后,请务必在“个人中心”或“设置”中修改默认密码!
### 4. 维护命令
再次运行 `./panel_install.sh` 脚本可以看到管理菜单:
1. 安装面板
2. 更新面板
3. 卸载面板
---
## 二、节点端部署
节点端运行在实际进行流量转发的服务器上,需要连接到面板端进行管理。
### 1. 获取接入密钥
1. 登录面板端。
2. 进入 **节点管理 (Node)** 页面。
3. 点击 **添加节点**。
4. 获取该节点的 **接入密钥 (Secret)**。
### 2. 一键安装脚本
在节点服务器上运行:
```bash
curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
**安装过程中会提示输入:**
- **服务器地址**: 面板端的通信地址(通常是 `http://<面板IP>:<后端端口>`,例如 `http://1.2.3.4:6365`)。
- **密钥**: 刚才在面板中获取的节点密钥。
或者直接使用带参数的命令(适用于自动化部署):
```bash
# 替换 <面板地址> 和 <密钥>
./install.sh -a "http://1.2.3.4:6365" -s "your_node_secret"
```
### 3. 验证安装
安装完成后,服务会自动启动。
- 查看状态: `systemctl status flux_agent`
- 回到面板 **节点管理** 页面,该节点状态应显示为 **在线**。
+35
View File
@@ -0,0 +1,35 @@
# 使用指南
## 1. 仪表盘 (Dashboard)
登录系统后首先看到的是仪表盘,这里展示了系统的整体运行状态,包括:
- 在线节点数量
- 用户总数
- 流量统计信息
## 2. 节点管理 (Node)
节点是实际承载流量转发的服务器。
- **添加节点**: 点击“添加”,获取密钥用于节点端安装。
- **管理**: 可以查看节点在线状态、版本信息,以及对节点进行编辑或删除。
## 3. 用户管理 (User)
管理员可以创建和管理普通用户。
- **创建用户**: 设置用户名、密码、流量配额等。
- **用户组**: 可以将用户分配到不同的组 (Group),便于统一管理权限或策略。
## 4. 转发管理 (Forward)
这是核心功能区,用于设置端口转发规则。
- **端口转发**: 将节点服务器的某个端口流量转发到目标地址。
- **协议**: 支持 TCP / UDP。
- **入口**: 选择入口节点和监听端口。
- **出口**: 设置目标 IP 和端口。
- **隧道转发**: 用于更复杂的网络穿透场景(具体配置视业务需求而定)。
## 5. 限制与策略 (Limit)
- **限速**: 可以对指定用户或指定隧道进行带宽限制,防止资源滥用。
- **计费模式**: 支持配置流量计算方式(单向或双向),适合运营场景。
## 6. 系统配置 (Config)
在此页面进行系统的全局设置。
## 7. 个人设置 (Profile)
- **修改密码**: 为了安全,建议定期修改管理员密码。
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+1
View File
@@ -12,6 +12,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
+1
View File
@@ -12,6 +12,7 @@ services:
JWT_SECRET: ${JWT_SECRET}
LOG_DIR: /app/logs
SERVER_ADDR: :6365
TZ: Asia/Shanghai
ports:
- "${BACKEND_PORT}:6365"
volumes:
+47
View File
@@ -0,0 +1,47 @@
# GO BACKEND KNOWLEDGE BASE
## OVERVIEW
Go-based Admin API for FLVX (formerly Flux Panel). Replaces the legacy Spring Boot backend.
**Stack:** Go 1.23, net/http (std lib), SQLite (modernc.org/sqlite).
## STRUCTURE
```
go-backend/
├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
├── internal/
│ ├── http/ # HTTP layer
│ │ ├── router.go # Routes (NewServeMux) + Middleware chain
│ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
│ │ ├── middleware/ # JWT, CORS, Logging, Recover
│ │ └── response/ # JSON response helpers
│ ├── store/sqlite/ # Data Access Layer (Repository pattern)
│ │ ├── repository.go # SQL queries & Struct definitions
│ │ └── sql/ # Embedded schema.sql & data.sql
│ └── auth/ # Auth logic
├── tests/ # Integration/Contract tests
├── Dockerfile # Multi-stage build (alpine)
└── Makefile # Build commands
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
| **DB Schema** | `go-backend/internal/store/sqlite/sql/schema.sql` | Embedded in binary |
| **SQL Queries** | `go-backend/internal/store/sqlite/repository.go` | Raw SQL, no ORM |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
## CONVENTIONS
- **No ORM**: Uses raw SQL with `database/sql` and `modernc.org/sqlite`.
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
## COMMANDS
```bash
cd go-backend
go run ./cmd/paneld
go test ./...
make build
```
+3 -1
View File
@@ -5,7 +5,9 @@ COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/paneld ./cmd/paneld
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} env ${TARGETARCH:+GOARCH=${TARGETARCH}} go build -o /out/paneld ./cmd/paneld
FROM debian:bookworm-slim
WORKDIR /app
@@ -59,6 +59,8 @@ type chainNodeRecord struct {
NodeID int64
Port int
NodeName string
Protocol string
Strategy string
}
type diagnosisTarget struct {
@@ -232,9 +234,9 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
return &n, nil
}
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int, error) {
func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *int64, *int, error) {
row := h.repo.DB().QueryRow(`
SELECT ut.id, sl.speed
SELECT ut.id, sl.id, sl.speed
FROM user_tunnel ut
LEFT JOIN speed_limit sl ON sl.id = ut.speed_id
WHERE ut.user_id = ? AND ut.tunnel_id = ?
@@ -242,19 +244,21 @@ func (h *Handler) resolveUserTunnelAndLimiter(userID, tunnelID int64) (int64, *i
LIMIT 1
`, userID, tunnelID)
var userTunnelID int64
var limiterID sql.NullInt64
var speed sql.NullInt64
err := row.Scan(&userTunnelID, &speed)
err := row.Scan(&userTunnelID, &limiterID, &speed)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil, nil
return 0, nil, nil, nil
}
return 0, nil, err
return 0, nil, nil, err
}
if !speed.Valid || speed.Int64 <= 0 {
return userTunnelID, nil, nil
if !limiterID.Valid || limiterID.Int64 <= 0 {
return userTunnelID, nil, nil, nil
}
v := int(speed.Int64)
return userTunnelID, &v, nil
v := limiterID.Int64
s := int(speed.Int64)
return userTunnelID, &v, &s, nil
}
func (h *Handler) listUserTunnelIDs(userID, tunnelID int64) ([]int64, error) {
@@ -326,18 +330,22 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return errors.New("转发入口端口不存在")
}
userTunnelID, limiter, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
userTunnelID, limiterID, speed, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
for _, fp := range ports {
if limiterID != nil && speed != nil {
h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed)
}
node, err := h.getNodeRecord(fp.NodeID)
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiter)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
@@ -360,7 +368,7 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
if len(ports) == 0 {
return nil
}
userTunnelID, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
userTunnelID, _, _, err := h.resolveUserTunnelAndLimiter(forward.UserID, forward.TunnelID)
if err != nil {
return err
}
@@ -862,7 +870,7 @@ func firstPortFromRange(portRange string) int {
func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name
SELECT ct.chain_type, COALESCE(ct.inx, 0), ct.node_id, COALESCE(ct.port, 0), n.name, ct.protocol, ct.strategy
FROM chain_tunnel ct
LEFT JOIN node n ON n.id = ct.node_id
WHERE ct.tunnel_id = ?
@@ -877,7 +885,9 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
for rows.Next() {
var item chainNodeRecord
var name sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name); err != nil {
var protocol sql.NullString
var strategy sql.NullString
if err := rows.Scan(&item.ChainType, &item.Inx, &item.NodeID, &item.Port, &name, &protocol, &strategy); err != nil {
return nil, err
}
if strings.TrimSpace(name.String) == "" {
@@ -885,6 +895,8 @@ func (h *Handler) listChainNodesForTunnel(tunnelID int64) ([]chainNodeRecord, er
} else {
item.NodeName = name.String
}
item.Protocol = defaultString(protocol.String, "tls")
item.Strategy = defaultString(strategy.String, "round")
result = append(result, item)
}
if err := rows.Err(); err != nil {
@@ -997,7 +1009,7 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiter *int) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1038,8 +1050,8 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
if tunnel != nil && tunnel.Type == 1 && strings.TrimSpace(node.InterfaceName) != "" {
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
}
if limiter != nil && *limiter > 0 {
service["limiter"] = strconv.Itoa(*limiter)
if limiterID != nil && *limiterID > 0 {
service["limiter"] = strconv.FormatInt(*limiterID, 10)
}
services = append(services, service)
}
@@ -1102,3 +1114,49 @@ func asBool(v interface{}, def bool) bool {
return def
}
}
func (h *Handler) sendLimiterConfig(limiterID int64, speedMbps int, tunnelID int64) error {
rate := float64(speedMbps) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
return nil
}
func (h *Handler) sendDeleteLimiterConfig(limiterID int64, tunnelID int64) error {
payload := map[string]interface{}{
"limiter": strconv.FormatInt(limiterID, 10),
}
nodes, err := h.tunnelEntryNodeIDs(tunnelID)
if err != nil {
return err
}
for _, nodeID := range nodes {
_, _ = h.sendNodeCommand(nodeID, "DeleteLimiters", payload, false, true)
}
return nil
}
func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) {
rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
payload := map[string]interface{}{
"name": strconv.FormatInt(limiterID, 10),
"limits": []string{limitStr},
}
_, _ = h.sendNodeCommand(nodeID, "AddLimiters", payload, false, false)
}
+110 -3
View File
@@ -731,6 +731,82 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
}
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
return nil, err
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil {
return nil, err
}
state := &tunnelCreateState{
TunnelID: tunnelID,
Type: tunnel.Type,
InNodes: make([]tunnelRuntimeNode, 0),
ChainHops: make([][]tunnelRuntimeNode, 0),
OutNodes: make([]tunnelRuntimeNode, 0),
Nodes: make(map[int64]*nodeRecord),
NodeIDList: make([]int64, 0),
}
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
for _, r := range inNodes {
state.InNodes = append(state.InNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 1,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, r := range outNodes {
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 3,
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
for _, hop := range chainHops {
stateHop := make([]tunnelRuntimeNode, 0)
for _, r := range hop {
stateHop = append(stateHop, tunnelRuntimeNode{
NodeID: r.NodeID,
Protocol: r.Protocol,
Strategy: r.Strategy,
ChainType: 2,
Inx: int(r.Inx),
Port: r.Port,
})
state.NodeIDList = append(state.NodeIDList, r.NodeID)
}
state.ChainHops = append(state.ChainHops, stateHop)
}
seen := make(map[int64]struct{})
for _, id := range state.NodeIDList {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
node, err := h.getNodeRecord(id)
if err != nil {
return nil, err
}
state.Nodes[id] = node
}
return state, nil
}
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
ids := idsFromBody(r, w)
if ids == nil {
@@ -739,6 +815,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
success := 0
fail := 0
for _, tunnelID := range ids {
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
fail++
continue
}
if tunnel.Type == 2 {
h.cleanupTunnelRuntime(tunnelID)
state, err := h.reconstructTunnelState(tunnelID)
if err != nil {
fail++
continue
}
_, _, applyErr := h.applyTunnelRuntime(state)
if applyErr != nil {
fail++
continue
}
}
forwards, err := h.listForwardsByTunnel(tunnelID)
if err != nil {
fail++
@@ -1373,12 +1469,15 @@ func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
return
}
now := time.Now().UnixMilli()
_, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
name, asInt(req["speed"], 100), tunnelID, tunnelName, now, now, asInt(req["status"], 1))
speed := asInt(req["speed"], 100)
res, err := h.repo.DB().Exec(`INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) VALUES(?, ?, ?, ?, ?, ?, ?)`,
name, speed, tunnelID, tunnelName, now, now, asInt(req["status"], 1))
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
id, _ := res.LastInsertId()
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1400,12 +1499,14 @@ func (h *Handler) speedLimitUpdate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("隧道不存在"))
return
}
speed := asInt(req["speed"], 100)
_, err := h.repo.DB().Exec(`UPDATE speed_limit SET name=?, speed=?, tunnel_id=?, tunnel_name=?, status=?, updated_time=? WHERE id=?`,
asString(req["name"]), asInt(req["speed"], 100), tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
asString(req["name"]), speed, tunnelID, tunnelName, asInt(req["status"], 1), time.Now().UnixMilli(), id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
_ = h.sendLimiterConfig(id, speed, tunnelID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -1414,11 +1515,17 @@ func (h *Handler) speedLimitDelete(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
var tunnelID int64
_ = h.repo.DB().QueryRow(`SELECT tunnel_id FROM speed_limit WHERE id = ?`, id).Scan(&tunnelID)
_, err := h.repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, id)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if tunnelID > 0 {
_ = h.sendDeleteLimiterConfig(id, tunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
+54 -11
View File
@@ -5,7 +5,6 @@ import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@@ -124,6 +123,11 @@ func Open(path string) (*Repository, error) {
return nil, err
}
if err := migrateSchema(db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}
@@ -1167,25 +1171,64 @@ func bootstrapSchema(db *sql.DB) error {
return errors.New("nil db")
}
var exists int
err := db.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name='user'`).Scan(&exists)
if err != nil {
return fmt.Errorf("check schema: %w", err)
}
if exists > 0 {
return nil
}
log.Printf("sqlite schema not found, bootstrapping embedded schema")
if _, err := db.Exec(embeddedSchema); err != nil {
return fmt.Errorf("apply schema.sql: %w", err)
}
if _, err := db.Exec(embeddedSeedData); err != nil {
return fmt.Errorf("apply data.sql: %w", err)
}
return nil
}
func migrateSchema(db *sql.DB) error {
if db == nil {
return errors.New("nil db")
}
ensureColumn := func(table, col, typ string) error {
var dummy interface{}
err := db.QueryRow(fmt.Sprintf("SELECT %s FROM %s LIMIT 1", col, table)).Scan(&dummy)
if err == nil || errors.Is(err, sql.ErrNoRows) {
return nil
}
if !strings.Contains(err.Error(), "no such column") {
return nil
}
if _, err := db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, typ)); err != nil {
return fmt.Errorf("add %s.%s: %w", table, col, err)
}
return nil
}
columnsByTable := map[string]map[string]string{
"node": {
"server_ip_v4": "VARCHAR(100)",
"server_ip_v6": "VARCHAR(100)",
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"tunnel": {
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"forward": {
"inx": "INTEGER NOT NULL DEFAULT 0",
},
"chain_tunnel": {
"inx": "INTEGER",
},
}
for table, cols := range columnsByTable {
for col, typ := range cols {
if err := ensureColumn(table, col, typ); err != nil {
return err
}
}
}
return nil
}
var osMkdirAll = func(path string) error {
return os.MkdirAll(path, 0o755)
}
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
@@ -17,6 +18,8 @@ import (
"go-backend/internal/http/handler"
"go-backend/internal/http/response"
"go-backend/internal/store/sqlite"
_ "modernc.org/sqlite"
)
func TestCaptchaVerifyLoginContract(t *testing.T) {
@@ -213,3 +216,117 @@ func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.
h := handler.New(repo, jwtSecret)
return httpserver.NewRouter(h, jwtSecret), repo
}
func TestOpenMigratesLegacyNodeDualStackColumns(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy-2.0.7-beta.db")
legacyDB, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy sqlite: %v", err)
}
t.Cleanup(func() {
_ = legacyDB.Close()
})
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS node (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
secret VARCHAR(100) NOT NULL,
server_ip VARCHAR(100) NOT NULL,
port TEXT NOT NULL,
interface_name VARCHAR(200),
version VARCHAR(100),
http INTEGER NOT NULL DEFAULT 0,
tls INTEGER NOT NULL DEFAULT 0,
socks INTEGER NOT NULL DEFAULT 0,
created_time INTEGER NOT NULL,
updated_time INTEGER,
status INTEGER NOT NULL,
tcp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]',
udp_listen_addr VARCHAR(100) NOT NULL DEFAULT '[::]'
)
`); err != nil {
t.Fatalf("create legacy node table: %v", err)
}
if _, err := legacyDB.Exec(`
CREATE TABLE IF NOT EXISTS tunnel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
traffic_ratio REAL NOT NULL DEFAULT 1.0,
type INTEGER NOT NULL,
protocol VARCHAR(10) NOT NULL DEFAULT 'tls',
flow INTEGER NOT NULL,
created_time INTEGER NOT NULL,
updated_time INTEGER NOT NULL,
status INTEGER NOT NULL,
in_ip TEXT
)
`); err != nil {
t.Fatalf("create legacy tunnel table: %v", err)
}
now := time.Now().UnixMilli()
if _, err := legacyDB.Exec(`
INSERT INTO node(name, secret, server_ip, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-node", "legacy-secret", "10.10.0.1", "10000-10010", "eth0", "v-old", 1, 1, 1, now, now, 1, "[::]", "[::]"); err != nil {
t.Fatalf("seed legacy node row: %v", err)
}
repo, err := sqlite.Open(dbPath)
if err != nil {
t.Fatalf("open migrated sqlite: %v", err)
}
t.Cleanup(func() {
_ = repo.Close()
})
nodes, err := repo.ListNodes()
if err != nil {
t.Fatalf("list nodes after migration: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node after migration, got %d", len(nodes))
}
columns := readTableColumns(t, repo.DB(), "node")
for _, required := range []string{"server_ip_v4", "server_ip_v6", "inx"} {
if !columns[required] {
t.Fatalf("expected node column %q to exist after migration", required)
}
}
tunnelColumns := readTableColumns(t, repo.DB(), "tunnel")
if !tunnelColumns["inx"] {
t.Fatalf("expected tunnel column %q to exist after migration", "inx")
}
}
func readTableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
t.Helper()
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
t.Fatalf("inspect %s columns: %v", table, err)
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
t.Fatalf("scan %s pragma row: %v", table, err)
}
columns[name] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate %s pragma rows: %v", table, err)
}
return columns
}
+31 -8
View File
@@ -3,6 +3,7 @@ package service
import (
"fmt"
"runtime"
"strconv"
"strings"
"time"
@@ -30,6 +31,7 @@ import (
logger_parser "github.com/go-gost/x/config/parsing/logger"
selector_parser "github.com/go-gost/x/config/parsing/selector"
tls_util "github.com/go-gost/x/internal/util/tls"
xtraffic "github.com/go-gost/x/limiter/traffic"
cache_limiter "github.com/go-gost/x/limiter/traffic/cache"
"github.com/go-gost/x/metadata"
mdutil "github.com/go-gost/x/metadata/util"
@@ -181,6 +183,32 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
)
}
var trafficLimiter listener.Option
if cfg.Limiter != "" {
lim := registry.TrafficLimiterRegistry().Get(cfg.Limiter)
if lim == nil {
// Try to parse as simple number (bandwidth in bytes/sec)
if val, err := strconv.Atoi(cfg.Limiter); err == nil && val > 0 {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %dB %dB", xtraffic.ServiceLimitKey, val, val)),
)
}
if lim == nil {
lim = xtraffic.NewTrafficLimiter(
xtraffic.LimitsOption(fmt.Sprintf("%s %s %s", xtraffic.ServiceLimitKey, cfg.Limiter, cfg.Limiter)),
)
}
}
trafficLimiter = listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
lim,
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
)
}
listenOpts := []listener.Option{
listener.AddrOption(cfg.Addr),
listener.RouterOption(xchain.NewRouter(routerOpts...)),
@@ -188,14 +216,6 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.AuthOption(auth_parser.Info(cfg.Listener.Auth)),
listener.TLSConfigOption(tlsConfig),
listener.AdmissionOption(xadmission.AdmissionGroup(admissions...)),
listener.TrafficLimiterOption(
cache_limiter.NewCachedTrafficLimiter(
registry.TrafficLimiterRegistry().Get(cfg.Limiter),
cache_limiter.RefreshIntervalOption(limiterRefreshInterval),
cache_limiter.CleanupIntervalOption(limiterCleanupInterval),
cache_limiter.ScopeOption(limiterScope),
),
),
listener.ConnLimiterOption(registry.ConnLimiterRegistry().Get(cfg.CLimiter)),
listener.ServiceOption(cfg.Name),
listener.ProxyProtocolOption(ppv),
@@ -203,6 +223,9 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
listener.NetnsOption(netnsIn),
listener.LoggerOption(listenerLogger),
}
if trafficLimiter != nil {
listenOpts = append(listenOpts, trafficLimiter)
}
if netnsIn != "" {
runtime.LockOSThread()
+6
View File
@@ -136,6 +136,9 @@ func (l *trafficLimiter) In(ctx context.Context, key string, opts ...limiter.Opt
return nil
case limiter.ScopeClient:
if lim, ok := l.inLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
@@ -215,6 +218,9 @@ func (l *trafficLimiter) Out(ctx context.Context, key string, opts ...limiter.Op
return nil
case limiter.ScopeClient:
if lim, ok := l.outLimits.Get(key); ok && lim != nil {
return lim.(traffic.Limiter)
}
return nil
case limiter.ScopeConn:
+1 -1
View File
@@ -28,7 +28,7 @@ COUNTRY=$(curl -s https://ipinfo.io/country)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
+40
View File
@@ -0,0 +1,40 @@
site_name: FLVX 文档
site_url: https://sagit-chu.github.io/flux-panel/
site_description: FLVX官方文档
site_author: Sagit-chu
theme:
name: material
language: zh
palette:
- scheme: default
primary: indigo
accent: indigo
- scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to dark mode
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.suggest
- search.highlight
- content.code.copy
docs_dir: doc
nav:
- 首页: index.md
- 安装部署: install.md
- 使用指南: usage.md
- 常见问题: faq.md
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
+1 -1
View File
@@ -15,7 +15,7 @@ COUNTRY=$(curl -s https://ipinfo.io/country)
maybe_proxy_url() {
local url="$1"
if [ "$COUNTRY" = "CN" ]; then
echo "https://ghfast.top/${url}"
echo "https://gcode.hostcentral.cc/${url}"
else
echo "$url"
fi
+1 -1
View File
@@ -3,7 +3,7 @@
**Generated:** Mon Feb 02 2026
## OVERVIEW
Web management console for Flux Panel.
Web management console for FLVX (formerly Flux Panel).
**Stack:** React 18, Vite 5, TypeScript, TailwindCSS 4, HeroUI.
## STRUCTURE
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<title>flux-panel</title>
<title>FLVX</title>
<script>
// 防止暗黑模式闪烁:在页面加载前立即应用系统主题
(function() {
@@ -137,13 +137,13 @@
}
}
</style>
<meta key="title" content="flux-panel" property="og:title" />
<meta key="title" content="FLVX" property="og:title" />
<meta
content="flux-panel"
content="FLVX"
property="og:description"
/>
<meta
content="flux-panel"
content="FLVX"
name="description"
/>
<meta
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "flux-panel",
"name": "flvx",
"private": true,
"version": "0.0.0",
"type": "module",
+2 -2
View File
@@ -12,7 +12,7 @@ const GITHUB_REPO =
const getInitialConfig = () => {
if (typeof window === "undefined") {
return {
name: "flux",
name: "FLVX",
version: VERSION,
app_version: APP_VERSION,
github_repo: GITHUB_REPO,
@@ -31,7 +31,7 @@ const getInitialConfig = () => {
}
return {
name: "flux",
name: "FLVX",
version: VERSION,
app_version: APP_VERSION,
};
+1 -1
View File
@@ -375,7 +375,7 @@ export default function AdminLayout({
rel="noopener noreferrer"
target="_blank"
>
flux-panel
FLVX
</a>
</p>
</div>
+1 -1
View File
@@ -229,7 +229,7 @@ export default function IndexPage() {
rel="noopener noreferrer"
target="_blank"
>
flux-panel
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
+1 -1
View File
@@ -309,7 +309,7 @@ export default function ProfilePage() {
rel="noopener noreferrer"
target="_blank"
>
flux-panel
FLVX
</a>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">