Compare commits
9 Commits
Author | SHA1 | Date |
---|---|---|
|
c2bd5a1acc | |
|
771a3e3260 | |
|
a881b94396 | |
|
33c0e603f8 | |
|
9fcae05d20 | |
|
0409ac3d08 | |
|
425a1cd094 | |
|
31910dc034 | |
|
7fa17adfb1 |
|
@ -266,3 +266,4 @@ tags
|
|||
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
|
||||
.flaskenv
|
||||
docker.env
|
||||
flask_session/
|
||||
|
|
|
@ -2,6 +2,7 @@ from flask import Flask, render_template, g
|
|||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from flask_mobility import Mobility
|
||||
from flask_session import Session
|
||||
|
||||
from . import filters
|
||||
from .lib import OIDCAuthentication
|
||||
|
@ -10,6 +11,8 @@ import os
|
|||
mobility = Mobility()
|
||||
|
||||
auth = OIDCAuthentication()
|
||||
# SESSION_TYPE = 'filesystem'
|
||||
sess = Session()
|
||||
|
||||
|
||||
def create_app(environment='development'):
|
||||
|
@ -50,6 +53,8 @@ def create_app(environment='development'):
|
|||
app.logger.info("jinja2 custom filters loaded")
|
||||
filters.init_app(app)
|
||||
|
||||
sess.init_app(app)
|
||||
|
||||
# Error handlers.
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_error(exc):
|
||||
|
|
27
app/lib.py
27
app/lib.py
|
@ -5,7 +5,7 @@ from flask import request, abort, current_app
|
|||
from flask import session as flask_session, jsonify
|
||||
from flask_pyoidc import OIDCAuthentication as _OIDCAuth
|
||||
from flask_pyoidc.user_session import UserSession
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata, ProviderMetadata
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
|
@ -51,35 +51,36 @@ class OIDCAuthentication(_OIDCAuth):
|
|||
super().init_app(app)
|
||||
app.auth = self
|
||||
|
||||
@property
|
||||
def userinfo(self) -> dict:
|
||||
log.debug(flask_session.get('userinfo', {}))
|
||||
return flask_session.get('userinfo', {})
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo['email'].split('@')[0]
|
||||
# This need to be changed after upgrading headscale version
|
||||
# when hs will use the preferred_username field as username
|
||||
return self.email.split('@')[0]
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo['email']
|
||||
return self.userinfo.get('email', 'unknown')
|
||||
|
||||
@property
|
||||
def login_name(self) -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo.get('preferred_username', self.username)
|
||||
return self.userinfo.get('preferred_username', self.username)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo.get('name')
|
||||
return self.userinfo.get('name', self.username)
|
||||
|
||||
@property
|
||||
def groups(self) -> list:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo.get('groups')
|
||||
return self.userinfo.get('groups', [])
|
||||
|
||||
@property
|
||||
def isAdmin(self) -> bool:
|
||||
userinfo = flask_session['userinfo']
|
||||
user_groups = userinfo.get('groups', [])
|
||||
user_groups = self.userinfo.get('groups', [])
|
||||
with current_app.app_context():
|
||||
admin_groups = current_app.config.get('ADMIN_GROUPS', [])
|
||||
admin_users = current_app.config.get('ADMIN_USERS', [])
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,8 @@
|
|||
/*!
|
||||
* Bootstrap Reboot v4.6.2 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2022 The Bootstrap Authors
|
||||
* Copyright 2011-2022 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
|
||||
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
|
||||
/*# sourceMappingURL=bootstrap-reboot.min.css.map */
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -64,7 +64,7 @@ a:active {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle {
|
||||
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle, a.plain {
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: unset;
|
||||
}
|
||||
|
|
|
@ -89,3 +89,39 @@ function backfillips(obj) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadACL(obj) {
|
||||
var fd = new FormData();
|
||||
var files = $("#upload")[0].files[0];
|
||||
fd.append("file", files);
|
||||
|
||||
// When we close the modal, we reload the page
|
||||
$("#uploadACL").on("hidden.bs.modal", function (event) {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "policy/upload",
|
||||
type: "POST",
|
||||
xhrFields: {
|
||||
withCredentials: true,
|
||||
},
|
||||
data: fd,
|
||||
contentType: false,
|
||||
processData: false,
|
||||
success: function (response) {
|
||||
if (response != 0) {
|
||||
$("#output").html("acl updated");
|
||||
} else {
|
||||
$("#output").html("acl not updated");
|
||||
}
|
||||
setTimeout(() => {
|
||||
$("#uploadACL").modal("hide");
|
||||
}, 5000);
|
||||
},
|
||||
error: function (response) {
|
||||
console.log(response.responseJSON.message);
|
||||
$("#output").html(response.responseJSON.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -41,16 +41,16 @@
|
|||
</div>
|
||||
<div class="col col-6">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
{% if auth.groups[0] in config['ADMIN_GROUPS'] %}
|
||||
{% if not auth.groups[0] or auth.groups[0] in config['ADMIN_GROUPS'] %}
|
||||
<span class="badge badge-pill badge-warning">
|
||||
{% else %}
|
||||
<span class="badge badge-pill badge-dark">
|
||||
{% endif %}
|
||||
{{ auth.groups[0]}}
|
||||
{{ auth.groups[0] | default('no group')}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% for group in auth.groups[1:] |sort %}
|
||||
{% for group in auth.groups[1:] | default([]) |sort %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
|
||||
|
|
|
@ -5,12 +5,14 @@
|
|||
<div class="col col-4">
|
||||
<h3>nodes</h3>
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
<div class="col col-4">
|
||||
<div class="float-right">
|
||||
<span data-toggle="tooltip" data-placement="right" title="Recheck all IP addresses of all nodes">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onClick="backfillips(this);">Backfill IP addresses</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<table id="nodes" class="display" style="width:100%">
|
||||
<thead>
|
||||
|
|
|
@ -1,13 +1,30 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row data justify-content-between">
|
||||
<div class="col col-4">
|
||||
<h3>policy</h3>
|
||||
<footer class="blockquote-footer">
|
||||
for more info see <a href="https://tailscale.com/kb/1337/acl-syntax" target="_blank">tailscale docs</a>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="col col-4">
|
||||
<div class="float-right">
|
||||
last update:
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ policy.updatedAt | fmt_datetime }}">
|
||||
{{policy.updatedAt | htime_dt }}
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain"><a class="plain" href="{{ url_for('main.policy', action='view') }}" target="_blank">View</a></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain"><a class="plain" href="{{ url_for('main.policy', action='download') }}">Download</a></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain" data-toggle="modal" data-target="#uploadACL">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-6">
|
||||
<div class="row">
|
||||
|
@ -79,8 +96,29 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <textarea readonly rows="30" style="width: 80%; height: 80%;">
|
||||
{{ policy.json }}
|
||||
</textarea> -->
|
||||
|
||||
<!-- upload acl modal -->
|
||||
<div class="modal fade" id="uploadACL" tabindex="-1" role="dialog" aria-labelledby="uploadACL" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renameModalLabel">upload ACL</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="file" name="upload" id="upload">
|
||||
</div>
|
||||
<div>
|
||||
<span id="output"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onClick="uploadACL(this);">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -6,9 +6,7 @@ from flask import render_template, Blueprint
|
|||
from flask import redirect, session, url_for
|
||||
from app import auth
|
||||
|
||||
# from ..lib import username
|
||||
|
||||
from flask import jsonify
|
||||
from flask import jsonify, make_response
|
||||
from flask_pyoidc.user_session import UserSession
|
||||
|
||||
from hsapi_client import Node, User, Route, PreAuthKey, Policy
|
||||
|
@ -47,6 +45,8 @@ def index():
|
|||
@main_blueprint.route('/logout')
|
||||
@auth.oidc_logout
|
||||
def logout():
|
||||
# UserSession(session).clear()
|
||||
session.clear()
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
|
||||
|
@ -132,9 +132,23 @@ def routes():
|
|||
routes=final)
|
||||
|
||||
|
||||
@main_blueprint.route('/policy', methods=['GET'])
|
||||
@main_blueprint.route('/policy', defaults={'action': None}, methods=['GET'])
|
||||
@main_blueprint.route('/policy/<action>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def policy():
|
||||
def policy(action):
|
||||
policy = Policy().get()
|
||||
if action == "view":
|
||||
return policy.json
|
||||
|
||||
elif action == "download":
|
||||
updateStr = policy.updatedAt.strftime(format='%Y%m%d-%H%M')
|
||||
log.debug(updateStr)
|
||||
filename = f"acl-{updateStr}.json"
|
||||
response = make_response(policy.json)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename={
|
||||
filename}'
|
||||
|
||||
return response
|
||||
else:
|
||||
return render_template("policy.html",
|
||||
policy=policy)
|
||||
|
|
|
@ -4,11 +4,14 @@ from flask import Blueprint, request
|
|||
from flask import redirect, url_for
|
||||
from app import auth
|
||||
|
||||
from flask import jsonify
|
||||
from flask import jsonify, make_response
|
||||
|
||||
from hsapi_client import Node, User, Route, PreAuthKey
|
||||
from hsapi_client import Node, User, Route, PreAuthKey, Policy
|
||||
from hsapi_client.preauthkeys import (v1CreatePreAuthKeyRequest,
|
||||
v1ExpirePreAuthKeyRequest)
|
||||
from hsapi_client.policies import v1Policy
|
||||
|
||||
from hsapi_client.config import HTTPException
|
||||
|
||||
from app.lib import remote_ip
|
||||
|
||||
|
@ -111,3 +114,21 @@ def expirePKA(userName: str, key: str):
|
|||
def backfillips():
|
||||
response = Node().backfillips()
|
||||
return jsonify(response.changes)
|
||||
|
||||
|
||||
@rest_blueprint.route('/policy/upload', methods=['POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def policyUpload():
|
||||
file = request.files['file']
|
||||
try:
|
||||
acl = ''.join(map(bytes.decode, file.readlines()))
|
||||
except UnicodeDecodeError as e:
|
||||
log.debug(e)
|
||||
return jsonify(message=str(e)), 422
|
||||
|
||||
policy = Policy()
|
||||
try:
|
||||
policy.put(acl)
|
||||
except HTTPException as e:
|
||||
return jsonify(message=str(e.message)), 422
|
||||
return jsonify(message="acl updated"), 200
|
||||
|
|
|
@ -13,8 +13,13 @@ class BaseConfig(object):
|
|||
APP_PREFIX = os.getenv('APP_PREFIX', '')
|
||||
DEBUG_TB_ENABLED = False
|
||||
WTF_CSRF_ENABLED = False
|
||||
# Session
|
||||
# We store sessions in filesystem, max 100 files, expire in 2 hours
|
||||
SESSION_TYPE = 'filesystem'
|
||||
SESSION_FILE_THRESHOLD = 100
|
||||
PERMANENT_SESSION_LIFETIME = 7200
|
||||
|
||||
# All the followinf vars can be overriden
|
||||
# All the following vars can be overriden
|
||||
# in the environment, using `HSMAN_` prefix
|
||||
SECRET_KEY = "secreto"
|
||||
ADMIN_GROUPS = "adminGroup"
|
||||
|
@ -22,6 +27,7 @@ class BaseConfig(object):
|
|||
OIDC_CLIENT_SECRET = 'client-secreto'
|
||||
OIDC_URL = "https://myidp.example.com/auth"
|
||||
OIDC_REDIRECT_URI = 'http://localhost:5000/auth'
|
||||
OIDC_CLOCK_SKEW = 30
|
||||
|
||||
# These are required by hsapi, should not be defined here
|
||||
# HSAPI_SERVER = "https://headscale.example.com"
|
||||
|
|
|
@ -17,7 +17,7 @@ RUN apk --update --no-cache add \
|
|||
libffi-dev \
|
||||
curl && \
|
||||
chmod g+w /run && \
|
||||
pip install poetry gunicorn
|
||||
pip install poetry gunicorn poetry-plugin-export
|
||||
|
||||
COPY . /hsman
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,10 +1,11 @@
|
|||
[tool.poetry]
|
||||
name = "hsman"
|
||||
version = "0.9.20"
|
||||
version = "0.9.24"
|
||||
description = "Flask Admin webui for Headscale"
|
||||
authors = ["Andrea Mistrali <andrea@mistrali.pw>"]
|
||||
license = "BSD"
|
||||
readme = "README.md"
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.11,<4.0"
|
||||
|
@ -15,7 +16,9 @@ flask-mobility = "^2.0.1"
|
|||
humanize = "^4.9.0"
|
||||
flask-pydantic = "^0.12.0"
|
||||
uvicorn = "^0.30.1"
|
||||
hsapi-client = "^0.9.8"
|
||||
hsapi-client = "^0.9.9"
|
||||
# hsapi_client = { path = "../hsapi-client", develop = true }
|
||||
flask-session = "^0.8.0"
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
|
Loading…
Reference in New Issue