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:
Your Name
2026-08-10 14:17:49 +08:00
commit 71214524f4
27 changed files with 2847 additions and 0 deletions
+37
View File
@@ -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
+204
View File
@@ -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.1GitHub: 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:7000dashboard 在 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 密码
- dashboard7500)只在 127.0.0.1 监听 — 如果要让远程看 dashboard,把它放到 nginx 反向代理后面 + HTTPS
- Web UI5390)同理,建议放到 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 也用 INIfrpc 端 INI 一直支持到最新版本,不受影响)
- Web UI 不支持批量操作(一次性添加多个代理)
- 没有内嵌 traffic chart;如需图表请用 Prometheus + frps 自带的 `/metrics` 端点
## License
MIT
+1178
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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")
+5
View File
@@ -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
+22
View File
@@ -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
+24
View File
@@ -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
+29
View File
@@ -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
+171
View File
@@ -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
+55
View File
@@ -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
+418
View File
@@ -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;
}
+57
View File
@@ -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>
+13
View File
@@ -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 %}
+45
View File
@@ -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 %}
+71
View File
@@ -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 %}
+118
View File
@@ -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 %}
+9
View File
@@ -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 %}
+46
View File
@@ -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"]
# ---------------------------------------------------------------
+14
View File
@@ -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 %}
+33
View File
@@ -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 %}
+83
View File
@@ -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 }}.&lt;base&gt;</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 %}
+67
View File
@@ -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 %}
+24
View File
@@ -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 %}
+34
View File
@@ -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 %}
+36
View File
@@ -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 %}
+31
View File
@@ -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 %}
+12
View File
@@ -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)")