├── .gitignore ├── Dockerfile ├── README.md ├── app.py ├── blueprints ├── __init__.py ├── acl.py ├── admin.py ├── auth.py ├── forms.py ├── get_captcha.py ├── log.py ├── node.py ├── preauthkey.py ├── route.py ├── set.py ├── system.py └── user.py ├── config-example.yaml ├── config_loader.py ├── data-example.json ├── derp-example.yaml ├── docker-compose.yml ├── exts.py ├── init.sh ├── login_setup.py ├── models.py ├── nginx-example.conf ├── static ├── adminui │ ├── dist │ │ ├── css │ │ │ ├── admin.css │ │ │ └── login.css │ │ └── modules │ │ │ ├── admin.js │ │ │ ├── index.js │ │ │ └── view.js │ └── src │ │ ├── css │ │ ├── admin.css │ │ └── login.css │ │ └── modules │ │ ├── admin.js │ │ ├── index.js │ │ └── view.js ├── config.js ├── index.js ├── layui │ ├── css │ │ └── layui.css │ ├── font │ │ ├── iconfont.eot │ │ ├── iconfont.svg │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 │ └── layui.js ├── modules │ ├── common.js │ ├── console.js │ ├── echarts.js │ └── echartsTheme.js ├── particles │ ├── particles.js │ └── particles.min.js └── views │ └── system │ ├── about.html │ └── theme.html ├── templates ├── admin │ ├── acl.html │ ├── console.html │ ├── deploy.html │ ├── help.html │ ├── index.html │ ├── info.html │ ├── log.html │ ├── node.html │ ├── password.html │ ├── preauthkey.html │ ├── route.html │ ├── set.html │ └── user.html ├── auth │ ├── error.html │ ├── login.html │ ├── reg.html │ └── register.html └── index.html └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | migrations/ 3 | blueprints/__pycache__/ 4 | headscale 5 | .* 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 第一阶段:构建阶段 2 | FROM ubuntu:22.04 AS builder 3 | 4 | # 创建一个工作目录 5 | WORKDIR /init_data 6 | 7 | # 更新包管理器并安装必要的工具 8 | RUN apt-get update && \ 9 | apt-get install pip wget -y && \ 10 | pip3 install flask wtforms captcha psutil flask_login requests apscheduler ruamel.yaml email_validator && \ 11 | wget -O headscale https://github.com/juanfont/headscale/releases/download/v0.25.1/headscale_0.25.1_linux_amd64 12 | 13 | # 将当前目录下的内容复制到工作目录中 14 | COPY . /init_data 15 | RUN mv data-example.json data.json && \ 16 | mv config-example.yaml config.yaml && \ 17 | mv derp-example.yaml derp.yaml && \ 18 | chmod u+x init.sh 19 | 20 | # 第二阶段:运行阶段 21 | FROM ubuntu:22.04 22 | 23 | # 创建一个工作目录 24 | WORKDIR /init_data 25 | 26 | # 安装运行时依赖 27 | RUN apt-get update && \ 28 | apt-get install tzdata net-tools iputils-ping python3 iproute2 -y && \ 29 | apt-get clean 30 | 31 | # 从构建阶段复制必要的文件 32 | COPY --from=builder /init_data /init_data 33 | COPY --from=builder /usr/local/lib/python3.10/dist-packages /usr/local/lib/python3.10/dist-packages 34 | 35 | CMD ["sh", "-c", "./init.sh 'python3 app.py'"] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 介绍 3 | [![GitHub repo size](https://img.shields.io/github/repo-size/arounyf/Headscale-Admin-Pro)](https://github.com/arounyf/headscale-Admin) 4 | [![Docker Image Size](https://img.shields.io/docker/image-size/runyf/hs-admin)](https://hub.docker.com/r/runyf/hs-admin) 5 | [![docker pulls](https://img.shields.io/docker/pulls/runyf/hs-admin.svg?color=brightgreen)](https://hub.docker.com/r/runyf/hs-admin) 6 | [![platfrom](https://img.shields.io/badge/platform-amd64%20%7C%20arm64-brightgreen)](https://hub.docker.com/r/runyf/hs-admin/tags) 7 | 8 | 重点升级: 9 | 1、基于本人发布的headscale-Admin使用python进行了后端重构 10 | 2、容器内置headscale、实现快速搭建 11 | 3、容器内置流量监测、无需额外安装插件 12 | 4、基于headscale最新新版本进行开发和测试 13 | 14 | 官方qq群: 892467054 15 | # 时间线 16 | 2024年6月我接触到了tailscale,后在个人博客上发布了derper与headscale的搭建教程 17 | 2024年9月8日headscale-Admin首个版本正式开源发布 18 | 2025年3月26日Headscale-Admin-Pro正式开源发布 19 | 20 | 21 | # 使用docker部署 22 | ```shell 23 | mkdir ~/hs-admin 24 | cd ~/hs-admin 25 | wget https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/refs/heads/main/docker-compose.yml 26 | docker-compose up -d 27 | ``` 28 | 29 | 30 | 1、访问 http://ip:5000,注册admin账户即为系统管理员账户 31 | 32 | 2、进入后台设置网卡名、并确认其它配置是否需要修改,修改之后重启headscale 33 | 34 | 4、配置nginx,配置示例 nginx-example.conf(可选) 35 | 36 | 37 | 38 | # 功能 39 | - 用户管理 40 | - 用户独立后台 41 | - 用户到期管理 42 | - 流量统计 43 | - 基于用户ACL 44 | - 节点管理 45 | - 路由管理 46 | - 日志管理 47 | - 预认证密钥管理 48 | - 角色管理 49 | - api和menu权限管理 50 | - 内置headscale 51 | - 内置配置在线修改 52 | - 一键添加节点(需配置反向代理) 53 | - 自动更新apikey 54 | 55 | 56 | # 兼容性测试 57 | headscale 0.25.0 58 | headscale 0.25.1 59 | 60 | 61 | 62 | # 系统截图 63 | runyf_20250506230722 64 | runyf_20250506230832 65 | runyf_20250506230908 66 | runyf_20250506231108 67 | runyf_20250506230937 68 | runyf_20250506230957 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template 2 | from apscheduler.schedulers.background import BackgroundScheduler 3 | from login_setup import init_login_manager 4 | from utils import get_data_record, start_headscale, to_init_db 5 | import config_loader 6 | 7 | # 导入蓝图 8 | from blueprints.auth import bp as auth_bp 9 | from blueprints.admin import bp as admin_bp 10 | from blueprints.user import bp as user_bp 11 | from blueprints.node import bp as node_bp 12 | from blueprints.system import bp as system_bp 13 | from blueprints.route import bp as route_bp 14 | from blueprints.acl import bp as acl_bp 15 | from blueprints.preauthkey import bp as preauthkey_bp 16 | from blueprints.log import bp as log_bp 17 | from blueprints.set import bp as set_bp 18 | 19 | # 创建 Flask 应用实例 20 | app = Flask(__name__) 21 | 22 | # 应用配置 23 | app.config.from_object(config_loader) 24 | app.json.ensure_ascii = False # 让接口返回的中文不转码 25 | 26 | # 初始化 Flask-login 27 | init_login_manager(app) 28 | 29 | # 创建蓝图列表 30 | blueprints = [auth_bp,admin_bp,user_bp,node_bp,system_bp,route_bp,acl_bp,preauthkey_bp,log_bp,set_bp] 31 | 32 | # 循环注册蓝图 33 | for blueprint in blueprints: 34 | app.register_blueprint(blueprint) 35 | 36 | 37 | #启动 headscale 38 | start_headscale() 39 | to_init_db(app) 40 | 41 | 42 | #定义一个定时任务函数,每个一个小时记录一下流量使用情况 43 | def my_task(): 44 | with app.app_context(): 45 | return get_data_record() 46 | 47 | 48 | # 创建调度 49 | scheduler = BackgroundScheduler() 50 | # 添加任务,每隔 1 Hour 执行一次 51 | scheduler.add_job(func=my_task, trigger='interval', seconds=3600) 52 | # 启动调度器 53 | scheduler.start() 54 | 55 | 56 | # 自定义404错误处理器 57 | @app.errorhandler(404) 58 | def page_not_found(e): 59 | return render_template('auth/error.html', message="404") 60 | 61 | if __name__ == '__main__': 62 | app.run(host="0.0.0.0", port=5000, debug=False) 63 | -------------------------------------------------------------------------------- /blueprints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/b3cd91929b146b72c10d2b69d8bc8ad7b15a7e10/blueprints/__init__.py -------------------------------------------------------------------------------- /blueprints/acl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask_login import login_required 3 | from exts import SqliteDB 4 | from login_setup import role_required 5 | from flask import Blueprint, request 6 | from utils import reload_headscale, to_rewrite_acl, table_res, res 7 | 8 | 9 | bp = Blueprint("acl", __name__, url_prefix='/api/acl') 10 | 11 | 12 | 13 | @bp.route('/getACL') 14 | @login_required 15 | @role_required("manager") 16 | def getACL(): 17 | page = request.args.get('page', default=1, type=int) 18 | per_page = request.args.get('limit', default=10, type=int) 19 | offset = (page - 1) * per_page 20 | 21 | with SqliteDB() as cursor: 22 | # 构建基础查询语句 23 | base_query = """ 24 | SELECT 25 | acl.id, 26 | acl.acl, 27 | users.name 28 | FROM 29 | acl acl 30 | JOIN 31 | users ON acl.user_id = users.id 32 | """ 33 | 34 | # 查询总记录数 35 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 36 | cursor.execute(count_query) 37 | total_count = cursor.fetchone()['total'] 38 | 39 | # 分页查询 40 | paginated_query = f"{base_query} LIMIT? OFFSET? " 41 | paginated_params = (per_page, offset) 42 | cursor.execute(paginated_query, paginated_params) 43 | acls = cursor.fetchall() 44 | 45 | # 数据格式化 46 | acls_list = [] 47 | for acl in acls: 48 | acls_list.append({ 49 | 'id': acl['id'], 50 | 'acl': acl['acl'], 51 | 'userName': acl['name'] 52 | }) 53 | 54 | 55 | 56 | return table_res('0', '获取成功',acls_list,total_count,len(acls_list)) 57 | 58 | 59 | 60 | @bp.route('/re_acl', methods=['GET','POST']) 61 | @login_required 62 | @role_required("manager") 63 | def re_acl(): 64 | acl_id = request.form.get('aclId') 65 | new_acl = request.form.get('newAcl') 66 | 67 | 68 | try: 69 | json.loads(new_acl) 70 | except json.JSONDecodeError: 71 | return res('1', '解析错误') 72 | 73 | 74 | with SqliteDB() as cursor: 75 | # 更新 ACL 记录 76 | update_query = "UPDATE acl SET acl =? WHERE id =?;" 77 | cursor.execute(update_query, (new_acl, acl_id)) 78 | 79 | 80 | return res('0','更新成功') 81 | 82 | 83 | 84 | @bp.route('/rewrite_acl', methods=['GET','POST']) 85 | @login_required 86 | @role_required("manager") 87 | def rewrite_acl(): 88 | return to_rewrite_acl() 89 | 90 | 91 | @bp.route('/read_acl', methods=['GET','POST']) 92 | @login_required 93 | @role_required("manager") 94 | def read_acl(): 95 | acl_path = "/etc/headscale/acl.hujson" 96 | try: 97 | with open(acl_path, 'r') as f: 98 | acl_data = json.load(f) 99 | 100 | except FileNotFoundError: 101 | return res('1', f"错误: 文件 {acl_path} 未找到。") 102 | except json.JSONDecodeError: 103 | return res('2', f"错误: 无法解析 {acl_path} 中的 JSON 数据。") 104 | except Exception as e: 105 | return res( '3', f"发生未知错误: {str(e)}") 106 | 107 | 108 | print(acl_data.get('acls', [])) 109 | 110 | html_content = "" 111 | html_content += "" 112 | 113 | for item in acl_data.get('acls', []): 114 | action = item['action'] 115 | src = ', '.join(item['src']) 116 | dst = ', '.join(item['dst']) 117 | html_content += f"" 118 | 119 | html_content += "
ActionSourceDestination
{action}{src}{dst}
" 120 | 121 | return res('0','读取成功',html_content) 122 | 123 | 124 | 125 | @bp.route('/reload', methods=['GET','POST']) 126 | @login_required 127 | @role_required("manager") 128 | def reload(): 129 | return reload_headscale() -------------------------------------------------------------------------------- /blueprints/admin.py: -------------------------------------------------------------------------------- 1 | from flask_login import login_required, current_user 2 | from login_setup import role_required 3 | from flask import Blueprint, render_template, current_app, request, json 4 | from utils import get_server_net, get_headscale_pid, get_headscale_version 5 | 6 | 7 | 8 | bp = Blueprint("admin", __name__, url_prefix='/admin') 9 | 10 | 11 | 12 | 13 | @bp.route('/') 14 | @login_required 15 | def admin(): 16 | # 定义每个菜单项及其对应的可访问角色 17 | 18 | menu_items = { 19 | 'console': {'html': '
控制台
', 'roles': ['manager']}, 20 | 'user': {'html': '
用户
', 'roles': ['manager']}, 21 | 'node': {'html': '
节点
', 'roles': ['manager', 'user']}, 22 | 'route': {'html': '
路由
', 'roles': ['manager', 'user']}, 23 | 'acl': {'html': '
ACL
','roles': ['manager']}, 24 | 'preauthkey': {'html': '
密钥
','roles': ['manager', 'user']}, 25 | 'deploy': {'html': '
指令
', 'roles': ['manager', 'user']}, 26 | 'help': {'html': '
文档
', 'roles': ['manager', 'user']}, 27 | 'set': {'html': '
设置
', 'roles': ['manager']}, 28 | 'log': {'html': '
日志
', 'roles': ['manager', 'user']} 29 | } 30 | 31 | 32 | role = current_user.role 33 | if(role == "manager"): 34 | default_page = "console" 35 | else: 36 | default_page = "node" 37 | menu_html = "".join(item['html'] for item in menu_items.values() if role in item['roles']) 38 | 39 | return render_template('admin/index.html', menu_html=menu_html,default_page=default_page) 40 | 41 | 42 | 43 | @bp.route('/console') 44 | @login_required 45 | @role_required("manager") 46 | def console(): 47 | region_html = current_app.config['REGION_HTML'] 48 | return render_template('admin/console.html',region_html = region_html) 49 | 50 | 51 | 52 | @bp.route('/user') 53 | @login_required 54 | @role_required("manager") 55 | def user(): 56 | return render_template('admin/user.html') 57 | 58 | 59 | 60 | 61 | 62 | @bp.route('/node') 63 | @login_required 64 | def node(): 65 | print(request.url) 66 | return render_template('admin/node.html') 67 | 68 | 69 | @bp.route('/route') 70 | @login_required 71 | def route(): 72 | return render_template('admin/route.html') 73 | 74 | 75 | @bp.route('/deploy') 76 | @login_required 77 | def deploy(): 78 | server_url = current_app.config['SERVER_URL'] 79 | return render_template('admin/deploy.html',server_url = server_url) 80 | 81 | 82 | @bp.route('/help') 83 | @login_required 84 | def help(): 85 | return render_template('admin/help.html') 86 | 87 | 88 | 89 | @bp.route('/acl') 90 | @login_required 91 | @role_required("manager") 92 | def acl(): 93 | return render_template('admin/acl.html') 94 | 95 | 96 | @bp.route('preauthkey') 97 | @login_required 98 | def preauthkey(): 99 | return render_template('admin/preauthkey.html') 100 | 101 | 102 | @bp.route('log') 103 | @login_required 104 | def log(): 105 | return render_template('admin/log.html') 106 | 107 | 108 | @bp.route('info') 109 | @login_required 110 | def info(): 111 | name = current_user.name 112 | cellphone = current_user.cellphone 113 | email = current_user.email 114 | node = current_user.node 115 | route = current_user.route 116 | expire = current_user.expire 117 | 118 | if (route == "1"): 119 | route = "checked" 120 | else: 121 | route = "" 122 | 123 | return render_template('admin/info.html', name = name, 124 | cellphone = cellphone, 125 | email = email, 126 | node = node, 127 | route = route, 128 | expire = expire 129 | ) 130 | 131 | 132 | 133 | 134 | @bp.route('set') 135 | @login_required 136 | def set(): 137 | apikey = current_app.config['BEARER_TOKEN'] 138 | server_url = current_app.config['SERVER_URL'] 139 | server_net = current_app.config['SERVER_NET'] 140 | default_reg_days = current_app.config['DEFAULT_REG_DAYS'] 141 | default_node_count = current_app.config['DEFAULT_NODE_COUNT'] 142 | open_user_reg = current_app.config['OPEN_USER_REG'] 143 | region_data = current_app.config['REGION_DATA'] 144 | 145 | options_html = "" 146 | for interface in get_server_net()["network_interfaces"]: 147 | if interface == server_net: 148 | options_html += f'\n' 149 | else: 150 | options_html += f'\n' 151 | 152 | region_html = current_app.config['REGION_HTML'] 153 | 154 | with open(current_app.config['DERP_PATH'], 'r') as f: 155 | derp_config = f.read() 156 | 157 | 158 | if get_headscale_pid(): 159 | headscale_status = "checked" 160 | else: 161 | headscale_status = "" 162 | 163 | 164 | if open_user_reg == 'on': 165 | open_user_reg = "checked" 166 | else: 167 | open_user_reg = "" 168 | 169 | 170 | return render_template('admin/set.html',apikey = apikey, 171 | server_url = server_url, 172 | server_net = options_html, 173 | region_html = region_html, 174 | headscale_status = headscale_status, 175 | default_reg_days = default_reg_days, 176 | default_node_count = default_node_count, 177 | open_user_reg = open_user_reg, 178 | region_data = region_data, 179 | version = get_headscale_version(), 180 | derp_config = derp_config 181 | ) 182 | 183 | 184 | 185 | @bp.route('password') 186 | @login_required 187 | def password(): 188 | return render_template('admin/password.html') 189 | 190 | 191 | -------------------------------------------------------------------------------- /blueprints/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import json 3 | from utils import record_log, reload_headscale, to_rewrite_acl, to_request 4 | from flask_login import login_user, logout_user, current_user, login_required 5 | from flask import Blueprint, render_template, request, session, redirect, url_for, current_app, json 6 | from exts import SqliteDB 7 | from utils import res 8 | from .forms import RegisterForm, LoginForm, PasswdForm 9 | from werkzeug.security import generate_password_hash 10 | from .get_captcha import get_captcha_code_and_content 11 | 12 | 13 | 14 | 15 | bp = Blueprint("auth", __name__, url_prefix='/') 16 | 17 | 18 | 19 | 20 | @bp.route('/') 21 | def index(): 22 | return render_template('index.html') 23 | 24 | 25 | @bp.route('/get_captcha') 26 | def get_captcha(): 27 | 28 | code,content = get_captcha_code_and_content() 29 | session['code'] = code 30 | return content 31 | 32 | def register_node(registrationID): 33 | url_path = f'/api/v1/node/register?user={current_user.name}&key={registrationID}' 34 | 35 | with SqliteDB() as cursor: 36 | # 查询当前用户的节点数量 37 | query = "SELECT COUNT(*) as count FROM nodes WHERE user_id =?;" 38 | cursor.execute(query, (current_user.id,)) 39 | result = cursor.fetchone() 40 | node_count = result['count'] if result else 0 41 | 42 | # 查询当前用户允许的节点数 43 | user_query = "SELECT node FROM users WHERE id =?;" 44 | cursor.execute(user_query, (current_user.id,)) 45 | user_result = cursor.fetchone() 46 | user_node_limit = user_result['node'] if user_result else 0 47 | 48 | if int(node_count) >= int(user_node_limit): 49 | return res('2', '超过此用户节点限制', '') 50 | else: 51 | 52 | result_post = to_request('POST',url_path) 53 | if result_post['code'] == '0': 54 | record_log(current_user.id,"节点添加成功") 55 | return res('0', '节点添加成功', result_post['data']) 56 | else: 57 | return res(result_post['code'], result_post['msg']) 58 | 59 | @bp.route('/register/', methods=['GET', 'POST']) 60 | def register(registrationID): 61 | if request.method == 'GET': 62 | # 如果用户已经登录,重定向到 admin 页面 63 | if current_user.is_authenticated: 64 | # 已登录,直接添加节点 65 | register_node_response = register_node(registrationID) 66 | error_info = '' 67 | print(register_node_response) 68 | if register_node_response['code'] == '0': 69 | try: 70 | # 获取 ipAddresses 的值 71 | ip_address = json.loads(register_node_response['data'])["node"]["ipAddresses"][0] 72 | except Exception as e: 73 | print(f"发生错误: {e}") 74 | ip_address = 'error' # headscale 错误提示 75 | error_info = json.loads(register_node_response['data']).get('message') 76 | else: 77 | ip_address = 'error' # hs-admin 错误提示 78 | error_info = register_node_response['msg'] 79 | 80 | return render_template('admin/node.html', error_info = error_info, ip_address = ip_address) 81 | else: 82 | return render_template('auth/register.html',registrationID = registrationID) 83 | else: 84 | form = LoginForm(request.form) 85 | 86 | if form.validate(): 87 | user = form.user # 获取表单中查询到的用户对象 88 | login_user(user) 89 | res_code,res_msg,res_data = '0','登录成功','' 90 | else: 91 | # return form.errors 92 | first_key = next(iter(form.errors.keys())) 93 | first_value = form.errors[first_key] 94 | res_code, res_msg,res_data = '1',str(first_value[0]),'' 95 | return res(res_code,res_msg,res_data) 96 | 97 | 98 | @bp.route('/reg', methods=['GET','POST']) 99 | def reg(): 100 | if current_app.config['OPEN_USER_REG'] != 'on': 101 | return '

当前服务器已关闭对外注册

' 102 | if request.method == 'GET': 103 | # 如果用户已经登录,重定向到 admin 页面 104 | if current_user.is_authenticated: 105 | return redirect(url_for('admin.admin')) 106 | else: 107 | return render_template('auth/reg.html') 108 | else: 109 | 110 | form = RegisterForm(request.form) 111 | if form.validate(): 112 | username = form.username.data 113 | password = generate_password_hash(form.password.data) 114 | phone_number = form.phone.data 115 | email = form.email.data 116 | default_reg_days = current_app.config['DEFAULT_REG_DAYS'] 117 | 118 | create_time = datetime.now() 119 | 120 | 121 | if (username == "admin"): 122 | role = "manager" 123 | expire = create_time + timedelta(1000) 124 | else: 125 | role = "user" 126 | expire = create_time + timedelta(days=int(default_reg_days)) # 新用户注册默认?天后到期 127 | 128 | default_reg_days = 1 if default_reg_days != 0 else default_reg_days # 若用户注册默认天数不为0,代表该用户启用 129 | 130 | 131 | # headscale用户注册请求参数构建 132 | json_data = { 133 | "name": username, 134 | "displayName": username, 135 | "email": email, 136 | "pictureUrl": "NULL" 137 | } 138 | 139 | result_reg = to_request('POST','/api/v1/user',data = json_data) # 直接使用数据库创建用户会出现ACL失效,所有使用api创建 140 | 141 | if result_reg['code'] == '0': 142 | try: 143 | user_id = json.loads(result_reg['data'])['user']['id'] # 获取 user_id 144 | except Exception as e: 145 | print(f"发生错误: {e}") 146 | return res('1','注册失败',result_reg['data']) 147 | else: 148 | return res('1', result_reg['msg']) 149 | 150 | 151 | with SqliteDB() as cursor: 152 | update_query = """ 153 | UPDATE users 154 | SET password = ?,created_at = ?,updated_at = ?,expire = ?,role = ?,cellphone = ?,node = ?,route = ?,enable = ? 155 | WHERE name = ? 156 | """ 157 | values = ( 158 | password, create_time, create_time, expire, role, phone_number, current_app.config['DEFAULT_NODE_COUNT'], '0', 159 | default_reg_days, username 160 | ) 161 | cursor.execute(update_query, values) 162 | 163 | # 初始化用户ACL规则 164 | init_acl = f'{{"action": "accept","src": ["{username}"],"dst": ["{username}:*"]}}' 165 | insert_query = "INSERT INTO acl (acl, user_id) VALUES (?,?);" 166 | cursor.execute(insert_query, (init_acl, user_id)) 167 | 168 | # 用户初始化 169 | to_rewrite_acl() 170 | reload_headscale() 171 | 172 | return res('0','注册成功','') 173 | 174 | else: 175 | # return form.errors 176 | first_key = next(iter(form.errors.keys())) 177 | first_value = form.errors[first_key] 178 | return res('1', str(first_value[0]),'') 179 | 180 | 181 | @bp.route('/login', methods=['GET','POST']) 182 | def login(): 183 | 184 | if request.method == 'GET': 185 | # 如果用户已经登录,重定向到 admin 页面 186 | if current_user.is_authenticated: 187 | return redirect(url_for('admin.admin')) 188 | else: 189 | return render_template('auth/login.html') 190 | else: 191 | form = LoginForm(request.form) 192 | 193 | if form.validate(): 194 | user = form.user # 获取表单中查询到的用户对象 195 | login_user(user) 196 | res_code,res_msg,res_data = '0', '登录成功','' 197 | 198 | record_log(user.id,"登录成功") 199 | else: 200 | # return form.errors 201 | first_key = next(iter(form.errors.keys())) 202 | first_value = form.errors[first_key] 203 | res_code,res_msg,res_data ='1',str(first_value[0]),'' 204 | return res(res_code,res_msg,res_data) 205 | # 206 | # 207 | # 208 | 209 | @bp.route('/logout', methods=['POST']) 210 | @login_required 211 | def logout(): 212 | # session.clear() 213 | logout_user() 214 | res_code,res_msg,res_data = '0', 'logout success','' 215 | return res(res_code,res_msg,res_data) 216 | 217 | 218 | @bp.route('/password', methods=['GET','POST']) 219 | @login_required 220 | def password(): 221 | form = PasswdForm(request.form) 222 | if form.validate(): 223 | new_password = form.new_password.data 224 | with SqliteDB() as cursor: 225 | hashed_password = generate_password_hash(new_password) 226 | # 更新用户密码 227 | update_query = "UPDATE users SET password =? WHERE id =?;" 228 | cursor.execute(update_query, (hashed_password, current_user.id)) 229 | res_code, res_msg, res_data = '0', '修改成功', '' 230 | logout_user() 231 | else: 232 | first_key = next(iter(form.errors.keys())) 233 | first_value = form.errors[first_key] 234 | res_code, res_msg, res_data = '1', str(first_value[0]), '' 235 | 236 | return res(res_code, res_msg, res_data) 237 | 238 | 239 | @bp.route('/derp') 240 | def derp(): 241 | return current_app.config['DERP_CONFIG'] 242 | 243 | @bp.route('/error') 244 | @login_required 245 | def error(): 246 | return render_template('auth/error.html') 247 | 248 | -------------------------------------------------------------------------------- /blueprints/forms.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import wtforms 3 | from flask import session 4 | from flask_login import current_user 5 | from werkzeug.security import check_password_hash 6 | from wtforms.validators import length, DataRequired, Regexp, Length, EqualTo, Email 7 | from exts import SqliteDB 8 | from models import User 9 | 10 | 11 | class RegisterForm(wtforms.Form): 12 | username = wtforms.StringField( 13 | validators=[ 14 | DataRequired(message='用户名不能为空'), 15 | Length(min=3, max=20, message='用户名长度需在3 - 20位之间'), 16 | Regexp( 17 | regex=r'^[a-zA-Z][a-zA-Z0-9]*$', 18 | message='用户名必须以字母开头,且只能包含字母和数字' 19 | ) 20 | ] 21 | ) 22 | password = wtforms.StringField(validators=[DataRequired(),Length(min=3,max=20,message='密码格式错误')]) 23 | confirmPassword = wtforms.StringField(validators=[EqualTo('password',message='密码输入不一致')]) 24 | phone = wtforms.StringField(validators=[DataRequired(),length(11, 11),Regexp(r'(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}', 0, '手机号码不合法')]) 25 | email = wtforms.StringField(validators=[Email(message='请输入有效的电子邮件地址')]) 26 | vercode = wtforms.StringField(validators=[Length(min=4, max=4, message='验证码格式错误')]) 27 | captcha_uuid = wtforms.StringField(validators=[Length(min=36, max=36, message='UUID错误')]) 28 | 29 | 30 | 31 | 32 | def validate_vercode(self,field): 33 | code = session['code'] 34 | if code != field.data: 35 | raise wtforms.ValidationError("验证码错误!") 36 | 37 | 38 | def validate_password(self,field): 39 | if ' ' in field.data: 40 | raise wtforms.ValidationError('密码不能包含空格') 41 | 42 | 43 | def validate_username(self,field): 44 | if ' ' in field.data: 45 | raise wtforms.ValidationError('用户名不能包含空格') 46 | else: 47 | with SqliteDB() as cursor: 48 | cursor.execute("SELECT name FROM users WHERE name =?", (field.data,)) 49 | cursor.row_factory = sqlite3.Row 50 | user_name = cursor.fetchone() 51 | if user_name: 52 | raise wtforms.ValidationError(f"{user_name['name']} 用户已注册!") 53 | 54 | 55 | def validate_email(self,field): 56 | with SqliteDB() as cursor: 57 | cursor.row_factory = sqlite3.Row 58 | cursor.execute("SELECT email FROM users WHERE email =?", (field.data,)) 59 | email = cursor.fetchone() 60 | if email: 61 | raise wtforms.ValidationError(f"{email['email']} 邮箱已被注册!") 62 | 63 | 64 | 65 | class LoginForm(wtforms.Form): 66 | username = wtforms.StringField(validators=[DataRequired(),Length(min=3,max=20,message='用户名格式错误')]) 67 | password = wtforms.StringField(validators=[DataRequired(),Length(min=3,max=20,message='密码格式错误')]) 68 | vercode = wtforms.StringField(validators=[Length(min=4, max=4, message='验证码格式错误')]) 69 | captcha_uuid = wtforms.StringField(validators=[Length(min=36, max=36, message='UUID错误')]) 70 | 71 | 72 | #user = None # 用于存储查询到的用户对象 73 | def validate_vercode(self, field): 74 | code = session['code'] 75 | if code != field.data: 76 | raise wtforms.ValidationError("验证码错误!") 77 | 78 | 79 | def validate_username(self, field): 80 | try: 81 | with SqliteDB() as cursor: 82 | query = """ 83 | SELECT id, name, created_at, updated_at,email, password,expire, cellphone, role, node, route, enable 84 | FROM users 85 | WHERE name =? 86 | """ 87 | cursor.execute(query, (field.data,)) 88 | user_data = cursor.fetchone() 89 | if user_data: 90 | user = User(*user_data) 91 | self.user = user 92 | input_password = self.password.data 93 | if check_password_hash(user.password, input_password): 94 | if user.enable == 0: 95 | raise wtforms.ValidationError("用户已被禁用!") 96 | else: 97 | raise wtforms.ValidationError("密码错误!") 98 | else: 99 | raise wtforms.ValidationError("用户不存在!") 100 | return True 101 | except sqlite3.Error as e: 102 | print(f"查询失败: {e}") 103 | return False 104 | 105 | 106 | 107 | 108 | class PasswdForm(wtforms.Form): 109 | password = wtforms.StringField(validators=[DataRequired(),Length(min=3,max=20,message='密码格式错误')]) 110 | new_password = wtforms.StringField(validators=[DataRequired(),Length(min=3,max=20,message='密码格式错误')]) 111 | confirmPassword = wtforms.StringField(validators=[EqualTo('new_password', message='密码输入不一致')]) 112 | 113 | def validate_password(self,field): 114 | if not (check_password_hash(current_user.password, field.data)): 115 | raise wtforms.ValidationError("当前密码输入错误!") -------------------------------------------------------------------------------- /blueprints/get_captcha.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from random import choices 3 | from captcha.image import ImageCaptcha 4 | from PIL import Image 5 | 6 | def gen_captcha(content="0123456789"): 7 | image = ImageCaptcha() 8 | captcha_text = "".join(choices(content,k=4)) 9 | captcha_image = Image.open(image.generate(captcha_text)) 10 | return captcha_text,captcha_image 11 | 12 | def get_captcha_code_and_content(): 13 | code,image = gen_captcha() 14 | out = BytesIO() 15 | image.save(out,"png") 16 | out.seek(0) 17 | content = out.read() 18 | return code,content 19 | 20 | if __name__=="__main__": 21 | code,content = get_captcha_code_and_content() 22 | print(code,content) -------------------------------------------------------------------------------- /blueprints/log.py: -------------------------------------------------------------------------------- 1 | from flask_login import login_required, current_user 2 | from flask import Blueprint, request 3 | from exts import SqliteDB 4 | 5 | 6 | bp = Blueprint("log", __name__, url_prefix='/api/log') 7 | 8 | 9 | @bp.route('/getLogs') 10 | @login_required 11 | def getLogs(): 12 | page = request.args.get('page', default=1, type=int) 13 | per_page = request.args.get('limit', default=10, type=int) 14 | offset = (page - 1) * per_page 15 | 16 | with SqliteDB() as cursor: 17 | # 构建基础查询语句 18 | base_query = """ 19 | SELECT 20 | log.id, 21 | log.content, 22 | users.name, 23 | strftime('%Y-%m-%d %H:%M:%S', log.created_at, 'localtime') as created_at 24 | FROM 25 | log 26 | JOIN 27 | users ON log.user_id = users.id 28 | """ 29 | 30 | # 判断用户角色 31 | if current_user.role != 'manager': 32 | base_query += " WHERE log.user_id =? " 33 | params = (current_user.id,) 34 | else: 35 | params = () 36 | 37 | # 查询总记录数 38 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 39 | cursor.execute(count_query, params) 40 | total_count = cursor.fetchone()['total'] 41 | 42 | # 分页查询 43 | paginated_query = f"{base_query} LIMIT? OFFSET? " 44 | paginated_params = params + (per_page, offset) 45 | cursor.execute(paginated_query, paginated_params) 46 | logs = cursor.fetchall() 47 | 48 | # 数据格式化 49 | logs_list = [] 50 | for log in logs: 51 | logs_list.append({ 52 | 'id': log['id'], 53 | 'content': log['content'], 54 | 'name': log['name'], 55 | 'create_time': log['created_at'] 56 | }) 57 | 58 | # 接口返回json数据 59 | res_json = { 60 | 'code': '0', 61 | 'data': logs_list, 62 | 'msg': '获取成功', 63 | 'count': total_count, 64 | 'totalRow': { 65 | 'count': len(logs_list) 66 | } 67 | } 68 | 69 | return res_json 70 | -------------------------------------------------------------------------------- /blueprints/node.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | from flask_login import current_user, login_required 4 | from blueprints.auth import register_node 5 | from exts import SqliteDB 6 | from login_setup import role_required 7 | from flask import Blueprint, request,current_app 8 | from utils import res, table_res, to_request 9 | 10 | bp = Blueprint("node", __name__, url_prefix='/api/node') 11 | 12 | 13 | @bp.route('/register', methods=['GET','POST']) 14 | @login_required 15 | def register(): 16 | nodekey = request.form.get('nodekey') 17 | register_node_response = register_node(nodekey) 18 | 19 | print(register_node_response) 20 | if register_node_response['code'] == '0': 21 | try: 22 | # 获取 ipAddresses 的值 23 | ip_address = json.loads(register_node_response['data'])["node"]["ipAddresses"][0] 24 | code,msg,data = '0',ip_address,'' 25 | except Exception as e: 26 | print(f"发生错误: {e}") 27 | headscale_error_msg = json.loads(register_node_response['data']).get('message') 28 | code, msg, data = '1', headscale_error_msg, '' 29 | else: 30 | error_msg = register_node_response['msg'] 31 | code, msg, data = '1', error_msg, '' 32 | return res(code,msg,data) 33 | 34 | 35 | @bp.route('/getNodes') 36 | @login_required 37 | def getNodes(): 38 | page = request.args.get('page', default=1, type=int) 39 | per_page = request.args.get('limit', default=10, type=int) 40 | offset = (page - 1) * per_page 41 | 42 | with SqliteDB() as cursor: 43 | # 构建基础查询语句 44 | base_query = """ 45 | SELECT 46 | nodes.id, 47 | users.name, 48 | nodes.given_name, 49 | nodes.user_id, 50 | nodes.ipv4, 51 | nodes.host_info, 52 | strftime('%Y-%m-%d %H:%M:%S', nodes.last_seen, 'localtime') as last_seen, 53 | strftime('%Y-%m-%d %H:%M:%S', nodes.expiry, 'localtime') as expiry, 54 | strftime('%Y-%m-%d %H:%M:%S', nodes.created_at, 'localtime') as created_at, 55 | strftime('%Y-%m-%d %H:%M:%S', nodes.updated_at, 'localtime') as updated_at, 56 | strftime('%Y-%m-%d %H:%M:%S', nodes.deleted_at, 'localtime') as deleted_at 57 | FROM 58 | nodes 59 | JOIN 60 | users ON nodes.user_id = users.id 61 | """ 62 | 63 | # 判断用户角色 64 | if current_user.role != 'manager': 65 | base_query += " WHERE nodes.user_id =? " 66 | params = (current_user.id,) 67 | else: 68 | params = () 69 | 70 | # 查询总记录数 71 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 72 | cursor.execute(count_query, params) 73 | total_count = cursor.fetchone()['total'] 74 | 75 | # 分页查询 76 | paginated_query = f"{base_query} LIMIT? OFFSET? " 77 | paginated_params = params + (per_page, offset) 78 | cursor.execute(paginated_query, paginated_params) 79 | nodes = cursor.fetchall() 80 | 81 | # 数据格式化 82 | nodes_list = [] 83 | for node in nodes: 84 | host_info = json.loads(node['host_info']) 85 | nodes_list.append({ 86 | 'id': node['id'], 87 | 'userName': node['name'], 88 | 'name': node['given_name'], 89 | 'ip': node['ipv4'], 90 | 'lastTime': node['last_seen'], 91 | 'createTime': node['created_at'], 92 | 'OS': (host_info.get("OS") or "") + (host_info.get("OSVersion") or ""), 93 | 'Client': host_info.get("IPNVersion") or "" 94 | }) 95 | 96 | 97 | 98 | return table_res('0', '获取成功',nodes_list,total_count,len(nodes_list)) 99 | 100 | 101 | 102 | 103 | 104 | @bp.route('/delete', methods=['POST']) 105 | @login_required 106 | def delete(): 107 | node_id = request.form.get('NodeId') 108 | 109 | url = f'/api/v1/node/{node_id}' 110 | 111 | with SqliteDB() as cursor: 112 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ", (node_id,)).fetchone()[0] 113 | if user_id == current_user.id or current_user.role == 'manager': 114 | response = to_request('DELETE', url) 115 | if response['code'] == '0': 116 | return res('0', '删除成功', response['data']) 117 | else: 118 | return res(response['code'], response['msg']) 119 | else: 120 | return res('1', '非法请求') 121 | 122 | 123 | 124 | 125 | @bp.route('/new_owner', methods=['GET','POST']) 126 | @login_required 127 | @role_required("manager") 128 | def new_owner(): 129 | 130 | node_id = request.form.get('nodeId') 131 | user_name = request.form.get('userName') 132 | 133 | url = f'/api/v1/node/{node_id}/user' # 替换为实际的目标 URL 134 | 135 | data = {"user": user_name} 136 | 137 | with SqliteDB() as cursor: 138 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ", (node_id,)).fetchone()[0] 139 | if user_id == current_user.id or current_user.role == 'manager': 140 | response = to_request('POST', url, data) 141 | if response['code'] == '0': 142 | return res('0', '更新成功', response['data']) 143 | else: 144 | return res(response['code'], response['msg']) 145 | else: 146 | return res('1', '非法请求') 147 | 148 | 149 | 150 | 151 | @bp.route('/rename', methods=['POST']) 152 | @login_required 153 | def rename(): 154 | 155 | node_id = request.form.get('nodeId') 156 | node_name = request.form.get('nodeName') 157 | 158 | url = f'/api/v1/node/{node_id}/rename/{node_name}' # 替换为实际的目标 URL 159 | 160 | with SqliteDB() as cursor: 161 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ",(node_id,)).fetchone()[0] 162 | if user_id == current_user.id or current_user.role == 'manager': 163 | response = to_request('POST',url) 164 | if response['code'] == '0': 165 | return res('0', '更新成功', response['data']) 166 | else: 167 | return res(response['code'], response['msg']) 168 | else: 169 | return res('1', '非法请求') 170 | -------------------------------------------------------------------------------- /blueprints/preauthkey.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime, timedelta 3 | from flask_login import current_user, login_required 4 | from flask import Blueprint, request,current_app 5 | from exts import SqliteDB 6 | from utils import table_res, res, to_request 7 | 8 | bp = Blueprint("preauthkey", __name__, url_prefix='/api/preauthkey') 9 | 10 | 11 | 12 | @bp.route('/getPreAuthKey') 13 | @login_required 14 | def getPreAuthKey(): 15 | page = request.args.get('page', default=1, type=int) 16 | per_page = request.args.get('limit', default=10, type=int) 17 | offset = (page - 1) * per_page 18 | 19 | with SqliteDB() as cursor: 20 | # 构建基础查询语句 21 | base_query = """ 22 | SELECT 23 | pre_auth_keys.id, 24 | pre_auth_keys.key, 25 | users.name, 26 | strftime('%Y-%m-%d %H:%M:%S', pre_auth_keys.created_at, 'localtime') as created_at, 27 | strftime('%Y-%m-%d %H:%M:%S', pre_auth_keys.expiration, 'localtime') as expiration 28 | FROM 29 | pre_auth_keys 30 | JOIN 31 | users ON pre_auth_keys.user_id = users.id 32 | """ 33 | 34 | # 判断用户角色 35 | if current_user.role != 'manager': 36 | base_query += " WHERE pre_auth_keys.user_id =? " 37 | params = (current_user.id,) 38 | else: 39 | params = () 40 | 41 | # 查询总记录数 42 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 43 | cursor.execute(count_query, params) 44 | total_count = cursor.fetchone()['total'] 45 | 46 | # 分页查询 47 | paginated_query = f"{base_query} LIMIT? OFFSET? " 48 | paginated_params = params + (per_page, offset) 49 | cursor.execute(paginated_query, paginated_params) 50 | pre_auth_keys = cursor.fetchall() 51 | 52 | # 数据格式化 53 | pre_auth_keys_list = [] 54 | for pre_auth_key in pre_auth_keys: 55 | pre_auth_keys_list.append({ 56 | 'id': pre_auth_key['id'], 57 | 'key': pre_auth_key['key'], 58 | 'name': pre_auth_key['name'], 59 | 'create_time': pre_auth_key['created_at'], 60 | 'expiration': pre_auth_key['expiration'] 61 | }) 62 | 63 | 64 | 65 | return table_res('0','获取成功',pre_auth_keys_list,total_count,len(pre_auth_keys_list)) 66 | 67 | @bp.route('/addKey', methods=['GET','POST']) 68 | @login_required 69 | def addKey(): 70 | 71 | user_name = current_user.name 72 | expire_date = datetime.now() + timedelta(days=7) 73 | 74 | url = f'/api/v1/preauthkey' 75 | data = {'user':user_name,'reusable':True,'ephemeral':False,'expiration':expire_date.isoformat() + 'Z'} 76 | 77 | response = to_request('POST',url,data) 78 | 79 | if response['code'] == '0': 80 | return res('0', '获取成功', response['data']) 81 | else: 82 | return res(response['code'], response['msg']) 83 | 84 | 85 | 86 | 87 | @bp.route('/delKey', methods=['GET','POST']) 88 | @login_required 89 | def delKey(): 90 | key_id = request.form.get('keyId') 91 | try: 92 | with SqliteDB() as cursor: 93 | user_id = cursor.execute("SELECT user_id FROM pre_auth_keys WHERE id =? ", (key_id,)).fetchone()[0] 94 | print(user_id) 95 | if user_id == current_user.id or current_user.role == 'manager': 96 | cursor.execute("DELETE FROM pre_auth_keys WHERE id =?", (key_id,)) 97 | return res('0', '删除成功') 98 | else: 99 | return res('1', '非法请求') 100 | except Exception as e: 101 | print(f"发生未知错误: {e}") 102 | return res('1', '删除失败') 103 | -------------------------------------------------------------------------------- /blueprints/route.py: -------------------------------------------------------------------------------- 1 | from flask_login import login_required, current_user 2 | from flask import Blueprint, request 3 | from exts import SqliteDB 4 | from utils import res, table_res, to_request 5 | 6 | bp = Blueprint("route", __name__, url_prefix='/api/route') 7 | 8 | 9 | 10 | 11 | @bp.route('/getRoute') 12 | @login_required 13 | def getRoute(): 14 | page = request.args.get('page', default=1, type=int) 15 | per_page = request.args.get('limit', default=10, type=int) 16 | offset = (page - 1) * per_page 17 | 18 | with SqliteDB() as cursor: 19 | # 构建基础查询语句 20 | base_query = """ 21 | SELECT 22 | routes.id, 23 | users.name, 24 | nodes.given_name, 25 | routes.prefix, 26 | routes.enabled, 27 | strftime('%Y-%m-%d %H:%M:%S', routes.created_at, 'localtime') as created_at 28 | FROM 29 | routes 30 | JOIN 31 | nodes ON routes.node_id = nodes.id 32 | JOIN 33 | users ON nodes.user_id = users.id 34 | """ 35 | 36 | # 判断用户角色 37 | if current_user.role != 'manager': 38 | base_query += " WHERE nodes.user_id =? " 39 | params = (current_user.id,) 40 | else: 41 | params = () 42 | 43 | # 查询总记录数 44 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 45 | cursor.execute(count_query, params) 46 | total_count = cursor.fetchone()['total'] 47 | 48 | # 分页查询 49 | paginated_query = f"{base_query} LIMIT? OFFSET? " 50 | paginated_params = params + (per_page, offset) 51 | cursor.execute(paginated_query, paginated_params) 52 | routes = cursor.fetchall() 53 | 54 | # 数据格式化 55 | routes_list = [] 56 | for route in routes: 57 | routes_list.append({ 58 | 'id': route['id'], 59 | 'name': route['name'], 60 | 'NodeName': route['given_name'], 61 | 'route': route['prefix'], 62 | 'createTime': route['created_at'], 63 | 'enable': int(route['enabled']) 64 | }) 65 | 66 | 67 | 68 | return table_res('0','获取成功',routes_list,total_count,len(routes_list)) 69 | 70 | @bp.route('/route_enable', methods=['GET','POST']) 71 | @login_required 72 | def route_enable(): 73 | route_id = request.form.get('routeId') 74 | enabled = request.form.get('Enable') 75 | 76 | if enabled == "true": 77 | url_path = f'/api/v1/routes/{route_id}/enable' 78 | else: 79 | url_path = f'/api/v1/routes/{route_id}/disable' 80 | 81 | with SqliteDB() as cursor: 82 | # 连接两表查询,获取 user_id 83 | query = """ 84 | SELECT nodes.user_id 85 | FROM routes 86 | JOIN nodes ON routes.node_id = nodes.id 87 | WHERE routes.id =? 88 | """ 89 | user_id = cursor.execute(query, (route_id,)).fetchone()[0] 90 | 91 | 92 | if current_user.route != '1': 93 | return res('1', '未获得使用权限') 94 | 95 | if current_user.role == 'manager' or user_id == current_user.id: 96 | response = to_request('POST',url_path) 97 | if response['code'] == '0': 98 | return res('0', '切换成功', response['data']) 99 | else: 100 | return res(response['code'], response['msg']) 101 | else: 102 | return res('1', '非法请求') 103 | 104 | 105 | -------------------------------------------------------------------------------- /blueprints/set.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from flask_login import login_required 3 | from exts import SqliteDB 4 | from login_setup import role_required 5 | from flask import Blueprint, request, current_app 6 | from utils import start_headscale, stop_headscale, save_config_yaml, res 7 | 8 | 9 | bp = Blueprint("set", __name__, url_prefix='/api/set') 10 | 11 | 12 | 13 | 14 | @bp.route('/upset' , methods=['GET','POST']) 15 | @login_required 16 | @role_required("manager") 17 | def upset(): 18 | # 反转 form_fields 字典的键值对 19 | form_fields = { 20 | 'BEARER_TOKEN': 'apiKey', 21 | 'SERVER_NET': 'serverNet', 22 | 'SERVER_URL':'serverUrl', 23 | 'REGION_HTML':'regionHtml', 24 | 'REGION_DATA':'regionData', 25 | 'DEFAULT_NODE_COUNT': 'defaultNodeCount', 26 | 'OPEN_USER_REG': 'openUserReg', 27 | 'DEFAULT_REG_DAYS': 'defaultRegDays', 28 | 'DERP_CONFIG': 'derpConfig' 29 | } 30 | 31 | # 构建字典 32 | config_mapping = {} 33 | 34 | for config_key, form_value in form_fields.items(): 35 | value = request.form.get(form_value) 36 | if value is not None: 37 | if config_key =='DERP_CONFIG': # 这里对derp url做特殊处理 38 | 39 | with open(current_app.config['DERP_PATH'], 'w') as f: 40 | f.write(value) 41 | print(value) 42 | else: 43 | config_mapping[config_key] = value 44 | 45 | return save_config_yaml(config_mapping) 46 | 47 | 48 | @bp.route('/get_apikey' , methods=['POST']) 49 | @login_required 50 | @role_required("manager") 51 | def get_apikey(): 52 | with SqliteDB() as cursor: 53 | try: 54 | # 删除所有记录 55 | delete_query = "DELETE FROM api_keys;" 56 | cursor.execute(delete_query) 57 | num_rows_deleted = cursor.rowcount 58 | print(f"成功删除 {num_rows_deleted} 条记录") 59 | except Exception as e: 60 | print(f"删除记录时出现错误: {e}") 61 | return res('1', f"删除记录时出现错误: {e}", '') 62 | 63 | try: 64 | headscale_command = "headscale apikey create" 65 | result = subprocess.run(headscale_command, shell=True, capture_output=True, text=True, check=True) 66 | return res('0', '执行成功', result.stdout) 67 | except subprocess.CalledProcessError as e: 68 | return res('1', '执行失败', f"错误信息:{e.stderr}") 69 | 70 | 71 | 72 | @bp.route('/switch_headscale', methods=['POST']) 73 | @login_required 74 | @role_required("manager") 75 | def switch_headscale(): 76 | # 获取表单中的 Switch 参数 77 | status = request.form.get('Switch') 78 | res_json = {'code': '', 'data': '', 'msg': ''} 79 | if status=="true": 80 | return start_headscale() 81 | else: 82 | return stop_headscale() 83 | 84 | return res_json 85 | 86 | -------------------------------------------------------------------------------- /blueprints/system.py: -------------------------------------------------------------------------------- 1 | import math 2 | from flask_login import login_required 3 | from flask import Blueprint, json, current_app 4 | from utils import get_sys_info, get_data_record 5 | 6 | 7 | bp = Blueprint("system", __name__, url_prefix='/api/system') 8 | 9 | 10 | # 获取系统相关信息 11 | @bp.route('/info', methods=['GET']) 12 | @login_required 13 | def get_info(): 14 | return get_sys_info() 15 | 16 | 17 | # 获取数据 18 | @bp.route('/data_usage', methods=['GET']) 19 | @login_required 20 | def data_usage(): 21 | with open(current_app.config['NET_TRAFFIC_RECORD_FILE'], 'r') as file: 22 | content = file.read() 23 | json_data = json.loads(content) 24 | 25 | data_dict = json_data 26 | 27 | # 处理 recv 字典 28 | recv_values = list(data_dict['recv'].values()) 29 | new_recv_values = [math.ceil(float(recv_values[i + 1]) - float(recv_values[i])) for i in 30 | range(len(recv_values) - 1)] 31 | new_recv_dict = {f"{chr(ord('a') + i)}": str(new_recv_values[i]) for i in range(len(new_recv_values))} 32 | 33 | # 处理 sent 字典 34 | sent_values = list(data_dict['sent'].values()) 35 | new_sent_values = [math.ceil(float(sent_values[i + 1]) - float(sent_values[i])) for i in 36 | range(len(sent_values) - 1)] 37 | new_sent_dict = {f"{chr(ord('a') + i)}": str(new_sent_values[i]) for i in range(len(new_sent_values))} 38 | 39 | # 构建新的字典 40 | new_data_dict = {"recv": new_recv_dict, "sent": new_sent_dict} 41 | 42 | # 转换回 JSON 字符串并打印 43 | new_data = json.dumps(new_data_dict) 44 | 45 | return new_data 46 | 47 | 48 | @bp.route('traffic_debug', methods=['GET']) 49 | @login_required 50 | def traffic_debug(): 51 | return get_data_record() 52 | 53 | @bp.route('visitor_distribution', methods=['GET']) 54 | @login_required 55 | def visitor_distribution(): 56 | return current_app.config['REGION_DATA'] 57 | -------------------------------------------------------------------------------- /blueprints/user.py: -------------------------------------------------------------------------------- 1 | from flask_login import current_user, login_required 2 | from exts import SqliteDB 3 | from login_setup import role_required 4 | from flask import Blueprint, request 5 | from utils import res, table_res 6 | 7 | bp = Blueprint("user", __name__, url_prefix='/api/user') 8 | 9 | 10 | @bp.route('/getUsers') 11 | @login_required 12 | @role_required("manager") 13 | def getUsers(): 14 | # 获取分页参数,默认第1页,每页10条 15 | page = request.args.get('page', default=1, type=int) 16 | per_page = request.args.get('limit', default=10, type=int) 17 | 18 | with SqliteDB() as cursor: 19 | # 查询总记录数和当前页用户数据 20 | query = """ 21 | SELECT COUNT(*) OVER() as total_count, id, name, 22 | strftime('%Y-%m-%d %H:%M:%S', created_at) as created_at, 23 | cellphone, 24 | strftime('%Y-%m-%d %H:%M:%S', expire) as expire, role, node, route, enable 25 | FROM users 26 | LIMIT? OFFSET? 27 | """ 28 | cursor.execute(query, (per_page, (page - 1) * per_page)) 29 | rows = cursor.fetchall() 30 | 31 | total_count = rows[0]['total_count'] if rows else 0 32 | 33 | # 创建用户字典列表 34 | users_list = [ 35 | { 36 | 'id': row['id'], 37 | 'userName': row['name'], 38 | 'createTime': str(row['created_at']), 39 | 'cellphone': row['cellphone'], 40 | 'expire': str(row['expire']), 41 | 'role': row['role'], 42 | 'node': row['node'], 43 | 'route': row['route'], 44 | 'enable': row['enable'] 45 | } 46 | for row in rows 47 | ] 48 | 49 | return table_res('0', '获取成功', users_list, total_count, len(users_list)) 50 | 51 | 52 | @bp.route('/re_expire',methods=['GET','POST']) 53 | @login_required 54 | @role_required("manager") 55 | def re_expire(): 56 | 57 | user_id = request.form.get('user_id') 58 | new_expire = request.form.get('new_expire') 59 | 60 | with SqliteDB() as cursor: 61 | # 更新用户的过期时间 62 | update_query = "UPDATE users SET expire =? WHERE id =?;" 63 | cursor.execute(update_query, (new_expire, user_id)) 64 | 65 | return res('0', '更新成功','') 66 | 67 | 68 | @bp.route('/re_node',methods=['GET','POST']) 69 | @login_required 70 | @role_required("manager") 71 | def re_node(): 72 | user_id = request.form.get('user_id') 73 | new_node = request.form.get('new_node') 74 | 75 | with SqliteDB() as cursor: 76 | # 更新用户的节点信息 77 | update_query = "UPDATE users SET node =? WHERE id =?;" 78 | cursor.execute(update_query, (new_node, user_id)) 79 | 80 | return res('0', '更新成功') 81 | 82 | 83 | @bp.route('/user_enable',methods=['GET','POST']) 84 | @login_required 85 | @role_required("manager") 86 | def user_enable(): 87 | user_id = request.form.get('user_id') 88 | enable = request.form.get('enable') 89 | 90 | with SqliteDB() as cursor: 91 | # 查询用户 92 | query = "SELECT * FROM users WHERE id =?;" 93 | cursor.execute(query, (user_id,)) 94 | user = cursor.fetchone() 95 | 96 | if enable == "true": 97 | update_query = "UPDATE users SET enable = 1 WHERE id =?;" 98 | msg = '启用成功' 99 | else: 100 | if user['name'] == 'admin': 101 | return res('1', '停用失败,无法停用admin用户') 102 | update_query = "UPDATE users SET enable = 0 WHERE id =?;" 103 | msg = '停用成功' 104 | 105 | cursor.execute(update_query, (user_id,)) 106 | 107 | return res('0', msg) 108 | 109 | 110 | @bp.route('/route_enable',methods=['GET','POST']) 111 | @login_required 112 | @role_required("manager") 113 | def route_enable(): 114 | user_id = request.form.get('user_id') 115 | enable = request.form.get('enable') 116 | 117 | with SqliteDB() as cursor: 118 | if enable == "true": 119 | update_query = "UPDATE users SET route = 1 WHERE id =?;" 120 | msg = '启用成功' 121 | else: 122 | update_query = "UPDATE users SET route = 0 WHERE id =?;" 123 | msg = '停用成功' 124 | cursor.execute(update_query, (user_id,)) 125 | 126 | return res('0', msg) 127 | 128 | 129 | @bp.route('/delUser',methods=['GET','POST']) 130 | @login_required 131 | @role_required("manager") 132 | def delUser(): 133 | user_id = request.form.get('user_id') 134 | 135 | with SqliteDB() as cursor: 136 | # 删除用户 137 | delete_query = "DELETE FROM users WHERE id =?;" 138 | cursor.execute(delete_query, (user_id,)) 139 | 140 | return res('0', '删除成功') 141 | 142 | 143 | @bp.route('/init_data',methods=['GET']) 144 | @login_required 145 | def init_data(): 146 | with SqliteDB() as cursor: 147 | # 查询当前用户的创建时间和过期时间 148 | current_user_id = current_user.id # 假设 current_user 有 id 属性 149 | user_query = """ 150 | SELECT 151 | strftime('%Y-%m-%d %H:%M:%S', created_at) as created_at, 152 | strftime('%Y-%m-%d %H:%M:%S', expire) as expire 153 | FROM users WHERE id =? 154 | """ 155 | cursor.execute(user_query, (current_user_id,)) 156 | user_info = cursor.fetchone() 157 | 158 | 159 | created_at = str(user_info['created_at']) 160 | expire = str(user_info['expire']) 161 | 162 | # 查询节点数量 163 | node_count = cursor.execute("SELECT COUNT(*) as count FROM nodes").fetchone()[0] 164 | # 查询路由数量 165 | route_count = cursor.execute("SELECT COUNT(*) as count FROM routes").fetchone()[0] 166 | 167 | data = { 168 | "created_at": created_at, 169 | "expire": expire, 170 | "node_count": node_count, 171 | "route_count": route_count 172 | } 173 | 174 | return res('0', '查询成功', data) 175 | 176 | -------------------------------------------------------------------------------- /config_loader.py: -------------------------------------------------------------------------------- 1 | from ruamel.yaml import YAML 2 | 3 | 4 | # 创建 YAML 对象,设置保留注释 5 | yaml = YAML() 6 | yaml.preserve_quotes = True 7 | yaml.indent(mapping=2, sequence=4, offset=2) 8 | 9 | # 读取 YAML 配置文件 10 | with open('/etc/headscale/config.yaml', 'r') as file: 11 | config_yaml = yaml.load(file) 12 | 13 | 14 | 15 | # 配置定义 16 | SECRET_KEY = 'SFhkrGKQL2yB9F' 17 | PERMANENT_SESSION_LIFETIME = 3600 18 | 19 | listen_addr = config_yaml.get('listen_addr', '0.0.0.0:8080') 20 | _, port_str = listen_addr.rsplit(':', 1) 21 | SERVER_HOST = f'http://127.0.0.1:{port_str}' #从headscale配置文件中获取端口号,内部通信使用 22 | 23 | 24 | 25 | # 从 yaml 配置文件中获取headscale配置项 26 | 27 | SERVER_URL = config_yaml.get('server_url', {}) 28 | DATABASE_URI = config_yaml.get('database', {}).get('sqlite', {}).get('path') 29 | ACL_PATH = "/etc/headscale/"+config_yaml.get('policy', {}).get('path') 30 | DERP_PATH = config_yaml.get('derp', {}).get('paths',"/etc/headscale/derp.yaml") 31 | 32 | 33 | 34 | 35 | # 从 yaml 配置文件中获取WEB UI配置项 36 | NET_TRAFFIC_RECORD_FILE = '/var/lib/headscale/data.json' 37 | BEARER_TOKEN = config_yaml.get('bearer_token', {}) 38 | SERVER_NET = config_yaml.get('server_net', {}) 39 | REGION_HTML = config_yaml.get('region_html', "\n\t1\n\t四川\n\t200 Mbps\n\n\n\t2\n\t浙江\n\t200 Mbps\n") 40 | DERP_CONFIG = config_yaml.get('derp_config', "{\n\"Regions\": {\n\t\"901\": {\n\t\t\"RegionID\": 901,\n\t\t\"RegionCode\"\ 41 | : \"chengdu\",\n\t\t\"RegionName\": \"chengdu\",\n\t\t\"Nodes\": [\n\t\t\t{\n\t\t\ 42 | \t\t\"Name\": \"901a\",\n\t\t\t\t\"RegionID\": 901,\n\t\t\t\t\"DERPPort\": 12345,\n\ 43 | \t\t\t\t\"IPv4\": \"127.0.0.1\",\n\t\t\t\t\"InsecureForTests\": true\n\t\t\ 44 | \t}\n\t\t]\n\t}\n}") 45 | DEFAULT_REG_DAYS = config_yaml.get('default_reg_days', '7') 46 | DEFAULT_NODE_COUNT = config_yaml.get('default_node_count', 2) 47 | OPEN_USER_REG = config_yaml.get('open_user_reg', 'on') 48 | REGION_DATA = config_yaml.get('region_data', "[{\"name\":\"西藏\", \"value\":0},{\"name\":\"青海\", \"value\":0},{\"name\"\ 49 | :\"宁夏\", \"value\":0},{\"name\":\"海南\", \"value\":0},{\"name\":\"甘肃\", \"value\"\ 50 | :0},{\"name\":\"贵州\", \"value\":0},{\"name\":\"新疆\", \"value\":0},{\"name\":\"云南\"\ 51 | , \"value\":0},{\"name\":\"重庆\", \"value\":0},{\"name\":\"吉林\", \"value\":0},{\"\ 52 | name\":\"山西\", \"value\":0},{\"name\":\"天津\", \"value\":0},{\"name\":\"江西\", \"\ 53 | value\":0},{\"name\":\"广西\", \"value\":0},{\"name\":\"陕西\", \"value\":0},{\"name\"\ 54 | :\"黑龙江\", \"value\":0},{\"name\":\"内蒙古\", \"value\":0},{\"name\":\"安徽\", \"value\"\ 55 | :0},{\"name\":\"北京\", \"value\":0},{\"name\":\"福建\", \"value\":0},{\"name\":\"上海\"\ 56 | , \"value\":0},{\"name\":\"湖北\", \"value\":0},{\"name\":\"湖南\", \"value\":0},{\"\ 57 | name\":\"四川\", \"value\":200},{\"name\":\"辽宁\", \"value\":0},{\"name\":\"河北\", \"\ 58 | value\":0},{\"name\":\"河南\", \"value\":0},{\"name\":\"浙江\", \"value\":200},{\"name\"\ 59 | :\"山东\", \"value\":0},{\"name\":\"江苏\", \"value\":0},{\"name\":\"广东\", \"value\"\ 60 | :0}]" 61 | ) 62 | -------------------------------------------------------------------------------- /data-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "sent": { 3 | "a": "0", 4 | "b": "0", 5 | "c": "0", 6 | "d": "0", 7 | "e": "0", 8 | "f": "0", 9 | "g": "0", 10 | "h": "0", 11 | "i": "0", 12 | "j": "0", 13 | "k": "0", 14 | "l": "0", 15 | "m": "0", 16 | "n": "0", 17 | "o": "0", 18 | "p": "0", 19 | "q": "0", 20 | "r": "0", 21 | "s": "0", 22 | "t": "0", 23 | "u": "0", 24 | "v": "0", 25 | "w": "0", 26 | "x": "0", 27 | "y": "0" 28 | }, 29 | "recv": { 30 | "a": "0", 31 | "b": "0", 32 | "c": "0", 33 | "d": "0", 34 | "e": "0", 35 | "f": "0", 36 | "g": "0", 37 | "h": "0", 38 | "i": "0", 39 | "j": "0", 40 | "k": "0", 41 | "l": "0", 42 | "m": "0", 43 | "n": "0", 44 | "o": "0", 45 | "p": "0", 46 | "q": "0", 47 | "r": "0", 48 | "s": "0", 49 | "t": "0", 50 | "u": "0", 51 | "v": "0", 52 | "w": "0", 53 | "x": "0", 54 | "y": "0" 55 | } 56 | } -------------------------------------------------------------------------------- /derp-example.yaml: -------------------------------------------------------------------------------- 1 | # If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/ 2 | regions: 3 | 900: 4 | regionid: 900 5 | regioncode: localhost 6 | regionname: localhost 7 | nodes: 8 | - name: 900a 9 | regionid: 900 10 | hostname: localhost 11 | stunport: 3478 12 | stunonly: false 13 | derpport: 12340 14 | 15 | 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hs-admin: 3 | image: runyf/hs-admin:v2.6 4 | # image: hs-admin 5 | container_name: hs-admin 6 | network_mode: host 7 | restart: unless-stopped 8 | volumes: 9 | - ~/hs-admin/app:/app 10 | - ~/hs-admin/config:/etc/headscale 11 | - ~/hs-admin/data:/var/lib/headscale 12 | environment: 13 | - TZ=Asia/Shanghai 14 | -------------------------------------------------------------------------------- /exts.py: -------------------------------------------------------------------------------- 1 | # -*- coding:utf-8 -*- 2 | import sqlite3 3 | import traceback 4 | import config_loader 5 | 6 | # 直接从配置文件导入数据库 URI 7 | DATABASE = config_loader.DATABASE_URI 8 | 9 | 10 | class SqliteDB(object): 11 | 12 | def __init__(self, database=DATABASE, isolation_level='', ignore_exc=False): 13 | self.database = database 14 | self.isolation_level = isolation_level 15 | self.ignore_exc = ignore_exc 16 | self.connection = None 17 | self.cursor = None 18 | 19 | def __enter__(self): 20 | try: 21 | self.connection = sqlite3.connect(database=self.database, isolation_level=self.isolation_level) 22 | self.cursor = self.connection.cursor() 23 | self.cursor.row_factory = sqlite3.Row # 设置返回类似字典的对象 24 | # 开启外键约束 25 | self.cursor.execute("PRAGMA foreign_keys = ON;") 26 | return self.cursor 27 | except Exception as ex: 28 | traceback.print_exc() 29 | raise ex 30 | 31 | def __exit__(self, exc_type, exc_val, exc_tb): 32 | try: 33 | if not exc_type is None: 34 | self.connection.rollback() 35 | return self.ignore_exc 36 | else: 37 | self.connection.commit() 38 | except Exception as ex: 39 | traceback.print_exc() 40 | raise ex 41 | finally: 42 | self.cursor.close() 43 | self.connection.close() -------------------------------------------------------------------------------- /init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # 接收传递进来的参数 5 | start_command="$1" 6 | 7 | # 定义宿主机挂载目录和容器内目标目录 8 | INIT_DATA_APP_DIR="/init_data" 9 | CONTAINER_CONFIG_DIR="/etc/headscale" 10 | CONTAINER_DB_DIR="/var/lib/headscale" 11 | CONTAINER_APP_DIR="/app" 12 | 13 | 14 | mkdir /app 15 | mkdir /etc/headscale 16 | mkdir /var/lib/headscale 17 | 18 | cd /app 19 | 20 | 21 | # 检查容器内的 headscale 目录是否为空 22 | if [ -z "$(ls -A $CONTAINER_CONFIG_DIR 2>/dev/null)" ]; then 23 | cp -r $INIT_DATA_APP_DIR/config.yaml $CONTAINER_CONFIG_DIR 24 | cp -r $INIT_DATA_APP_DIR/derp.yaml $CONTAINER_CONFIG_DIR 25 | echo "复制headscale配置文件" 26 | touch $CONTAINER_CONFIG_DIR/acl.hujson 27 | echo "创建ACL文件" 28 | else 29 | echo "检测到headscale存在配置文件" 30 | fi 31 | 32 | 33 | # 检查容器内的 app 目录是否为空 34 | if [ -z "$(ls -A $CONTAINER_APP_DIR 2>/dev/null)" ]; then 35 | echo "复制flask-app文件" 36 | cp -r $INIT_DATA_APP_DIR/* $CONTAINER_APP_DIR 37 | rm $CONTAINER_APP_DIR/config.yaml 38 | rm $CONTAINER_APP_DIR/derp.yaml 39 | rm $CONTAINER_APP_DIR/init.sh 40 | rm $CONTAINER_APP_DIR/Dockerfile 41 | rm $CONTAINER_APP_DIR/README.md 42 | rm $CONTAINER_APP_DIR/docker-compose.yml 43 | rm $CONTAINER_APP_DIR/nginx-example.conf 44 | rm $CONTAINER_APP_DIR/data.json 45 | else 46 | echo "检测到flask-app存在已有数据" 47 | fi 48 | 49 | # 检查容器内的 DB存放 目录是否为空 50 | if [ -z "$(ls -A $CONTAINER_DB_DIR 2>/dev/null)" ]; then 51 | echo "将自动生成流量统计文件" 52 | cp -r $INIT_DATA_APP_DIR/data.json $CONTAINER_DB_DIR 53 | else 54 | echo "检测到data已有数据" 55 | fi 56 | 57 | 58 | cp headscale /usr/bin 59 | chmod u+x /usr/bin/headscale 60 | 61 | # 执行传递进来的启动命令 62 | eval $start_command 63 | -------------------------------------------------------------------------------- /login_setup.py: -------------------------------------------------------------------------------- 1 | # login_setup.py 2 | import functools 3 | from flask import redirect, url_for, render_template 4 | from flask_login import LoginManager, current_user 5 | from exts import SqliteDB 6 | from models import User 7 | 8 | 9 | login_manager = LoginManager() 10 | 11 | 12 | def init_login_manager(app): 13 | login_manager.init_app(app) 14 | login_manager.login_view = 'auth.error' 15 | 16 | 17 | # 自定义未授权处理函数 18 | @login_manager.unauthorized_handler 19 | def unauthorized(): 20 | # 这里可以添加要传递的参数 21 | message = "未登录系统或需重新登录" 22 | return render_template('auth/error.html',message=message) 23 | 24 | 25 | 26 | @login_manager.user_loader 27 | def user_loader(user_id): 28 | try: 29 | with SqliteDB() as cursor: 30 | query = """ 31 | SELECT 32 | id, name, 33 | strftime('%Y-%m-%d %H:%M:%S', created_at, 'localtime') as created_at, 34 | strftime('%Y-%m-%d %H:%M:%S', updated_at, 'localtime') as updated_at, 35 | email, password, 36 | strftime('%Y-%m-%d %H:%M:%S', expire, 'localtime') as expire, 37 | cellphone, role, node, route, enable 38 | FROM 39 | users 40 | WHERE 41 | id =? 42 | """ 43 | cursor.execute(query, (user_id,)) 44 | user_data = cursor.fetchone() 45 | if user_data: 46 | return User(*user_data) 47 | return None 48 | except Exception as e: 49 | print(f"Error loading user: {e}") 50 | return None 51 | 52 | 53 | def role_required(role): 54 | def wrapper(fn): 55 | @functools.wraps(fn) # 使用 functools.wraps 保留原函数元数据 56 | def decorated_view(*args, **kwargs): 57 | if not current_user.is_authenticated: 58 | return login_manager.unauthorized() 59 | if current_user.role != role: 60 | return 'You do not have permission to access this page.', 403 61 | return fn(*args, **kwargs) 62 | return decorated_view 63 | return wrapper -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from flask_login import UserMixin 2 | 3 | 4 | # 定义 User 类,继承自 UserMixin 以适配 Flask-Login 5 | class User(UserMixin): 6 | def __init__(self, id, name, created_at, updated_at, email, password, expire, cellphone, role, node, route, enable): 7 | self.id = id 8 | self.name = name 9 | self.created_at = created_at 10 | self.updated_at = updated_at 11 | self.email = email 12 | 13 | 14 | self.password = password 15 | self.expire = expire 16 | self.cellphone = cellphone 17 | self.role = role 18 | self.node = node 19 | self.route = route 20 | self.enable = enable 21 | 22 | 23 | -------------------------------------------------------------------------------- /nginx-example.conf: -------------------------------------------------------------------------------- 1 | map $http_upgrade $connection_upgrade { 2 | default keep-alive; 3 | 'websocket' upgrade; 4 | '' close; 5 | } 6 | 7 | 8 | server { 9 | listen 80; 10 | # listen 443 ssl; 11 | server_name hs-admin; 12 | 13 | #HTTP_TO_HTTPS_START 14 | if ($server_port !~ 443){ 15 | # rewrite ^(/.*)$ https://$host$1 permanent; 16 | } 17 | 18 | # flask-app 19 | location / { 20 | proxy_pass http://172.17.0.1:5000; 21 | proxy_set_header X-Real-IP $remote_addr; 22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 23 | proxy_set_header X-Forwarded-Proto $scheme; 24 | } 25 | 26 | # headscale 27 | location ~ ^/(health|oidc|windows|apple|key|drep|bootstrap-dns|swagger|ts2021|machine) { 28 | proxy_pass http://172.17.0.1:8080; 29 | proxy_http_version 1.1; 30 | proxy_set_header Upgrade $http_upgrade; 31 | proxy_set_header Connection $connection_upgrade; 32 | proxy_set_header Host $server_name; 33 | # proxy_redirect http:// https://; 34 | proxy_buffering off; 35 | proxy_set_header X-Real-IP $remote_addr; 36 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 37 | proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; 38 | add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always; 39 | } 40 | 41 | 42 | 43 | #PROXY-END/ 44 | 45 | 46 | # ssl_certificate /etc/nginx/ssl/hs-admin.crt; 47 | # ssl_certificate_key /etc/nginx/ssl/hs-admin.key; 48 | 49 | 50 | } -------------------------------------------------------------------------------- /static/adminui/dist/css/login.css: -------------------------------------------------------------------------------- 1 | #LAY_app,body,html{height:100%}.layui-layout-body{overflow:auto}#LAY-user-login,.layadmin-user-display-show{display:block!important}.layadmin-user-login{position:relative;left:0;top:0;padding:110px 0;min-height:100%;box-sizing:border-box}.layadmin-user-login-main{width:375px;margin:0 auto;box-sizing:border-box}.layadmin-user-login-box{padding:20px}.layadmin-user-login-header{text-align:center}.layadmin-user-login-header h2{margin-bottom:10px;font-weight:300;font-size:30px;color:#000}.layadmin-user-login-header p{font-weight:300;color:#999}.layadmin-user-login-body .layui-form-item{position:relative}.layadmin-user-login-icon{position:absolute;left:1px;top:1px;width:38px;line-height:36px;text-align:center;color:#d2d2d2}.layadmin-user-login-body .layui-form-item .layui-input{padding-left:38px}.layadmin-user-login-codeimg{max-height:38px;width:100%;cursor:pointer;box-sizing:border-box}.layadmin-user-login-other{position:relative;font-size:0;line-height:38px;padding-top:20px}.layadmin-user-login-other>*{display:inline-block;vertical-align:middle;margin-right:10px;font-size:14px}.layadmin-user-login-other .layui-icon{position:relative;top:2px;font-size:26px}.layadmin-user-login-other a:hover{opacity:.8}.layadmin-user-jump-change{float:right}.layadmin-user-login-footer{position:absolute;left:0;bottom:0;width:100%;line-height:30px;padding:20px;text-align:center;box-sizing:border-box;color:rgba(0,0,0,.5)}.layadmin-user-login-footer span{padding:0 5px}.layadmin-user-login-footer a{padding:0 5px;color:rgba(0,0,0,.5)}.layadmin-user-login-footer a:hover{color:#000}.layadmin-user-login-main[bgimg]{background-color:#fff;box-shadow:0 0 5px rgba(0,0,0,.05)}.ladmin-user-login-theme{position:fixed;bottom:0;left:0;width:100%;text-align:center}.ladmin-user-login-theme ul{display:inline-block;padding:5px;background-color:#fff}.ladmin-user-login-theme ul li{display:inline-block;vertical-align:top;width:64px;height:43px;cursor:pointer;transition:all .3s;-webkit-transition:all .3s;background-color:#f2f2f2}.ladmin-user-login-theme ul li:hover{opacity:.9}@media screen and (max-width:768px){.layadmin-user-login{padding-top:60px}.layadmin-user-login-main{width:300px}.layadmin-user-login-box{padding:10px}} -------------------------------------------------------------------------------- /static/adminui/dist/modules/admin.js: -------------------------------------------------------------------------------- 1 | /** The Web UI Theme-v2.5.1 */;layui.define("view",function(e){function t(e){var a=e.attr("lay-id"),t=(e.attr("lay-attr"),e.index());T.tabsBodyChange(t,{url:a,title:e.children("span").text()})}var d=layui.jquery,s=layui.laytpl,a=layui.table,i=layui.element,l=layui.util,n=layui.upload,r=(layui.form,layui.setter),o=layui.view,u=layui.device(),c=d(window),y=d(document),m=d("body"),f=d("#"+r.container),h="layui-show",p="layui-this",b="layui-disabled",v="#LAY_app_body",g="LAY_app_flexible",x="layadmin-layout-tabs",C="layadmin-side-spread-sm",k="layadmin-tabsbody-item",P="layui-icon-shrink-right",F="layui-icon-spread-left",A="layadmin-side-shrink",T={v:"2.5.1",mode:"iframe",req:o.req,exit:o.exit,escape:l.escape,on:function(e,a){return layui.onevent.call(this,r.MOD_NAME,e,a)},sendAuthCode:function(i){function l(e){--n<0?(t.removeClass(b).html("\u83b7\u53d6\u9a8c\u8bc1\u7801"),n=i.seconds,clearInterval(a)):t.addClass(b).html(n+"\u79d2\u540e\u91cd\u83b7"),e||(a=setInterval(function(){l(!0)},1e3))}var a,n=(i=d.extend({seconds:60,elemPhone:"#LAY_phone",elemVercode:"#LAY_vercode"},i)).seconds,t=d(i.elem);i.elemPhone=d(i.elemPhone),i.elemVercode=d(i.elemVercode),t.on("click",function(){var a,e=i.elemPhone,t=e.val();if(n===i.seconds&&!d(this).hasClass(b)){if(!/^1\d{10}$/.test(t))return e.focus(),layer.msg("\u8bf7\u8f93\u5165\u6b63\u786e\u7684\u624b\u673a\u53f7");"object"==typeof i.ajax&&(a=i.ajax.success,delete i.ajax.success),T.req(d.extend(!0,{url:"",type:"get",data:{phone:t},success:function(e){layer.msg("\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u81f3\u4f60\u7684\u624b\u673a\uff0c\u8bf7\u6ce8\u610f\u67e5\u6536",{icon:1,shade:0}),i.elemVercode.focus(),l(),a&&a(e)}},i.ajax))}})},screen:function(){var e=c.width();return 1200.layui-nav-item>.layui-nav-child","{background-color:{{d.color.main}} !important;}",".layadmin-pagetabs .layui-tab-title li:after,",".layadmin-pagetabs .layui-tab-title li.layui-this:after,",".layui-nav-tree .layui-this,",".layui-nav-tree .layui-this>a,",".layui-nav-tree .layui-nav-child dd.layui-this,",".layui-nav-tree .layui-nav-child dd.layui-this a,",".layui-nav-tree .layui-nav-bar","{background-color:{{d.color.selected}} !important;}",".layadmin-pagetabs .layui-tab-title li:hover,",".layadmin-pagetabs .layui-tab-title li.layui-this","{color: {{d.color.selected}} !important;}",".layui-layout-admin .layui-logo{background-color:{{d.color.logo || d.color.main}} !important;}","{{# if(d.color.header){ }}",".layui-layout-admin .layui-header{background-color:{{ d.color.header }};}",".layui-layout-admin .layui-header a,",".layui-layout-admin .layui-header a cite{color: #f8f8f8;}",".layui-layout-admin .layui-header a:hover{color: #fff;}",".layui-layout-admin .layui-header .layui-nav .layui-nav-more{border-top-color: #fbfbfb;}",".layui-layout-admin .layui-header .layui-nav .layui-nav-mored{border-color: transparent; border-bottom-color: #fbfbfb;}",".layui-layout-admin .layui-header .layui-nav .layui-this:after, .layui-layout-admin .layui-header .layui-nav-bar{background-color: #fff; background-color: rgba(255,255,255,.5);}",".layadmin-pagetabs .layui-tab-title li:after{display: none;}","{{# } }}"].join("")).render(e=d.extend({},t.theme,e));"styleSheet"in l?(l.setAttribute("type","text/css"),l.styleSheet.cssText=n):l.innerHTML=n,l.id=a,i&&m[0].removeChild(i),m[0].appendChild(l),e.color&&m.attr("layadmin-themealias",e.color.alias),t.theme=t.theme||{},layui.each(e,function(e,a){t.theme[e]=a}),layui.data(r.tableName,{key:"theme",value:t.theme})},initTheme:function(e){var a=r.theme;a.color[e=e||0]&&(a.color[e].index=e,T.theme({color:a.color[e]}))},tabsPage:{},tabsBody:function(e){return d(v).find("."+k).eq(e||0)},tabsBodyChange:function(e,a){a=a||{},T.tabsBody(e).addClass(h).siblings().removeClass(h),_.rollPage("auto",e),T.recordURL(a),layui.event.call(this,r.MOD_NAME,"tabsPage({*})",a)},recordURL:function(e){var a;(r.record||{}).url&&e.url&&(/^(\w*:)*\/\/.+/.test(e.url)&&(e.url=""),location.hash=T.correctRouter(e.url),e.url&&e.title&&((a={})[e.url]=e.title,layui.data(r.tableName,{key:"record",value:a})))},resize:function(e){var a=layui.router().path.join("-");T.resizeFn[a]&&(c.off("resize",T.resizeFn[a]),delete T.resizeFn[a]),"off"!==e&&(e(),T.resizeFn[a]=e,c.on("resize",T.resizeFn[a]))},resizeFn:{},runResize:function(){var e=layui.router().path.join("-");T.resizeFn[e]&&T.resizeFn[e]()},delResize:function(){this.resize("off")},closeThisTabs:function(){T.tabsPage.index&&d(L).eq(T.tabsPage.index).find(".layui-tab-close").trigger("click")},fullScreen:function(){var e=document.documentElement,a=e.requestFullscreen||e.webkitRequestFullScreen||e.mozRequestFullScreen||e.msRequestFullscreen;void 0!==a&&a&&a.call(e)},exitScreen:function(){document.documentElement;document.exitFullscreen?document.exitFullscreen():document.mozCancelFullScreen?document.mozCancelFullScreen():document.webkitCancelFullScreen?document.webkitCancelFullScreen():document.msExitFullscreen&&document.msExitFullscreen()},correctRouter:function(e){return(e=/^\//.test(e)?e:"/"+e).replace(/^(\/+)/,"/").replace(new RegExp("/"+r.entry+"$"),"/")}},_=T.events={flexible:function(e){e=e.find("#"+g).hasClass(F);T.sideFlexible(e?"spread":null)},refresh:function(){var e=d("."+k).length;T.tabsPage.index>=e&&(T.tabsPage.index=e-1),T.tabsBody(T.tabsPage.index).find(".layadmin-iframe")[0].contentWindow.location.reload(!0)},serach:function(t){t.off("keypress").on("keypress",function(e){var a;this.value.replace(/\s/g,"")&&13===e.keyCode&&(e=t.attr("lay-action"),a=t.attr("lay-title")||t.attr("lay-text")||"\u641c\u7d22",e+=this.value,a=a+": "+T.escape(this.value),T.openTabsPage({url:e,title:a,highlight:"color: #FF5722;"}),_.serach.keys||(_.serach.keys={}),_.serach.keys[T.tabsPage.index]=this.value,this.value===_.serach.keys[T.tabsPage.index]&&_.refresh(t),this.value="")})},message:function(e){e.find(".layui-badge-dot").remove()},theme:function(){T.popupRight({id:"LAY_adminPopupTheme",success:function(){o(this.id).render("system/theme")}})},note:function(e){var a=T.screen()<2,t=layui.data(r.tableName).note;_.note.index=T.popup({title:"\u672c\u5730\u4fbf\u7b7e",shade:0,offset:["41px",a?null:e.offset().left-250+"px"],anim:-1,id:"LAY_adminNote",skin:"layadmin-note layui-anim layui-anim-upbit",content:'',resize:!1,success:function(e,a){e.find("textarea").val(void 0===t?"\u4fbf\u7b7e\u4e2d\u7684\u5185\u5bb9\u4f1a\u5b58\u50a8\u5728\u672c\u5730\uff0c\u8fd9\u6837\u5373\u4fbf\u4f60\u5173\u6389\u4e86\u6d4f\u89c8\u5668\uff0c\u5728\u4e0b\u6b21\u6253\u5f00\u65f6\uff0c\u4f9d\u7136\u4f1a\u8bfb\u53d6\u5230\u4e0a\u4e00\u6b21\u7684\u8bb0\u5f55\u3002\u662f\u4e2a\u975e\u5e38\u5c0f\u5de7\u5b9e\u7528\u7684\u672c\u5730\u5907\u5fd8\u5f55":t).focus().on("keyup",function(){layui.data(r.tableName,{key:"note",value:this.value})})}})},fullscreen:function(e,a){function t(e){e?n.addClass(l).removeClass(i):n.addClass(i).removeClass(l)}var i="layui-icon-screen-full",l="layui-icon-screen-restore",n=e.children("i"),e=n.hasClass(i);if(a)return t(a.status);t(e),e?T.fullScreen():T.exitScreen()},about:function(){T.popupRight({id:"LAY_adminPopupAbout",success:function(){o(this.id).render("system/about")}})},more:function(){T.popupRight({id:"LAY_adminPopupMore",success:function(){o(this.id).render("system/more")}})},back:function(){history.back()},setTheme:function(e){var a=e.data("index");e.siblings(".layui-this").data("index");e.hasClass(p)||(e.addClass(p).siblings(".layui-this").removeClass(p),T.initTheme(a),o("LAY_adminPopupTheme").render("system/theme"))},rollPage:function(e,a){var t,i=d("#LAY_app_tabsheader"),l=i.children("li"),n=(i.prop("scrollWidth"),i.outerWidth()),s=parseFloat(i.css("left"));if("left"===e)!s&&s<=0||(t=-s-n,l.each(function(e,a){a=d(a).position().left;if(t<=a)return i.css("left",-a),!1}));else if("auto"===e){var r,e=l.eq(a);if(e[0]){if((a=e.position().left)<-s)return void i.css("left",-a);a+e.outerWidth()>=n-s&&(r=a+e.outerWidth()-(n-s),l.each(function(e,a){a=d(a).position().left;if(0=n-s)return i.css("left",-t),!1})},leftPage:function(){_.rollPage("left")},rightPage:function(){_.rollPage()},closeThisTabs:function(){(parent===self?T:parent.layui.admin).closeThisTabs()},closeOtherTabs:function(e){var t="LAY-system-pagetabs-remove",i=T.tabsPage.index;"all"===e?(d(L+":gt(0)").remove(),d(v).find("."+k+":gt(0)").remove(),d(L).eq(0).trigger("click")):"right"===e?(d(L+":gt("+i+")").remove(),d(v).find("."+k+":gt("+i+")").remove()):(d(L).each(function(e,a){e&&e!=i&&(d(a).addClass(t),T.tabsBody(e).addClass(t))}),d("."+t).remove())},closeRightTabs:function(){_.closeOtherTabs("right")},closeAllTabs:function(){_.closeOtherTabs("all")},shade:function(){T.sideFlexible()},im:function(){T.popup({id:"LAY-popup-layim-demo",shade:0,area:["800px","300px"],title:"\u9762\u677f\u5916\u7684\u64cd\u4f5c\u793a\u4f8b",offset:"lb",success:function(){layui.view(this.id).render("layim/demo").then(function(){layui.use("im")})}})}},L=("pageTabs"in layui.setter||(layui.setter.pageTabs=!0),r.pageTabs||(d("#LAY_app_tabs").addClass("layui-hide"),f.addClass("layadmin-tabspage-none")),u.ie&&u.ie<10&&o.error("IE"+u.ie+"\u4e0b\u8bbf\u95ee\u53ef\u80fd\u4e0d\u4f73\uff0c\u63a8\u8350\u4f7f\u7528\uff1aChrome / Firefox / Edge \u7b49\u9ad8\u7ea7\u6d4f\u89c8\u5668",{offset:"auto",id:"LAY_errorIE"}),(l=layui.data(r.tableName)).theme?T.theme(l.theme):r.theme&&T.initTheme(r.theme.initColorIndex),i.on("tab("+x+")",function(e){T.tabsPage.index=e.index}),T.on("tabsPage(setMenustatus)",function(e){function s(e){return{list:e.children(".layui-nav-child"),a:e.children("*[lay-href]"),name:e.data("name")}}var r=e.url,o=r.split("/"),e=d("#LAY-system-side-menu"),u="layui-nav-itemed";e.find("."+p).removeClass(p),T.screen()<2&&T.sideFlexible(),e.children("li").each(function(e,a){var a=d(a),n=s(a),t=n.list.children("dd"),i=o[0]==n.name||r===n.a.attr("lay-href");if(t.each(function(e,a){var a=d(a),t=s(a),i=t.list.children("dd"),l=o[0]==n.name&&o[1]==t.name||r===t.a.attr("lay-href");if(i.each(function(e,a){var a=d(a),t=s(a);if(r===t.a.attr("lay-href"))return t=t.list[0]?u:p,a.addClass(t).siblings().removeClass(t),!1}),l)return i=t.list[0]?u:p,a.addClass(i).siblings().removeClass(i),!1}),i)return t=n.list[0]?u:p,a.addClass(t).siblings().removeClass(t),!1})}),i.on("nav(layadmin-system-side-menu)",function(e){e.siblings(".layui-nav-child")[0]&&f.hasClass(A)&&(T.sideFlexible("spread"),layer.close(e.data("index"))),T.tabsPage.type="nav"}),i.on("nav(layadmin-pagetabs-nav)",function(e){e=e.parent();e.removeClass(p),e.parent().removeClass(h)}),"#LAY_app_tabsheader>li"),z=(m.on("click",L,function(){var e=d(this),a=e.index();T.tabsPage.type="tab",T.tabsPage.index=a,t(e)}),i.on("tabDelete("+x+")",function(e){var a=d(L+".layui-this");e.index&&T.tabsBody(e.index).remove(),t(a),T.delResize()}),m.on("click","*[lay-href]",function(){var e=d(this),a=e.attr("lay-href"),t=e.attr("lay-title")||e.attr("lay-text")||e.text();T.tabsPage.elem=e,(parent===self?layui:r.parentLayui||top.layui).admin.openTabsPage({url:a,title:t}),r.pageTabs&&r.refreshCurrPage&&a===T.tabsBody(T.tabsPage.index).find("iframe").attr("src")&&T.events.refresh()}),m.on("click","*[layadmin-event]",function(){var e=d(this),a=e.attr("layadmin-event");_[a]&&_[a].call(this,e)}),m.on("mouseenter","*[lay-tips]",function(){var t,e,a,i=d(this);i.parent().hasClass("layui-nav-item")&&!f.hasClass(A)||(a=i.attr("lay-tips"),t=i.attr("lay-offset"),e=i.attr("lay-direction"),a=layer.tips(a,this,{tips:e||1,time:-1,success:function(e,a){t&&e.css("margin-left",t+"px")}}),i.data("index",a))}).on("mouseleave","*[lay-tips]",function(){layer.close(d(this).data("index"))}),layui.data.resizeSystem=function(){layer.closeAll("tips"),z.lock||setTimeout(function(){T.sideFlexible(T.screen()<2?"":"spread"),delete z.lock},100),z.lock=!0});c.on("resize",layui.data.resizeSystem),y.on("fullscreenchange",function(){_.fullscreen(d('[layadmin-event="fullscreen"]'),{status:document.fullscreenElement})}),(u=r.request).tokenName&&((l={})[u.tokenName]=layui.data(r.tableName)[u.tokenName]||"",a.set({headers:l,where:l}),n.set({headers:l,data:l})),e("admin",T)}); -------------------------------------------------------------------------------- /static/adminui/dist/modules/index.js: -------------------------------------------------------------------------------- 1 | /** The Web UI Theme-v2.5.1 */;layui.define("admin",function(a){function n(e){function a(){r.tabChange(o,e.url),d.tabsBodyChange(s.index,{url:e.url,title:e.title})}e=c.extend({url:"",escape:!0},e);var i,t=c("#LAY_app_tabsheader>li"),n=e.url.replace(/(^http(s*):)|(\?[\s\S]*$)/g,"");t.each(function(a){c(this).attr("lay-id")===e.url&&(i=!0,s.index=a)}),e.title=e.title||(0===s.index?"":"\u65b0\u6807\u7b7e\u9875"),l.pageTabs?i||(setTimeout(function(){c("#LAY_app_body").append(['
','',"
"].join("")),a()},10),s.index=t.length,r.tabAdd(o,{title:""+(t=u.escape(e.title),e.highlight?''+t+"":t)+"",id:e.url,attr:n})):d.tabsBody(d.tabsPage.index).find(".layadmin-iframe")[0].contentWindow.location.href=e.url,a()}var l=layui.setter,r=layui.element,d=layui.admin,s=d.tabsPage,e=layui.view,u=layui.util,o="layadmin-layout-tabs",c=layui.$,e=(c(window),d.screen()<2&&d.sideFlexible(),e().autoRender(),function a(){var e=layui.url().hash,i=l.record||{},e=e.path.join("/"),t=(layui.data(l.tableName).record||{})[e]||"";return i.url&&e&&(i=c.trim(e),/^(\w*:)*\/\/.+/.test(i)&&-1===i.indexOf(location.hostname)||n({url:e,title:t})),setTimeout(function(){c("#"+l.container).css("visibility","visible")},300),a}(),{openTabsPage:n});c.extend(d,e),a("adminIndex",e)}); -------------------------------------------------------------------------------- /static/adminui/dist/modules/view.js: -------------------------------------------------------------------------------- 1 | /** The Web UI Theme-v2.5.1 */;layui.define(["laytpl","layer"],function(e){function u(e){return new t(e)}function t(e){this.id=e,this.container=c("#"+(e||a))}var c=layui.jquery,p=layui.laytpl,r=layui.layer,s=layui.setter,y=(layui.device(),layui.hint()),a="LAY_app_body";u.loading=function(e){e.append(this.elemLoad=c(''))},u.removeLoad=function(){this.elemLoad&&this.elemLoad.remove()},u.exit=function(e){layui.data(s.tableName,{key:s.request.tokenName,remove:!0}),e&&e()},u.req=function(a){function n(){return s.debug?"
URL\uff1a"+a.url:""}var e,r=a.success,o=a.error,t=s.request,i=s.response;return a.data=a.data||{},a.headers=a.headers||{},t.tokenName&&(e="string"==typeof a.data?JSON.parse(a.data):a.data,a.data[t.tokenName]=t.tokenName in e?a.data[t.tokenName]:layui.data(s.tableName)[t.tokenName]||"",a.headers[t.tokenName]=t.tokenName in a.headers?a.headers[t.tokenName]:layui.data(s.tableName)[t.tokenName]||""),delete a.success,delete a.error,c.ajax(c.extend({type:"get",dataType:"json",success:function(e){var t=i.statusCode;e[i.statusName]==t.ok?"function"==typeof a.done&&a.done(e):e[i.statusName]==t.logout?u.exit():(t=["Error\uff1a "+(e[i.msgName]||"\u8fd4\u56de\u72b6\u6001\u7801\u5f02\u5e38"),n()].join(""),u.error(t)),"function"==typeof r&&r(e)},error:function(e,t){var a=["\u8bf7\u6c42\u5f02\u5e38\uff0c\u8bf7\u91cd\u8bd5
\u9519\u8bef\u4fe1\u606f\uff1a"+t,n()].join("");u.error(a),"function"==typeof o&&o.apply(this,arguments)}},a))},u.popup=function(e){var n=e.success,t=e.skin;return delete e.success,delete e.skin,r.open(c.extend({type:1,title:"\u63d0\u793a",content:"",id:"LAY-system-view-popup",skin:"layui-layer-admin"+(t?" "+t:""),shadeClose:!0,closeBtn:!1,success:function(e,t){var a=c('');e.append(a),a.on("click",function(){r.close(t)}),"function"==typeof n&&n.apply(this,arguments)}},e))},u.error=function(e,t){return u.popup(c.extend({content:e,maxWidth:300,offset:"t",anim:6,id:"LAY_adminError"},t))},t.prototype.render=function(e,n){var r=this;layui.router();return e=(s.paths&&s.paths.views?s.paths:s).views+e+s.engine,c("#"+a).children(".layadmin-loading").remove(),u.loading(r.container),c.ajax({url:e,type:"get",dataType:"html",data:{v:layui.cache.version},success:function(e){var t=c(e="
"+e+"
").find("title"),a={title:t.text()||(e.match(/\([\s\S]*)\<\/title>/)||[])[1],body:e};t.remove(),r.params=n||{},r.then&&(r.then(a),delete r.then),r.parse(e),u.removeLoad(),r.done&&(r.done(a),delete r.done)},error:function(e){if(u.removeLoad(),r.render.isError)return u.error("\u8bf7\u6c42\u89c6\u56fe\u6587\u4ef6\u5f02\u5e38\uff0c\u72b6\u6001\uff1a"+e.status);404===e.status?r.render("template/tips/404"):r.render("template/tips/error"),r.render.isError=!0}}),r},t.prototype.parse=function(e,t,n){function o(t){var e=p(t.dataElem.html()),a=c.extend({params:d.params},t.res);t.dataElem.after(e.render(a)),"function"==typeof n&&n();try{t.done&&new Function("d",t.done)(a)}catch(e){console.error(t.dataElem[0],"\n\u5b58\u5728\u9519\u8bef\u56de\u8c03\u811a\u672c\n\n",e)}}var a=this,r="object"==typeof e,i=r?e:c(e),s=r?e:i.find("*[template]"),d=layui.router();i.find("title").remove(),a.container[t?"after":"html"](i.children()),d.params=a.params||{};for(var l=s.length;0新增用户:{c}" 103 | }, 104 | xAxis : [{ //X轴 105 | type : 'category', 106 | data : ['11-07', '11-08', '11-09', '11-10', '11-11', '11-12', '11-13'] 107 | }], 108 | yAxis : [{ //Y轴 109 | type : 'value' 110 | }], 111 | series : [{ //内容 112 | type: 'line', 113 | data:[200, 300, 400, 610, 150, 270, 380], 114 | }] 115 | } 116 | ] 117 | ,elemDataView = $('#LAY-index-dataview').children('div') 118 | ,renderDataView = function(index){ 119 | echartsApp[index] = echarts.init(elemDataView[index], layui.echartsTheme); 120 | echartsApp[index].setOption(options[index]); 121 | // window.onresize = echartsApp[index].resize; 122 | admin.resize(function(){ 123 | echartsApp[index].resize(); 124 | }); 125 | }; 126 | 127 | 128 | 129 | //没找到DOM,终止执行 130 | if(!elemDataView[0]) return; 131 | 132 | 133 | 134 | //renderDataView(0); 135 | 136 | //触发数据概览轮播 137 | var carouselIndex = 0; 138 | carousel.on('change(LAY-index-dataview)', function(obj){ 139 | renderDataView(carouselIndex = obj.index); 140 | }); 141 | 142 | //触发侧边伸缩 143 | layui.admin.on('side', function(){ 144 | setTimeout(function(){ 145 | renderDataView(carouselIndex); 146 | }, 300); 147 | }); 148 | 149 | //触发路由 150 | layui.admin.on('hash(tab)', function(){ 151 | layui.router().path.join('') || renderDataView(carouselIndex); 152 | }); 153 | 154 | 155 | 156 | function generateLast24Hours() { 157 | let result = []; 158 | for (let i = 23; i >= 0; i--) { 159 | let date = new Date(); 160 | date.setHours(date.getHours() - i); 161 | result.push(date.getHours()); 162 | } 163 | return result; 164 | } 165 | 166 | 167 | 168 | function reloadData(){ 169 | // 添加 jQuery 的 ajax 请求来获取新数据并更新今日流量趋势的数据 170 | $.ajax({ 171 | url:'/api/system/data_usage', 172 | type: 'get', 173 | dataType: 'json', 174 | success: function(res) { 175 | options[0].series[0].data = Object.values(res.sent); // 更新 sent 数据 176 | options[0].series[1].data = Object.values(res.recv); // 更新 recv 数据 177 | options[0].xAxis[0].data = Object.values(generateLast24Hours()); // 更新 recv 数据 178 | 179 | renderDataView(0); // 重新渲染图表 180 | }, 181 | error: function(error) { 182 | console.error('获取数据出错:', error); 183 | } 184 | }); 185 | } 186 | //加载数据 187 | reloadData() 188 | 189 | 190 | }); 191 | 192 | 193 | 194 | //地图 195 | layui.use(['carousel', 'echarts'], function () { 196 | var $ = layui.$ 197 | , carousel = layui.carousel 198 | , echarts = layui.echarts; 199 | 200 | var echartsApp = [], options = [ 201 | { 202 | title: { 203 | text: '服务器地区分布', 204 | subtext: '敬请期待更多服务器' 205 | }, 206 | tooltip: { 207 | trigger: 'item' 208 | }, 209 | dataRange: { 210 | orient: 'horizontal', 211 | min: 0, 212 | max: 300, 213 | text: ['高', '低'], 214 | splitNumber: 0 215 | }, 216 | series: [ 217 | { 218 | name: '访客地区分布', 219 | type: 'map', 220 | mapType: 'china', 221 | selectedMode: 'multiple', 222 | itemStyle: { 223 | normal: { label: { show: true } }, 224 | emphasis: { label: { show: true } } 225 | }, 226 | data: [] // 初始为空,等待 AJAX 数据填充 227 | } 228 | ] 229 | } 230 | ] 231 | , elemDataView = $('#LAY-index-pagethree-home').children('div') 232 | , renderDataView = function (index) { 233 | echartsApp[index] = echarts.init(elemDataView[index], layui.echartsTheme); 234 | echartsApp[index].setOption(options[index]); 235 | window.onresize = echartsApp[index].resize; 236 | }; 237 | //没找到DOM,终止执行 238 | if (!elemDataView[0]) return; 239 | 240 | function loadVisitorData() { 241 | $.ajax({ 242 | url: '/api/system/visitor_distribution', // 替换为实际的 API 地址 243 | type: 'get', 244 | dataType: 'json', 245 | success: function (res) { 246 | options[0].series[0].data = res; // 更新访客分布数据 247 | renderDataView(0); // 重新渲染图表 248 | }, 249 | error: function (error) { 250 | console.error('获取访客分布数据出错:', error); 251 | } 252 | }); 253 | } 254 | 255 | // 加载访客分布数据 256 | loadVisitorData(); 257 | 258 | }); 259 | 260 | 261 | exports('console', {}) 262 | }); -------------------------------------------------------------------------------- /static/modules/echartsTheme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Set echarts theme 3 | */ 4 | 5 | 6 | layui.define(function(exports) { 7 | exports('echartsTheme', { 8 | // 默认色板 9 | color: [ 10 | '#16baaa','#1E9FFF','#16b777','#FFB980','#D87A80', 11 | '#8d98b3','#e5cf0d','#97b552','#95706d','#dc69aa', 12 | '#07a2a4','#9a7fd1','#588dd5','#f5994e','#c05050', 13 | '#59678c','#c9ab00','#7eb00a','#6f5553','#c14089' 14 | ], 15 | 16 | // 图表标题 17 | title: { 18 | textStyle: { 19 | fontWeight: 'normal', 20 | color: '#666' // 主标题文字颜色 21 | } 22 | }, 23 | 24 | // 值域 25 | dataRange: { 26 | itemWidth: 15, 27 | color: ['#16baaa','#e0ffff'] 28 | }, 29 | 30 | // 工具箱 31 | toolbox: { 32 | color : ['#1e90ff', '#1e90ff', '#1e90ff', '#1e90ff'], 33 | effectiveColor : '#ff4500' 34 | }, 35 | 36 | // 提示框 37 | tooltip: { 38 | backgroundColor: 'rgba(50,50,50,0.5)', // 提示背景颜色,默认为透明度为0.7的黑色 39 | axisPointer : { // 坐标轴指示器,坐标轴触发有效 40 | type : 'line', // 默认为直线,可选为:'line' | 'shadow' 41 | lineStyle : { // 直线指示器样式设置 42 | color: '#16baaa' 43 | }, 44 | crossStyle: { 45 | color: '#008acd' 46 | }, 47 | shadowStyle : { // 阴影指示器样式设置 48 | color: 'rgba(200,200,200,0.2)' 49 | } 50 | } 51 | }, 52 | 53 | // 区域缩放控制器 54 | dataZoom: { 55 | dataBackgroundColor: '#efefff', // 数据背景颜色 56 | fillerColor: 'rgba(182,162,222,0.2)', // 填充颜色 57 | handleColor: '#008acd' // 手柄颜色 58 | }, 59 | 60 | // 网格 61 | grid: { 62 | borderColor: '#eee' 63 | }, 64 | 65 | // 类目轴 - X轴 66 | categoryAxis: { 67 | axisLine: { // 坐标轴线 68 | lineStyle: { // 属性lineStyle控制线条样式 69 | color: '#16baaa' 70 | } 71 | }, 72 | axisTick: { //小标记 73 | show: false 74 | }, 75 | splitLine: { // 分隔线 76 | lineStyle: { // 属性lineStyle(详见lineStyle)控制线条样式 77 | color: ['#eee'] 78 | } 79 | } 80 | }, 81 | 82 | // 数值型坐标轴默认参数 - Y轴 83 | valueAxis: { 84 | axisLine: { // 坐标轴线 85 | lineStyle: { // 属性lineStyle控制线条样式 86 | color: '#16baaa' 87 | } 88 | }, 89 | splitArea : { 90 | show : true, 91 | areaStyle : { 92 | color: ['rgba(250,250,250,0.1)','rgba(200,200,200,0.1)'] 93 | } 94 | }, 95 | splitLine: { // 分隔线 96 | lineStyle: { // 属性lineStyle(详见lineStyle)控制线条样式 97 | color: ['#eee'] 98 | } 99 | } 100 | }, 101 | 102 | polar : { 103 | axisLine: { // 坐标轴线 104 | lineStyle: { // 属性lineStyle控制线条样式 105 | color: '#ddd' 106 | } 107 | }, 108 | splitArea : { 109 | show : true, 110 | areaStyle : { 111 | color: ['rgba(250,250,250,0.2)','rgba(200,200,200,0.2)'] 112 | } 113 | }, 114 | splitLine : { 115 | lineStyle : { 116 | color : '#ddd' 117 | } 118 | } 119 | }, 120 | 121 | timeline : { 122 | lineStyle : { 123 | color : '#16baaa' 124 | }, 125 | controlStyle : { 126 | normal : { color : '#16baaa'}, 127 | emphasis : { color : '#16baaa'} 128 | }, 129 | symbol : 'emptyCircle', 130 | symbolSize : 3 131 | }, 132 | 133 | // 柱形图默认参数 134 | bar: { 135 | itemStyle: { 136 | normal: { 137 | barBorderRadius: 2 138 | }, 139 | emphasis: { 140 | barBorderRadius: 2 141 | } 142 | } 143 | }, 144 | 145 | // 折线图默认参数 146 | line: { 147 | smooth : true, 148 | symbol: 'emptyCircle', // 拐点图形类型 149 | symbolSize: 3 // 拐点图形大小 150 | }, 151 | 152 | // K线图默认参数 153 | k: { 154 | itemStyle: { 155 | normal: { 156 | color: '#d87a80', // 阳线填充颜色 157 | color0: '#2ec7c9', // 阴线填充颜色 158 | lineStyle: { 159 | color: '#d87a80', // 阳线边框颜色 160 | color0: '#2ec7c9' // 阴线边框颜色 161 | } 162 | } 163 | } 164 | }, 165 | 166 | // 散点图默认参数 167 | scatter: { 168 | symbol: 'circle', // 图形类型 169 | symbolSize: 4 // 图形大小,半宽(半径)参数,当图形为方向或菱形则总宽度为symbolSize * 2 170 | }, 171 | 172 | // 雷达图默认参数 173 | radar : { 174 | symbol: 'emptyCircle', // 图形类型 175 | symbolSize:3 176 | //symbol: null, // 拐点图形类型 177 | //symbolRotate : null, // 图形旋转控制 178 | }, 179 | 180 | map: { 181 | itemStyle: { 182 | normal: { 183 | areaStyle: { 184 | color: '#ddd' 185 | }, 186 | label: { 187 | textStyle: { 188 | color: '#d87a80' 189 | } 190 | } 191 | }, 192 | emphasis: { // 也是选中样式 193 | areaStyle: { 194 | color: '#fe994e' 195 | } 196 | } 197 | } 198 | }, 199 | 200 | force : { 201 | itemStyle: { 202 | normal: { 203 | linkStyle : { 204 | color : '#1e90ff' 205 | } 206 | } 207 | } 208 | }, 209 | 210 | chord : { 211 | itemStyle : { 212 | normal : { 213 | borderWidth: 1, 214 | borderColor: 'rgba(128, 128, 128, 0.5)', 215 | chordStyle : { 216 | lineStyle : { 217 | color : 'rgba(128, 128, 128, 0.5)' 218 | } 219 | } 220 | }, 221 | emphasis : { 222 | borderWidth: 1, 223 | borderColor: 'rgba(128, 128, 128, 0.5)', 224 | chordStyle : { 225 | lineStyle : { 226 | color : 'rgba(128, 128, 128, 0.5)' 227 | } 228 | } 229 | } 230 | } 231 | }, 232 | 233 | gauge : { 234 | axisLine: { // 坐标轴线 235 | lineStyle: { // 属性lineStyle控制线条样式 236 | color: [[0.2, '#2ec7c9'],[0.8, '#5ab1ef'],[1, '#d87a80']], 237 | width: 10 238 | } 239 | }, 240 | axisTick: { // 坐标轴小标记 241 | splitNumber: 10, // 每份split细分多少段 242 | length :15, // 属性length控制线长 243 | lineStyle: { // 属性lineStyle控制线条样式 244 | color: 'auto' 245 | } 246 | }, 247 | splitLine: { // 分隔线 248 | length :22, // 属性length控制线长 249 | lineStyle: { // 属性lineStyle(详见lineStyle)控制线条样式 250 | color: 'auto' 251 | } 252 | }, 253 | pointer : { 254 | width : 5 255 | } 256 | }, 257 | 258 | textStyle: { 259 | fontFamily: '微软雅黑, Arial, Verdana, sans-serif' 260 | } 261 | }); 262 | }); -------------------------------------------------------------------------------- /static/particles/particles.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * A lightweight, dependency-free and responsive javascript plugin for particle backgrounds. 3 | * 4 | * @author Marc Bruederlin 5 | * @version 2.2.3 6 | * @license MIT 7 | * @see https://github.com/marcbruederlin/particles.js 8 | */ 9 | var Particles=function(e,t){"use strict";var n,i={};function o(e,t){return e.xt.x?1:e.yt.y?1:0}return(n=function(){return function(){var e=this;e.defaults={responsive:null,selector:null,maxParticles:100,sizeVariations:3,showParticles:!0,speed:.5,color:"#000000",minDistance:120,connectParticles:!1},e.element=null,e.context=null,e.ratio=null,e.breakpoints=[],e.activeBreakpoint=null,e.breakpointSettings=[],e.originalSettings=null,e.storage=[],e.usingPolyfill=!1}}()).prototype.init=function(e){var t=this;return t.options=t._extend(t.defaults,e),t.originalSettings=JSON.parse(JSON.stringify(t.options)),t._animate=t._animate.bind(t),t._initializeCanvas(),t._initializeEvents(),t._registerBreakpoints(),t._checkResponsive(),t._initializeStorage(),t._animate(),t},n.prototype.destroy=function(){var t=this;t.storage=[],t.element.remove(),e.removeEventListener("resize",t.listener,!1),e.clearTimeout(t._animation),cancelAnimationFrame(t._animation)},n.prototype._initializeCanvas=function(){var n,i,o=this;if(!o.options.selector)return console.warn("particles.js: No selector specified! Check https://github.com/marcbruederlin/particles.js#options"),!1;o.element=t.querySelector(o.options.selector),o.context=o.element.getContext("2d"),n=e.devicePixelRatio||1,i=o.context.webkitBackingStorePixelRatio||o.context.mozBackingStorePixelRatio||o.context.msBackingStorePixelRatio||o.context.oBackingStorePixelRatio||o.context.backingStorePixelRatio||1,o.ratio=n/i,o.element.width=o.element.offsetParent?o.element.offsetParent.clientWidth*o.ratio:o.element.clientWidth*o.ratio,o.element.offsetParent&&"BODY"===o.element.offsetParent.nodeName?o.element.height=e.innerHeight*o.ratio:o.element.height=o.element.offsetParent?o.element.offsetParent.clientHeight*o.ratio:o.element.clientHeight*o.ratio,o.element.style.width="100%",o.element.style.height="100%",o.context.scale(o.ratio,o.ratio)},n.prototype._initializeEvents=function(){var t=this;t.listener=function(){t._resize()}.bind(this),e.addEventListener("resize",t.listener,!1)},n.prototype._initializeStorage=function(){var e=this;e.storage=[];for(var t=e.options.maxParticles;t--;)e.storage.push(new i(e.context,e.options))},n.prototype._registerBreakpoints=function(){var e,t,n,i=this,o=i.options.responsive||null;if("object"==typeof o&&null!==o&&o.length){for(e in o)if(n=i.breakpoints.length-1,t=o[e].breakpoint,o.hasOwnProperty(e)){for(;n>=0;)i.breakpoints[n]&&i.breakpoints[n]===t&&i.breakpoints.splice(n,1),n--;i.breakpoints.push(t),i.breakpointSettings[t]=o[e].options}i.breakpoints.sort(function(e,t){return t-e})}},n.prototype._checkResponsive=function(){var t,n=this,i=!1,o=e.innerWidth;if(n.options.responsive&&n.options.responsive.length&&null!==n.options.responsive){for(t in i=null,n.breakpoints)n.breakpoints.hasOwnProperty(t)&&o<=n.breakpoints[t]&&(i=n.breakpoints[t]);null!==i?(n.activeBreakpoint=i,n.options=n._extend(n.options,n.breakpointSettings[i])):null!==n.activeBreakpoint&&(n.activeBreakpoint=null,i=null,n.options=n._extend(n.options,n.originalSettings))}},n.prototype._refresh=function(){this._initializeStorage(),this._draw()},n.prototype._resize=function(){var t=this;t.element.width=t.element.offsetParent?t.element.offsetParent.clientWidth*t.ratio:t.element.clientWidth*t.ratio,t.element.offsetParent&&"BODY"===t.element.offsetParent.nodeName?t.element.height=e.innerHeight*t.ratio:t.element.height=t.element.offsetParent?t.element.offsetParent.clientHeight*t.ratio:t.element.clientHeight*t.ratio,t.context.scale(t.ratio,t.ratio),clearTimeout(t.windowDelay),t.windowDelay=e.setTimeout(function(){t._checkResponsive(),t._refresh()},50)},n.prototype._animate=function(){var t=this;t._draw(),t._animation=e.requestAnimFrame(t._animate)},n.prototype.resumeAnimation=function(){this._animation||this._animate()},n.prototype.pauseAnimation=function(){var t=this;if(t._animation){if(t.usingPolyfill)e.clearTimeout(t._animation);else(e.cancelAnimationFrame||e.webkitCancelAnimationFrame||e.mozCancelAnimationFrame)(t._animation);t._animation=null}},n.prototype._draw=function(){var t=this,n=t.element,i=n.offsetParent?n.offsetParent.clientWidth:n.clientWidth,r=n.offsetParent?n.offsetParent.clientHeight:n.clientHeight,a=t.options.showParticles,s=t.storage;n.offsetParent&&"BODY"===n.offsetParent.nodeName&&(r=e.innerHeight),t.context.clearRect(0,0,n.width,n.height),t.context.beginPath();for(var l=s.length;l--;){var c=s[l];a&&c._draw(),c._updateCoordinates(i,r)}t.options.connectParticles&&(s.sort(o),t._updateEdges())},n.prototype._updateEdges=function(){for(var e=this,t=e.options.minDistance,n=Math.sqrt,i=Math.abs,o=e.storage,r=o.length,a=0;at)break;c<=t&&e._drawEdge(s,f,1.2-c/t)}},n.prototype._drawEdge=function(e,t,n){var i=this,o=i.context.createLinearGradient(e.x,e.y,t.x,t.y),r=this._hex2rgb(e.color),a=this._hex2rgb(t.color);o.addColorStop(0,"rgba("+r.r+","+r.g+","+r.b+","+n+")"),o.addColorStop(1,"rgba("+a.r+","+a.g+","+a.b+","+n+")"),i.context.beginPath(),i.context.strokeStyle=o,i.context.moveTo(e.x,e.y),i.context.lineTo(t.x,t.y),i.context.stroke(),i.context.fill(),i.context.closePath()},n.prototype._extend=function(e,t){return Object.keys(t).forEach(function(n){e[n]=t[n]}),e},n.prototype._hex2rgb=function(e){var t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return t?{r:parseInt(t[1],16),g:parseInt(t[2],16),b:parseInt(t[3],16)}:null},(i=function(n,i){var o=this,r=Math.random,a=i.speed,s=i.color instanceof Array?i.color[Math.floor(Math.random()*i.color.length)]:i.color;o.context=n,o.options=i;var l=t.querySelector(i.selector);o.x=l.offsetParent?r()*l.offsetParent.clientWidth:r()*l.clientWidth,l.offsetParent&&"BODY"===l.offsetParent.nodeName?o.y=r()*e.innerHeight:o.y=l.offsetParent?r()*l.offsetParent.clientHeight:r()*l.clientHeight,o.vx=r()*a*2-a,o.vy=r()*a*2-a,o.radius=r()*r()*i.sizeVariations,o.color=s,o._draw()}).prototype._draw=function(){var e=this;e.context.save(),e.context.translate(e.x,e.y),e.context.moveTo(0,0),e.context.beginPath(),e.context.arc(0,0,e.radius,0,2*Math.PI,!1),e.context.fillStyle=e.color,e.context.fill(),e.context.restore()},i.prototype._updateCoordinates=function(e,t){var n=this,i=n.x+this.vx,o=n.y+this.vy,r=n.radius;i+r>e?i=r:i-r<0&&(i=e-r),o+r>t?o=r:o-r<0&&(o=t-r),n.x=i,n.y=o},e.requestAnimFrame=function(){var t=e.requestAnimationFrame||e.webkitRequestAnimationFrame||e.mozRequestAnimationFrame;return t||(this._usingPolyfill=!0,function(t){return e.setTimeout(t,1e3/60)})}(),new n}(window,document);!function(){"use strict";"function"==typeof define&&define.amd?define("Particles",function(){return Particles}):"undefined"!=typeof module&&module.exports?module.exports=Particles:window.Particles=Particles}(); -------------------------------------------------------------------------------- /static/views/system/about.html: -------------------------------------------------------------------------------- 1 | 2 |
版本
3 |
4 |
5 | 8 |
9 |
10 | 11 | -------------------------------------------------------------------------------- /static/views/system/theme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | 37 | 86 | -------------------------------------------------------------------------------- /templates/admin/acl.html: -------------------------------------------------------------------------------- 1 | {% raw %} 2 | 3 | 4 | 5 | 6 | layui table 组件综合演示 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
访问控制中心
22 |
23 |
24 | 25 | 32 | 33 | 34 |
35 |
36 |
37 |
38 |
39 | 40 | 41 | 183 | 184 | 185 | 186 | {% endraw %} -------------------------------------------------------------------------------- /templates/admin/deploy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 入门文档 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
指令中心
17 | 18 |
19 | 20 |

 21 |             
22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
/
46 |
47 | 48 |
49 |
50 |
51 | 52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 | 60 |
61 | 62 |
63 | 64 |
65 | 66 |
67 |
68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 | 78 | 79 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /templates/admin/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 入门文档 8 | 9 | 10 | 11 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
文档中心
26 |
27 | 28 | 29 |

windows安装教程

30 |
31 |

安卓安装教程

32 |
33 |

Linux安装教程

34 | 35 | 36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 | 44 | 45 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /templates/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 异地组网管理中心 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 149 | 150 | 151 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /templates/admin/info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 设置我的资料 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
我的资料
19 |
20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 |
49 | 50 |
51 | 52 |
53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 | 63 | 64 |
65 | 66 |
67 | 68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {#
#} 78 | {#
#} 79 | {# #} 80 | {#
#} 81 | {#
#} 82 |
83 | 84 |
85 |
86 |
87 |
88 |
89 | 90 | 91 | 106 | -------------------------------------------------------------------------------- /templates/admin/log.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | layui table 组件综合演示 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 |
日志中心
21 |
22 |
23 | 24 | 29 | 30 | 31 |
32 |
33 |
34 |
35 |
36 | 37 | 38 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /templates/admin/node.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 节点中心 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
节点中心
22 |
23 |
24 | 25 | 39 | {% raw %} 40 | 41 | 50 | {% endraw %} 51 | 52 | 55 |
56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | 308 | 309 | 310 | 311 | 312 | -------------------------------------------------------------------------------- /templates/admin/password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 设置我的密码 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
修改密码
19 |
20 | 21 |
22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 |
6到16个字符
35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | 66 | 103 | 104 | -------------------------------------------------------------------------------- /templates/admin/preauthkey.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | layui table 组件综合演示 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
预共享密钥中心
22 |
23 |
24 | 25 | 31 | 32 | 33 | 34 | 38 | 39 | 40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /templates/admin/route.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | layui table 组件综合演示 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
路由中心
22 |
23 |
24 | 25 | 30 | 31 | 32 | {% raw %} 33 | 40 | {% endraw %} 41 | 42 | 43 |
44 |
45 |
46 |
47 |
48 | 49 | 50 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /templates/admin/set.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 系统设置 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
系统设置
18 |
19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 |
apikey是与headscale通信的凭证
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 |
headscale对外提供服务的URL
44 |
45 | 46 | 47 |
48 | 49 |
50 | 51 |
52 |
新用户注册默认到期天数,0代表默认禁用
53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 |
新用户注册默认节点数量限制
61 |
62 | 63 |
64 | 65 |
66 |
关闭
68 |
69 |
是否允许新用户注册
70 |
71 | 72 |
73 | 74 | 75 | {# #} 76 |
77 | 83 |
84 | 85 | 86 |
流量统计使用
87 |
88 | 89 | 90 |
91 | 92 |
93 | 94 |
95 |
96 | 97 | 98 |
99 | 100 |
101 | 102 |
103 |
104 | 105 | 106 | 107 |
108 |
109 | 110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 |
122 |
123 |
headscale {{ version }}进程管理
124 |
125 |
126 |
关闭
127 |
128 |
129 |
130 |
131 | 132 | 133 | 134 | 135 |
136 |
137 |
derp配置
138 |
139 |
140 | 141 |
142 | 143 | 144 |
145 | 146 |
147 | 148 |
149 | 150 | 151 | 152 |
153 | 154 |
155 |
156 |
157 |
158 | 159 | 160 |
161 |
162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 270 | 271 | 272 | 273 | -------------------------------------------------------------------------------- /templates/admin/user.html: -------------------------------------------------------------------------------- 1 | {% raw %} 2 | 3 | 4 | 5 | 6 | 7 | 用户中心 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 |
用户中心
22 |
23 |
24 | 25 | 39 | 40 | 41 | 42 | 43 | 44 | 49 | 50 | 54 | 55 | 59 | 60 | 61 | 62 | 63 | 88 | 89 | 90 |
91 |
92 |
93 |
94 |
95 | 96 | 97 | 342 | 343 | 344 | 345 | 346 | 347 | {% endraw %} -------------------------------------------------------------------------------- /templates/auth/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 出错了 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 | {% if message %} 21 |

{{ message }}

22 | {% endif %} 23 |
24 | 25 |
26 |
27 | 28 | 29 | 30 | 40 | 41 | -------------------------------------------------------------------------------- /templates/auth/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 异地组网登录页面 7 | 8 | {# #} 9 | 10 | 11 | 12 | 13 | 22 |

异地组网

23 |
24 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 151 | 152 | -------------------------------------------------------------------------------- /templates/auth/reg.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 用户注册中心 7 | 8 | {# #} 9 | 10 | 11 | 12 | 13 | 14 | 21 | 24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 | 43 |
44 | 45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | 54 |
55 | 56 |
57 |
58 |
59 | 60 | 61 | 62 |
63 |
64 |
65 | 66 |
67 | 68 |
69 |
70 |
71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |
83 | 84 |
85 | 86 |
87 |
88 |
89 |
90 | 91 |
92 |
93 |
94 | 95 |
96 | 97 | 98 | 用户协议 99 | 100 |
101 |
102 | 103 |
104 | 用已有帐号登入 105 | 登入 106 |
107 |
108 | 109 | 110 | 111 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /templates/auth/register.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 异地组网登录页面 7 | 8 | 9 | 10 | 11 | 12 | 13 | 22 |

节点注册

23 |
24 | 66 |
67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 138 | 139 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 小远异地组网 - 安全高效的创建私有网络 7 | 8 | 9 | 10 | 11 | 12 | 41 | 42 | 43 | 44 | 50 |

异地组网中心

51 |
52 | 53 | 54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 |
概述
62 |
63 | headscale-Admin异地组网是基于开源项目headscale开发,是一款基于Wireguard协议构建的现代异地组网工具 64 |
65 |
66 |
67 |
68 |
69 |
适用
70 |
71 | 支持windows、linux、安卓、苹果等等各种设备 72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
速度
82 |
83 | 90%的情况下可以使用p2p直连,即使失败也可以获得高达200Mbps的中转速率 84 |
85 |
86 |
87 |
88 |
89 |
计划
90 |
91 | 计划在全国多地搭建中转服务器 92 |
93 |
94 |
95 |
96 |
97 |
98 | 99 | 100 | 101 | 102 | 103 | 132 | 133 | --------------------------------------------------------------------------------