├── blueprints ├── __init__.py ├── get_captcha.py ├── system.py ├── log.py ├── route.py ├── set.py ├── preauthkey.py ├── acl.py ├── forms.py ├── user.py ├── admin.py ├── auth.py └── node.py ├── static ├── adminui │ ├── src │ │ ├── css │ │ │ ├── admin.css │ │ │ └── login.css │ │ └── modules │ │ │ ├── admin.js │ │ │ ├── index.js │ │ │ └── view.js │ └── dist │ │ ├── modules │ │ ├── index.js │ │ └── view.js │ │ └── css │ │ └── login.css ├── layui │ └── font │ │ ├── iconfont.eot │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 ├── views │ └── system │ │ ├── about.html │ │ └── theme.html ├── modules │ ├── common.js │ ├── echartsTheme.js │ └── console.js ├── index.js ├── config.js └── particles │ └── particles.min.js ├── .gitignore ├── docker-compose.yml ├── derp-example.yaml ├── models.py ├── Dockerfile ├── templates ├── auth │ ├── error.html │ ├── register.html │ ├── login.html │ └── reg.html ├── admin │ ├── help.html │ ├── log.html │ ├── password.html │ ├── info.html │ ├── preauthkey.html │ ├── deploy.html │ ├── acl.html │ ├── index.html │ ├── set.html │ ├── user.html │ ├── console.html │ └── node.html └── index.html ├── data-example.json ├── .github └── workflows │ └── main.yml ├── config_loader.py ├── exts.py ├── nginx-example.conf ├── init.sh ├── app.py ├── login_setup.py └── README.md /blueprints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/adminui/src/css/admin.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/adminui/src/css/login.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/adminui/src/modules/admin.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/adminui/src/modules/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/adminui/src/modules/view.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | migrations/ 3 | blueprints/__pycache__/ 4 | headscale 5 | 6 | -------------------------------------------------------------------------------- /static/layui/font/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/HEAD/static/layui/font/iconfont.eot -------------------------------------------------------------------------------- /static/layui/font/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/HEAD/static/layui/font/iconfont.ttf -------------------------------------------------------------------------------- /static/layui/font/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/HEAD/static/layui/font/iconfont.woff -------------------------------------------------------------------------------- /static/layui/font/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arounyf/Headscale-Admin-Pro/HEAD/static/layui/font/iconfont.woff2 -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | hs-admin: 3 | image: runyf/hs-admin:latest 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 | - FLASK_DEBUG=False 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /static/views/system/about.html: -------------------------------------------------------------------------------- 1 | 2 |
版本信息
3 |
4 |
5 | 8 |
9 | 10 |
11 | 14 |
15 | 16 |
17 | 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /static/modules/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * common demo 3 | */ 4 | 5 | layui.define(function(exports){ 6 | var $ = layui.$ 7 | ,layer = layui.layer 8 | ,laytpl = layui.laytpl 9 | ,setter = layui.setter 10 | ,view = layui.view 11 | ,admin = layui.admin 12 | 13 | //公共业务的逻辑处理可以写在此处,切换任何页面都会执行 14 | //…… 15 | 16 | 17 | 18 | //退出 19 | admin.events.logout = function(){ 20 | //执行退出接口 21 | admin.req({ 22 | url: '/logout' 23 | ,type: 'post' 24 | ,data: {} 25 | ,done: function(res){ //这里要说明一下:done 是只有 response 的 code 正常才会执行。而 succese 则是只要 http 为 200 就会执行 26 | 27 | //清空本地记录的 token,并跳转到登入页 28 | admin.exit(function(){ 29 | location.href = '/login'; 30 | }); 31 | } 32 | }); 33 | }; 34 | 35 | 36 | //对外暴露的接口 37 | exports('common', {}); 38 | }); -------------------------------------------------------------------------------- /static/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 初始化主题入口模块 3 | */ 4 | 5 | layui.extend({ 6 | setter: 'config' // 将 config.js 扩展到 layui 模块 7 | }).define(['setter'], function(exports){ 8 | var setter = layui.setter; 9 | 10 | // 将核心库扩展到 layui 模块 11 | layui.each({ 12 | admin: 'admin', 13 | view: 'view', 14 | adminIndex: 'index' 15 | }, function(modName, fileName){ 16 | var libs = {}; 17 | libs[modName] = '{/}'+ setter.paths.core +'/modules/'+ fileName; 18 | layui.extend(libs); 19 | }); 20 | 21 | // 指定业务模块基础目录 22 | layui.config({ 23 | base: setter.paths.modules 24 | }); 25 | 26 | // 将业务模块中的特殊模块扩展到 layui 模块 27 | layui.each(setter.extend, function(key, value){ 28 | var mods = {}; 29 | mods[key] = '{/}' + layui.cache.base + value; 30 | layui.extend(mods); 31 | }); 32 | 33 | // 加载主题核心库入口模块 34 | layui.use('adminIndex', function(){ 35 | layui.use('common'); // 加载公共业务模块,如不需要可剔除 36 | 37 | // 输出模块 / 模块加载完毕标志 38 | exports('index', layui.admin); 39 | }); 40 | }); -------------------------------------------------------------------------------- /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://pan.runyf.cn/directlink/downloads/headscale/headscale-v0.27.1-runyf 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ 'v*' ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | 19 | - name: Login to Docker Hub 20 | uses: docker/login-action@v3 21 | with: 22 | username: ${{ secrets.DOCKERHUB_USERNAME }} 23 | password: ${{ secrets.DOCKERHUB_TOKEN }} 24 | 25 | - name: Extract metadata (tags, labels) 26 | id: meta 27 | uses: docker/metadata-action@v5 28 | with: 29 | images: runyf/hs-admin 30 | tags: | 31 | # 删掉 type=ref,event=branch(避免生成main标签) 32 | type=ref,event=tag # 标签触发时生成:runyf/hs-admin:v1.0.0 33 | # 仅main分支触发时生成latest标签(无main标签) 34 | type=raw,value=latest,enable={{is_default_branch}} 35 | 36 | - name: Build and push 37 | uses: docker/build-push-action@v5 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /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 | 31 | 32 | 33 | 34 | # 从 yaml 配置文件中获取WEB UI配置项 35 | NET_TRAFFIC_RECORD_FILE = '/var/lib/headscale/data.json' 36 | BEARER_TOKEN = config_yaml.get('bearer_token', {}) 37 | SERVER_NET = config_yaml.get('server_net', {}) 38 | 39 | DEFAULT_REG_DAYS = config_yaml.get('default_reg_days', '7') 40 | DEFAULT_NODE_COUNT = config_yaml.get('default_node_count', 2) 41 | OPEN_USER_REG = config_yaml.get('open_user_reg', 'on') 42 | -------------------------------------------------------------------------------- /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)}); -------------------------------------------------------------------------------- /templates/admin/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 入门文档 8 | 9 | 10 | 11 | 19 | 20 | 21 |
22 |
23 |
24 |
25 |
文档中心
26 |
27 | 28 | 29 |

windows安装教程

30 |
31 |

安卓安装教程

32 |
33 |

Linux安装教程

34 | 35 | 36 |
37 |
38 |
39 |
40 |
41 | 42 | 43 | 44 | 45 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /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() -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 54 | -------------------------------------------------------------------------------- /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,os 6 | 7 | 8 | # 导入蓝图 9 | from blueprints.auth import bp as auth_bp 10 | from blueprints.admin import bp as admin_bp 11 | from blueprints.user import bp as user_bp 12 | from blueprints.node import bp as node_bp 13 | from blueprints.system import bp as system_bp 14 | from blueprints.route import bp as route_bp 15 | from blueprints.acl import bp as acl_bp 16 | from blueprints.preauthkey import bp as preauthkey_bp 17 | from blueprints.log import bp as log_bp 18 | from blueprints.set import bp as set_bp 19 | 20 | # 创建 Flask 应用实例 21 | app = Flask(__name__) 22 | 23 | # 应用配置 24 | app.config.from_object(config_loader) 25 | app.json.ensure_ascii = False # 让接口返回的中文不转码 26 | 27 | 28 | # 初始化 Flask-login 29 | init_login_manager(app) 30 | 31 | # 创建蓝图列表 32 | blueprints = [auth_bp,admin_bp,user_bp,node_bp,system_bp,route_bp,acl_bp,preauthkey_bp,log_bp,set_bp] 33 | 34 | # 循环注册蓝图 35 | for blueprint in blueprints: 36 | app.register_blueprint(blueprint) 37 | 38 | 39 | #启动 headscale 40 | start_headscale() 41 | to_init_db(app) 42 | 43 | 44 | #定义一个定时任务函数,每个一个小时记录一下流量使用情况 45 | def my_task(): 46 | with app.app_context(): 47 | return get_data_record() 48 | 49 | 50 | # 创建调度 51 | scheduler = BackgroundScheduler() 52 | # 添加任务,每隔 1 Hour 执行一次 53 | scheduler.add_job(func=my_task, trigger='interval', seconds=3600) 54 | # 启动调度器 55 | scheduler.start() 56 | 57 | 58 | # 自定义404错误处理器 59 | @app.errorhandler(404) 60 | def page_not_found(e): 61 | return render_template('auth/error.html', message="404") 62 | 63 | if __name__ == '__main__': 64 | debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() in ('true', '1', 't') 65 | 66 | app.run(host="0.0.0.0", port=5000, debug=debug_mode) -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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}} -------------------------------------------------------------------------------- /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/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 | @bp.route('/getRoute') 9 | @login_required 10 | def getRoute(): 11 | page = request.args.get('page', default=1, type=int) 12 | per_page = request.args.get('limit', default=10, type=int) 13 | offset = (page - 1) * per_page 14 | 15 | with SqliteDB() as cursor: 16 | # 构建基础查询语句 17 | base_query = """ 18 | SELECT 19 | nodes.id, 20 | users.name, 21 | nodes.given_name, 22 | nodes.approved_routes, 23 | strftime('%Y-%m-%d %H:%M:%S', nodes.created_at, 'localtime') as created_at 24 | FROM 25 | nodes 26 | JOIN 27 | users ON nodes.user_id = users.id 28 | WHERE nodes.approved_routes is NOT NULL 29 | 30 | """ 31 | 32 | # 判断用户角色 33 | if current_user.role != 'manager': 34 | base_query += " AND user_id =? " 35 | params = (current_user.id,) 36 | else: 37 | params = () 38 | 39 | 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 | # 分页查询 48 | paginated_query = f"{base_query} LIMIT? OFFSET? " 49 | paginated_params = params + (per_page, offset) 50 | cursor.execute(paginated_query, paginated_params) 51 | routes = cursor.fetchall() 52 | 53 | 54 | 55 | 56 | # 数据格式化 57 | routes_list = [] 58 | for route in routes: 59 | # 处理可能是bytes或str的字段 60 | approved_routes = route['approved_routes'] 61 | # 如果是bytes类型则解码为str,否则直接使用 62 | route_str = approved_routes.decode('utf-8') if isinstance(approved_routes, bytes) else approved_routes 63 | 64 | routes_list.append({ 65 | 'id': route['id'], 66 | 'name': route['name'], 67 | 'NodeName': route['given_name'], 68 | 'route': route_str, # 使用处理后的结果 69 | 'createTime': route['created_at'], 70 | 'enable': 1 71 | }) 72 | 73 | 74 | return table_res('0','获取成功',routes_list,total_count,len(routes_list)) 75 | 76 | 77 | -------------------------------------------------------------------------------- /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 | 'DEFAULT_NODE_COUNT': 'defaultNodeCount', 24 | 'OPEN_USER_REG': 'openUserReg', 25 | 'DEFAULT_REG_DAYS': 'defaultRegDays', 26 | } 27 | 28 | # 构建字典 29 | config_mapping = {} 30 | 31 | for config_key, form_value in form_fields.items(): 32 | value = request.form.get(form_value) 33 | # 关键修复:将获取到的值存入 config_mapping 字典 34 | if value is not None: # 确保值不是None,避免覆盖现有配置为None 35 | config_mapping[config_key] = value 36 | 37 | return save_config_yaml(config_mapping) 38 | 39 | 40 | @bp.route('/get_apikey' , methods=['POST']) 41 | @login_required 42 | @role_required("manager") 43 | def get_apikey(): 44 | with SqliteDB() as cursor: 45 | try: 46 | # 删除所有记录 47 | delete_query = "DELETE FROM api_keys;" 48 | cursor.execute(delete_query) 49 | num_rows_deleted = cursor.rowcount 50 | print(f"成功删除 {num_rows_deleted} 条记录") 51 | except Exception as e: 52 | print(f"删除记录时出现错误: {e}") 53 | return res('1', f"删除记录时出现错误: {e}", '') 54 | 55 | try: 56 | headscale_command = "headscale apikey create" 57 | result = subprocess.run(headscale_command, shell=True, capture_output=True, text=True, check=True) 58 | return res('0', '执行成功', result.stdout) 59 | except subprocess.CalledProcessError as e: 60 | return res('1', '执行失败', f"错误信息:{e.stderr}") 61 | 62 | 63 | 64 | @bp.route('/switch_headscale', methods=['POST']) 65 | @login_required 66 | @role_required("manager") 67 | def switch_headscale(): 68 | # 获取表单中的 Switch 参数 69 | status = request.form.get('Switch') 70 | res_json = {'code': '', 'data': '', 'msg': ''} 71 | if status=="true": 72 | return start_headscale() 73 | else: 74 | return stop_headscale() 75 | 76 | return res_json 77 | 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 介绍 3 | [![GitHub repo size](https://img.shields.io/github/repo-size/arounyf/Headscale-Admin-Pro)](https://github.com/arounyf/headscale-Admin) 4 | [![Docker Image Size](https://img.shields.io/docker/image-size/runyf/hs-admin)](https://hub.docker.com/r/runyf/hs-admin) 5 | [![docker pulls](https://img.shields.io/docker/pulls/runyf/hs-admin.svg?color=brightgreen)](https://hub.docker.com/r/runyf/hs-admin) 6 | [![platfrom](https://img.shields.io/badge/platform-amd64%20%7C%20arm64-brightgreen)](https://hub.docker.com/r/runyf/hs-admin/tags) 7 | 8 | 重点升级: 9 | 1、基于本人发布的headscale-Admin使用python进行了后端重构 10 | 2、容器内置headscale、实现快速搭建 11 | 3、容器内置流量监测、无需额外安装插件 12 | 4、基于headscale最新新版本进行开发和测试 13 | 14 | 官方qq群: 892467054 15 | # 时间线 16 | 2024年6月开始接触 headscale 17 | 2024年9月8日 headscale-Admin 首个版本正式发布 18 | 2025年3月26日 Headscale-Admin-Pro 基于python重构新版本正式发布 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、进入后台设置,修改你server url、网卡名等,修改之后点击保存,最后重启headscale 33 | 34 | 3、配置derp中转服务器(headscale配置文件路径 ~/hs-admin/config/config.yaml) 35 | 36 | 4、配置nginx,配置示例 nginx-example.conf(可选) 37 | 38 | 39 | 40 | # 功能 41 | - 用户管理 42 | - 用户独立后台 43 | - 用户到期管理 44 | - 流量统计 45 | - 基于用户ACL 46 | - 节点管理 47 | - 路由管理 48 | - 日志管理 49 | - 预认证密钥管理 50 | - 角色管理 51 | - api和menu权限管理 52 | - 内置headscale 53 | - 内置配置在线修改 54 | - 一键添加节点(需配置反向代理) 55 | - 自动更新apikey 56 | 57 | 58 | # 版本关系 59 | 注意 runyf代表非官方原版headscale,因数据库适配问题不得不对headscale代码进行修改 60 | | Headscale-Admin-Pro | headscale | 61 | | --- | --- | 62 | | v2.7 | v0.25.1 | 63 | | v2.8 | v0.26.1 | 64 | | v3.0 | v0.27.1-runyf | 65 | 66 | 67 | 68 | # 系统截图 69 | runyf_20250506230722 70 | runyf_20250506230832 71 | runyf_20250506230908 72 | runyf_20250506231108 73 | runyf_20250506230937 74 | runyf_20250506230957 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /static/views/system/theme.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | 37 | 86 | -------------------------------------------------------------------------------- /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/password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 设置我的密码 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
修改密码
19 |
20 | 21 |
22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 |
6到16个字符
35 |
36 | 37 |
38 | 39 |
40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 | 52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | 66 | 103 | 104 | -------------------------------------------------------------------------------- /templates/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 | -------------------------------------------------------------------------------- /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.id 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 | 104 | -------------------------------------------------------------------------------- /templates/admin/info.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 设置我的资料 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
我的资料
19 |
20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 | 48 |
49 | 50 |
51 | 52 |
53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 |
61 | 62 | 63 | 64 |
65 | 66 |
67 | 68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {#
#} 78 | {#
#} 79 | {# #} 80 | {#
#} 81 | {#
#} 82 |
83 | 84 |
85 |
86 |
87 |
88 |
89 | 90 | 91 | 106 | -------------------------------------------------------------------------------- /blueprints/acl.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask_login import login_required 3 | from exts import SqliteDB 4 | from login_setup import role_required 5 | from flask import Blueprint, request 6 | from utils import reload_headscale, to_rewrite_acl, table_res, res 7 | 8 | 9 | bp = Blueprint("acl", __name__, url_prefix='/api/acl') 10 | 11 | 12 | 13 | @bp.route('/getACL') 14 | @login_required 15 | @role_required("manager") 16 | def getACL(): 17 | page = request.args.get('page', default=1, type=int) 18 | per_page = request.args.get('limit', default=10, type=int) 19 | offset = (page - 1) * per_page 20 | 21 | with SqliteDB() as cursor: 22 | # 构建基础查询语句 23 | base_query = """ 24 | SELECT 25 | acl.id, 26 | acl.acl, 27 | users.name 28 | FROM 29 | acl acl 30 | JOIN 31 | users ON acl.user_id = users.id 32 | """ 33 | 34 | # 查询总记录数 35 | count_query = f"SELECT COUNT(*) as total FROM ({base_query})" 36 | cursor.execute(count_query) 37 | total_count = cursor.fetchone()['total'] 38 | 39 | # 分页查询 40 | paginated_query = f"{base_query} LIMIT? OFFSET? " 41 | paginated_params = (per_page, offset) 42 | cursor.execute(paginated_query, paginated_params) 43 | acls = cursor.fetchall() 44 | 45 | # 数据格式化 46 | acls_list = [] 47 | for acl in acls: 48 | acls_list.append({ 49 | 'id': acl['id'], 50 | 'acl': acl['acl'], 51 | 'userName': acl['name'] 52 | }) 53 | 54 | 55 | 56 | return table_res('0', '获取成功',acls_list,total_count,len(acls_list)) 57 | 58 | 59 | 60 | @bp.route('/re_acl', methods=['GET','POST']) 61 | @login_required 62 | @role_required("manager") 63 | def re_acl(): 64 | acl_id = request.form.get('aclId') 65 | new_acl = request.form.get('newAcl') 66 | 67 | 68 | try: 69 | json.loads(new_acl) 70 | except json.JSONDecodeError: 71 | return res('1', '解析错误') 72 | 73 | 74 | with SqliteDB() as cursor: 75 | # 更新 ACL 记录 76 | update_query = "UPDATE acl SET acl =? WHERE id =?;" 77 | cursor.execute(update_query, (new_acl, acl_id)) 78 | 79 | 80 | return res('0','更新成功') 81 | 82 | 83 | 84 | @bp.route('/rewrite_acl', methods=['GET','POST']) 85 | @login_required 86 | @role_required("manager") 87 | def rewrite_acl(): 88 | return to_rewrite_acl() 89 | 90 | 91 | @bp.route('/read_acl', methods=['GET','POST']) 92 | @login_required 93 | @role_required("manager") 94 | def read_acl(): 95 | acl_path = "/etc/headscale/acl.hujson" 96 | try: 97 | with open(acl_path, 'r') as f: 98 | acl_data = json.load(f) 99 | 100 | except FileNotFoundError: 101 | return res('1', f"错误: 文件 {acl_path} 未找到。") 102 | except json.JSONDecodeError: 103 | return res('2', f"错误: 无法解析 {acl_path} 中的 JSON 数据。") 104 | except Exception as e: 105 | return res( '3', f"发生未知错误: {str(e)}") 106 | 107 | 108 | print(acl_data.get('acls', [])) 109 | 110 | html_content = "" 111 | html_content += "" 112 | 113 | for item in acl_data.get('acls', []): 114 | action = item['action'] 115 | src = ', '.join(item['src']) 116 | dst = ', '.join(item['dst']) 117 | html_content += f"" 118 | 119 | html_content += "
ActionSourceDestination
{action}{src}{dst}
" 120 | 121 | return res('0','读取成功',html_content) 122 | 123 | 124 | 125 | @bp.route('/reload', methods=['GET','POST']) 126 | @login_required 127 | @role_required("manager") 128 | def reload(): 129 | return reload_headscale() -------------------------------------------------------------------------------- /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 2 | 3 | 4 | 5 | 6 | 异地组网登录页面 7 | 8 | 9 | 10 | 11 | 12 | 13 | 22 |

节点注册

23 |
24 | 66 |
67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 138 | 139 | -------------------------------------------------------------------------------- /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("当前密码输入错误!") -------------------------------------------------------------------------------- /templates/auth/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 异地组网登录页面 7 | 8 | {# #} 9 | 10 | 11 | 12 | 13 | 22 |

异地组网

23 |
24 | 67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 151 | 152 | -------------------------------------------------------------------------------- /templates/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/deploy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 入门文档 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
指令中心
17 | 18 |
19 | 20 |

 21 |             
22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
/
46 |
47 | 48 |
49 |
50 |
51 | 52 | 53 |
54 | 55 |
56 |
57 |
58 | 59 | 60 |
61 | 62 |
63 | 64 |
65 | 66 |
67 |
68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 | 78 | 79 | 171 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /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 %} -------------------------------------------------------------------------------- /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 ,email 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 | 'email': row['email'] 46 | } 47 | for row in rows 48 | ] 49 | 50 | return table_res('0', '获取成功', users_list, total_count, len(users_list)) 51 | 52 | 53 | @bp.route('/re_expire',methods=['GET','POST']) 54 | @login_required 55 | @role_required("manager") 56 | def re_expire(): 57 | 58 | user_id = request.form.get('user_id') 59 | new_expire = request.form.get('new_expire') 60 | 61 | with SqliteDB() as cursor: 62 | # 更新用户的过期时间 63 | update_query = "UPDATE users SET expire =? WHERE id =?;" 64 | cursor.execute(update_query, (new_expire, user_id)) 65 | 66 | return res('0', '更新成功','') 67 | 68 | 69 | @bp.route('/re_node',methods=['GET','POST']) 70 | @login_required 71 | @role_required("manager") 72 | def re_node(): 73 | user_id = request.form.get('user_id') 74 | new_node = request.form.get('new_node') 75 | 76 | with SqliteDB() as cursor: 77 | # 更新用户的节点信息 78 | update_query = "UPDATE users SET node =? WHERE id =?;" 79 | cursor.execute(update_query, (new_node, user_id)) 80 | 81 | return res('0', '更新成功') 82 | 83 | 84 | @bp.route('/user_enable',methods=['GET','POST']) 85 | @login_required 86 | @role_required("manager") 87 | def user_enable(): 88 | user_id = request.form.get('user_id') 89 | enable = request.form.get('enable') 90 | 91 | with SqliteDB() as cursor: 92 | # 查询用户 93 | query = "SELECT * FROM users WHERE id =?;" 94 | cursor.execute(query, (user_id,)) 95 | user = cursor.fetchone() 96 | 97 | if enable == "true": 98 | update_query = "UPDATE users SET enable = 1 WHERE id =?;" 99 | msg = '启用成功' 100 | else: 101 | if user['name'] == 'admin': 102 | return res('1', '停用失败,无法停用admin用户') 103 | update_query = "UPDATE users SET enable = 0 WHERE id =?;" 104 | msg = '停用成功' 105 | 106 | cursor.execute(update_query, (user_id,)) 107 | 108 | return res('0', msg) 109 | 110 | 111 | @bp.route('/route_enable',methods=['GET','POST']) 112 | @login_required 113 | @role_required("manager") 114 | def route_enable(): 115 | user_id = request.form.get('user_id') 116 | enable = request.form.get('enable') 117 | 118 | with SqliteDB() as cursor: 119 | if enable == "true": 120 | update_query = "UPDATE users SET route = 1 WHERE id =?;" 121 | msg = '启用成功' 122 | else: 123 | update_query = "UPDATE users SET route = 0 WHERE id =?;" 124 | msg = '停用成功' 125 | cursor.execute(update_query, (user_id,)) 126 | 127 | return res('0', msg) 128 | 129 | 130 | @bp.route('/delUser',methods=['GET','POST']) 131 | @login_required 132 | @role_required("manager") 133 | def delUser(): 134 | user_id = request.form.get('user_id') 135 | 136 | with SqliteDB() as cursor: 137 | # 删除用户 138 | delete_query = "DELETE FROM users WHERE id =?;" 139 | cursor.execute(delete_query, (user_id,)) 140 | 141 | return res('0', '删除成功') 142 | 143 | 144 | @bp.route('/init_data',methods=['GET']) 145 | @login_required 146 | def init_data(): 147 | with SqliteDB() as cursor: 148 | # 查询当前用户的创建时间和过期时间 149 | current_user_id = current_user.id # 假设 current_user 有 id 属性 150 | user_query = """ 151 | SELECT 152 | strftime('%Y-%m-%d %H:%M:%S', created_at) as created_at, 153 | strftime('%Y-%m-%d %H:%M:%S', expire) as expire 154 | FROM users WHERE id =? 155 | """ 156 | cursor.execute(user_query, (current_user_id,)) 157 | user_info = cursor.fetchone() 158 | 159 | 160 | created_at = str(user_info['created_at']) 161 | expire = str(user_info['expire']) 162 | 163 | # 查询节点数量 164 | node_count = cursor.execute("SELECT COUNT(*) as count FROM nodes").fetchone()[0] 165 | # 查询路由数量 166 | route_count = cursor.execute("SELECT COUNT(*) as count FROM nodes where approved_routes IS NOT NULL").fetchone()[0] 167 | 168 | 169 | data = { 170 | "created_at": created_at, 171 | "expire": expire, 172 | "node_count": node_count, 173 | "route_count": route_count 174 | } 175 | 176 | return res('0', '查询成功', data) 177 | 178 | -------------------------------------------------------------------------------- /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 | return render_template('admin/console.html') 48 | 49 | 50 | 51 | @bp.route('/user') 52 | @login_required 53 | @role_required("manager") 54 | def user(): 55 | return render_template('admin/user.html') 56 | 57 | 58 | 59 | 60 | 61 | @bp.route('/node') 62 | @login_required 63 | def node(): 64 | print(request.url) 65 | return render_template('admin/node.html',current_user=current_user ) 66 | 67 | 68 | @bp.route('/route') 69 | @login_required 70 | def route(): 71 | return render_template('admin/route.html') 72 | 73 | 74 | @bp.route('/deploy') 75 | @login_required 76 | def deploy(): 77 | server_url = current_app.config['SERVER_URL'] 78 | return render_template('admin/deploy.html',server_url = server_url) 79 | 80 | 81 | @bp.route('/help') 82 | @login_required 83 | def help(): 84 | return render_template('admin/help.html') 85 | 86 | 87 | 88 | @bp.route('/acl') 89 | @login_required 90 | @role_required("manager") 91 | def acl(): 92 | return render_template('admin/acl.html') 93 | 94 | 95 | @bp.route('preauthkey') 96 | @login_required 97 | def preauthkey(): 98 | return render_template('admin/preauthkey.html') 99 | 100 | 101 | @bp.route('log') 102 | @login_required 103 | def log(): 104 | return render_template('admin/log.html') 105 | 106 | 107 | @bp.route('info') 108 | @login_required 109 | def info(): 110 | name = current_user.name 111 | cellphone = current_user.cellphone 112 | email = current_user.email 113 | node = current_user.node 114 | route = current_user.route 115 | expire = current_user.expire 116 | 117 | if (route == "1"): 118 | route = "checked" 119 | else: 120 | route = "" 121 | 122 | return render_template('admin/info.html', name = name, 123 | cellphone = cellphone, 124 | email = email, 125 | node = node, 126 | route = route, 127 | expire = expire 128 | ) 129 | 130 | 131 | 132 | 133 | @bp.route('set') 134 | @login_required 135 | def set(): 136 | apikey = current_app.config['BEARER_TOKEN'] 137 | server_url = current_app.config['SERVER_URL'] 138 | server_net = current_app.config['SERVER_NET'] 139 | default_reg_days = current_app.config['DEFAULT_REG_DAYS'] 140 | default_node_count = current_app.config['DEFAULT_NODE_COUNT'] 141 | open_user_reg = current_app.config['OPEN_USER_REG'] 142 | 143 | options_html = "" 144 | for interface in get_server_net()["network_interfaces"]: 145 | if interface == server_net: 146 | options_html += f'\n' 147 | else: 148 | options_html += f'\n' 149 | 150 | 151 | 152 | 153 | 154 | if get_headscale_pid(): 155 | headscale_status = "checked" 156 | else: 157 | headscale_status = "" 158 | 159 | 160 | if open_user_reg == 'on': 161 | open_user_reg = "checked" 162 | else: 163 | open_user_reg = "" 164 | 165 | 166 | return render_template('admin/set.html',apikey = apikey, 167 | server_url = server_url, 168 | server_net = options_html, 169 | headscale_status = headscale_status, 170 | default_reg_days = default_reg_days, 171 | default_node_count = default_node_count, 172 | open_user_reg = open_user_reg, 173 | version = get_headscale_version(), 174 | 175 | ) 176 | 177 | 178 | 179 | @bp.route('password') 180 | @login_required 181 | def password(): 182 | return render_template('admin/password.html') 183 | 184 | 185 | -------------------------------------------------------------------------------- /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/modules/console.js: -------------------------------------------------------------------------------- 1 | /** 2 | * console demo 3 | */ 4 | 5 | 6 | 7 | 8 | layui.define(function(exports){ 9 | 10 | /* 11 | 下面通过 layui.use 分段加载不同的模块,实现不同区域的同时渲染,从而保证视图的快速呈现 12 | */ 13 | 14 | 15 | //区块轮播切换 16 | layui.use(['admin', 'carousel'], function(){ 17 | var $ = layui.$ 18 | ,admin = layui.admin 19 | ,carousel = layui.carousel 20 | ,element = layui.element 21 | ,device = layui.device(); 22 | 23 | //轮播切换 24 | $('.layadmin-carousel').each(function(){ 25 | var othis = $(this); 26 | carousel.render({ 27 | elem: this 28 | ,width: '100%' 29 | ,arrow: 'none' 30 | ,interval: othis.data('interval') 31 | ,autoplay: othis.data('autoplay') === true 32 | ,trigger: (device.ios || device.android) ? 'click' : 'hover' 33 | ,anim: othis.data('anim') 34 | }); 35 | }); 36 | 37 | element.render('progress'); 38 | 39 | }); 40 | 41 | 42 | 43 | //数据概览 44 | layui.use(['admin', 'carousel', 'echarts'], function(){ 45 | var $ = layui.$ 46 | ,admin = layui.admin 47 | ,carousel = layui.carousel 48 | ,echarts = layui.echarts; 49 | 50 | var echartsApp = [], options = [ 51 | //今日流量趋势 52 | { 53 | title: { 54 | text: '最近24小时流量趋势', 55 | x: 'center', 56 | textStyle: { 57 | fontSize: 14 58 | } 59 | }, 60 | tooltip : { 61 | trigger: 'axis' 62 | }, 63 | legend: { 64 | data:['',''] 65 | }, 66 | xAxis : [{ 67 | type : 'category', 68 | boundaryGap : false, 69 | data: [] 70 | }], 71 | yAxis : [{ 72 | type : 'value' 73 | }], 74 | series : [{ 75 | name:'发送', 76 | type:'line', 77 | smooth:true, 78 | itemStyle: {normal: {areaStyle: {type: 'default'}}}, 79 | data: [] 80 | },{ 81 | name:'接收', 82 | type:'line', 83 | smooth:true, 84 | itemStyle: {normal: {areaStyle: {type: 'default'}}}, 85 | data: [] 86 | }] 87 | }, 88 | 89 | 90 | 91 | //新增的用户量 92 | { 93 | title: { 94 | text: '最近一周新增的用户量', 95 | x: 'center', 96 | textStyle: { 97 | fontSize: 14 98 | } 99 | }, 100 | tooltip : { //提示框 101 | trigger: 'axis', 102 | formatter: "{b}
新增用户:{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 | layui.use('table', function(){ 195 | var $ = layui.$ 196 | ,table = layui.table; 197 | 198 | 199 | layui.use(['table', 'jquery', 'layer'], function(){ 200 | var table = layui.table; 201 | var $ = layui.jquery; 202 | 203 | // 今日热贴 - 前端分页实现 204 | // 1. 手动发起AJAX请求,获取全部数据 205 | $.get('/api/node/topNodes', function(res) { 206 | // 2. 检查数据是否获取成功 207 | if (res && res.code === '0' && res.data && res.data.length > 0) { 208 | // 3. 渲染表格,直接将获取到的数据传给 data 参数 209 | table.render({ 210 | elem: '#LAY-index-topCard', 211 | data: res.data, // 核心:使用已获取的完整数据 212 | page: true, // 开启分页 213 | limit: 10, // 每页显示10条 214 | limits: [10, 20, 30, 50], // 可选的每页数量 215 | cellMinWidth: 120, 216 | cols: [[ 217 | {type: 'numbers', fixed: 'left'}, 218 | {field: 'name', title: '用户名', minWidth: 200, sort: true}, 219 | {field: 'online', title: '在线节点', sort: true}, 220 | {field: 'nodes', title: '累计节点', sort: true}, 221 | {field: 'routes', title: '路由数量', sort: true} 222 | ]], 223 | skin: 'line', 224 | // 注意:这里不需要 url 参数了 225 | }); 226 | } else { 227 | // 数据为空或请求失败 228 | table.render({ 229 | elem: '#LAY-index-topCard', 230 | data: [], 231 | page: false, 232 | cols: [[ 233 | {type: 'numbers', fixed: 'left'}, 234 | {field: 'name', title: '用户名', minWidth: 200}, 235 | {field: 'online', title: '在线节点'}, 236 | {field: 'nodes', title: '累计节点'}, 237 | {field: 'routes', title: '路由数量'} 238 | ]], 239 | skin: 'line' 240 | }); 241 | // layer.msg(res.msg || '暂无数据', {icon: 7}); 242 | } 243 | }).fail(function() { 244 | layer.msg('网络错误,无法获取数据', {icon: 5}); 245 | }); 246 | }); 247 | 248 | 249 | 250 | }); 251 | 252 | 253 | 254 | 255 | exports('console', {}) 256 | }); -------------------------------------------------------------------------------- /templates/admin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 异地组网管理中心 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 149 | 150 | 151 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /templates/auth/reg.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 用户注册中心 7 | 8 | {# #} 9 | 10 | 11 | 12 | 13 | 14 | 21 | 24 |
25 |
26 | 27 | 28 |
29 |
30 |
31 | 32 |
33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 | 43 |
44 | 45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 | 54 |
55 | 56 |
57 |
58 |
59 | 60 | 61 | 62 |
63 |
64 |
65 | 66 |
67 | 68 |
69 |
70 |
71 |
72 |
73 | 74 |
75 | 76 |
77 |
78 | 79 |
80 |
81 |
82 |
83 | 84 |
85 | 86 |
87 |
88 |
89 |
90 | 91 |
92 |
93 |
94 | 95 |
96 | 97 | 98 | 用户协议 99 | 100 |
101 |
102 | 103 |
104 | 用已有帐号登入 105 | 登入 106 |
107 |
108 | 109 | 110 | 111 | 198 | 199 | 200 | -------------------------------------------------------------------------------- /templates/admin/set.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 系统设置 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
系统设置
18 |
19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 |
apikey是与headscale通信的凭证
33 | 34 |
35 | 36 | 37 |
38 | 39 |
40 | 41 | 42 |
43 |
headscale对外提供服务的URL
44 |
45 | 46 | 47 |
48 | 49 |
50 | 51 |
52 |
新用户注册默认到期天数,0代表默认禁用
53 |
54 | 55 |
56 | 57 |
58 | 59 |
60 |
新用户注册默认节点数量限制
61 |
62 | 63 |
64 | 65 |
66 |
关闭
68 |
69 |
是否允许新用户注册
70 |
71 | 72 |
73 | 74 | 75 | {# #} 76 |
77 | 83 |
84 | 85 | 86 |
流量统计使用
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 |
96 | 97 |
98 |
99 |
100 |
101 |
102 |
103 | 104 | 105 |
106 |
107 |
headscale 进程管理
108 |
109 |
110 |
关闭
111 |
112 |
113 |
114 |
115 | 116 | 117 |
118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /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 | 200 | else: 201 | # return form.errors 202 | first_key = next(iter(form.errors.keys())) 203 | first_value = form.errors[first_key] 204 | res_code,res_msg,res_data ='1',str(first_value[0]),'' 205 | return res(res_code,res_msg,res_data) 206 | # 207 | # 208 | # 209 | 210 | @bp.route('/logout', methods=['POST']) 211 | @login_required 212 | def logout(): 213 | # session.clear() 214 | logout_user() 215 | res_code,res_msg,res_data = '0', 'logout success','' 216 | return res(res_code,res_msg,res_data) 217 | 218 | 219 | @bp.route('/password', methods=['GET','POST']) 220 | @login_required 221 | def password(): 222 | form = PasswdForm(request.form) 223 | if form.validate(): 224 | new_password = form.new_password.data 225 | with SqliteDB() as cursor: 226 | hashed_password = generate_password_hash(new_password) 227 | # 更新用户密码 228 | update_query = "UPDATE users SET password =? WHERE id =?;" 229 | cursor.execute(update_query, (hashed_password, current_user.id)) 230 | res_code, res_msg, res_data = '0', '修改成功', '' 231 | logout_user() 232 | else: 233 | first_key = next(iter(form.errors.keys())) 234 | first_value = form.errors[first_key] 235 | res_code, res_msg, res_data = '1', str(first_value[0]), '' 236 | 237 | return res(res_code, res_msg, res_data) 238 | 239 | 240 | 241 | @bp.route('/error') 242 | @login_required 243 | def error(): 244 | return render_template('auth/error.html') 245 | 246 | -------------------------------------------------------------------------------- /blueprints/node.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import requests 4 | from flask_login import current_user, login_required 5 | from blueprints.auth import register_node 6 | from exts import SqliteDB 7 | from login_setup import role_required 8 | from flask import Blueprint, request 9 | from utils import res, table_res, to_request 10 | 11 | bp = Blueprint("node", __name__, url_prefix='/api/node') 12 | 13 | 14 | @bp.route('/register', methods=['GET','POST']) 15 | @login_required 16 | def register(): 17 | nodekey = request.form.get('nodekey') 18 | register_node_response = register_node(nodekey) 19 | 20 | print(register_node_response) 21 | if register_node_response['code'] == '0': 22 | try: 23 | # 获取 ipAddresses 的值 24 | ip_address = json.loads(register_node_response['data'])["node"]["ipAddresses"][0] 25 | code,msg,data = '0',ip_address,'' 26 | except Exception as e: 27 | print(f"发生错误: {e}") 28 | headscale_error_msg = json.loads(register_node_response['data']).get('message') 29 | code, msg, data = '1', headscale_error_msg, '' 30 | else: 31 | error_msg = register_node_response['msg'] 32 | code, msg, data = '1', error_msg, '' 33 | return res(code,msg,data) 34 | 35 | 36 | 37 | 38 | @bp.route('/getNodes') 39 | @login_required 40 | def getNodes(): 41 | search_name= request.args.get('search_name',default='') 42 | print(search_name) 43 | 44 | if current_user.name == 'admin': 45 | if search_name != "": 46 | user_name = search_name 47 | else: 48 | user_name = "" 49 | else: 50 | user_name = current_user.name 51 | 52 | 53 | 54 | 55 | url = f'/api/v1/node?user={user_name}' 56 | response = to_request('GET', url) 57 | 58 | if response['code'] == '0': 59 | # 解析返回的节点数据 60 | data = json.loads(response['data']) 61 | nodes = data.get('nodes', []) 62 | total_count = len(nodes) 63 | 64 | # 数据格式化 65 | nodes_list = [] 66 | for node in nodes: 67 | nodes_list.append({ 68 | 'id': node['id'], 69 | 'userName': node['user']['name'], # 从user对象中获取用户名 70 | 'name': node['givenName'], 71 | 'ip': ', '.join(node['ipAddresses']), # 拼接IPv4和IPv6地址 72 | 'lastTime': node['lastSeen'], 73 | 'createTime': node['createdAt'], 74 | 'OS': '', # 原数据中未包含host_info,暂时留空 75 | 'Client': '', # 原数据中未包含IPNVersion,暂时留空 76 | 'online': node['online'], 77 | 'approvedRoutes': ', '.join(node['approvedRoutes']), 78 | 'availableRoutes': ', '.join(node['availableRoutes']) 79 | }) 80 | 81 | return table_res('0', '获取成功', nodes_list, total_count, len(nodes_list)) 82 | else: 83 | return res(response['code'], response['msg']) 84 | 85 | 86 | 87 | 88 | @bp.route('/topNodes') 89 | @login_required 90 | @role_required("manager") 91 | def topNodes(): 92 | url = f'/api/v1/node' 93 | response = to_request('GET', url) 94 | 95 | if response['code'] == '0': 96 | # 解析返回的节点数据 97 | data = json.loads(response['data']) 98 | nodes = data.get('nodes', []) 99 | 100 | # 使用字典来按用户名分组统计 101 | # 字典的键是用户名,值是一个包含统计信息的字典 102 | user_stats = {} 103 | 104 | for node in nodes: 105 | # 安全地获取用户名,如果用户信息不存在则使用'未知用户' 106 | user_name = node.get('user', {}).get('name', '未知用户') 107 | 108 | # 如果该用户是第一次出现,则初始化其统计信息 109 | if user_name not in user_stats: 110 | user_stats[user_name] = { 111 | 'name': user_name, 112 | 'online': 0, 113 | 'nodes': 0, 114 | 'routes': 0 115 | } 116 | 117 | # 更新该用户的统计数据 118 | user_stats[user_name]['nodes'] += 1 # 累计节点数加1 119 | 120 | # 如果节点在线,在线节点数加1 121 | if node.get('online', False): 122 | user_stats[user_name]['online'] += 1 123 | 124 | # 累加该节点的路由数量 125 | # 路由列表可能不存在,所以要做安全检查 126 | approved_routes = node.get('approvedRoutes', []) 127 | user_stats[user_name]['routes'] += len(approved_routes) 128 | 129 | # 将字典的值(统计信息)转换为列表,以便前端表格展示 130 | # 并按累计节点数降序排序 131 | result_list = sorted(user_stats.values(), key=lambda x: x['nodes'], reverse=True) 132 | 133 | # 计算总节点数,用于表格分页等功能 134 | total_nodes_count = len(nodes) 135 | 136 | # 返回数据给前端 137 | return table_res('0', '获取成功', result_list, total_nodes_count, len(result_list)) 138 | else: 139 | return res(response['code'], response['msg']) 140 | 141 | 142 | @bp.route('/delete', methods=['POST']) 143 | @login_required 144 | def delete(): 145 | node_id = request.form.get('NodeId') 146 | 147 | url = f'/api/v1/node/{node_id}' 148 | 149 | with SqliteDB() as cursor: 150 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ", (node_id,)).fetchone()[0] 151 | if user_id == current_user.id or current_user.role == 'manager': 152 | response = to_request('DELETE', url) 153 | if response['code'] == '0': 154 | return res('0', '删除成功', response['data']) 155 | else: 156 | return res(response['code'], response['msg']) 157 | else: 158 | return res('1', '非法请求') 159 | 160 | 161 | 162 | 163 | @bp.route('/new_owner', methods=['GET','POST']) 164 | @login_required 165 | @role_required("manager") 166 | def new_owner(): 167 | 168 | node_id = request.form.get('nodeId') 169 | user_name = request.form.get('userName') 170 | 171 | url = f'/api/v1/node/{node_id}/user' # 替换为实际的目标 URL 172 | 173 | data = {"user": user_name} 174 | 175 | with SqliteDB() as cursor: 176 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ", (node_id,)).fetchone()[0] 177 | if user_id == current_user.id or current_user.role == 'manager': 178 | response = to_request('POST', url, data) 179 | if response['code'] == '0': 180 | return res('0', '更新成功', response['data']) 181 | else: 182 | return res(response['code'], response['msg']) 183 | else: 184 | return res('1', '非法请求') 185 | 186 | 187 | 188 | 189 | @bp.route('/rename', methods=['POST']) 190 | @login_required 191 | def rename(): 192 | 193 | node_id = request.form.get('nodeId') 194 | node_name = request.form.get('nodeName') 195 | 196 | url = f'/api/v1/node/{node_id}/rename/{node_name}' # 替换为实际的目标 URL 197 | 198 | with SqliteDB() as cursor: 199 | user_id = cursor.execute("SELECT user_id FROM nodes WHERE id =? ",(node_id,)).fetchone()[0] 200 | if user_id == current_user.id or current_user.role == 'manager': 201 | response = to_request('POST',url) 202 | if response['code'] == '0': 203 | return res('0', '更新成功', response['data']) 204 | else: 205 | return res(response['code'], response['msg']) 206 | else: 207 | return res('1', '非法请求') 208 | 209 | @bp.route('/node_info', methods=['GET', 'POST']) 210 | @login_required 211 | def node_info(): 212 | """ 213 | 通过节点ID从本地SQLite数据库获取节点详细信息 214 | (使用封装的 SqliteDB,并严格参考 getNodes 函数中的字段) 215 | """ 216 | # 1. 获取请求中的 NodeId 217 | node_id = request.args.get('NodeId') or request.form.get('NodeId') 218 | 219 | if not node_id: 220 | return res("1", "缺少参数: NodeId", []) 221 | 222 | try: 223 | node_id = int(node_id) 224 | except ValueError: 225 | return res("1", "NodeId 必须是整数", []) 226 | 227 | # 2. 构建查询SQL语句和参数 228 | # 严格参考 getNodes 函数中的字段 229 | base_query = """ 230 | SELECT 231 | nodes.host_info 232 | FROM 233 | nodes 234 | JOIN 235 | users ON nodes.user_id = users.id 236 | """ 237 | 238 | conditions = [] 239 | params = [] 240 | 241 | # 根据角色添加用户ID过滤 242 | if current_user.role != 'manager': 243 | conditions.append("nodes.user_id = ?") 244 | params.append(current_user.id) 245 | 246 | # 添加节点ID过滤 247 | conditions.append("nodes.id = ?") 248 | params.append(node_id) 249 | 250 | # 拼接 WHERE 子句 251 | if conditions: 252 | base_query += " WHERE " + " AND ".join(conditions) 253 | 254 | # 3. 使用封装的 SqliteDB 执行查询 255 | with SqliteDB() as cursor: 256 | cursor.execute(base_query, params) 257 | node = cursor.fetchone() 258 | 259 | # 4. 检查查询结果 260 | if not node: 261 | return res("1", f"未找到ID为 {node_id} 的节点,或您没有权限查看。", []) 262 | 263 | # 5. 数据格式化 264 | # 这里的格式化逻辑也参考了 getNodes 函数 265 | host_info = json.loads(node['host_info']) if node['host_info'] else {} 266 | 267 | formatted_item = { 268 | "OS": (host_info.get("OS") or "") + (host_info.get("OSVersion") or ""), 269 | "Client": host_info.get("IPNVersion") or "", 270 | # 你可以根据需要,从 host_info 中提取更多字段 271 | } 272 | 273 | # 6. 返回格式化后的结果 274 | return res("0", "获取成功", [formatted_item]) 275 | 276 | 277 | 278 | 279 | 280 | @bp.route('/node_route_info', methods=['GET', 'POST']) 281 | @login_required 282 | def node_route_info(): 283 | node_id = request.form.get('NodeId') 284 | url = f'/api/v1/node/{node_id}' 285 | 286 | response = to_request('GET', url) 287 | 288 | if response['code'] == '0': 289 | try: 290 | # 解析原始响应数据 291 | raw_data = json.loads(response['data']) 292 | node_data = raw_data['node'] # 提取node对象 293 | except (json.JSONDecodeError, KeyError) as e: 294 | return res("1", f"数据解析错误: {str(e)}", []) 295 | 296 | # 时间格式化:仅替换T为空格,去除Z 297 | def format_time(utc_time_str): 298 | if not utc_time_str: 299 | return "" 300 | return utc_time_str.replace('T', ' ').replace('Z', '') 301 | 302 | # 提取并保留一级路由字段(approvedRoutes和availableRoutes) 303 | formatted_item = { 304 | # 关键路由字段(一级主要字段,突出显示) 305 | "approvedRoutes": node_data.get('approvedRoutes', []), # 已批准路由 306 | "availableRoutes": node_data.get('availableRoutes', []), # 可用路由 307 | 308 | } 309 | 310 | data_list = [formatted_item] 311 | return res("0", "获取成功", data_list) 312 | 313 | else: 314 | return res("1", "请求失败", []) 315 | 316 | @bp.route('/approve_routes', methods=['GET','POST']) 317 | @login_required 318 | def approve_routes(): 319 | 320 | node_id = request.form.get('nodeId') 321 | routes = request.form.get('routes') 322 | 323 | url = f'/api/v1/node/' + str(node_id)+'/approve_routes' 324 | data = {"routes": [routes]} 325 | 326 | 327 | with SqliteDB() as cursor: 328 | route = cursor.execute("SELECT route FROM users WHERE id =? ",(current_user.id,)).fetchone()[0] 329 | 330 | 331 | if route == "0": 332 | return res('1', '你当前无此权限!请联系管理员') 333 | else: 334 | response = to_request('POST',url,data) 335 | 336 | if response['code'] == '0': 337 | return res('0', '提交成功', response['data']) 338 | else: 339 | return res(response['code'], response['msg']) 340 | 341 | 342 | 343 | -------------------------------------------------------------------------------- /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 | 344 | 345 | 346 | 347 | 348 | 349 | {% endraw %} -------------------------------------------------------------------------------- /templates/admin/console.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 控制台 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
快捷方式
20 |
21 | 22 | 84 | 85 |
86 |
87 |
88 |
89 |
90 |
数据统计
91 |
92 | 93 | 125 |
126 |
127 |
128 |
129 | 130 | 131 | 132 | 133 | 134 | 135 |
136 |
数据概览
137 |
138 | 139 | 144 |
145 |
146 | 147 | 148 |
149 |
150 |
    151 |
  • 使用情况
  • 152 | 153 |
154 |
155 |
156 |
157 |
158 | 159 |
160 |
161 |
162 | 163 | 164 |
165 |
166 |
167 | 168 | 169 | 170 |
171 |
172 |
账户信息
173 |
174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 189 | 190 | 191 | 192 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 |
注册时间
距离到期 187 | 188 |
项目地址 193 | https://github.com/arounyf/Headscale-Admin-Pro 194 |
公告中心欢迎使用Headscale-Admin-Pro
203 |
204 |
205 | 206 | 207 | 208 |
209 |
进度占用
210 |
211 |
212 |

CPU 使用率

213 |
214 |
215 |
216 |

内存占用率

217 |
218 |
219 |
220 | 221 |
222 | 223 | 224 | 225 |
226 |
产品动态
227 |
228 | 235 |
236 |
237 | 238 | 239 | 240 | 241 | 242 |
243 | 244 |
245 |
246 | 247 | 248 | 334 | 335 | 336 | 337 | -------------------------------------------------------------------------------- /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 | {% if current_user.role == 'manager' %} 22 | 23 |
24 |
25 | 26 | 27 |
28 | 31 |
32 | {% endif %} 33 | 34 | 35 | 36 |
37 | 38 | 39 | 48 | 49 | 50 | {% raw %} 51 | 60 | {% endraw %} 61 | 62 | 63 | 67 |
68 |
69 |
70 |
71 |
72 | 73 | 74 | 75 | 316 | 317 | --------------------------------------------------------------------------------