commit 71214524f4e485d239ddfe8104a5e5ff221d731e Author: Your Name Date: Mon Aug 10 14:17:49 2026 +0800 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ced5124 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ae62dd --- /dev/null +++ b/README.md @@ -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 /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://: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 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..48fbfd8 --- /dev/null +++ b/app.py @@ -0,0 +1,1178 @@ +#!/usr/bin/env python3 +"""frps-manager — Flask + SQLite Web management UI for frp server (frps 0.70.1). + +Design notes: + fatedier/frp 0.70.1 has a broken TOML auth story: + * The TOML parser does NOT accept the dot-notation "auth.token" key + (despite what the official example shows). + * The --token CLI flag sets c.Auth.Token but is then OVERWRITTEN by + LoadConfigureFromFile which always re-inits ServerConfig from scratch. + * Only the legacy INI format actually loads token correctly. + Therefore we generate frps.ini (legacy INI) — the example file warns it + is deprecated, but until 0.70+ fixes the TOML auth, INI is the only + option that actually works. + + Same applies to frpc.ini: the verified working config is INI legacy. + + This file does NOT include [[proxies]] blocks — frps 0.70+ has no + server-side proxy config; all proxies are pushed by frpc at connection. +""" +import configparser +import io +import os +import secrets +import string +import subprocess +import time +from datetime import datetime, timedelta +from functools import wraps + +import requests +from flask import (Flask, Response, abort, flash, g, jsonify, redirect, + render_template, request, session, url_for) +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import check_password_hash, generate_password_hash + +# ---------------------------------------------------------------------------- +# Paths & config +# ---------------------------------------------------------------------------- +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +RUNTIME_DIR = os.path.join(BASE_DIR, "runtime") +CONF_DIR = os.path.join(RUNTIME_DIR, "conf") +LOG_DIR = os.path.join(RUNTIME_DIR, "logs") +BIN_DIR = os.path.join(RUNTIME_DIR, "bin") +INSTANCE_DIR = os.path.join(BASE_DIR, "instance") +SERVER_TOKEN_FILE = os.path.join(INSTANCE_DIR, "server_token") + +FRPS_BIN = os.environ.get("FRPS_BIN", os.path.join(BIN_DIR, "frps")) +# frps 0.70.1's TOML parser doesn't accept auth.token, so we use legacy INI +FRPS_CONFIG = os.environ.get("FRPS_CONFIG", os.path.join(CONF_DIR, "frps.ini")) +FRPS_SERVICE = os.environ.get("FRPS_SERVICE", "frps") +FRPS_DASHBOARD_URL = os.environ.get( + "FRPS_DASHBOARD_URL", "http://127.0.0.1:7500") +FRPS_DASHBOARD_USER = os.environ.get("FRPS_DASHBOARD_USER", "admin") +FRPS_DASHBOARD_PASS = os.environ.get("FRPS_DASHBOARD_PASS", "admin123") +BIND_PORT = int(os.environ.get("FRPS_BIND_PORT", "7000")) +WEB_BIND_ADDR = os.environ.get("FRPS_WEB_ADDR", "127.0.0.1") +WEB_BIND_PORT = int(os.environ.get("FRPS_WEB_PORT", "7500")) + +for d in (RUNTIME_DIR, CONF_DIR, LOG_DIR, INSTANCE_DIR): + os.makedirs(d, exist_ok=True) + +DB_PATH = os.path.join(INSTANCE_DIR, "frps_manager.db") +SECRET_KEY_FILE = os.path.join(INSTANCE_DIR, "secret_key") + + +def _get_or_create_secret_key(): + """Persistent SECRET_KEY shared by all gunicorn workers. + + CRITICAL: with >1 worker, a random per-import key breaks sessions — + login lands on worker A, the redirect on worker B, and B can't + verify A's cookie. The key must be identical across workers and + survive restarts, so we persist it to instance/secret_key. + """ + env = os.environ.get("FLASK_SECRET") + if env: + return env + if os.path.exists(SECRET_KEY_FILE): + with open(SECRET_KEY_FILE, "r", encoding="utf-8") as f: + key = f.read().strip() + if key: + return key + key = secrets.token_hex(32) + with open(SECRET_KEY_FILE, "w", encoding="utf-8") as f: + f.write(key) + os.chmod(SECRET_KEY_FILE, 0o600) + return key + + +app = Flask(__name__) +app.config["SECRET_KEY"] = _get_or_create_secret_key() +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=12) + + +@app.context_processor +def inject_globals(): + return {"now": datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")} + + +db = SQLAlchemy(app) + +# ---------------------------------------------------------------------------- +# Models +# ---------------------------------------------------------------------------- +ROLES = ("admin", "tenant_admin", "user") + + +class User(db.Model): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + role = db.Column(db.String(16), nullable=False, default="user") + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), nullable=True) + display_name = db.Column(db.String(128)) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_login_at = db.Column(db.DateTime) + tenant = db.relationship("Tenant", backref="users", foreign_keys=[tenant_id]) + + def set_password(self, raw): + self.password_hash = generate_password_hash(raw) + + def check_password(self, raw): + return check_password_hash(self.password_hash, raw) + + +class Tenant(db.Model): + __tablename__ = "tenants" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), unique=True, nullable=False) + description = db.Column(db.String(256)) + bandwidth_in_mbps = db.Column(db.Integer, default=0) + bandwidth_out_mbps = db.Column(db.Integer, default=0) + max_connections = db.Column(db.Integer, default=0) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Client(db.Model): + """An frpc registration identified by its [common] user/name.""" + __tablename__ = "clients" + id = db.Column(db.Integer, primary_key=True) + tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), nullable=False) + name = db.Column(db.String(128), nullable=False) + # Label token (NOT used by frp 0.70+; for documentation / future) + label_token = db.Column(db.String(64), nullable=False) + server_addr = db.Column(db.String(128)) + description = db.Column(db.String(256)) + is_active = db.Column(db.Boolean, default=True) + # When proxy config changes, we set this; the dashboard tells user + # "frpc config out of date — pull fresh one and frpc reload" + config_revision = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + tenant = db.relationship("Tenant", backref="clients") + proxies = db.relationship( + "Proxy", backref="client", cascade="all, delete-orphan") + __table_args__ = ( + db.UniqueConstraint("tenant_id", "name", name="uq_client_tenant_name"), + ) + + +PROXY_TYPES = ("tcp", "udp", "http", "https", "tcpmux") + + +class Proxy(db.Model): + """A proxy rule owned by a client (rendered into frpc.ini).""" + __tablename__ = "proxies" + id = db.Column(db.Integer, primary_key=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False) + name = db.Column(db.String(128), nullable=False) + type = db.Column(db.String(16), nullable=False, default="tcp") + local_ip = db.Column(db.String(64), default="127.0.0.1") + local_port = db.Column(db.Integer) + remote_port = db.Column(db.Integer) + custom_domains = db.Column(db.String(512)) + subdomain = db.Column(db.String(64)) + locations = db.Column(db.String(256)) + use_encryption = db.Column(db.Boolean, default=False) + use_compression = db.Column(db.Boolean, default=False) + health_check_type = db.Column(db.String(16)) + health_check_url = db.Column(db.String(128)) + health_check_interval_s = db.Column(db.Integer, default=30) + health_check_timeout_s = db.Column(db.Integer, default=3) + note = db.Column(db.String(256)) + is_enabled = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow) + __table_args__ = ( + db.UniqueConstraint("client_id", "name", name="uq_proxy_client_name"), + ) + + +class AuditLog(db.Model): + __tablename__ = "audit_logs" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + username = db.Column(db.String(64)) + action = db.Column(db.String(64), nullable=False) + target_type = db.Column(db.String(32)) + target_id = db.Column(db.String(64)) + target_label = db.Column(db.String(256)) + detail = db.Column(db.Text) + ip = db.Column(db.String(64)) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +# ---------------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------------- +def log_action(action, target_type=None, target_id=None, target_label=None, + detail=None): + u = g.current_user if hasattr(g, "current_user") else None + try: + entry = AuditLog( + user_id=u.id if u else None, + username=u.username if u else None, + action=action, target_type=target_type, + target_id=str(target_id) if target_id is not None else None, + target_label=target_label, detail=detail, + ip=request.remote_addr if request else None, + ) + db.session.add(entry) + db.session.commit() + except Exception as e: + app.logger.warning("audit log failed: %s", e) + try: + db.session.rollback() + except Exception: + pass + + +def random_token(n=24): + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(n)) + + +def current_user(): + if "user_id" not in session: + return None + if hasattr(g, "current_user_obj") and g.current_user_obj: + return g.current_user_obj + u = User.query.get(session["user_id"]) + if not u or not u.is_active: + return None + g.current_user_obj = u + return u + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + u = current_user() + if not u: + return redirect(url_for("login", next=request.path)) + g.current_user = u + return view(*args, **kwargs) + return wrapped + + +def role_required(*roles): + def deco(view): + @wraps(view) + def wrapped(*args, **kwargs): + u = current_user() + if not u: + return redirect(url_for("login")) + if u.role not in roles: + abort(403) + g.current_user = u + return view(*args, **kwargs) + return wrapped + return deco + + +def visible_tenants(user): + if user.role == "admin": + return Tenant.query.order_by(Tenant.name).all() + if user.tenant_id: + return Tenant.query.filter_by(id=user.tenant_id).all() + return [] + + +def visible_clients(user, tenant_id=None): + q = Client.query + if user.role == "admin": + if tenant_id: + q = q.filter_by(tenant_id=tenant_id) + else: + if not user.tenant_id: + return [] + q = q.filter_by(tenant_id=user.tenant_id) + if tenant_id and tenant_id != user.tenant_id: + return [] + return q.order_by(Client.name).all() + + +def visible_proxies(user, client_id=None, tenant_id=None): + client_ids = [c.id for c in visible_clients(user, tenant_id=tenant_id)] + if not client_ids: + return [] + q = Proxy.query.filter(Proxy.client_id.in_(client_ids)) + if client_id: + q = q.filter_by(client_id=client_id) + return q.order_by(Proxy.name).all() + + +# ---------------------------------------------------------------------------- +# Server token +# ---------------------------------------------------------------------------- +def get_or_create_server_token(): + if os.path.exists(SERVER_TOKEN_FILE): + with open(SERVER_TOKEN_FILE, "r") as f: + tok = f.read().strip() + if tok: + return tok + tok = random_token(32) + with open(SERVER_TOKEN_FILE, "w") as f: + f.write(tok) + os.chmod(SERVER_TOKEN_FILE, 0o600) + return tok + + +def rotate_server_token(): + tok = random_token(32) + with open(SERVER_TOKEN_FILE, "w") as f: + f.write(tok) + os.chmod(SERVER_TOKEN_FILE, 0o600) + return tok + + +# ---------------------------------------------------------------------------- +# frps INI rendering (legacy INI is the only format that loads auth token +# correctly in frps 0.70.1) +# ---------------------------------------------------------------------------- +def render_frps_ini(): + """Build runtime frps.ini in legacy INI format.""" + token = get_or_create_server_token() + cp = configparser.RawConfigParser() + cp.optionxform = str # preserve case + cp["common"] = { + "bind_addr": "0.0.0.0", + "bind_port": str(BIND_PORT), + # Authentication + "authentication_method": "token", + "token": token, + # Dashboard + "dashboard_addr": WEB_BIND_ADDR, + "dashboard_port": str(WEB_BIND_PORT), + "dashboard_user": FRPS_DASHBOARD_USER, + "dashboard_pwd": FRPS_DASHBOARD_PASS, + # HTTP/HTTPS virtual host ports — required for http/https proxies + "vhost_http_port": str(os.environ.get("FRPS_VHOST_HTTP_PORT", "80")), + "vhost_https_port": str(os.environ.get("FRPS_VHOST_HTTPS_PORT", "443")), + # Logging + "log_file": os.path.join(LOG_DIR, "frps.log"), + "log_level": "info", + "log_max_days": "7", + } + out = io.StringIO() + cp.write(out) + return ( + "# Managed by frps-manager — DO NOT EDIT BY HAND\n" + + out.getvalue() + ) + + +def write_frps_config(): + content = render_frps_ini() + tmp = FRPS_CONFIG + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, FRPS_CONFIG) + return content + + +def reload_frps(action="restart"): + """Restart frps and wait for dashboard to come back.""" + if action not in ("reload", "restart"): + action = "restart" + try: + result = subprocess.run( + ["systemctl", action, FRPS_SERVICE + ".service"], + capture_output=True, text=True, timeout=20) + if result.returncode != 0: + return False, (result.stdout + result.stderr).strip() + except FileNotFoundError: + return _restart_frps_direct() + except Exception as e: + return False, str(e) + deadline = time.time() + 12 + while time.time() < deadline: + try: + r = requests.get( + FRPS_DASHBOARD_URL + "/api/serverinfo", + auth=(FRPS_DASHBOARD_USER, FRPS_DASHBOARD_PASS), timeout=2) + if r.status_code == 200: + return True, "ok" + except Exception: + pass + time.sleep(0.4) + return False, "frps did not become ready within 12s" + + +def _restart_frps_direct(): + """Restart frps directly (dev mode).""" + pidfile = os.path.join(LOG_DIR, "frps.pid") + if os.path.exists(pidfile): + try: + with open(pidfile) as f: + old_pid = int(f.read().strip()) + os.kill(old_pid, 15) + time.sleep(0.5) + except (OSError, ValueError): + pass + log_file = open(os.path.join(LOG_DIR, "frps.log"), "a") + proc = subprocess.Popen( + [FRPS_BIN, "-c", FRPS_CONFIG], + stdout=log_file, stderr=log_file, cwd=RUNTIME_DIR) + with open(pidfile, "w") as f: + f.write(str(proc.pid)) + time.sleep(1.5) + try: + r = requests.get( + FRPS_DASHBOARD_URL + "/api/serverinfo", + auth=(FRPS_DASHBOARD_USER, FRPS_DASHBOARD_PASS), timeout=2) + return (r.status_code == 200), "started pid=%d" % proc.pid + except Exception as e: + return False, str(e) + + +def get_dashboard_info(): + base = FRPS_DASHBOARD_URL + auth = (FRPS_DASHBOARD_USER, FRPS_DASHBOARD_PASS) + out = {} + try: + r = requests.get(f"{base}/api/serverinfo", auth=auth, timeout=3) + r.raise_for_status() + out["server"] = r.json() + except Exception as e: + return {"error": str(e)} + try: + r = requests.get(f"{base}/api/v2/clients", auth=auth, timeout=3) + r.raise_for_status() + out["clients"] = r.json().get("data", {}) + except Exception as e: + out["clients"] = {"items": [], "total": 0, "error": str(e)} + try: + r = requests.get(f"{base}/api/v2/proxies", auth=auth, timeout=3) + r.raise_for_status() + out["proxies"] = r.json().get("data", {}) + except Exception as e: + out["proxies"] = {"items": [], "total": 0, "error": str(e)} + return out + + +def enrich_clients_with_status(items, info): + client_index = {} + if info and not info.get("error"): + for c in info.get("clients", {}).get("items", []): + client_index[c.get("user", "") or c.get("clientID", "")] = c + for c in items: + d = client_index.get(c.name, {}) + c._online = bool(d) + c._nat = d.get("nat", "-") if d else "-" + c._version = d.get("version", "-") if d else "-" + c._conn_count = d.get("conn_count", 0) if d else 0 + + +def bump_client_config_revision(client_id): + """Mark the client's config as needing reload.""" + c = Client.query.get(client_id) + if c: + c.config_revision = (c.config_revision or 0) + 1 + db.session.commit() + + +# ---------------------------------------------------------------------------- +# frpc.ini rendering (legacy INI) +# ---------------------------------------------------------------------------- +def render_frpc_ini(client, server_addr, bind_port): + """Build frpc.ini in legacy INI format for a single client. + + Always includes admin_addr/admin_port (default 7400) so the user can + run `frpc reload -c /etc/frpc.ini` after pulling a new copy. + """ + token = get_or_create_server_token() + proxies = Proxy.query.filter_by( + client_id=client.id, is_enabled=True).order_by(Proxy.name).all() + lines = [] + lines.append(f"# Generated by frps-manager for client: {client.name}") + lines.append("# (tenant={}, label_token={})".format( + client.tenant.name, client.label_token)) + lines.append("# Server token comes from your admin (shared secret).") + lines.append("# Copy to your frpc host as /etc/frpc.ini, then:") + lines.append("# systemctl enable --now frpc") + lines.append("# frpc reload -c /etc/frpc.ini # to apply changes") + lines.append("") + lines.append("[common]") + lines.append(f"server_addr = {server_addr}") + lines.append(f"server_port = {bind_port}") + lines.append("authentication_method = token") + lines.append(f"token = {token}") + # Admin API for `frpc reload` command + lines.append(f"admin_addr = {os.environ.get('FRPC_ADMIN_ADDR', '127.0.0.1')}") + lines.append(f"admin_port = {os.environ.get('FRPC_ADMIN_PORT', '7400')}") + lines.append(f"admin_user = {os.environ.get('FRPC_ADMIN_USER', 'admin')}") + lines.append(f"admin_pwd = {os.environ.get('FRPC_ADMIN_PWD', 'admin')}") + lines.append("") + for p in proxies: + lines.append(f"[{p.name}]") + lines.append(f"type = {p.type}") + lines.append(f"local_ip = {p.local_ip}") + lines.append(f"local_port = {p.local_port}") + if p.type in ("tcp", "udp") and p.remote_port: + lines.append(f"remote_port = {p.remote_port}") + if p.type in ("http", "https"): + if p.custom_domains: + # INI supports comma-separated values + lines.append(f"custom_domains = {p.custom_domains}") + if p.subdomain: + lines.append(f"subdomain = {p.subdomain}") + if p.locations: + lines.append(f"locations = {p.locations}") + if p.use_encryption: + lines.append("use_encryption = true") + if p.use_compression: + lines.append("use_compression = true") + if p.health_check_type: + lines.append(f"health_check_type = {p.health_check_type}") + if p.health_check_type == "http" and p.health_check_url: + lines.append(f"health_check_url = {p.health_check_url}") + lines.append(f"health_check_interval_s = {p.health_check_interval_s or 30}") + lines.append(f"health_check_timeout_s = {p.health_check_timeout_s or 3}") + lines.append("") + return "\n".join(lines) + + +# ---------------------------------------------------------------------------- +# Seed +# ---------------------------------------------------------------------------- +def seed_admin(): + if not User.query.filter_by(username="admin").first(): + u = User(username="admin", role="admin", display_name="Administrator") + u.set_password("admin") + db.session.add(u) + db.session.commit() + print("[seed] created default admin (admin/admin) — CHANGE PASSWORD") + if Tenant.query.count() == 0: + t = Tenant(name="default", description="Default tenant") + db.session.add(t) + db.session.commit() + print("[seed] created default tenant") + get_or_create_server_token() + + +# ---------------------------------------------------------------------------- +# Routes — auth +# ---------------------------------------------------------------------------- +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username", "") + password = request.form.get("password", "") + app.logger.info(f"login attempt: user={username!r} pw_len={len(password)} " + f"ip={request.remote_addr} ua={request.user_agent!r}") + u = User.query.filter_by(username=username).first() + if u and u.is_active and u.check_password(password): + session["user_id"] = u.id + session.permanent = True + u.last_login_at = datetime.utcnow() + db.session.commit() + log_action("login", target_type="user", target_id=u.id, + target_label=u.username) + app.logger.info(f"login OK: user={username!r} user_id={u.id}") + return redirect(request.args.get("next") or url_for("dashboard")) + app.logger.warning(f"login FAIL: user={username!r} u_exists={bool(u)} " + f"active={u.is_active if u else 'N/A'} pw_match={u.check_password(password) if u else False}") + flash("Invalid credentials", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + u = current_user() + if u: + log_action("logout", target_type="user", target_id=u.id, + target_label=u.username) + session.clear() + return redirect(url_for("login")) + + +@app.route("/change-password", methods=["GET", "POST"]) +@login_required +def change_password(): + u = g.current_user + if request.method == "POST": + old = request.form.get("old_password", "") + new = request.form.get("new_password", "") + if not u.check_password(old): + flash("Old password incorrect", "error") + elif len(new) < 6: + flash("New password must be at least 6 characters", "error") + else: + u.set_password(new) + db.session.commit() + log_action("change_password", target_type="user", target_id=u.id, + target_label=u.username) + flash("Password updated", "success") + return redirect(url_for("dashboard")) + return render_template("change_password.html", user=u) + + +# ---------------------------------------------------------------------------- +# Routes — main pages +# ---------------------------------------------------------------------------- +@app.route("/") +@login_required +def dashboard(): + info = get_dashboard_info() + tenant_count = Tenant.query.count() + client_count = Client.query.count() + proxy_count = Proxy.query.count() + return render_template( + "dashboard.html", user=g.current_user, + info=info, tenant_count=tenant_count, + client_count=client_count, proxy_count=proxy_count, + server_token=get_or_create_server_token(), + ) + + +# ---- Tenants ---------------------------------------------------------------- +@app.route("/tenants") +@role_required("admin") +def tenants_list(): + items = Tenant.query.order_by(Tenant.name).all() + return render_template("tenants.html", user=g.current_user, items=items) + + +@app.route("/tenants/new", methods=["GET", "POST"]) +@role_required("admin") +def tenants_new(): + if request.method == "POST": + name = request.form.get("name", "").strip() + if not name or Tenant.query.filter_by(name=name).first(): + flash("Tenant name must be unique", "error") + return redirect(url_for("tenants_new")) + t = Tenant( + name=name, + description=request.form.get("description", ""), + bandwidth_in_mbps=int(request.form.get("bandwidth_in_mbps", 0) or 0), + bandwidth_out_mbps=int(request.form.get("bandwidth_out_mbps", 0) or 0), + max_connections=int(request.form.get("max_connections", 0) or 0), + is_active=bool(request.form.get("is_active")), + ) + db.session.add(t) + db.session.commit() + log_action("create", target_type="tenant", target_id=t.id, + target_label=t.name) + flash(f"Tenant '{t.name}' created", "success") + return redirect(url_for("tenants_list")) + return render_template("tenant_form.html", user=g.current_user, item=None) + + +@app.route("/tenants//edit", methods=["GET", "POST"]) +@role_required("admin") +def tenants_edit(tid): + t = Tenant.query.get_or_404(tid) + if request.method == "POST": + t.description = request.form.get("description", "") + t.bandwidth_in_mbps = int(request.form.get("bandwidth_in_mbps", 0) or 0) + t.bandwidth_out_mbps = int(request.form.get("bandwidth_out_mbps", 0) or 0) + t.max_connections = int(request.form.get("max_connections", 0) or 0) + t.is_active = bool(request.form.get("is_active")) + db.session.commit() + log_action("update", target_type="tenant", target_id=t.id, + target_label=t.name) + flash("Tenant updated", "success") + return redirect(url_for("tenants_list")) + return render_template("tenant_form.html", user=g.current_user, item=t) + + +@app.route("/tenants//delete", methods=["POST"]) +@role_required("admin") +def tenants_delete(tid): + t = Tenant.query.get_or_404(tid) + if t.clients: + flash(f"Cannot delete: tenant has {len(t.clients)} client(s)", "error") + return redirect(url_for("tenants_list")) + name = t.name + db.session.delete(t) + db.session.commit() + log_action("delete", target_type="tenant", target_id=tid, + target_label=name) + flash(f"Tenant '{name}' deleted", "success") + return redirect(url_for("tenants_list")) + + +# ---- Clients --------------------------------------------------------------- +@app.route("/clients") +@login_required +def clients_list(): + tid = request.args.get("tenant_id", type=int) + tenants = visible_tenants(g.current_user) + items = visible_clients(g.current_user, tenant_id=tid) + info = get_dashboard_info() + enrich_clients_with_status(items, info) + return render_template( + "clients.html", user=g.current_user, items=items, + tenants=tenants, filter_tenant_id=tid, + ) + + +@app.route("/clients/new", methods=["GET", "POST"]) +@login_required +def clients_new(): + tenants = visible_tenants(g.current_user) + if not tenants: + flash("No tenants available — ask an admin to create one first", + "error") + return redirect(url_for("clients_list")) + if request.method == "POST": + name = request.form.get("name", "").strip() + tid = int(request.form.get("tenant_id", 0)) + if not any(t.id == tid for t in tenants): + flash("Tenant not allowed", "error") + return redirect(url_for("clients_new")) + if not name or Client.query.filter_by( + tenant_id=tid, name=name).first(): + flash("Client name must be unique within tenant", "error") + return redirect(url_for("clients_new")) + c = Client( + tenant_id=tid, name=name, + label_token=random_token(), + server_addr=request.form.get("server_addr", "").strip() or None, + description=request.form.get("description", ""), + is_active=bool(request.form.get("is_active", "on")), + ) + db.session.add(c) + db.session.commit() + log_action("create", target_type="client", target_id=c.id, + target_label=c.name, detail=f"tenant_id={tid}") + flash(f"Client '{c.name}' created. Add proxies or download frpc.ini.", + "success") + return redirect(url_for("clients_list")) + return render_template("client_form.html", user=g.current_user, + item=None, tenants=tenants) + + +@app.route("/clients//edit", methods=["GET", "POST"]) +@login_required +def clients_edit(cid): + c = Client.query.get_or_404(cid) + tenants = visible_tenants(g.current_user) + if not any(t.id == c.tenant_id for t in tenants): + abort(403) + if request.method == "POST": + if g.current_user.role == "admin": + new_tid = int(request.form.get("tenant_id", c.tenant_id)) + if any(t.id == new_tid for t in tenants): + c.tenant_id = new_tid + c.server_addr = request.form.get("server_addr", "").strip() or None + c.description = request.form.get("description", "") + c.is_active = bool(request.form.get("is_active")) + db.session.commit() + log_action("update", target_type="client", target_id=c.id, + target_label=c.name) + flash("Client updated", "success") + return redirect(url_for("clients_list")) + return render_template("client_form.html", user=g.current_user, + item=c, tenants=tenants) + + +@app.route("/clients//delete", methods=["POST"]) +@login_required +def clients_delete(cid): + c = Client.query.get_or_404(cid) + tenants = visible_tenants(g.current_user) + if not any(t.id == c.tenant_id for t in tenants): + abort(403) + name = c.name + db.session.delete(c) + db.session.commit() + log_action("delete", target_type="client", target_id=cid, + target_label=name) + flash(f"Client '{name}' and its proxies deleted.", "success") + return redirect(url_for("clients_list")) + + +@app.route("/clients//frpc-config") +@login_required +def clients_frpc_config(cid): + c = Client.query.get_or_404(cid) + tenants = visible_tenants(g.current_user) + if not any(t.id == c.tenant_id for t in tenants): + abort(403) + server_addr = request.args.get( + "server_addr", "").strip() or c.server_addr or request.host.split(":")[0] + bind_port = request.args.get("bind_port", type=int) or BIND_PORT + body = render_frpc_ini(c, server_addr, bind_port) + return Response(body, mimetype="text/plain; charset=utf-8", + headers={"Content-Disposition": + f"attachment; filename=frpc-{c.name}.ini"}) + + +# ---- Proxies --------------------------------------------------------------- +@app.route("/proxies") +@login_required +def proxies_list(): + tid = request.args.get("tenant_id", type=int) + cid = request.args.get("client_id", type=int) + tenants = visible_tenants(g.current_user) + clients = visible_clients(g.current_user, tenant_id=tid) + items = visible_proxies(g.current_user, client_id=cid) + info = get_dashboard_info() + online_names = set() + if info and not info.get("error"): + for p in info.get("proxies", {}).get("items", []): + online_names.add(p.get("name", "")) + return render_template( + "proxies.html", user=g.current_user, items=items, + clients=clients, tenants=tenants, + filter_tenant_id=tid, filter_client_id=cid, + online_names=online_names, + ) + + +@app.route("/proxies/new", methods=["GET", "POST"]) +@login_required +def proxies_new(): + clients = visible_clients(g.current_user) + if not clients: + flash("Create a client first before adding proxies", "error") + return redirect(url_for("proxies_list")) + if request.method == "POST": + try: + cid = int(request.form.get("client_id", 0)) + except ValueError: + cid = 0 + if not any(c.id == cid for c in clients): + abort(403) + name = request.form.get("name", "").strip() + if not name or Proxy.query.filter_by( + client_id=cid, name=name).first(): + flash("Proxy name must be unique within client", "error") + return redirect(url_for("proxies_new")) + ptype = request.form.get("type", "tcp") + if ptype not in PROXY_TYPES: + flash("Invalid proxy type", "error") + return redirect(url_for("proxies_new")) + rp = request.form.get("remote_port", "") + p = Proxy( + client_id=cid, name=name, type=ptype, + local_ip=request.form.get("local_ip", "127.0.0.1"), + local_port=int(request.form.get("local_port", 0) or 0), + remote_port=int(rp) if rp else None, + custom_domains=request.form.get("custom_domains", ""), + subdomain=request.form.get("subdomain", ""), + locations=request.form.get("locations", ""), + use_encryption=bool(request.form.get("use_encryption")), + use_compression=bool(request.form.get("use_compression")), + health_check_type=request.form.get("health_check_type", ""), + health_check_url=request.form.get("health_check_url", ""), + health_check_interval_s=int( + request.form.get("health_check_interval_s", 30) or 30), + health_check_timeout_s=int( + request.form.get("health_check_timeout_s", 3) or 3), + note=request.form.get("note", ""), + is_enabled=bool(request.form.get("is_enabled")), + ) + db.session.add(p) + db.session.commit() + bump_client_config_revision(cid) + log_action("create", target_type="proxy", target_id=p.id, + target_label=p.name, detail=f"client_id={cid} type={ptype}") + flash(f"Proxy '{p.name}' created. Tell the frpc owner to pull " + "new frpc.ini and run `frpc reload`.", "success") + return redirect(url_for("proxies_list")) + prefill_client = request.args.get("client_id", type=int) + return render_template( + "proxy_form.html", user=g.current_user, item=None, + clients=clients, types=PROXY_TYPES, + prefill_client=prefill_client, + ) + + +@app.route("/proxies//edit", methods=["GET", "POST"]) +@login_required +def proxies_edit(pid): + p = Proxy.query.get_or_404(pid) + clients = visible_clients(g.current_user) + if not any(c.id == p.client_id for c in clients): + abort(403) + if request.method == "POST": + p.local_ip = request.form.get("local_ip", "127.0.0.1") + try: + p.local_port = int(request.form.get("local_port", 0) or 0) + except ValueError: + p.local_port = 0 + rp = request.form.get("remote_port", "") + p.remote_port = int(rp) if rp else None + p.custom_domains = request.form.get("custom_domains", "") + p.subdomain = request.form.get("subdomain", "") + p.locations = request.form.get("locations", "") + p.use_encryption = bool(request.form.get("use_encryption")) + p.use_compression = bool(request.form.get("use_compression")) + p.health_check_type = request.form.get("health_check_type", "") + p.health_check_url = request.form.get("health_check_url", "") + try: + p.health_check_interval_s = int( + request.form.get("health_check_interval_s", 30) or 30) + except ValueError: + pass + try: + p.health_check_timeout_s = int( + request.form.get("health_check_timeout_s", 3) or 3) + except ValueError: + pass + p.note = request.form.get("note", "") + p.is_enabled = bool(request.form.get("is_enabled")) + p.updated_at = datetime.utcnow() + db.session.commit() + bump_client_config_revision(p.client_id) + log_action("update", target_type="proxy", target_id=p.id, + target_label=p.name) + flash(f"Proxy '{p.name}' updated. Tell the frpc owner to pull " + "new frpc.ini and run `frpc reload`.", "success") + return redirect(url_for("proxies_list")) + return render_template( + "proxy_form.html", user=g.current_user, item=p, + clients=clients, types=PROXY_TYPES, prefill_client=p.client_id, + ) + + +@app.route("/proxies//toggle", methods=["POST"]) +@login_required +def proxies_toggle(pid): + p = Proxy.query.get_or_404(pid) + clients = visible_clients(g.current_user) + if not any(c.id == p.client_id for c in clients): + abort(403) + p.is_enabled = not p.is_enabled + db.session.commit() + bump_client_config_revision(p.client_id) + log_action("toggle", target_type="proxy", target_id=p.id, + target_label=p.name, detail=f"enabled={p.is_enabled}") + flash(f"Proxy '{p.name}' {'enabled' if p.is_enabled else 'disabled'}.", + "success") + return redirect(request.referrer or url_for("proxies_list")) + + +@app.route("/proxies//delete", methods=["POST"]) +@login_required +def proxies_delete(pid): + p = Proxy.query.get_or_404(pid) + clients = visible_clients(g.current_user) + if not any(c.id == p.client_id for c in clients): + abort(403) + name = p.name + cid = p.client_id + db.session.delete(p) + db.session.commit() + bump_client_config_revision(cid) + log_action("delete", target_type="proxy", target_id=pid, + target_label=name) + flash(f"Proxy '{name}' deleted.", "success") + return redirect(url_for("proxies_list")) + + +# ---- Audit log ------------------------------------------------------------- +@app.route("/logs") +@role_required("admin") +def logs_list(): + page = max(1, request.args.get("page", 1, type=int)) + per = 50 + q = AuditLog.query.order_by(AuditLog.id.desc()) + total = q.count() + items = q.offset((page - 1) * per).limit(per).all() + return render_template( + "logs.html", user=g.current_user, items=items, + page=page, per=per, total=total, + ) + + +# ---- frps management ------------------------------------------------------- +@app.route("/frps/status") +@login_required +def frps_status(): + return jsonify(get_dashboard_info()) + + +@app.route("/frps/config") +@role_required("admin") +def frps_config_view(): + if not os.path.exists(FRPS_CONFIG): + return Response("# frps.ini not generated yet\n", mimetype="text/plain") + with open(FRPS_CONFIG, "r", encoding="utf-8") as f: + return Response(f.read(), mimetype="text/plain; charset=utf-8") + + +@app.route("/frps/reload", methods=["POST"]) +@role_required("admin") +def frps_reload(): + write_frps_config() + ok, msg = reload_frps("restart") + log_action("frps_reload", target_type="frps", detail=msg, + target_label="ok" if ok else "failed") + flash(("frps reloaded" if ok else f"frps reload failed: {msg}"), + "success" if ok else "error") + return redirect(url_for("dashboard")) + + +@app.route("/frps/rotate-token", methods=["POST"]) +@role_required("admin") +def frps_rotate_token(): + rotate_server_token() + write_frps_config() + ok, msg = reload_frps("restart") + log_action("frps_rotate_token", target_type="frps", detail=msg) + flash(("Server token rotated. frps reloaded. " + "All existing frpc connections will drop." + if ok else f"rotate failed: {msg}"), + "success" if ok else "error") + return redirect(url_for("dashboard")) + + +# ---- User management ------------------------------------------------------- +@app.route("/users") +@role_required("admin") +def users_list(): + items = User.query.order_by(User.username).all() + tenants = Tenant.query.order_by(Tenant.name).all() + return render_template("users.html", user=g.current_user, + items=items, tenants=tenants) + + +@app.route("/users/new", methods=["GET", "POST"]) +@role_required("admin") +def users_new(): + tenants = Tenant.query.order_by(Tenant.name).all() + if request.method == "POST": + username = request.form.get("username", "").strip() + pw = request.form.get("password", "") + if not username or not pw: + flash("Username and password required", "error") + elif User.query.filter_by(username=username).first(): + flash("Username already exists", "error") + else: + u = User( + username=username, + role=request.form.get("role", "user"), + display_name=request.form.get("display_name", ""), + tenant_id=int(request.form.get("tenant_id", 0) or 0) or None, + is_active=bool(request.form.get("is_active")), + ) + u.set_password(pw) + db.session.add(u) + db.session.commit() + log_action("create", target_type="user", target_id=u.id, + target_label=u.username, detail=f"role={u.role}") + flash(f"User '{u.username}' created", "success") + return redirect(url_for("users_list")) + return render_template("user_form.html", user=g.current_user, item=None, + tenants=tenants) + + +@app.route("/users//edit", methods=["GET", "POST"]) +@role_required("admin") +def users_edit(uid): + u = User.query.get_or_404(uid) + tenants = Tenant.query.order_by(Tenant.name).all() + if request.method == "POST": + u.role = request.form.get("role", u.role) + u.display_name = request.form.get("display_name", "") + u.tenant_id = int(request.form.get("tenant_id", 0) or 0) or None + u.is_active = bool(request.form.get("is_active")) + new_pw = request.form.get("password", "") + if new_pw: + if len(new_pw) < 6: + flash("Password must be at least 6 characters", "error") + return redirect(url_for("users_edit", uid=uid)) + u.set_password(new_pw) + db.session.commit() + log_action("update", target_type="user", target_id=u.id, + target_label=u.username) + flash("User updated", "success") + return redirect(url_for("users_list")) + return render_template("user_form.html", user=g.current_user, item=u, + tenants=tenants) + + +@app.route("/users//delete", methods=["POST"]) +@role_required("admin") +def users_delete(uid): + u = User.query.get_or_404(uid) + if u.id == g.current_user.id: + flash("Cannot delete yourself", "error") + return redirect(url_for("users_list")) + if u.username == "admin": + flash("Cannot delete the bootstrap admin user", "error") + return redirect(url_for("users_list")) + name = u.username + db.session.delete(u) + db.session.commit() + log_action("delete", target_type="user", target_id=uid, + target_label=name) + flash(f"User '{name}' deleted", "success") + return redirect(url_for("users_list")) + + +# ---------------------------------------------------------------------------- +# Jinja filters +# ---------------------------------------------------------------------------- +@app.template_filter("fmt_dt") +def fmt_dt(dt): + if not dt: + return "-" + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +@app.template_filter("fmt_bytes") +def fmt_bytes(n): + if n is None: + return "-" + try: + n = int(n) + except Exception: + return str(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024: + return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + +@app.template_filter("fmt_mbps") +def fmt_mbps(n): + try: + n = int(n) + except Exception: + return "-" + return f"{n} Mbps" if n else "unlimited" + + +# ---------------------------------------------------------------------------- +# Error handlers +# ---------------------------------------------------------------------------- +@app.errorhandler(403) +def err_403(e): + return render_template("error.html", user=current_user(), + code=403, message="Forbidden"), 403 + + +@app.errorhandler(404) +def err_404(e): + return render_template("error.html", user=current_user(), + code=404, message="Not found"), 404 + + +# ---------------------------------------------------------------------------- +# Bootstrap DB on import +# ---------------------------------------------------------------------------- +def init_db(): + with app.app_context(): + db.create_all() + seed_admin() + + +# Top-level import requires io +init_db() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", + port=int(os.environ.get("PORT", "5390")), + debug=False) \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..06cff8d --- /dev/null +++ b/init_db.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..135fa19 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/runtime/frpc@.service b/runtime/frpc@.service new file mode 100644 index 0000000..4d22642 --- /dev/null +++ b/runtime/frpc@.service @@ -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@.service +# +# The specifier lets you run multiple frpc instances per host. +# Place per-instance configs under /etc/frpc/.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 \ No newline at end of file diff --git a/runtime/frps-manager.service b/runtime/frps-manager.service new file mode 100644 index 0000000..73006dd --- /dev/null +++ b/runtime/frps-manager.service @@ -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 \ No newline at end of file diff --git a/runtime/frps.service b/runtime/frps.service new file mode 100644 index 0000000..3c8383c --- /dev/null +++ b/runtime/frps.service @@ -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 \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..f07261b --- /dev/null +++ b/scripts/deploy.sh @@ -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 <: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 \ No newline at end of file diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 0000000..3c32990 --- /dev/null +++ b/scripts/dev.sh @@ -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 \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6bfc8b6 --- /dev/null +++ b/static/style.css @@ -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; +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..41fa806 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,57 @@ + + + + + +{% block title %}frps-manager{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+ frps-manager · frps 0.70+ · {{ now }} +
+ + \ No newline at end of file diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..f2a7f7b --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}修改密码 · frps-manager{% endblock %} +{% block content %} +

修改密码

+
+ + +
+ + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/client_form.html b/templates/client_form.html new file mode 100644 index 0000000..77fa45c --- /dev/null +++ b/templates/client_form.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if item else '新建' }}客户端 · frps-manager{% endblock %} +{% block content %} +

{{ '编辑客户端' if item else '新建客户端 (frpc 注册)' }}

+
+ {% if item %} + + + {% else %} + + + {% endif %} + + + +
+ + 取消 +
+ {% if item %} +

+ 创建后请到客户端列表页点 frpc.toml 按钮下载配置。 + frpc 端不需要填 token — server token 是共享的,在仪表盘上能看到。 +

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/clients.html b/templates/clients.html new file mode 100644 index 0000000..52233de --- /dev/null +++ b/templates/clients.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% block title %}客户端 · frps-manager{% endblock %} +{% block content %} + + +{% if tenants|length > 1 %} +
+ +
+{% endif %} + + + + + + + {% for c in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
名称 (frpc user)租户在线NAT版本frpc 连接状态操作
+ {{ c.name }} +
{{ c.description or '' }} + {% if c.server_addr %}
frps: {{ c.server_addr }}{% endif %} +
{{ c.tenant.name }} + {% if c._online %} + 在线 + {{ c._version }} + {% else %} + 离线 + {% endif %} + {{ c._nat }}{{ c._version }}{{ c._conn_count }}{% if c.is_active %}启用{% else %}禁用{% endif %} + frpc.toml + 编辑 +
+ +
+
暂无客户端
+ +
+

使用流程

+
    +
  1. 复制上面任意客户端的 frpc.toml 到目标 frpc 主机
  2. +
  3. 在该主机的 frpc.toml 末尾追加 [[proxies]] 段定义要暴露的本地服务
  4. +
  5. systemctl enable --now frpcfrpc -c /etc/frpc.toml
  6. +
  7. 回到仪表盘或「在线代理」页,应能看到客户端上线
  8. +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..5fa2f15 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}仪表盘 · frps-manager{% endblock %} +{% block content %} +

仪表盘

+ +
+
+
frps 版本
+
+ {% if info.error %} + 离线 + {% else %} + {{ info.server.version }} + {% endif %} +
+
+
+
监听端口
+
+ {% if info.error %}-{% else %}{{ info.server.bindPort }}{% endif %} +
+
+
+
在线 frpc 客户端
+
+ {% if info.error %}-{% else %}{{ info.clients.total }}{% endif %} +
+
+
+
活跃代理
+
+ {% if info.error %}-{% else %}{{ info.proxies.total }}{% endif %} +
+
+
+
租户 / 客户端 / 代理
+
{{ tenant_count }} / {{ client_count }} / {{ proxy_count }}
+
+
+ +{% if info.error %} +
+ 无法连接 frps dashboard({{ info.error }})。请确认 frps 服务已启动且 dashboard 配置正确。 +
+{% endif %} + +{% if user.role == 'admin' %} +
+

服务控制

+
+ +
+
+ +
+ 查看 frps.ini +
+ +
+

Server Token(所有 frpc 共享)

+

frpc 配置 token = ... 字段用此值。轮换会让所有连接断掉。

+ {{ server_token }} +
+{% endif %} + +
+

在线 frpc 客户端

+ {% if info.error or not info.clients.get("items") %} +

尚无客户端连接

+ {% else %} + + + + + + {% for c in info.clients.get("items", []) %} + + + + + + + + {% endfor %} + +
用户 (frpc [common] user)客户端 ID版本NAT连接数
{{ c.user or '(unnamed)' }}{{ c.clientID[:12] }}…{{ c.version }}{{ c.nat }}{{ c.conn_count }}
+ {% endif %} +
+ +
+

活跃代理(来自 frps dashboard)

+ {% if info.error or not info.proxies.get("items") %} +

尚无活跃代理

+ {% else %} + + + + + + {% for p in info.proxies.get("items", []) %} + + + + + + + + + {% endfor %} + +
名称类型状态流量 in流量 out来自 frpc
{{ p.name }}{{ p.type }} + {% if p.status.phase == 'online' %}在线 + {% else %}离线{% endif %} + {{ p.status.todayTrafficIn|fmt_bytes }}{{ p.status.todayTrafficOut|fmt_bytes }}{{ p.clientID[:12] or '-' }}…
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..171ae2e --- /dev/null +++ b/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}{{ code }} · frps-manager{% endblock %} +{% block content %} +
+

{{ code }}

+

{{ message }}

+ 回首页 +
+{% endblock %} \ No newline at end of file diff --git a/templates/frpc.toml.j2 b/templates/frpc.toml.j2 new file mode 100644 index 0000000..6776ca3 --- /dev/null +++ b/templates/frpc.toml.j2 @@ -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"] +# --------------------------------------------------------------- \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..75629a0 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}登录 · frps-manager{% endblock %} +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..60f3ff3 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}审计日志 · frps-manager{% endblock %} +{% block content %} +

审计日志

+

共 {{ total }} 条

+ + + + + + {% for e in items %} + + + + + + + + + {% endfor %} + +
时间用户动作对象详情IP
{{ e.created_at|fmt_dt }}{{ e.username or '-' }}{{ e.action }} + {{ e.target_type or '-' }} + {% if e.target_label %}
{{ e.target_label }}{% endif %} +
{{ e.detail or '' }}{{ e.ip or '-' }}
+{% if total > per %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/proxies.html b/templates/proxies.html new file mode 100644 index 0000000..2b2f928 --- /dev/null +++ b/templates/proxies.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}代理 · frps-manager{% endblock %} +{% block content %} + + +{% if tenants|length > 1 or clients|length > 1 %} +
+ {% if tenants|length > 1 %} + + {% endif %} + +
+{% endif %} + + + + + + + {% for p in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
名称类型客户端远端本地在线启用操作
+ {{ p.name }} + {% if p.note %}
{{ p.note }}{% endif %} +
{{ p.type }}{{ p.client.name }}
{{ p.client.tenant.name }}
+ {% if p.type in ('tcp','udp') %} + :{{ p.remote_port or '?' }} + {% elif p.subdomain %} + {{ p.subdomain }}.<base> + {% elif p.custom_domains %} + {{ p.custom_domains.split(',')[0] }}{% if p.custom_domains.count(',') > 0 %} ...{% endif %} + {% else %}-{% endif %} + {{ p.local_ip }}:{{ p.local_port }} + {% if p.name in online_names %}在线 + {% else %}离线{% endif %} + {% if p.is_enabled %}启用{% else %}禁用{% endif %} + 编辑 +
+ +
+
+ +
+
暂无代理
+ +
+

+ 修改代理规则后,对应 frpc 主机需要重新拉取 frpc.ini 并执行 frpc reload -c /etc/frpc.ini 让新代理生效。 + 仪表盘上的「在线」状态来自 frps 实时报告,需要 frpc reload 后才会刷新。 +

+
+{% endblock %} \ No newline at end of file diff --git a/templates/proxy_form.html b/templates/proxy_form.html new file mode 100644 index 0000000..993b1ab --- /dev/null +++ b/templates/proxy_form.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if item else '新建' }}代理 · frps-manager{% endblock %} +{% block content %} +

{{ '编辑代理' if item else '新建代理规则' }}

+
+ {% if item %} + + + + {% else %} + + + + {% endif %} + +
+ 本地服务(frpc 那台机器上的) + + +
+ +
+ 对外暴露(frps 上的端口 / 域名) + + + + +
+ +
+ 传输 / 健康检查 + + + + + + +
+ + + + +

+ 保存后,frpc 主机的 frpc.ini 需要重新拉取(点客户端列表的 frpc.ini 按钮), + 然后在 frpc 主机执行 frpc reload -c /etc/frpc.ini 让新代理生效。 +

+ +
+ + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/tenant_form.html b/templates/tenant_form.html new file mode 100644 index 0000000..87a780c --- /dev/null +++ b/templates/tenant_form.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if item else '新建' }}租户 · frps-manager{% endblock %} +{% block content %} +

{{ '编辑租户' if item else '新建租户' }}

+
+ {% if item %} + + {% else %} + + {% endif %} + + + + + +
+ + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/tenants.html b/templates/tenants.html new file mode 100644 index 0000000..dcc772b --- /dev/null +++ b/templates/tenants.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}租户 · frps-manager{% endblock %} +{% block content %} + + + + + + + + {% for t in items %} + + + + + + + + + {% else %} + + {% endfor %} + +
名称带宽 in/out最大连接客户端数状态操作
{{ t.name }}
{{ t.description or '' }}
{{ t.bandwidth_in_mbps|fmt_mbps }} / {{ t.bandwidth_out_mbps|fmt_mbps }}{{ t.max_connections or 'unlimited' }}{{ t.clients|length }}{% if t.is_active %}启用{% else %}禁用{% endif %} + 编辑 +
+ +
+
暂无租户
+{% endblock %} \ No newline at end of file diff --git a/templates/user_form.html b/templates/user_form.html new file mode 100644 index 0000000..26d3a48 --- /dev/null +++ b/templates/user_form.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}{{ '编辑' if item else '新建' }}用户 · frps-manager{% endblock %} +{% block content %} +

{{ '编辑用户' if item else '新建用户' }}

+
+ {% if item %} + + {% else %} + + {% endif %} + + + + + +
+ + 取消 +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..86dca77 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}用户 · frps-manager{% endblock %} +{% block content %} + + + + + + + {% for u in items %} + + + + + + + + + {% endfor %} + +
用户名角色归属租户状态最近登录操作
{{ u.username }}
{{ u.display_name or '' }}
{{ u.role }}{{ u.tenant.name if u.tenant else '-' }}{% if u.is_active %}启用{% else %}禁用{% endif %}{{ u.last_login_at|fmt_dt }} + 编辑 +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..c99a323 --- /dev/null +++ b/wsgi.py @@ -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)") \ No newline at end of file