├── .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 | [](https://github.com/arounyf/headscale-Admin)
4 | [](https://hub.docker.com/r/runyf/hs-admin)
5 | [](https://hub.docker.com/r/runyf/hs-admin)
6 | [](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 |
64 |
65 |
66 |
67 |
68 |
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 += "Action | Source | Destination |
"
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"{action} | {src} | {dst} |
"
118 |
119 | html_content += "
"
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 |
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 |
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 |
30 |
31 |
32 |
33 |
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 |
17 |
18 |
90 |
91 |
92 |
112 |
113 |
114 |
115 |
116 |
117 |
130 |
131 |
134 |
135 |
136 |
137 |
138 |
139 |
144 |
145 |
146 |
147 |
148 |
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 |
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 |
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 |
116 |
117 |
118 |
119 |
120 |
121 |
131 |
132 |
133 |
134 |
135 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------