Compare commits

...

9 Commits

Author SHA1 Message Date
Andrea Mistrali c2bd5a1acc
Bump version to 0.9.24 2025-01-17 12:37:24 +01:00
Andrea Mistrali 771a3e3260
Fix username property 2025-01-17 12:37:02 +01:00
Andrea Mistrali a881b94396
Bump 2025-01-17 10:25:59 +01:00
Andrea Mistrali 33c0e603f8
Fix authentication on Keycloak 2025-01-17 10:24:21 +01:00
Andrea Mistrali 9fcae05d20 Merge pull request 'Bug fix for routes' (#2) from hotfix20241217 into master
Reviewed-on: #2
2024-12-17 11:32:46 +00:00
Andrea Mistrali 0409ac3d08
Bug fix for routes 2024-12-17 12:30:55 +01:00
Andrea Mistrali 425a1cd094
Bump version 2024-12-17 12:10:55 +01:00
Andrea Mistrali 31910dc034
Support for policy upload/download
Plus a small bugfix for groups
2024-12-17 12:10:40 +01:00
Andrea Mistrali 7fa17adfb1
Upgrade bootstrap 2024-12-17 12:10:04 +01:00
19 changed files with 853 additions and 548 deletions

1
.gitignore vendored
View File

@ -266,3 +266,4 @@ tags
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
.flaskenv .flaskenv
docker.env docker.env
flask_session/

View File

@ -2,6 +2,7 @@ from flask import Flask, render_template, g
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from flask_mobility import Mobility from flask_mobility import Mobility
from flask_session import Session
from . import filters from . import filters
from .lib import OIDCAuthentication from .lib import OIDCAuthentication
@ -10,6 +11,8 @@ import os
mobility = Mobility() mobility = Mobility()
auth = OIDCAuthentication() auth = OIDCAuthentication()
# SESSION_TYPE = 'filesystem'
sess = Session()
def create_app(environment='development'): def create_app(environment='development'):
@ -50,6 +53,8 @@ def create_app(environment='development'):
app.logger.info("jinja2 custom filters loaded") app.logger.info("jinja2 custom filters loaded")
filters.init_app(app) filters.init_app(app)
sess.init_app(app)
# Error handlers. # Error handlers.
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def handle_http_error(exc): def handle_http_error(exc):

View File

@ -5,7 +5,7 @@ from flask import request, abort, current_app
from flask import session as flask_session, jsonify from flask import session as flask_session, jsonify
from flask_pyoidc import OIDCAuthentication as _OIDCAuth from flask_pyoidc import OIDCAuthentication as _OIDCAuth
from flask_pyoidc.user_session import UserSession 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 from typing import Callable, List
@ -51,35 +51,36 @@ class OIDCAuthentication(_OIDCAuth):
super().init_app(app) super().init_app(app)
app.auth = self app.auth = self
@property
def userinfo(self) -> dict:
log.debug(flask_session.get('userinfo', {}))
return flask_session.get('userinfo', {})
@property @property
def username(self) -> str: def username(self) -> str:
userinfo = flask_session['userinfo'] # This need to be changed after upgrading headscale version
return userinfo['email'].split('@')[0] # when hs will use the preferred_username field as username
return self.email.split('@')[0]
@property @property
def email(self) -> str: def email(self) -> str:
userinfo = flask_session['userinfo'] return self.userinfo.get('email', 'unknown')
return userinfo['email']
@property @property
def login_name(self) -> str: def login_name(self) -> str:
userinfo = flask_session['userinfo'] return self.userinfo.get('preferred_username', self.username)
return userinfo.get('preferred_username', self.username)
@property @property
def full_name(self) -> str: def full_name(self) -> str:
userinfo = flask_session['userinfo'] return self.userinfo.get('name', self.username)
return userinfo.get('name')
@property @property
def groups(self) -> list: def groups(self) -> list:
userinfo = flask_session['userinfo'] return self.userinfo.get('groups', [])
return userinfo.get('groups')
@property @property
def isAdmin(self) -> bool: def isAdmin(self) -> bool:
userinfo = flask_session['userinfo'] user_groups = self.userinfo.get('groups', [])
user_groups = userinfo.get('groups', [])
with current_app.app_context(): with current_app.app_context():
admin_groups = current_app.config.get('ADMIN_GROUPS', []) admin_groups = current_app.config.get('ADMIN_GROUPS', [])
admin_users = current_app.config.get('ADMIN_USERS', []) admin_users = current_app.config.get('ADMIN_USERS', [])

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; 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-line: none;
text-decoration-style: unset; 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>
<div class="col col-6"> <div class="col col-6">
<i class="fas fa-angle-right"></i> <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"> <span class="badge badge-pill badge-warning">
{% else %} {% else %}
<span class="badge badge-pill badge-dark"> <span class="badge badge-pill badge-dark">
{% endif %} {% endif %}
{{ auth.groups[0]}} {{ auth.groups[0] | default('no group')}}
</span> </span>
</div> </div>
</div> </div>
{% for group in auth.groups[1:] |sort %} {% for group in auth.groups[1:] | default([]) |sort %}
<div class="row data"> <div class="row data">
<div class="col col-2"> <div class="col col-2">
&nbsp; &nbsp;

View File

@ -5,11 +5,13 @@
<div class="col col-4"> <div class="col col-4">
<h3>nodes</h3> <h3>nodes</h3>
</div> </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"> <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> <button type="button" class="btn btn-outline-primary btn-sm" onClick="backfillips(this);">Backfill IP addresses</button>
</span> </span>
</div> </div>
</div>
</div> </div>
<hr> <hr>
<table id="nodes" class="display" style="width:100%"> <table id="nodes" class="display" style="width:100%">

View File

@ -1,13 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="row data justify-content-between">
<h3>policy</h3> <div class="col col-4">
<footer class="blockquote-footer"> <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> for more info see <a href="https://tailscale.com/kb/1337/acl-syntax" target="_blank">tailscale docs</a>
</footer> </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> <hr>
<div class="row"> <div class="row">
<div class="col col-6"> <div class="col col-6">
<div class="row"> <div class="row">
@ -79,8 +96,29 @@
</div> </div>
</div> </div>
<!-- <textarea readonly rows="30" style="width: 80%; height: 80%;"> <!-- upload acl modal -->
{{ policy.json }} <div class="modal fade" id="uploadACL" tabindex="-1" role="dialog" aria-labelledby="uploadACL" aria-hidden="true">
</textarea> --> <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 %} {% endblock %}

View File

@ -6,9 +6,7 @@ from flask import render_template, Blueprint
from flask import redirect, session, url_for from flask import redirect, session, url_for
from app import auth from app import auth
# from ..lib import username from flask import jsonify, make_response
from flask import jsonify
from flask_pyoidc.user_session import UserSession from flask_pyoidc.user_session import UserSession
from hsapi_client import Node, User, Route, PreAuthKey, Policy from hsapi_client import Node, User, Route, PreAuthKey, Policy
@ -47,6 +45,8 @@ def index():
@main_blueprint.route('/logout') @main_blueprint.route('/logout')
@auth.oidc_logout @auth.oidc_logout
def logout(): def logout():
# UserSession(session).clear()
session.clear()
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
@ -132,9 +132,23 @@ def routes():
routes=final) 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') @auth.authorize_admins('default')
def policy(): def policy(action):
policy = Policy().get() 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", return render_template("policy.html",
policy=policy) policy=policy)

View File

@ -4,11 +4,14 @@ from flask import Blueprint, request
from flask import redirect, url_for from flask import redirect, url_for
from app import auth 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, from hsapi_client.preauthkeys import (v1CreatePreAuthKeyRequest,
v1ExpirePreAuthKeyRequest) v1ExpirePreAuthKeyRequest)
from hsapi_client.policies import v1Policy
from hsapi_client.config import HTTPException
from app.lib import remote_ip from app.lib import remote_ip
@ -111,3 +114,21 @@ def expirePKA(userName: str, key: str):
def backfillips(): def backfillips():
response = Node().backfillips() response = Node().backfillips()
return jsonify(response.changes) 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

View File

@ -13,8 +13,13 @@ class BaseConfig(object):
APP_PREFIX = os.getenv('APP_PREFIX', '') APP_PREFIX = os.getenv('APP_PREFIX', '')
DEBUG_TB_ENABLED = False DEBUG_TB_ENABLED = False
WTF_CSRF_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 # in the environment, using `HSMAN_` prefix
SECRET_KEY = "secreto" SECRET_KEY = "secreto"
ADMIN_GROUPS = "adminGroup" ADMIN_GROUPS = "adminGroup"
@ -22,6 +27,7 @@ class BaseConfig(object):
OIDC_CLIENT_SECRET = 'client-secreto' OIDC_CLIENT_SECRET = 'client-secreto'
OIDC_URL = "https://myidp.example.com/auth" OIDC_URL = "https://myidp.example.com/auth"
OIDC_REDIRECT_URI = 'http://localhost:5000/auth' OIDC_REDIRECT_URI = 'http://localhost:5000/auth'
OIDC_CLOCK_SKEW = 30
# These are required by hsapi, should not be defined here # These are required by hsapi, should not be defined here
# HSAPI_SERVER = "https://headscale.example.com" # HSAPI_SERVER = "https://headscale.example.com"

View File

@ -17,7 +17,7 @@ RUN apk --update --no-cache add \
libffi-dev \ libffi-dev \
curl && \ curl && \
chmod g+w /run && \ chmod g+w /run && \
pip install poetry gunicorn pip install poetry gunicorn poetry-plugin-export
COPY . /hsman COPY . /hsman

1145
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
[tool.poetry] [tool.poetry]
name = "hsman" name = "hsman"
version = "0.9.20" version = "0.9.24"
description = "Flask Admin webui for Headscale" description = "Flask Admin webui for Headscale"
authors = ["Andrea Mistrali <andrea@mistrali.pw>"] authors = ["Andrea Mistrali <andrea@mistrali.pw>"]
license = "BSD" license = "BSD"
readme = "README.md" readme = "README.md"
package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.11,<4.0" python = ">=3.11,<4.0"
@ -15,7 +16,9 @@ flask-mobility = "^2.0.1"
humanize = "^4.9.0" humanize = "^4.9.0"
flask-pydantic = "^0.12.0" flask-pydantic = "^0.12.0"
uvicorn = "^0.30.1" 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] [tool.poetry.group.dev.dependencies]