6 Commits

Author SHA1 Message Date
9fcae05d20 Merge pull request 'Bug fix for routes' (#2) from hotfix20241217 into master
Reviewed-on: #2
2024-12-17 11:32:46 +00:00
0409ac3d08 Bug fix for routes 2024-12-17 12:30:55 +01:00
425a1cd094 Bump version 2024-12-17 12:10:55 +01:00
31910dc034 Support for policy upload/download
Plus a small bugfix for groups
2024-12-17 12:10:40 +01:00
7fa17adfb1 Upgrade bootstrap 2024-12-17 12:10:04 +01:00
3301a36b5f Fix logging 2024-10-07 10:19:57 +02:00
16 changed files with 683 additions and 526 deletions

View File

@ -74,7 +74,7 @@ class OIDCAuthentication(_OIDCAuth):
@property
def groups(self) -> list:
userinfo = flask_session['userinfo']
return userinfo.get('groups')
return userinfo.get('groups') or []
@property
def isAdmin(self) -> bool:

View File

@ -13,7 +13,7 @@ level = INFO
handlers = console
[logger_access]
level = INFO
level = ERROR
handlers = console
qualname = gunicorn.access
propagate = 0

File diff suppressed because one or more lines are too long

View File

@ -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

View File

@ -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;
}

View File

@ -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);
},
});
}

View File

@ -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">
&nbsp;

View File

@ -5,10 +5,12 @@
<div class="col col-4">
<h3>nodes</h3>
</div>
<div class="col col-2">
<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 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>

View File

@ -1,13 +1,30 @@
{% extends "base.html" %}
{% block content %}
<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 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">&times;</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 %}

View File

@ -8,7 +8,7 @@ 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
@ -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()
return render_template("policy.html",
policy=policy)
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)

View File

@ -4,12 +4,16 @@ 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.nodes import v1BackfillNodeIPsResponse
from hsapi_client.policies import v1Policy
from hsapi_client.config import HTTPException
from app.lib import remote_ip
log = logging.getLogger()
@ -30,7 +34,7 @@ def routeToggle(routeId: int):
action = 'enabled'
log.info(
f"route '{route.prefix}' via '{route.node.givenName}' "
f"{action} by '{auth.username}'")
f"{action} by '{auth.username}@{remote_ip()}'")
Route().toggle(routeId)
return redirect(request.referrer)
@ -46,7 +50,7 @@ def expireNode(nodeId: int):
if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{auth.username}'")
log.info(f"node '{nodeId}' expired by '{auth.username}@{remote_ip()}'")
return redirect(request.referrer)
@ -58,7 +62,7 @@ def deleteNode(nodeId: int):
return auth.unathorized
Node().expire(nodeId)
Node().delete(nodeId)
log.info(f"node '{nodeId}' deleted by '{auth.username}'")
log.info(f"node '{nodeId}' deleted by '{auth.username}@{remote_ip()}'")
return redirect(request.referrer)
@ -87,7 +91,6 @@ def deleteUser(userName: str):
@auth.authorize_admins('default')
def createPKA(userName: str):
data = request.json
log.debug(data)
expiration = f"{data['expiration']}:00Z"
req = v1CreatePreAuthKeyRequest(user=userName,
reusable=data['reusable'],
@ -100,7 +103,6 @@ def createPKA(userName: str):
@rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
@auth.authorize_admins('default')
def expirePKA(userName: str, key: str):
log.debug(key)
req = v1ExpirePreAuthKeyRequest(user=userName, key=key)
PreAuthKey().expire(req)
@ -112,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

987
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "hsman"
version = "0.9.19"
version = "0.9.21"
description = "Flask Admin webui for Headscale"
authors = ["Andrea Mistrali <andrea@mistrali.pw>"]
license = "BSD"
@ -15,7 +15,8 @@ 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 }
[tool.poetry.group.dev.dependencies]