feat: frps-manager v1.0 - frp 0.70.1 multi-tenant web management system
- Multi-tenant client/proxy management with Flask+SQLite - Frps server control (start/restart/token rotation) - Generate frpc.ini with shared server token + admin API - Dashboard API integration (live client/proxy status) - RBAC: admin/tenant_admin/user roles - Audit logging for all operations - Systemd service files included - Requires legacy INI format (frp 0.70 TOML auth broken)
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.egg-info/
|
||||
|
||||
# venv
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# ---- Runtime / secrets (never commit) ----
|
||||
instance/
|
||||
runtime/conf/frps*.ini
|
||||
runtime/conf/*.ini.tmp
|
||||
runtime/*.pid
|
||||
|
||||
# Dev/test scratch
|
||||
_smoke_test.py
|
||||
|
||||
# editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# binary (deploy.sh downloads)
|
||||
runtime/bin/frps
|
||||
|
||||
# runtime logs (created at startup)
|
||||
runtime/logs/
|
||||
runtime/*.log
|
||||
@@ -0,0 +1,204 @@
|
||||
# frps-manager
|
||||
|
||||
基于 frps 0.70.1 的内网穿透 Web 管理系统,多租户 + Web 端代理规则 CRUD。
|
||||
|
||||
## 功能
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 仪表盘 | frps 状态、监听端口、在线客户端/代理数、Server Token 显示、实时在线列表 |
|
||||
| 租户 | 多租户隔离,每个租户独立带宽/连接配额 |
|
||||
| 客户端 (frpc) | 注册 frpc 客户端(user 字段 + token),归属租户,启停、旋转 token |
|
||||
| 代理规则 | 支持 tcp / udp / http / https,配置远端端口、域名、加密压缩、健康检查 |
|
||||
| frpc.ini 生成 | 点客户端的「frpc.ini」按钮下载一份含所有代理 + 共享 server token + admin API 的配置 |
|
||||
| 热重载支持 | 生成的 frpc.ini 默认带 `admin_addr`/`admin_port`,修改代理后用户在 frpc 主机执行 `frpc reload -c /etc/frpc.ini` 即可生效(无需 SSH 重启) |
|
||||
| 用户 | admin / tenant_admin / user 三级 RBAC,每个用户绑定到租户 |
|
||||
| 审计日志 | 全部管理操作(创建/删除/旋转 token/重载 frps…)写入日志 |
|
||||
| frps 控制 | 通过 Web 重写 frps.ini 并重启 frps、旋转 server token、查看生成的 frps.ini |
|
||||
|
||||
## 关键设计:frps 0.70 的特殊性
|
||||
|
||||
**fatedier/frp 0.70.1 的 TOML 配置和 auth 流程有问题**:
|
||||
|
||||
- TOML 解析器**不接受** `auth.method = "token"` / `auth.token = "..."` 这种点路径键(尽管官方 example 文件这么写)
|
||||
- `--token` CLI flag 设的值会被 TOML 加载流程覆盖为**空值**
|
||||
- **唯一真正可用的格式**:`legacy INI`(`[common]` + `token = xxx`),frp 在加载时会打印 `WARNING: ini format is deprecated`
|
||||
|
||||
所以本系统:
|
||||
- 生成 `frps.ini`(legacy INI)而不是 frps.toml
|
||||
- 生成 `frpc.ini`(legacy INI)给用户
|
||||
- 会在 frps 日志看到 deprecation warning — 这是 frp 项目已知问题,未来升级到 0.71+(修复 TOML auth 后)只需把 `render_frps_ini` 改成 TOML 渲染
|
||||
|
||||
**另一个特殊性**:frps 0.70+ **没有 server-side `[[proxies]]` 段** — 所有代理规则都在 frpc 端 `[[xxx]]` 配置里,启动时通过 control message 推给 frps。所以 Web 系统**所有代理数据都在 frpc.ini 里**,frps.ini 只管 bind/dashboard/auth/log。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Flask 3.0 + Flask-SQLAlchemy + SQLite
|
||||
- **WSGI**: Gunicorn (2 workers)
|
||||
- **前端**: Jinja2 模板 + 原生 CSS(无前端框架依赖)
|
||||
- **被管理**: frps 0.70.1(GitHub: fatedier/frp)+ frpc 0.70.1
|
||||
- **认证**: Werkzeug password hashing + Flask session
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
frps-manager/
|
||||
├── app.py # Flask 应用主文件(模型、路由、INI 渲染逻辑)
|
||||
├── wsgi.py # Gunicorn 入口(DB 初始化)
|
||||
├── init_db.py # 独立 DB 初始化脚本
|
||||
├── requirements.txt
|
||||
├── README.md
|
||||
├── _smoke_test.py # 端到端测试(起 frps + frpc + reload 验证)
|
||||
├── scripts/
|
||||
│ ├── deploy.sh # 一键部署脚本(systemd)
|
||||
│ └── dev.sh # 本地开发启动
|
||||
├── templates/ # Jinja2 模板
|
||||
│ ├── base.html # 布局 + 导航
|
||||
│ ├── login.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── tenants.html / tenant_form.html
|
||||
│ ├── clients.html / client_form.html
|
||||
│ ├── proxies.html / proxy_form.html
|
||||
│ ├── users.html / user_form.html
|
||||
│ ├── logs.html
|
||||
│ ├── change_password.html
|
||||
│ └── error.html
|
||||
├── static/
|
||||
│ └── style.css # 全部样式
|
||||
├── instance/ # SQLite DB + server token(运行时生成)
|
||||
│ ├── frps_manager.db
|
||||
│ └── server_token # 0600 权限,仅 root 可读
|
||||
└── runtime/ # 运行时数据
|
||||
├── bin/frps # frps 二进制(deploy.sh 自动下载)
|
||||
├── conf/frps.ini # 由 app.py 生成的 frps 配置
|
||||
├── logs/ # gunicorn / frps 日志
|
||||
├── frps.service # frps systemd 单元
|
||||
├── frps-manager.service # web systemd 单元
|
||||
└── frpc@.service # 用户给 frpc 主机用的模板
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
### 一键部署(推荐)
|
||||
|
||||
```bash
|
||||
git clone <repo-url> /opt/frps-manager
|
||||
cd /opt/frps-manager
|
||||
sudo bash scripts/deploy.sh
|
||||
```
|
||||
|
||||
部署脚本会自动:
|
||||
1. 安装系统依赖(python3, pip, venv, curl)
|
||||
2. 下载 frp 0.70.1 二进制到 `runtime/bin/frps`
|
||||
3. 创建 venv 并安装依赖
|
||||
4. 初始化 SQLite(默认管理员 `admin / admin`,默认租户 `default`)
|
||||
5. 生成 `frps.ini`(含自动生成的 server token)
|
||||
6. 安装并启动两个 systemd 服务:
|
||||
- `frps.service` — 监听 0.0.0.0:7000,dashboard 在 127.0.0.1:7500
|
||||
- `frps-manager.service` — Web UI 在 0.0.0.0:5390
|
||||
|
||||
启动后访问 `http://<host>:5390`,用 `admin / admin` 登录,**立刻去「改密」改掉默认密码**。
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
cd frps-manager
|
||||
bash scripts/dev.sh
|
||||
# 浏览器打开 http://localhost:5390
|
||||
```
|
||||
|
||||
## 多租户使用流程
|
||||
|
||||
1. **管理员**登录 → 创建 **租户**(带带宽/连接配额元数据,文档用)
|
||||
2. 创建 **普通用户**(绑定到该租户,role = user)
|
||||
3. 租户用户登录 → 创建 **frpc 客户端**(分配名称 + 租户)
|
||||
4. 在该客户端下添加 **代理规则**(类型、远端端口/域名、本地端口、加密/压缩/健康检查…)
|
||||
5. 点客户端列表的「frpc.ini」下载配置(包含所有代理 + 共享 server token + admin API)
|
||||
6. 把 `frpc.ini` 拷贝到 frpc 主机,启动 frpc
|
||||
7. **改代理后**:用户拉到新 frpc.ini 后在 frpc 主机执行 `frpc reload -c /etc/frpc.ini`,frpc 自动应用新配置(dashboard 立刻显示新代理在线)
|
||||
|
||||
## frpc 配置示例
|
||||
|
||||
Web 生成的 frpc.ini:
|
||||
|
||||
```ini
|
||||
# Generated by frps-manager for client: laptop-jdoe
|
||||
[common]
|
||||
server_addr = your.frps.host
|
||||
server_port = 7000
|
||||
authentication_method = token
|
||||
token = AbCdEf123456... # 与所有 frpc 共用的 server token
|
||||
admin_addr = 127.0.0.1
|
||||
admin_port = 7400
|
||||
admin_user = admin
|
||||
admin_pwd = admin # 用于 `frpc reload` 命令
|
||||
|
||||
[laptop-jdoe_ssh]
|
||||
type = tcp
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 22
|
||||
remote_port = 6001
|
||||
use_encryption = true
|
||||
|
||||
[laptop-jdoe_web]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 8080
|
||||
custom_domains = web.example.com
|
||||
```
|
||||
|
||||
存到 frpc 主机 `/etc/frpc/laptop-jdoe.ini`,然后用模板 unit:
|
||||
|
||||
```bash
|
||||
sudo cp /path/to/frps-manager/runtime/frpc@.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now frpc@laptop-jdoe
|
||||
```
|
||||
|
||||
改代理后:
|
||||
```bash
|
||||
# 在 frpc 主机执行
|
||||
sudo frpc reload -c /etc/frpc/laptop-jdoe.ini
|
||||
# 或者 frpc reload -c /etc/frpc.ini(按你保存的路径)
|
||||
```
|
||||
|
||||
## 配置(环境变量)
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `FLASK_SECRET` | 随机 | Flask session 密钥(生产必须设置一个长随机串) |
|
||||
| `FRPS_BIN` | `runtime/bin/frps` | frps 可执行文件路径 |
|
||||
| `FRPS_CONFIG` | `runtime/conf/frps.ini` | frps.ini 路径 |
|
||||
| `FRPS_SERVICE` | `frps` | systemd 单元名(不含 .service) |
|
||||
| `FRPS_DASHBOARD_URL` | `http://127.0.0.1:7500` | frps dashboard URL |
|
||||
| `FRPS_DASHBOARD_USER` | `admin` | dashboard 用户名 |
|
||||
| `FRPS_DASHBOARD_PASS` | `admin123` | dashboard 密码 |
|
||||
| `FRPS_BIND_PORT` | `7000` | frps 监听端口 |
|
||||
| `FRPS_WEB_ADDR` | `127.0.0.1` | dashboard 监听地址 |
|
||||
| `FRPS_WEB_PORT` | `7500` | dashboard 端口 |
|
||||
| `FRPS_VHOST_HTTP_PORT` | `80` | http 虚拟主机端口 |
|
||||
| `FRPS_VHOST_HTTPS_PORT` | `443` | https 虚拟主机端口 |
|
||||
| `FRPC_ADMIN_ADDR` | `127.0.0.1` | 写入 frpc.ini 的 admin API 地址 |
|
||||
| `FRPC_ADMIN_PORT` | `7400` | 写入 frpc.ini 的 admin API 端口(每台 frpc 主机必须唯一) |
|
||||
| `FRPC_ADMIN_USER` | `admin` | 写入 frpc.ini 的 admin 用户名 |
|
||||
| `FRPC_ADMIN_PWD` | `admin` | 写入 frpc.ini 的 admin 密码 |
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 改默认 admin 密码
|
||||
- dashboard(7500)只在 127.0.0.1 监听 — 如果要让远程看 dashboard,把它放到 nginx 反向代理后面 + HTTPS
|
||||
- Web UI(5390)同理,建议放到 nginx + HTTPS 后
|
||||
- 不要把 frps 暴露到公网 0.0.0.0:7000 而不设防火墙 — **每个 frpc 用同一个 server token**,谁拿到 token 都能连
|
||||
- 如果需要更强的隔离,部署多个 frps 实例在不同端口,每个 frps 一个 server token
|
||||
- `instance/server_token` 包含所有 frpc 的共享密钥,备份时加密
|
||||
|
||||
## 已知限制
|
||||
|
||||
- frps 0.70.1 INI 格式被官方 deprecation warning — 升级到 0.71+ 后只需把 `render_frps_ini` 改为 TOML
|
||||
- frpc 0.70.1 也用 INI(frpc 端 INI 一直支持到最新版本,不受影响)
|
||||
- Web UI 不支持批量操作(一次性添加多个代理)
|
||||
- 没有内嵌 traffic chart;如需图表请用 Prometheus + frps 自带的 `/metrics` 端点
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize the frps-manager database."""
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from app import app, db, seed_admin
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_admin()
|
||||
print("Database initialized successfully")
|
||||
@@ -0,0 +1,5 @@
|
||||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Werkzeug==3.0.1
|
||||
requests==2.31.0
|
||||
gunicorn==21.2.0
|
||||
@@ -0,0 +1,22 @@
|
||||
# Example frpc systemd unit. Copy to /etc/systemd/system/frpc@.service on
|
||||
# the frpc host, then:
|
||||
# systemctl daemon-reload
|
||||
# systemctl enable --now frpc@<client-name>.service
|
||||
#
|
||||
# The <instance> specifier lets you run multiple frpc instances per host.
|
||||
# Place per-instance configs under /etc/frpc/<instance>.ini.
|
||||
|
||||
[Unit]
|
||||
Description=frpc — fatedier/frp client (%i)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/frpc -c /etc/frpc/%i.ini
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=frps-manager — Flask web UI for managing frps
|
||||
Documentation=https://github.com/fatedier/frp
|
||||
After=network-online.target frps.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/fs/1000/ftp/Project/frps-manager
|
||||
Environment="FRPS_VHOST_HTTP_PORT=80"
|
||||
Environment="FRPS_VHOST_HTTPS_PORT=443"
|
||||
ExecStart=/fs/1000/ftp/Project/frps-manager/venv/bin/gunicorn \
|
||||
--workers 2 \
|
||||
--bind 0.0.0.0:5390 \
|
||||
--access-logfile /fs/1000/ftp/Project/frps-manager/runtime/logs/web-access.log \
|
||||
--error-logfile /fs/1000/ftp/Project/frps-manager/runtime/logs/web-error.log \
|
||||
--timeout 60 \
|
||||
wsgi:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=frps — frp server managed by frps-manager
|
||||
Documentation=https://github.com/fatedier/frp
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# frps 0.70 does not support SIGHUP reload — `systemctl reload` will be
|
||||
# turned into a full restart by systemd (ExecReload forces a fresh start).
|
||||
ExecStart=/fs/1000/ftp/Project/frps-manager/runtime/bin/frps \
|
||||
-c /fs/1000/ftp/Project/frps-manager/runtime/conf/frps.ini
|
||||
# frps 0.70 doesn't implement SIGHUP reload — the manager invokes
|
||||
# `systemctl restart frps.service` whenever a config change is applied.
|
||||
ExecReload=/usr/bin/systemctl restart frps.service
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
# Don't start before the web manager; if frps is down the web manager
|
||||
# still works (it can reload / restart frps).
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# frps-manager one-shot deploy script.
|
||||
# Installs frps + frps-manager as systemd services on a Debian/Ubuntu or RHEL box.
|
||||
# Run as root (or with sudo).
|
||||
#
|
||||
# Usage: sudo bash scripts/deploy.sh [--dry-run] [--no-systemd] [--dev]
|
||||
# What it does:
|
||||
# 1. Detects distro + package manager, installs system deps
|
||||
# 2. Downloads the latest stable frps binary if runtime/bin/frps is missing
|
||||
# 3. Creates a Python venv, installs requirements
|
||||
# 4. Initializes the SQLite database
|
||||
# 5. Generates a default frps.ini (admin user, default tenant, bindPort 7000)
|
||||
# 6. Installs + starts systemd services (frps + frps-manager on :5390)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=0
|
||||
NO_SYSTEMD=0
|
||||
DEV=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--no-systemd) NO_SYSTEMD=1 ;;
|
||||
--dev) DEV=1 ;;
|
||||
-h|--help)
|
||||
grep '^#' "$0" | sed 's/^# \?//'
|
||||
exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
log() { echo "[deploy] $*"; }
|
||||
die() { echo "[deploy][FATAL] $*" >&2; exit 1; }
|
||||
|
||||
# --- distro detection -------------------------------------------------------
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
OS_VER="$VERSION_ID"
|
||||
else
|
||||
die "Cannot detect /etc/os-release"
|
||||
fi
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian|raspbian) PKG=apt; SUDO=sudo ;;
|
||||
centos|rhel|rocky|almalinux|opencloudos|openanolis) PKG=yum; SUDO=sudo ;;
|
||||
fedora) PKG=dnf; SUDO=sudo ;;
|
||||
*)
|
||||
log "Unknown distro $OS_ID — assuming apt-compatible"
|
||||
PKG=apt; SUDO=sudo ;;
|
||||
esac
|
||||
[ "$(id -u)" -eq 0 ] && SUDO=""
|
||||
|
||||
log "Detected: $OS_ID $OS_VER (pkg manager: $PKG)"
|
||||
|
||||
# --- frps download ----------------------------------------------------------
|
||||
if [ ! -x runtime/bin/frps ]; then
|
||||
log "frps binary missing — fetching v0.70.1 from GitHub"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
log "DRY: would download frp_0.70.1_linux_amd64.tar.gz"
|
||||
else
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap "rm -rf $TMPDIR" EXIT
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) FRPS_ARCH=amd64 ;;
|
||||
aarch64|arm64) FRPS_ARCH=arm64 ;;
|
||||
armv7l|armv7) FRPS_ARCH=arm ;;
|
||||
*) die "Unsupported arch: $ARCH (please build/download frps manually)" ;;
|
||||
esac
|
||||
URL="https://github.com/fatedier/frp/releases/download/v0.70.1/frp_0.70.1_linux_${FRPS_ARCH}.tar.gz"
|
||||
log "Downloading $URL"
|
||||
curl -fSL --max-time 120 -o "$TMPDIR/frp.tgz" "$URL" \
|
||||
|| die "Failed to download frps"
|
||||
tar xzf "$TMPDIR/frp.tgz" -C "$TMPDIR"
|
||||
mkdir -p runtime/bin
|
||||
cp "$TMPDIR"/frp_0.70.1_linux_${FRPS_ARCH}/frps runtime/bin/frps
|
||||
chmod +x runtime/bin/frps
|
||||
log "Installed runtime/bin/frps"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- system deps ------------------------------------------------------------
|
||||
log "Installing system dependencies"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
case "$PKG" in
|
||||
apt)
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
$SUDO apt-get install -y python3 python3-venv python3-pip curl ca-certificates >/dev/null
|
||||
;;
|
||||
yum|dnf)
|
||||
$SUDO $PKG install -y python3 python3-pip python3-venv curl ca-certificates >/dev/null
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- venv -------------------------------------------------------------------
|
||||
if [ ! -d venv ]; then
|
||||
log "Creating Python venv"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install --upgrade pip >/dev/null
|
||||
venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- db init ----------------------------------------------------------------
|
||||
log "Initializing database"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
venv/bin/python init_db.py
|
||||
fi
|
||||
|
||||
# --- frps systemd -----------------------------------------------------------
|
||||
if [ "$NO_SYSTEMD" = 0 ]; then
|
||||
log "Installing systemd unit frps.service"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
$SUDO cp runtime/frps.service /etc/systemd/system/frps.service
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable frps.service
|
||||
$SUDO systemctl restart frps.service || true
|
||||
sleep 2
|
||||
if ! systemctl is-active --quiet frps.service; then
|
||||
log "frps.service not active — check 'journalctl -u frps'"
|
||||
else
|
||||
log "frps.service is active"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Installing systemd unit frps-manager.service (port 5390)"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
$SUDO cp runtime/frps-manager.service /etc/systemd/system/frps-manager.service
|
||||
$SUDO systemctl daemon-reload
|
||||
$SUDO systemctl enable frps-manager.service
|
||||
$SUDO systemctl restart frps-manager.service || true
|
||||
sleep 2
|
||||
if ! systemctl is-active --quiet frps-manager.service; then
|
||||
log "frps-manager.service not active — check 'journalctl -u frps-manager'"
|
||||
else
|
||||
log "frps-manager.service is active on http://0.0.0.0:5390"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "Skipped systemd (--no-systemd)"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
================================================================
|
||||
frps-manager deployed!
|
||||
|
||||
Web UI: http://<this-host>:5390
|
||||
Default: admin / admin (CHANGE IMMEDIATELY under "改密")
|
||||
|
||||
frps bind: 0.0.0.0:7000
|
||||
frps dashboard: 127.0.0.1:7500 (admin / admin123)
|
||||
frps vhost_http: 0.0.0.0:80 (set FRPS_VHOST_HTTP_PORT in service to override)
|
||||
frps vhost_https: 0.0.0.0:443
|
||||
|
||||
To customize, edit:
|
||||
/etc/systemd/system/frps.service (frps CLI flags)
|
||||
/etc/systemd/system/frps-manager.service (Web UI port / env)
|
||||
|
||||
To upgrade later:
|
||||
cd $PROJECT_DIR
|
||||
sudo systemctl stop frps-manager frps
|
||||
git pull
|
||||
venv/bin/pip install -r requirements.txt
|
||||
sudo systemctl start frps frps-manager
|
||||
================================================================
|
||||
EOF
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local dev launcher — runs frps-manager on :5300 with gunicorn using the
|
||||
# project venv. Use this when iterating without systemd.
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
mkdir -p runtime/conf runtime/logs instance
|
||||
|
||||
if [ ! -x runtime/bin/frps ]; then
|
||||
echo "[dev] runtime/bin/frps missing — running deploy.sh to fetch it"
|
||||
bash scripts/deploy.sh --no-systemd --dry-run || true
|
||||
fi
|
||||
|
||||
if [ ! -d venv ]; then
|
||||
echo "[dev] venv missing — create one and install requirements"
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
# Ensure frps service is *not* taking port 7000/7500 while we develop
|
||||
if systemctl is-active --quiet frps.service 2>/dev/null; then
|
||||
echo "[dev] stopping existing frps.service so dev frps can take port 7000"
|
||||
sudo systemctl stop frps.service
|
||||
fi
|
||||
|
||||
# Start frps under the dev user (not as a daemon) if not already running
|
||||
if ! ss -tlnp 2>/dev/null | grep -q ':7000 '; then
|
||||
echo "[dev] starting frps from runtime/conf/frps.toml"
|
||||
mkdir -p runtime/conf
|
||||
if [ ! -s runtime/conf/frps.toml ]; then
|
||||
venv/bin/python -c "
|
||||
import os
|
||||
os.environ.setdefault('FRPS_CONFIG', os.path.abspath('runtime/conf/frps.toml'))
|
||||
from app import write_frps_config, FRPS_CONFIG
|
||||
write_frps_config()
|
||||
print('wrote', os.environ.get('FRPS_CONFIG'))
|
||||
"
|
||||
fi
|
||||
nohup runtime/bin/frps -c runtime/conf/frps.toml > runtime/logs/frps.log 2>&1 &
|
||||
echo $! > runtime/logs/frps.pid
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Initialize DB (idempotent)
|
||||
venv/bin/python init_db.py
|
||||
|
||||
echo "[dev] launching gunicorn on :5300"
|
||||
exec venv/bin/gunicorn \
|
||||
--workers 2 \
|
||||
--bind 0.0.0.0:5300 \
|
||||
--reload \
|
||||
--access-logfile runtime/logs/web-access.log \
|
||||
--error-logfile runtime/logs/web-error.log \
|
||||
wsgi:app
|
||||
@@ -0,0 +1,418 @@
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--card: #ffffff;
|
||||
--border: #e3e6ed;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--success: #16a34a;
|
||||
--warn: #f59e0b;
|
||||
--danger: #dc2626;
|
||||
--off: #9ca3af;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.navbar {
|
||||
background: linear-gradient(135deg, #1e293b, #0f172a);
|
||||
color: #fff;
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.nav-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
gap: 24px;
|
||||
height: 56px;
|
||||
}
|
||||
.brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-links a {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.nav-links a:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.nav-links a.active { color: #fff; background: rgba(255,255,255,0.12); }
|
||||
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.nav-user a {
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.nav-user a:hover { color: #fff; background: rgba(255,255,255,0.08); }
|
||||
.user-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 24px auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
h1 { font-size: 22px; margin: 0 0 20px; font-weight: 600; }
|
||||
h2 { font-size: 16px; margin: 24px 0 12px; font-weight: 600; }
|
||||
h1 + section, h2 + section, section + section { margin-top: 16px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-header h1 { margin: 0; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.btn:hover { background: #f9fafb; border-color: #d1d5db; }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||
.btn-warning { background: var(--warn); color: #fff; border-color: var(--warn); }
|
||||
.btn-warning:hover { background: #d97706; border-color: #d97706; }
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
.link-btn.link-danger { color: var(--danger); }
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.table th, .table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.table thead th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.table tbody tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover { background: #f9fafb; }
|
||||
.table code {
|
||||
font-size: 12px;
|
||||
background: #f1f5f9;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.token-cell {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace;
|
||||
font-size: 11px !important;
|
||||
word-break: break-all;
|
||||
display: inline-block;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.actions { white-space: nowrap; }
|
||||
.actions form, .actions a { margin-right: 8px; }
|
||||
|
||||
/* Cards & stats */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
.stat-label { color: var(--muted); font-size: 12px; }
|
||||
.stat-value { font-size: 20px; font-weight: 600; margin-top: 4px; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-card {
|
||||
background: var(--card);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 720px;
|
||||
}
|
||||
.form-card label {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
.form-card input[type=text],
|
||||
.form-card input[type=password],
|
||||
.form-card input[type=number],
|
||||
.form-card input:not([type]),
|
||||
.form-card select,
|
||||
.form-card textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-card input:focus, .form-card select:focus, .form-card textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
|
||||
}
|
||||
.form-card input[disabled] { background: #f3f4f6; color: var(--muted); }
|
||||
.form-card label.checkbox {
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.form-card label.checkbox input { width: auto; margin: 0; }
|
||||
.form-card fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.form-card fieldset legend {
|
||||
padding: 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Badges & tags */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-ok { background: #dcfce7; color: #166534; }
|
||||
.badge-off { background: #f3f4f6; color: var(--off); }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tag-tcp { background: #dbeafe; color: #1e40af; }
|
||||
.tag-udp { background: #fef3c7; color: #92400e; }
|
||||
.tag-http { background: #dcfce7; color: #166534; }
|
||||
.tag-https { background: #f3e8ff; color: #6b21a8; }
|
||||
.tag-tcpmux { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.role-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #e2e8f0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.role-admin { background: #fee2e2; color: #991b1b; }
|
||||
.role-tenant_admin { background: #fef3c7; color: #92400e; }
|
||||
.role-user { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.action-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Flash messages */
|
||||
.flash-area { margin-bottom: 16px; }
|
||||
.flash {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid var(--primary);
|
||||
background: #eff6ff;
|
||||
color: #1e40af;
|
||||
}
|
||||
.flash-success { border-color: var(--success); background: #f0fdf4; color: #166534; }
|
||||
.flash-error { border-color: var(--danger); background: #fef2f2; color: #991b1b; }
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.alert-error { background: #fef2f2; color: #991b1b; border-left: 3px solid var(--danger); }
|
||||
|
||||
/* Login */
|
||||
.login-card {
|
||||
max-width: 360px;
|
||||
margin: 80px auto;
|
||||
background: var(--card);
|
||||
padding: 32px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.login-card h1 { margin: 0 0 4px; }
|
||||
.login-card form { text-align: left; margin-top: 20px; }
|
||||
.login-card form label { display: block; margin-bottom: 12px; }
|
||||
.login-card form input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.login-card button { width: 100%; margin-top: 8px; }
|
||||
|
||||
/* Misc */
|
||||
.muted { color: var(--muted); }
|
||||
.muted.small { font-size: 12px; }
|
||||
.offline { color: var(--danger); font-weight: 600; }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-bar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.filter-bar select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination a {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.error-card h1 { font-size: 60px; color: var(--muted); margin: 0; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}frps-manager{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-inner">
|
||||
<a class="brand" href="{{ url_for('dashboard') }}">frps-manager</a>
|
||||
{% if user %}
|
||||
<ul class="nav-links">
|
||||
<li><a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' else '' }}">仪表盘</a></li>
|
||||
{% if user.role == 'admin' %}
|
||||
<li><a href="{{ url_for('tenants_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('tenants') else '' }}">租户</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('clients_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('clients') else '' }}">客户端</a></li>
|
||||
<li><a href="{{ url_for('proxies_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('proxies') else '' }}">代理</a></li>
|
||||
{% if user.role == 'admin' %}
|
||||
<li><a href="{{ url_for('users_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('users') else '' }}">用户</a></li>
|
||||
<li><a href="{{ url_for('logs_list') }}" class="{{ 'active' if request.endpoint and request.endpoint.startswith('logs') else '' }}">日志</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('frps_config_view') }}" target="_blank">配置</a></li>
|
||||
</ul>
|
||||
<div class="nav-user">
|
||||
<span class="user-badge">
|
||||
{{ user.username }}
|
||||
<span class="role-tag role-{{ user.role }}">{{ user.role }}</span>
|
||||
</span>
|
||||
<a href="{{ url_for('change_password') }}">改密</a>
|
||||
<a href="{{ url_for('logout') }}">登出</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-area">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
frps-manager · frps 0.70+ · {{ now }}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}修改密码 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>修改密码</h1>
|
||||
<form method="post" class="form-card">
|
||||
<label>当前密码 <input name="old_password" type="password" required></label>
|
||||
<label>新密码 (至少 6 位) <input name="new_password" type="password" required minlength="6"></label>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">保存</button>
|
||||
<a class="btn" href="{{ url_for('dashboard') }}">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if item else '新建' }}客户端 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ '编辑客户端' if item else '新建客户端 (frpc 注册)' }}</h1>
|
||||
<form method="post" class="form-card">
|
||||
{% if item %}
|
||||
<label>名称 (frpc user) <input value="{{ item.name }}" disabled></label>
|
||||
<label>租户
|
||||
<select name="tenant_id">
|
||||
{% for t in tenants %}
|
||||
<option value="{{ t.id }}" {% if t.id == item.tenant_id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% else %}
|
||||
<label>租户
|
||||
<select name="tenant_id" required>
|
||||
{% for t in tenants %}<option value="{{ t.id }}">{{ t.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>名称 (frpc user 字段)
|
||||
<input name="name" required pattern="[a-zA-Z0-9_.-]+" title="字母数字下划线短横线点">
|
||||
</label>
|
||||
{% endif %}
|
||||
<label>frps 地址 (文档用)
|
||||
<input name="server_addr" value="{{ item.server_addr or '' if item else '' }}"
|
||||
placeholder="例如 your.frps.host">
|
||||
</label>
|
||||
<label>描述 <input name="description" value="{{ item.description or '' if item else '' }}"></label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="is_active" {% if item is none or item.is_active %}checked{% endif %}>
|
||||
启用(禁用后 frpc 即使配了相同 token 也会被 dashboard 列为 inactive)
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">保存</button>
|
||||
<a class="btn" href="{{ url_for('clients_list') }}">取消</a>
|
||||
</div>
|
||||
{% if item %}
|
||||
<p class="muted small">
|
||||
创建后请到客户端列表页点 <strong>frpc.toml</strong> 按钮下载配置。
|
||||
frpc 端不需要填 token — server token 是共享的,在仪表盘上能看到。
|
||||
</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}客户端 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>客户端 (frpc)</h1>
|
||||
<a class="btn btn-primary" href="{{ url_for('clients_new') }}">+ 新建客户端</a>
|
||||
</div>
|
||||
|
||||
{% if tenants|length > 1 %}
|
||||
<form method="get" class="filter-bar">
|
||||
<label>租户
|
||||
<select name="tenant_id" onchange="this.form.submit()">
|
||||
<option value="">全部</option>
|
||||
{% for t in tenants %}
|
||||
<option value="{{ t.id }}" {% if filter_tenant_id == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称 (frpc user)</th><th>租户</th><th>在线</th><th>NAT</th><th>版本</th><th>frpc 连接</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ c.name }}</strong>
|
||||
<br><span class="muted small">{{ c.description or '' }}</span>
|
||||
{% if c.server_addr %}<br><span class="muted small">frps: {{ c.server_addr }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ c.tenant.name }}</td>
|
||||
<td>
|
||||
{% if c._online %}
|
||||
<span class="badge badge-ok">在线</span>
|
||||
<span class="muted small">{{ c._version }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-off">离线</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ c._nat }}</td>
|
||||
<td>{{ c._version }}</td>
|
||||
<td>{{ c._conn_count }}</td>
|
||||
<td>{% if c.is_active %}<span class="badge badge-ok">启用</span>{% else %}<span class="badge badge-off">禁用</span>{% endif %}</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('clients_frpc_config', cid=c.id) }}" target="_blank">frpc.toml</a>
|
||||
<a href="{{ url_for('clients_edit', cid=c.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('clients_delete', cid=c.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('删除客户端 {{ c.name }} ?注意:frps 0.70 用共享 token,删除注册不会立即断开已连的 frpc。');">
|
||||
<button class="link-btn link-danger">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">暂无客户端</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section style="margin-top: 24px;">
|
||||
<h2>使用流程</h2>
|
||||
<ol class="muted">
|
||||
<li>复制上面任意客户端的 <strong>frpc.toml</strong> 到目标 frpc 主机</li>
|
||||
<li>在该主机的 frpc.toml 末尾追加 <code>[[proxies]]</code> 段定义要暴露的本地服务</li>
|
||||
<li><code>systemctl enable --now frpc</code> 或 <code>frpc -c /etc/frpc.toml</code></li>
|
||||
<li>回到仪表盘或「在线代理」页,应能看到客户端上线</li>
|
||||
</ol>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}仪表盘 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>仪表盘</h1>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">frps 版本</div>
|
||||
<div class="stat-value">
|
||||
{% if info.error %}
|
||||
<span class="offline">离线</span>
|
||||
{% else %}
|
||||
{{ info.server.version }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">监听端口</div>
|
||||
<div class="stat-value">
|
||||
{% if info.error %}-{% else %}{{ info.server.bindPort }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">在线 frpc 客户端</div>
|
||||
<div class="stat-value">
|
||||
{% if info.error %}-{% else %}{{ info.clients.total }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">活跃代理</div>
|
||||
<div class="stat-value">
|
||||
{% if info.error %}-{% else %}{{ info.proxies.total }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">租户 / 客户端 / 代理</div>
|
||||
<div class="stat-value">{{ tenant_count }} / {{ client_count }} / {{ proxy_count }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if info.error %}
|
||||
<div class="alert alert-error">
|
||||
无法连接 frps dashboard({{ info.error }})。请确认 frps 服务已启动且 dashboard 配置正确。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.role == 'admin' %}
|
||||
<section class="actions">
|
||||
<h2>服务控制</h2>
|
||||
<form method="post" action="{{ url_for('frps_reload') }}" style="display:inline">
|
||||
<button class="btn">重写 frps.ini 并重启</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('frps_rotate_token') }}" style="display:inline"
|
||||
onsubmit="return confirm('旋转 server token 会让所有 frpc 立刻断连,确定?');">
|
||||
<button class="btn btn-warning">旋转 server token</button>
|
||||
</form>
|
||||
<a class="btn" href="{{ url_for('frps_config_view') }}" target="_blank">查看 frps.ini</a>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Server Token(所有 frpc 共享)</h2>
|
||||
<p class="muted small">frpc 配置 <code>token = ...</code> 字段用此值。轮换会让所有连接断掉。</p>
|
||||
<code class="token-cell">{{ server_token }}</code>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2>在线 frpc 客户端</h2>
|
||||
{% if info.error or not info.clients.get("items") %}
|
||||
<p class="muted">尚无客户端连接</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>用户 (frpc [common] user)</th><th>客户端 ID</th><th>版本</th><th>NAT</th><th>连接数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in info.clients.get("items", []) %}
|
||||
<tr>
|
||||
<td><code>{{ c.user or '(unnamed)' }}</code></td>
|
||||
<td><code>{{ c.clientID[:12] }}…</code></td>
|
||||
<td>{{ c.version }}</td>
|
||||
<td>{{ c.nat }}</td>
|
||||
<td>{{ c.conn_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>活跃代理(来自 frps dashboard)</h2>
|
||||
{% if info.error or not info.proxies.get("items") %}
|
||||
<p class="muted">尚无活跃代理</p>
|
||||
{% else %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>类型</th><th>状态</th><th>流量 in</th><th>流量 out</th><th>来自 frpc</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in info.proxies.get("items", []) %}
|
||||
<tr>
|
||||
<td><code>{{ p.name }}</code></td>
|
||||
<td><span class="tag tag-{{ p.type }}">{{ p.type }}</span></td>
|
||||
<td>
|
||||
{% if p.status.phase == 'online' %}<span class="badge badge-ok">在线</span>
|
||||
{% else %}<span class="badge badge-off">离线</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ p.status.todayTrafficIn|fmt_bytes }}</td>
|
||||
<td>{{ p.status.todayTrafficOut|fmt_bytes }}</td>
|
||||
<td><code>{{ p.clientID[:12] or '-' }}…</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ code }} · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="error-card">
|
||||
<h1>{{ code }}</h1>
|
||||
<p class="muted">{{ message }}</p>
|
||||
<a class="btn" href="{{ url_for('dashboard') }}">回首页</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by frps-manager for client: {{ client.name }}
|
||||
# Copy to your frpc host (e.g. /etc/frpc.toml), then:
|
||||
# systemctl enable --now frpc # if you used the example systemd unit
|
||||
# # OR directly:
|
||||
# frpc -c /etc/frpc.toml
|
||||
#
|
||||
# IMPORTANT:
|
||||
# * serverAddr / serverPort / auth.token come from your admin — do not change
|
||||
# unless the admin told you to.
|
||||
# * All clients use the SAME shared token; frpc identifies itself via `user`.
|
||||
# * Append your own [[proxies]] blocks below this line to expose local services.
|
||||
|
||||
serverAddr = "{{ server_addr }}"
|
||||
serverPort = {{ bind_port }}
|
||||
|
||||
[auth]
|
||||
method = "token"
|
||||
token = "{{ server_token }}"
|
||||
|
||||
# Optional transport defaults (uncomment if you want them)
|
||||
# transport.useEncryption = true
|
||||
# transport.useCompression = true
|
||||
|
||||
[webServer]
|
||||
addr = "127.0.0.1"
|
||||
port = 7400
|
||||
user = "admin"
|
||||
password = "admin"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Add your proxies below. Examples:
|
||||
#
|
||||
# [[proxies]]
|
||||
# name = "ssh"
|
||||
# type = "tcp"
|
||||
# localIP = "127.0.0.1"
|
||||
# localPort = 22
|
||||
# remotePort = 6000
|
||||
#
|
||||
# [[proxies]]
|
||||
# name = "web"
|
||||
# type = "http"
|
||||
# localIP = "127.0.0.1"
|
||||
# localPort = 8080
|
||||
# customDomains = ["your.domain.example.com"]
|
||||
# ---------------------------------------------------------------
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="login-card">
|
||||
<h1>frps-manager</h1>
|
||||
<p class="muted">frp 内网穿透 Web 管理控制台</p>
|
||||
<form method="post">
|
||||
<label>用户名 <input name="username" autofocus required></label>
|
||||
<label>密码 <input name="password" type="password" required></label>
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
</form>
|
||||
<p class="muted small">默认管理员 admin / admin(首次登录后请立刻改密)</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}审计日志 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>审计日志</h1>
|
||||
<p class="muted small">共 {{ total }} 条</p>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>用户</th><th>动作</th><th>对象</th><th>详情</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in items %}
|
||||
<tr>
|
||||
<td>{{ e.created_at|fmt_dt }}</td>
|
||||
<td>{{ e.username or '-' }}</td>
|
||||
<td><span class="action-tag">{{ e.action }}</span></td>
|
||||
<td>
|
||||
{{ e.target_type or '-' }}
|
||||
{% if e.target_label %}<br><code>{{ e.target_label }}</code>{% endif %}
|
||||
</td>
|
||||
<td>{{ e.detail or '' }}</td>
|
||||
<td><code>{{ e.ip or '-' }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total > per %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}<a href="?page={{ page-1 }}">上一页</a>{% endif %}
|
||||
<span class="muted">第 {{ page }} 页 / 共 {{ ((total + per - 1) // per) }} 页</span>
|
||||
{% if page * per < total %}<a href="?page={{ page+1 }}">下一页</a>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}代理 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>代理规则</h1>
|
||||
<a class="btn btn-primary" href="{{ url_for('proxies_new') }}">+ 新建代理</a>
|
||||
</div>
|
||||
|
||||
{% if tenants|length > 1 or clients|length > 1 %}
|
||||
<form method="get" class="filter-bar">
|
||||
{% if tenants|length > 1 %}
|
||||
<label>租户
|
||||
<select name="tenant_id" onchange="this.form.submit()">
|
||||
<option value="">全部</option>
|
||||
{% for t in tenants %}
|
||||
<option value="{{ t.id }}" {% if filter_tenant_id == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
<label>客户端
|
||||
<select name="client_id" onchange="this.form.submit()">
|
||||
<option value="">全部</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if filter_client_id == c.id %}selected{% endif %}>{{ c.name }} ({{ c.tenant.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>类型</th><th>客户端</th><th>远端</th><th>本地</th><th>在线</th><th>启用</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ p.name }}</strong>
|
||||
{% if p.note %}<br><span class="muted small">{{ p.note }}</span>{% endif %}
|
||||
</td>
|
||||
<td><span class="tag tag-{{ p.type }}">{{ p.type }}</span></td>
|
||||
<td>{{ p.client.name }}<br><span class="muted small">{{ p.client.tenant.name }}</span></td>
|
||||
<td>
|
||||
{% if p.type in ('tcp','udp') %}
|
||||
<code>:{{ p.remote_port or '?' }}</code>
|
||||
{% elif p.subdomain %}
|
||||
<code>{{ p.subdomain }}.<base></code>
|
||||
{% elif p.custom_domains %}
|
||||
<code>{{ p.custom_domains.split(',')[0] }}{% if p.custom_domains.count(',') > 0 %} ...{% endif %}</code>
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td><code>{{ p.local_ip }}:{{ p.local_port }}</code></td>
|
||||
<td>
|
||||
{% if p.name in online_names %}<span class="badge badge-ok">在线</span>
|
||||
{% else %}<span class="badge badge-off">离线</span>{% endif %}
|
||||
</td>
|
||||
<td>{% if p.is_enabled %}<span class="badge badge-ok">启用</span>{% else %}<span class="badge badge-off">禁用</span>{% endif %}</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('proxies_edit', pid=p.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('proxies_toggle', pid=p.id) }}" style="display:inline">
|
||||
<button class="link-btn">{% if p.is_enabled %}禁用{% else %}启用{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('proxies_delete', pid=p.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('删除代理 {{ p.name }} ?');">
|
||||
<button class="link-btn link-danger">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="muted">暂无代理</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section style="margin-top: 24px;">
|
||||
<p class="muted small">
|
||||
修改代理规则后,对应 frpc 主机需要重新拉取 <code>frpc.ini</code> 并执行 <code>frpc reload -c /etc/frpc.ini</code> 让新代理生效。
|
||||
仪表盘上的「在线」状态来自 frps 实时报告,需要 frpc reload 后才会刷新。
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if item else '新建' }}代理 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ '编辑代理' if item else '新建代理规则' }}</h1>
|
||||
<form method="post" class="form-card">
|
||||
{% if item %}
|
||||
<label>名称 <input value="{{ item.name }}" disabled></label>
|
||||
<label>类型 <input value="{{ item.type }}" disabled></label>
|
||||
<label>客户端 <input value="{{ item.client.name }} ({{ item.client.tenant.name }})" disabled></label>
|
||||
{% else %}
|
||||
<label>客户端
|
||||
<select name="client_id" required>
|
||||
{% for c in clients %}<option value="{{ c.id }}" {% if prefill_client == c.id %}selected{% endif %}>{{ c.name }} ({{ c.tenant.name }})</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>名称 <input name="name" required pattern="[a-zA-Z0-9_.-]+"></label>
|
||||
<label>类型
|
||||
<select name="type" required>
|
||||
{% for t in types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>本地服务(frpc 那台机器上的)</legend>
|
||||
<label>本地 IP <input name="local_ip" value="{{ item.local_ip if item else '127.0.0.1' }}"></label>
|
||||
<label>本地端口 <input name="local_port" type="number" min="1" max="65535" required value="{{ item.local_port if item else '' }}"></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>对外暴露(frps 上的端口 / 域名)</legend>
|
||||
<label>远端端口 (tcp/udp 专用) <input name="remote_port" type="number" min="1" max="65535" value="{{ item.remote_port or '' }}"></label>
|
||||
<label>custom_domains (http/https,逗号分隔) <input name="custom_domains" value="{{ item.custom_domains or '' }}" placeholder="a.example.com,b.example.com"></label>
|
||||
<label>subdomain (http/https) <input name="subdomain" value="{{ item.subdomain or '' }}"></label>
|
||||
<label>locations (http 路径,逗号分隔) <input name="locations" value="{{ item.locations or '' }}" placeholder="/api,/static"></label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>传输 / 健康检查</legend>
|
||||
<label class="checkbox"><input type="checkbox" name="use_encryption" {% if item and item.use_encryption %}checked{% endif %}> 启用加密</label>
|
||||
<label class="checkbox"><input type="checkbox" name="use_compression" {% if item and item.use_compression %}checked{% endif %}> 启用压缩</label>
|
||||
<label>健康检查类型
|
||||
<select name="health_check_type">
|
||||
<option value="">不启用</option>
|
||||
<option value="tcp" {% if item and item.health_check_type == 'tcp' %}selected{% endif %}>tcp</option>
|
||||
<option value="http" {% if item and item.health_check_type == 'http' %}selected{% endif %}>http</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>健康检查 URL (http 类型) <input name="health_check_url" value="{{ item.health_check_url or '' }}" placeholder="/health"></label>
|
||||
<label>健康检查间隔(秒) <input name="health_check_interval_s" type="number" min="5" value="{{ item.health_check_interval_s if item else 30 }}"></label>
|
||||
<label>健康检查超时(秒) <input name="health_check_timeout_s" type="number" min="1" value="{{ item.health_check_timeout_s if item else 3 }}"></label>
|
||||
</fieldset>
|
||||
|
||||
<label>备注 <textarea name="note" rows="2">{{ item.note or '' if item else '' }}</textarea></label>
|
||||
<label class="checkbox"><input type="checkbox" name="is_enabled" {% if item is none or item.is_enabled %}checked{% endif %}> 启用</label>
|
||||
|
||||
<p class="muted small">
|
||||
保存后,frpc 主机的 <code>frpc.ini</code> 需要重新拉取(点客户端列表的 <strong>frpc.ini</strong> 按钮),
|
||||
然后在 frpc 主机执行 <code>frpc reload -c /etc/frpc.ini</code> 让新代理生效。
|
||||
</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">保存</button>
|
||||
<a class="btn" href="{{ url_for('proxies_list') }}">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if item else '新建' }}租户 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ '编辑租户' if item else '新建租户' }}</h1>
|
||||
<form method="post" class="form-card">
|
||||
{% if item %}
|
||||
<label>名称 <input value="{{ item.name }}" disabled></label>
|
||||
{% else %}
|
||||
<label>名称 <input name="name" required pattern="[a-zA-Z0-9_-]+" title="字母数字下划线短横线"></label>
|
||||
{% endif %}
|
||||
<label>描述 <input name="description" value="{{ item.description or '' }}"></label>
|
||||
<label>入站带宽 (Mbps, 0=不限) <input name="bandwidth_in_mbps" type="number" min="0" value="{{ item.bandwidth_in_mbps if item else 0 }}"></label>
|
||||
<label>出站带宽 (Mbps, 0=不限) <input name="bandwidth_out_mbps" type="number" min="0" value="{{ item.bandwidth_out_mbps if item else 0 }}"></label>
|
||||
<label>最大并发连接 (0=不限) <input name="max_connections" type="number" min="0" value="{{ item.max_connections if item else 0 }}"></label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="is_active" {% if item is none or item.is_active %}checked{% endif %}>
|
||||
启用
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">保存</button>
|
||||
<a class="btn" href="{{ url_for('tenants_list') }}">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}租户 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>租户</h1>
|
||||
<a class="btn btn-primary" href="{{ url_for('tenants_new') }}">+ 新建租户</a>
|
||||
</div>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>名称</th><th>带宽 in/out</th><th>最大连接</th><th>客户端数</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in items %}
|
||||
<tr>
|
||||
<td><strong>{{ t.name }}</strong><br><span class="muted small">{{ t.description or '' }}</span></td>
|
||||
<td>{{ t.bandwidth_in_mbps|fmt_mbps }} / {{ t.bandwidth_out_mbps|fmt_mbps }}</td>
|
||||
<td>{{ t.max_connections or 'unlimited' }}</td>
|
||||
<td>{{ t.clients|length }}</td>
|
||||
<td>{% if t.is_active %}<span class="badge badge-ok">启用</span>{% else %}<span class="badge badge-off">禁用</span>{% endif %}</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('tenants_edit', tid=t.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('tenants_delete', tid=t.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('确定删除租户 {{ t.name }} 吗?');">
|
||||
<button class="link-btn link-danger">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="muted">暂无租户</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ '编辑' if item else '新建' }}用户 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ '编辑用户' if item else '新建用户' }}</h1>
|
||||
<form method="post" class="form-card">
|
||||
{% if item %}
|
||||
<label>用户名 <input value="{{ item.username }}" disabled></label>
|
||||
{% else %}
|
||||
<label>用户名 <input name="username" required pattern="[a-zA-Z0-9_.-]+"></label>
|
||||
{% endif %}
|
||||
<label>显示名 <input name="display_name" value="{{ item.display_name or '' if item else '' }}"></label>
|
||||
<label>密码 {% if item %}<span class="muted small">(留空表示不修改)</span>{% endif %}
|
||||
<input name="password" type="password" {% if not item %}required{% endif %} minlength="6">
|
||||
</label>
|
||||
<label>角色
|
||||
<select name="role">
|
||||
<option value="user" {% if item and item.role == 'user' %}selected{% endif %}>user (普通用户,只能看自己的租户)</option>
|
||||
<option value="tenant_admin" {% if item and item.role == 'tenant_admin' %}selected{% endif %}>tenant_admin (租户管理员)</option>
|
||||
<option value="admin" {% if item and item.role == 'admin' %}selected{% endif %}>admin (系统管理员)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>归属租户
|
||||
<select name="tenant_id">
|
||||
<option value="">(不绑定)</option>
|
||||
{% for t in tenants %}
|
||||
<option value="{{ t.id }}" {% if item and item.tenant_id == t.id %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox"><input type="checkbox" name="is_active" {% if item is none or item.is_active %}checked{% endif %}> 启用</label>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" type="submit">保存</button>
|
||||
<a class="btn" href="{{ url_for('users_list') }}">取消</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}用户 · frps-manager{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>用户管理</h1>
|
||||
<a class="btn btn-primary" href="{{ url_for('users_new') }}">+ 新建用户</a>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>用户名</th><th>角色</th><th>归属租户</th><th>状态</th><th>最近登录</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in items %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong><br><span class="muted small">{{ u.display_name or '' }}</span></td>
|
||||
<td><span class="role-tag role-{{ u.role }}">{{ u.role }}</span></td>
|
||||
<td>{{ u.tenant.name if u.tenant else '-' }}</td>
|
||||
<td>{% if u.is_active %}<span class="badge badge-ok">启用</span>{% else %}<span class="badge badge-off">禁用</span>{% endif %}</td>
|
||||
<td>{{ u.last_login_at|fmt_dt }}</td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('users_edit', uid=u.id) }}">编辑</a>
|
||||
<form method="post" action="{{ url_for('users_delete', uid=u.id) }}" style="display:inline"
|
||||
onsubmit="return confirm('删除用户 {{ u.username }} 吗?');">
|
||||
<button class="link-btn link-danger">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
os.makedirs(os.path.join(os.path.dirname(__file__), "instance"), exist_ok=True)
|
||||
|
||||
from app import app, db, seed_admin
|
||||
|
||||
# Initialize DB - safe for multi-worker gunicorn
|
||||
with app.app_context():
|
||||
try:
|
||||
db.create_all()
|
||||
seed_admin()
|
||||
except Exception as e:
|
||||
print(f"[wsgi] DB init: {e} (likely race condition with another worker, safe to ignore)")
|
||||
Reference in New Issue
Block a user