28 Commits

Author SHA1 Message Date
c2bd5a1acc Bump version to 0.9.24 2025-01-17 12:37:24 +01:00
771a3e3260 Fix username property 2025-01-17 12:37:02 +01:00
a881b94396 Bump 2025-01-17 10:25:59 +01:00
33c0e603f8 Fix authentication on Keycloak 2025-01-17 10:24:21 +01:00
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
da542f0bc2 Bump version 2024-10-04 14:58:38 +02:00
2cfbafbda5 Change HTTP log format 2024-10-04 14:58:20 +02:00
9b7b903c57 Read only policy support 2024-10-04 12:58:40 +02:00
379fef4b00 Improved copiable fields 2024-09-11 17:22:12 +02:00
bdba6db42d Add support for backfillips 2024-09-10 11:51:53 +02:00
4fb45c41bd Bump version 2024-09-05 11:13:16 +02:00
a1c66152ae Fixed view for non admin users 2024-09-05 11:12:55 +02:00
2a38fb14dd Bump version 2024-09-03 11:41:45 +02:00
d86b2b58c2 Add register method info 2024-09-03 11:40:53 +02:00
b91e73f3a5 Bump hsapi-client version 2024-09-03 08:48:58 +02:00
71a3413cbe Fix delete user URL 2024-08-07 10:16:37 +02:00
a1dadcd709 Allow users to rename their devices 2024-08-07 09:07:36 +02:00
b9fd722016 Better logging of remote IP address 2024-08-07 08:38:05 +02:00
c780b12b74 Gunicorn access log format 2024-08-07 08:17:12 +02:00
2719ecadf0 Cleanup unused files and minor change to routes 2024-07-29 16:43:04 +02:00
fe9b42e8a2 Bump to 0.9.8 2024-07-29 13:43:47 +02:00
14b09a0fcf Merge pull request 'Fixed permissions and referrers' (#1) from refactor_auth_and_rest into master
Reviewed-on: #1
2024-07-29 11:40:12 +00:00
c39c3a0ab6 Fixed permissions and referrers 2024-07-29 13:39:25 +02:00
29 changed files with 1335 additions and 685 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)
.flaskenv
docker.env
flask_session/

View File

@@ -1,9 +1,8 @@
from flask import Flask, render_template
from flask import Flask, render_template, g
from werkzeug.exceptions import HTTPException
from flask_mobility import Mobility
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from flask_session import Session
from . import filters
from .lib import OIDCAuthentication
@@ -12,6 +11,8 @@ import os
mobility = Mobility()
auth = OIDCAuthentication()
# SESSION_TYPE = 'filesystem'
sess = Session()
def create_app(environment='development'):
@@ -30,7 +31,7 @@ def create_app(environment='development'):
config[env].configure(app)
app.config['APP_TZ'] = os.environ.get('TZ', 'UTC')
app.config['ADMIN_GROUPS'] = list(
map(str.strip, app.config['ADMIN_GROUPS'].split(',')))
map(str.strip, app.config.get('ADMIN_GROUPS', "").split(',')))
app.logger.debug(f"admin groups: {app.config['ADMIN_GROUPS']}")
app.logger.info("middleware init: mobility")
@@ -41,21 +42,26 @@ def create_app(environment='development'):
# Register blueprints.
from .views import main_blueprint, rest_blueprint
app.logger.info(f"registering main blueprint with prefix '{
main_blueprint.url_prefix}'")
app.logger.info(f"register blueprint: 'main' [prefix '{
main_blueprint.url_prefix}']")
app.register_blueprint(main_blueprint)
app.logger.info(f"registering rest blueprint with prefix '{
rest_blueprint.url_prefix}'")
app.logger.info(f"register blueprint: 'rest' [prefix '{
rest_blueprint.url_prefix}']")
app.register_blueprint(rest_blueprint)
app.logger.info("jinja2 custom filters loaded")
filters.init_app(app)
# Error handlers.
sess.init_app(app)
# Error handlers.
@app.errorhandler(HTTPException)
def handle_http_error(exc):
return render_template('error.html', error=exc), exc.code
@app.context_processor
def inject_auth():
return dict(auth=auth)
return app

View File

@@ -2,10 +2,10 @@ import os
import functools
from flask import request, abort, current_app
from flask import session as flask_session
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
@@ -21,19 +21,6 @@ def remote_ip() -> str:
return str(request.environ.get('REMOTE_ADDR'))
def username() -> str:
userinfo = flask_session['userinfo']
return userinfo['email'].split('@')[0]
def login_name() -> str:
userinfo = flask_session['userinfo']
if 'preferred_username' in userinfo:
return userinfo['preferred_username']
else:
return username()
def webMode() -> bool:
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"
@@ -64,6 +51,69 @@ 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:
# 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:
return self.userinfo.get('email', 'unknown')
@property
def login_name(self) -> str:
return self.userinfo.get('preferred_username', self.username)
@property
def full_name(self) -> str:
return self.userinfo.get('name', self.username)
@property
def groups(self) -> list:
return self.userinfo.get('groups', [])
@property
def isAdmin(self) -> bool:
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', [])
authorized_groups = set(admin_groups).intersection(user_groups)
if len(authorized_groups):
log.debug(f"'{self.username}' is a member of {
authorized_groups}. isAdmin == True")
return True
if self.username in admin_users:
log.debug(f"'{self.username}' is an admin user")
return True
return False
@property
def unathorized(self):
response = jsonify(
{'message': f"not authorized",
'comment': 'nice try, info logged',
'logged': f"'{self.username}@{remote_ip()}",
'result': 'GO AWAY!'})
log.warning(
f"user '{self.username}' attempted denied operation from {remote_ip()}")
return response, 403
def userOrAdmin(self, username: str):
"""
Check is the current user is an admin OR the username passed as argument
"""
return self.isAdmin or self.username == username
def authorize(self, provider_name: str, authz_fn: Callable, **kwargs):
if provider_name not in self._provider_configurations:
raise ValueError(
@@ -76,7 +126,7 @@ class OIDCAuthentication(_OIDCAuth):
# Decorator
def oidc_decorator(view_func):
@ functools.wraps(view_func)
@functools.wraps(view_func)
def wrapper(*args, **kwargs):
# Retrieve session and client
session = UserSession(flask_session, provider_name)
@@ -165,23 +215,7 @@ class OIDCAuthentication(_OIDCAuth):
"""
def _authz_fn(session) -> bool:
user_groups = session.userinfo.get('groups', [])
username = session.userinfo.get('preferred_username', "")
with current_app.app_context():
admin_groups = current_app.config.get('ADMIN_GROUPS', [])
admin_users = current_app.config.get('ADMIN_USERS', [])
authorized_groups = set(admin_groups).intersection(user_groups)
if len(authorized_groups):
log.debug(f"'{username}' is a member of {
authorized_groups}")
return True
if username in admin_users:
log.debug(f"'{username}' is an admin user")
return True
return False
return self.isAdmin
return self.authorize(provider_name,
authz_fn=_authz_fn)

View File

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

View File

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

@@ -20,6 +20,8 @@ body > .container,
body > .container-fluid {
margin-top: 30px;
margin-bottom: 30px;
margin-left: 200px;
margin-right: 200px;
}
.footer {
@@ -62,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;
}
@@ -118,3 +120,15 @@ i.disabled {
span.expired {
color: #888;
}
.copy:hover {
transform: scale(1.5, 1.5);
-ms-transform: scale(1.5, 1.5)); /* IE 9 */
-webkit-transform: scale(1.5, 1.5);
}
.copy:hover::after {
content: "📄 click to copy";
font-size: 80%;
/* font-style:oblique; */
font-family: monospace;
}

View File

@@ -14,12 +14,10 @@ function renameNode(nodeId) {
}
function createPKA(username) {
console.log(username);
var url = `${username}/pakcreate`;
var ephemereal = $("#ephemereal").is(":checked");
var reusable = $("#reusable").is(":checked");
var expiration = $("#expiration").val();
console.log(expiration);
$.ajax({
url: url,
method: "POST",
@@ -65,3 +63,65 @@ function toggleExpired(obj) {
$(".pka-expired").addClass("pka-hide");
}
}
function backfillips(obj) {
var url = "backfillips";
var button = $(obj);
var original = button.html();
$.ajax({
url: url,
method: "POST",
dataType: "json",
contentType: "application/json; charset=utf-8",
xhrFields: {
withCredentials: true,
},
data: {},
success: function (data) {
if (data.length) {
button.html("Updated");
} else {
button.html("Done");
}
setTimeout(function () {
button.html(original);
}, 500);
},
});
}
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

@@ -50,15 +50,20 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mr-auto">
{% if auth.isAdmin %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.nodes') }}">nodes</a>
<a class="nav-link" href="{{ url_for('main.nodes') }}"><i class="fas fa-desktop"></i> nodes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.users') }}">users</a>
<a class="nav-link" href="{{ url_for('main.users') }}"><i class="fas fa-address-card"></i> users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.routes') }}">routes</a>
<a class="nav-link" href="{{ url_for('main.routes') }}"><i class="fas fa-satellite-dish"></i> routes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.policy') }}"><i class="fas fa-user-shield"></i> policy</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<li class="nav-item me-right">
@@ -74,8 +79,9 @@
{% if g.is_mobile %}
<div class="container-fluid">
{% else %}
<div class="container">
<div class="container-lg">
{% endif %}
<p></p>
<!-- Main Content -->
{% block content %}{% endblock %}
</div>

View File

@@ -1,9 +1,10 @@
{% extends "base.html" %}
{% block content %}
<div class="jumbotron my-4">
<div class="jumbotron jumbotron-fluid my-4">
<div class="text-center">
<h1>{{ '%s - %s' % (error.code, error.name) }}</h1>
<h1>Oops, something went wrong</h1>
<h1>{{ '%s - %s' % (error.code, error.name) }}</h2>
<p>{{ error.description }}.</p>
</div>
</div>

View File

@@ -2,17 +2,19 @@
{% block content %}
<h3>
Welcome, {{ session.userinfo.name }}
<!-- Welcome, {{ auth.full_name }} -->
authentication info
</h3>
<hr>
<h4>authentication info</h4>
<!-- <h4>authentication info</h4> -->
<div class="row data">
<div class="col col-2">
<strong>email</strong>
<strong>full name</strong>
</div>
<div class="col col-6">
{{ session.userinfo.email }}
<!-- {{ session.userinfo.email_verified | fancyBool | safe }} -->
<span data-toggle="tooltip" data-placement="right" title="OIDC username: {{ auth.login_name }}">
{{ auth.full_name }}
</span>
</div>
</div>
<div class="row data">
@@ -20,7 +22,17 @@
<strong>username</strong>
</div>
<div class="col col-6">
{{ session.userinfo.preferred_username }}
<span data-toggle="tooltip" data-placement="right" title="OIDC username: {{ auth.login_name }}">
{{ auth.username }}
</span>
</div>
</div>
<div class="row data">
<div class="col col-2">
<strong>email</strong>
</div>
<div class="col col-6">
{{ auth.email }}
</div>
</div>
<div class="row data">
@@ -29,16 +41,16 @@
</div>
<div class="col col-6">
<i class="fas fa-angle-right"></i>
{% if session.userinfo.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 %}
{{ session.userinfo.groups[0]}}
{{ auth.groups[0] | default('no group')}}
</span>
</div>
</div>
{% for group in session.userinfo.groups[1:] |sort %}
{% for group in auth.groups[1:] | default([]) |sort %}
<div class="row data">
<div class="col col-2">
&nbsp;
@@ -55,6 +67,18 @@
</div>
</div>
{% endfor %}
<div class="row data">
<div class="col col-2">
<strong>access level</strong>
</div>
<div class="col col-6">
{% if auth.isAdmin %}
<span class="badge badge-pill badge-danger">ADMIN</span>
{% else %}
<span class="badge badge-pill badge-info">USER</span>
{% endif %}
</div>
</div>
<hr>
<h4>your devices</h4>
<div class="row strong">
@@ -67,7 +91,7 @@
{% for node in userNodeList %}
<div class="row data">
<div class="col col-2">
{{ node.givenName}}
<a href="{{url_for('main.node', nodeId=node.id) }}">{{ node.givenName}}</a>
</div>
<div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
@@ -84,7 +108,7 @@
</div>
<div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="{{ url_for('rest.deleteOwnNode', nodeId=node.id) }}">
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
<i class="fas fa-trash"></i>
</a>
</span>

View File

@@ -13,7 +13,6 @@
</a>
</h3>
<hr>
<p></p>
<div class="row">
<div class="col col-3 float-left">
<strong>status</strong>
@@ -36,6 +35,9 @@
<div class="col col-8 float-left">
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
{{ node.createdAt | htime_dt }}
<span class="badge badge-pill badge-warning">
{{ node.registerMethod.name }}
</span>
</span>
</div>
</div>
@@ -73,8 +75,11 @@
<h5>addresses</h5>
{% for ip in node.ipAddresses %}
<div class="row data">
<div class="col col-3">
{{ ip }}
<div class="col col-6">
<span class="address copy"
value="{{ ip }}">
{{ ip }}
</span>
</div>
</div>
{% endfor %}
@@ -126,7 +131,9 @@
<strong>machineKey</strong>
</div>
<div class="col col-8 float-left">
<code>{{ node.machineKey }}</code>
<span class="copy" value="{{ node.machineKey }}">
<code>{{ node.machineKey }}</code>
</span>
</div>
</div>
@@ -135,7 +142,9 @@
<strong>nodeKey</strong>
</div>
<div class="col col-8 float-left">
<code>{{ node.nodeKey }}</code>
<span class="copy" value="{{ node.nodeKey }}">
<code>{{ node.nodeKey }}</code>
</span>
</div>
</div>
@@ -144,7 +153,9 @@
<strong>discoKey</strong>
</div>
<div class="col col-8 float-left">
<span class="copy" value="{{ node.discoKey }}">
<code>{{ node.discoKey }}</code>
</span>
</div>
</div>
<p></p>
@@ -210,3 +221,13 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(function () {
$('.copy').on('click', function() {
copyToClipboard(this)
})
})
</script>
{% endblock %}

View File

@@ -1,9 +1,19 @@
{% extends "base.html" %}
{% block content %}
<h3>nodes</h3>
<div class="row data justify-content-between">
<div class="col col-4">
<h3>nodes</h3>
</div>
<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>
<p></p>
<table id="nodes" class="display" style="width:100%">
<thead>
<tr>
@@ -52,7 +62,7 @@
<td class="no-sort">
{% if node.expireDate and not node.expired %}
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
<a class="nodeco" href="{{ url_for('rest.expireNodeList', nodeId=node.id) }}">
<a class="nodeco" href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
<i class="fas fa-plug"></i>
</a>
</span>

124
app/templates/policy.html Normal file
View File

@@ -0,0 +1,124 @@
{% 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">
<div class="col col-4"><h5>source</h5></div>
<div class="col col-8"><h5>destination</h5></div>
</div>
{% for acl in policy.policy['acls'] %}
<div class="row data">
<div class="col col-4">
{{ ','.join(acl['src']) }}</div>
<div class="col col-8">{{ ',<br />'.join(acl['dst']) | safe }}</div>
</div>
{% endfor %}
</div>
<div class="col col-6">
<!-- groups -->
<div id="groups">
<div class="card">
<div class="card-header" id="groupsHeading">
<h5 class="mb-0">
<button class="btn btn-link" data-toggle="collapse" data-target="#groupsContent" aria-expanded="true" aria-controls="groupsContent">
groups
</button>
</h5>
</div>
<div id="groupsContent" class="collapse show" aria-labelledby="groupsHeading" data-parent="#groups">
<div class="card-body">
{% for group,users in policy.policy['groups'].items() %}
<div class="row data">
<div class="col col-6">
{{ group }}
</div>
<div class="col col-6">
{{ ", ".join(users) }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- hosts -->
<div class="hosts">
<div class="card">
<div class="card-header" id="hostsHeading">
<h5 class="mb-0">
<button class="btn btn-link" data-toggle="collapse" data-target="#hostsContent" aria-expanded="true" aria-controls="hostsContent">
hosts
</button>
</h5>
</div>
<div id="hostsContent" class="collapse" aria-labelledby="hostsHeading" data-parent="#hosts">
<div class="card-body">
{% for host, value in policy.policy['hosts'].items() %}
<div class="row data">
<div class="col col3">
{{ host }}
</div>
<div class="col col3">
{{ value }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 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

@@ -2,14 +2,15 @@
{% block content %}
<h5>
Routing table
<small class="text-muted">
click on the icon in <em>enabled</em> column to toggle route status
</small>
</h5>
<h3>
routing table
<span class="text-muted" style="font-size: 60%;">
</span>
</h3>
<footer class="blockquote-footer">
click on the icon in <em>enabled</em> column to toggle route status
</footer>
<hr>
<p></p>
<div class="row">
<div class="col col-12">

View File

@@ -3,7 +3,6 @@
<h3>{{ user.name }}</h3>
<hr>
<p></p>
<div class="row">
<div class="col col-3">
<strong>registered</strong>
@@ -49,7 +48,7 @@
<td class="no-sort">
{% if node.expireDate and not node.expired %}
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
<a class="nodeco" href="{{ url_for('rest.expireNodeUser', nodeId=node.id) }}">
<a class="nodeco" href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
<i class="fas fa-plug"></i>
</a>
</span>
@@ -57,7 +56,7 @@
<i class="fas fa-plug disabled"></i>
{% endif %}
<span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="{{ url_for('rest.deleteNodeUser', nodeId=node.id) }}">
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
<i class="fas fa-trash"></i>
</a>
</span>

View File

@@ -3,7 +3,6 @@
{% block content %}
<h3>users</h3>
<hr>
<p></p>
<table id="users" class="display" style="width:100%">
<thead>
<tr>
@@ -17,7 +16,7 @@
{% for user in users %}
<tr>
<td>
<a class="plain" href="{{ url_for('main.user', userName=user.name)}}">
<a class="plain" href="{{ url_for('main.user', userName=user.name) }}">
{{user.name}}
</a>
</td>
@@ -31,7 +30,7 @@
</td>
<td class="no-sort">
<span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="/user/{{user.name}}/delete">
<a class="nodeco" href="{{ url_for('rest.deleteUser', userName=user.name) }}">
<i class="fas fa-trash"></i>
</a>
</span>

View File

@@ -2,16 +2,14 @@ import logging
import datetime
import os
from flask import current_app
from flask import render_template, Blueprint, request
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
from hsapi_client import Node, User, Route, PreAuthKey, Policy
from hsapi_client.preauthkeys import v1ListPreAuthKeyRequest
@@ -26,30 +24,29 @@ def health():
return jsonify(dict(status="OK", version=current_app.config['APP_VERSION']))
@main_blueprint.route('/', methods=['GET', 'POST'])
@auth.access_control('default')
def index():
user_session = UserSession(session)
hs_user = user_session.userinfo['email'].split('@')[0]
userNodeList = [n for n in Node().list().nodes if n.user.name == hs_user]
return render_template('index.html',
userNodeList=userNodeList,
session=user_session)
@main_blueprint.route('/token', methods=['GET', 'POST'])
@auth.authorize_admins('default')
def token():
user_session = UserSession(session)
# return jsonify(user_session.userinfo)
return jsonify(access_token=user_session.access_token,
id_token=user_session.id_token,
userinfo=user_session.userinfo)
@main_blueprint.route('/', methods=['GET', 'POST'])
@auth.access_control('default')
def index():
hs_user = auth.username
userNodeList = [n for n in Node().list().nodes if n.user.name == hs_user]
return render_template('index.html',
userNodeList=userNodeList)
@main_blueprint.route('/logout')
@auth.oidc_logout
def logout():
# UserSession(session).clear()
session.clear()
return redirect(url_for('main.index'))
@@ -62,12 +59,14 @@ def nodes():
@main_blueprint.route('/node/<int:nodeId>', methods=['GET'])
@auth.authorize_admins('default')
@auth.access_control('default')
def node(nodeId):
# There is a bug in HS api with retrieving a single node
# and we added a workaround to hsapi, so node.get() returns a
# v1Node object instead of v1NodeResponse, so we access directly
# `node`, instead of `node.node`
if not auth.userOrAdmin(auth.username):
return auth.unathorized
node = Node().get(nodeId)
routes = Node().routes(nodeId)
isExitNode = any(
@@ -131,3 +130,25 @@ def routes():
return render_template("routes.html",
exitNodes=exitNodes,
routes=final)
@main_blueprint.route('/policy', defaults={'action': None}, methods=['GET'])
@main_blueprint.route('/policy/<action>', methods=['GET'])
@auth.authorize_admins('default')
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)

View File

@@ -4,18 +4,21 @@ from flask import Blueprint, request
from flask import redirect, url_for
from app import auth
from ..lib import login_name, username
from flask import jsonify, make_response
from flask import jsonify
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
log = logging.getLogger()
# REST calls
# REST calls
rest_blueprint = Blueprint(
'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/'))
@@ -23,98 +26,58 @@ rest_blueprint = Blueprint(
@rest_blueprint.route('/routeToggle/<int:routeId>', methods=['GET'])
@auth.authorize_admins('default')
def routeToggle(routeId: int):
routes = Route().list()
route = [r for r in routes.routes if r.id == routeId]
route = Route().get(routeId)
if route:
route = route[0]
if route.enabled:
action = 'disabled'
Route().disable(routeId)
else:
Route().enable(routeId)
action = 'enabled'
log.info(
f"route '{route.prefix}' via '{route.node.givenName}'"
f"{action} by '{username()}'")
return redirect(url_for("main.routes"))
f"route '{route.prefix}' via '{route.node.givenName}' "
f"{action} by '{auth.username}@{remote_ip()}'")
Route().toggle(routeId)
return redirect(request.referrer)
@rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
@auth.authorize_admins('default')
@auth.access_control('default')
def expireNode(nodeId: int):
"""
This expires a node from the node page.
The difference from above is that it returns to the /node/nodeId page
"""
Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'")
return redirect(url_for("main.node", nodeId=nodeId))
@rest_blueprint.route('/node/<int:nodeId>/user-expire', methods=['GET'])
@auth.authorize_admins('default')
def expireNodeUser(nodeId: int):
"""
This expires a node from the node page.
The difference from above is that it returns to the /node/nodeId page
"""
node = Node().get(nodeId)
userName = node.user.name
if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'")
return redirect(url_for("main.user", userName=userName))
log.info(f"node '{nodeId}' expired by '{auth.username}@{remote_ip()}'")
return redirect(request.referrer)
@rest_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET'])
@auth.authorize_admins('default')
def expireNodeList(nodeId: int):
"""
This expires a node from the node list.
The difference from above is that it returns to the /nodes page
"""
Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'")
return redirect(url_for("main.nodes"))
@ rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
@ auth.authorize_admins('default')
@rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
@auth.access_control('default')
def deleteNode(nodeId: int):
Node().delete(nodeId)
log.info(f"node '{nodeId}' deleted by '{username()}'")
return redirect(url_for("main.nodes"))
@rest_blueprint.route('/node/<int:nodeId>/delete-own', methods=['GET'])
@auth.access_control('default')
def deleteOwnNode(nodeId: int):
node = Node().get(nodeId)
if node.user.name != username():
response = jsonify({'message': 'not authorized'})
return response, 401
if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().expire(nodeId)
Node().delete(nodeId)
log.info(f"'{username()}' delete their own node '{nodeId}'")
return redirect(url_for("main.index"))
log.info(f"node '{nodeId}' deleted by '{auth.username}@{remote_ip()}'")
return redirect(request.referrer)
@rest_blueprint.route('/node/<int:nodeId>/delete-user', methods=['GET'])
@rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
@auth.access_control('default')
def deleteNodeUser(nodeId: int):
node = Node().get(nodeId)
Node().delete(nodeId)
log.info(f"'{username()}' delete their own node '{nodeId}'")
return redirect(url_for("main.user", userName=node.user.name))
@ rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
@ auth.authorize_admins('default')
def renameNode(nodeId: int, newName: str):
node = Node().get(nodeId)
if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().rename(nodeId, newName)
return jsonify(dict(newName=newName))
@ rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
@ auth.authorize_admins('default')
@rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
@auth.authorize_admins('default')
def deleteUser(userName: str):
nodes = Node().byUser(userName)
for node in nodes.nodes:
@@ -124,11 +87,10 @@ def deleteUser(userName: str):
return redirect(url_for("main.users"))
@ rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
@ auth.authorize_admins('default')
@rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
@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'],
@@ -138,11 +100,35 @@ def createPKA(userName: str):
return jsonify(dict(key=pak.preAuthKey.key))
@ rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
@ auth.authorize_admins('default')
@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)
return redirect(url_for('main.user', userName=userName))
@rest_blueprint.route('/backfillips', methods=['POST'])
@auth.authorize_admins('default')
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

View File

@@ -13,15 +13,21 @@ 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"]
ADMIN_GROUPS = "adminGroup"
OIDC_CLIENT_ID = 'client-id'
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"
@@ -38,6 +44,7 @@ class DevelopmentConfig(BaseConfig):
DEBUG = True
ENVIRONMENT = "develop"
TEMPLATES_AUTO_RELOAD = True
class TestingConfig(BaseConfig):

View File

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

View File

@@ -11,7 +11,7 @@ preload_app = True
# logconfig = "app/logging/production.ini"
logconfig = "app/logging/production.ini"
# access_log_format = "%(h)s %(l)s %(t)s %(r)s %(s)s %(b)s %(f)s %(a)s"
access_log_format = "%(h)s %({x-forwarded-for}i)s %(r)s %(s)s %(b)s %(L)s"
# Log to stdout.
accesslog = "-"
errorlog = "-"

1293
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
[tool.poetry]
name = "hsman"
version = "0.9.7"
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.2"
hsapi-client = "^0.9.9"
# hsapi_client = { path = "../hsapi-client", develop = true }
flask-session = "^0.8.0"
[tool.poetry.group.dev.dependencies]

View File

@@ -1,6 +1,5 @@
from app import create_app
from app import lib
from app import models
import logging
import logging.config
import os
@@ -20,7 +19,7 @@ log.debug(f"Running in web mode: {lib.webMode()}")
def get_context():
# flask cli context setup
"""Objects exposed here will be automatically available from the shell."""
return dict(app=app, models=models)
return dict(app=app)
if __name__ == '__main__':