Reorganize repos

This commit is contained in:
2024-07-23 09:22:19 +02:00
parent 1a371c9349
commit b362c56b73
49 changed files with 0 additions and 0 deletions

58
app/__init__.py Normal file
View File

@ -0,0 +1,58 @@
from flask import Flask, render_template
from werkzeug.exceptions import HTTPException
from flask_mobility import Mobility
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from . import filters
from .lib import OIDCAuthentication
import os
mobility = Mobility()
auth = OIDCAuthentication()
def create_app(environment='development'):
from config import config
# Instantiate app.
app_prefix = os.getenv('APPLICATION_ROOT', '')
app = Flask(__name__,
static_url_path=f"{app_prefix}/static")
# Set app config.
env = os.environ.get('FLASK_ENV', environment)
app.config.from_object(config[env])
app.config.from_prefixed_env(prefix="HSMAN")
config[env].configure(app)
app.config['APP_TZ'] = os.environ.get('TZ', 'UTC')
app.logger.info("middleware init: mobility")
mobility.init_app(app)
app.logger.info("middleware init: auth")
auth.init_app(app)
# Register blueprints.
from .views import main_blueprint, rest_blueprint
app.logger.info(f"registering main blueprint with prefix '{
main_blueprint.url_prefix}'")
app.register_blueprint(main_blueprint)
app.logger.info(f"registering rest blueprint with prefix '{
rest_blueprint.url_prefix}'")
app.register_blueprint(rest_blueprint)
app.logger.info("jinja2 custom filters loaded")
filters.init_app(app)
# Error handlers.
@app.errorhandler(HTTPException)
def handle_http_error(exc):
return render_template('error.html', error=exc), exc.code
return app

87
app/filters.py Normal file
View File

@ -0,0 +1,87 @@
import humanize
import datetime
from flask import current_app
from zoneinfo import ZoneInfo
import logging
log = logging.getLogger(__name__)
def htime(timestamp):
if timestamp:
dt = datetime.datetime.fromtimestamp(timestamp)
return humanize.naturaltime(dt)
def htime_dt(datetime):
if datetime:
try:
return humanize.naturaltime(datetime)
except ValueError:
return "Never"
return "Never"
def hdate(timestamp):
if timestamp:
dt = datetime.datetime.fromtimestamp(timestamp)
return humanize.naturaldate(dt)
def hdate_dt(datetime):
if datetime:
try:
return humanize.naturaldate(datetime)
except ValueError:
return "Never"
return "Never"
def fmt_timestamp(timestamp):
with current_app.app_context():
tz = ZoneInfo(current_app.config['APP_TZ'])
if timestamp:
local_ts = datetime.datetime.fromtimestamp(timestamp, tz)
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),
local_ts.tzname())
def fmt_datetime(datetime):
with current_app.app_context():
tz = ZoneInfo(current_app.config['APP_TZ'])
if datetime:
try:
local_ts = datetime.fromtimestamp(datetime.timestamp(), tz)
except OverflowError:
return "Never"
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),
local_ts.tzname())
def fancyBool(bool_):
if bool_:
return '<span class="fancyTrue">&#x2714;</span>'
else:
return '<span class="fancyFalse">&#x2718;</span>'
def fancyOnline(online):
return ["offline", "online"][online]
# A list of all of the template_filter functions to add in `init_app`
FILTERS = [hdate,
hdate_dt,
htime,
htime_dt,
fmt_timestamp,
fmt_datetime,
fancyBool,
fancyOnline]
def init_app(app):
"""Register the template filters with an app instance"""
log.debug("filters init")
for func in FILTERS:
app.jinja_env.filters[func.__name__] = func

180
app/lib.py Normal file
View File

@ -0,0 +1,180 @@
import os
import functools
from flask import request, abort, current_app
from flask import session as flask_session
from flask_pyoidc import OIDCAuthentication as _OIDCAuth
from flask_pyoidc.user_session import UserSession
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from typing import Callable, List
import logging
log = logging.getLogger(__name__)
def remote_ip() -> str:
if 'HTTP_X_FORWARDED_FOR' in request.environ:
xff_parts = request.environ.get('HTTP_X_FORWARDED_FOR').split(',')
return xff_parts[0]
else:
return str(request.environ.get('REMOTE_ADDR'))
def username() -> str:
userinfo = flask_session['userinfo']
if 'preferred_username' in userinfo:
return userinfo['preferred_username']
return userinfo['email']
def webMode() -> bool:
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"
return is_gunicorn or is_werkzeug
class OIDCAuthentication(_OIDCAuth):
def __init__(self):
pass
def init_app(self, app):
client_metadata = ClientMetadata(
client_id=app.config['OIDC_CLIENT_ID'],
client_secret=app.config['OIDC_CLIENT_SECRET'])
provider_config = ProviderConfiguration(
issuer=app.config['OIDC_URL'],
client_metadata=client_metadata,
auth_request_params={
'scope': ['openid',
'profile',
'groups',
'email']},
session_refresh_interval_seconds=1800)
super().__init__({'default': provider_config})
super().init_app(app)
def authorize(self, provider_name: str, authz_fn: Callable, **kwargs):
if provider_name not in self._provider_configurations:
raise ValueError(
f"Provider name '{provider_name}' not in configured providers: {
self._provider_configurations.keys()}."
)
# We save args with which we have been called
external_args = kwargs
# Decorator
def oidc_decorator(view_func):
@ functools.wraps(view_func)
def wrapper(*args, **kwargs):
# Retrieve session and client
session = UserSession(flask_session, provider_name)
client = self.clients[session.current_provider]
# Check session validity
if session.should_refresh(client.session_refresh_interval_seconds):
log.debug('user auth will be refreshed "silently"')
return self._authenticate(client, interactive=False)
elif session.is_authenticated():
log.debug('user is already authenticated')
else:
log.debug('user not authenticated, start flow')
return self._authenticate(client)
# Call authorization function that must return true or false
authorized = authz_fn(session, **external_args)
if authorized:
return view_func(*args, **kwargs)
else:
return abort(403)
return wrapper
return oidc_decorator
def authorize_domains(self,
provider_name: str,
domains: List[str]):
"""
Authorize a user if the email domain is in a list of domains
"""
def _authz_fn(session, domains) -> bool:
email = session.userinfo.get('email', "")
domain = email.split('@')[-1]
if domain in domains:
return True
return False
return self.authorize(provider_name,
authz_fn=_authz_fn,
domains=domains)
def authorize_users(self, provider_name: str, users: List[str]):
"""
Authorize a user if the username of the user part of the email
is in a list of usernames
"""
def _authz_fn(session, users) -> bool:
username = session.userinfo.get('preferred_username', "")
email = session.userinfo.get('email', "")
email_user = email.split('@')[0]
if username in users or email_user in users:
return True
return False
return self.authorize(provider_name,
authz_fn=_authz_fn,
users=users)
def authorize_groups(self, provider_name: str, groups: List[str]):
"""
Authorize members of a list of groups
"""
def _authz_fn(session, groups) -> bool:
user_groups = session.userinfo.get('groups', [])
if len(set(groups).intersection(user_groups)):
return True
return False
return self.authorize(provider_name,
authz_fn=_authz_fn,
groups=groups)
def authorize_admins(self, provider_name: str):
"""
Authorize admins.
Admins are taken from the app config:
- members of groups in ADMIN_GROUPS
- users in ADMIN_USERS
"""
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.authorize(provider_name,
authz_fn=_authz_fn)

View File

@ -0,0 +1,95 @@
# Logging configuration
[loggers]
keys = root, wsgi, app, views, lib, model, sqlalchemy, scheduler, werkzeug, gunicorn
[handlers]
keys = console, accesslog
[formatters]
keys = generic, colored, accesslog
[logger_root]
level = DEBUG
handlers = console
[logger_gunicorn]
level = DEBUG
handlers = accesslog
qualname = gunicorn
propagate = 0
[logger_werkzeug]
level = INFO
handlers = accesslog
qualname = werkzeug
propagate = 0
[logger_wsgi]
level = DEBUG
handlers = console
qualname = wsgi
propagate = 0
[logger_app]
level = DEBUG
handlers = console
qualname = app
propagate = 0
[logger_views]
level = DEBUG
handlers = console
qualname = app.views
propagate = 0
[logger_lib]
level = DEBUG
handlers = console
qualname = app.lib
propagate = 0
[logger_model]
level = DEBUG
handlers = console
qualname = app.models
propagate = 0
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine.Engine
propagate = 0
; qualname = sqlalchemy.engine.Engine
# "level = INFO" logs SQL queries.
# "level = DEBUG" logs SQL queries and results.
# "level = WARN" logs neither. (Recommended for production systems.)
[logger_scheduler]
level = WARN
handlers = console
qualname = app.scheduler, apscheduler
propagate = 0
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = colored
; formatter = generic
[handler_accesslog]
class = StreamHandler
args = (sys.stdout,)
level = NOTSET
formatter = accesslog
[formatter_generic]
format = %(asctime)s,%(msecs)03d %(levelname)-7.7s [%(name)s/%(filename)s:%(lineno)d]: %(message)s
datefmt = %Y-%m-%d %H:%M:%S
[formatter_colored]
format = %(asctime)s,%(msecs)03d %(levelname)-7.7s [%(name)s/%(filename)s:%(lineno)d]: %(message)s
datefmt = %H:%M:%S
[formatter_accesslog]
format = %(name)s %(message)s

View File

@ -0,0 +1,60 @@
# Logging configuration
[loggers]
keys = root, wsgi, app, views, lib, access, error
[handlers]
keys = console
[formatters]
keys = json
[logger_root]
level = INFO
handlers = console
[logger_access]
level = INFO
handlers = console
qualname = gunicorn.access
propagate = 0
[logger_error]
level = INFO
handlers = console
qualname = gunicorn.error
propagate = 0
[logger_wsgi]
level = INFO
handlers = console
qualname = wsgi
propagate = 0
[logger_app]
level = INFO
handlers = console
qualname = app.
propagate = 0
[logger_views]
level = INFO
handlers = console
qualname = app.views
propagate = 0
[logger_lib]
level = INFO
handlers = console
qualname = app.lib
propagate = 0
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
; formatter = colored
formatter = json
[formatter_json]
format={"timestamp": "%(asctime)s.%(msecs)03d", "level": "%(levelname)s", "logger": "%(name)s", "message": "%(message)s"}
datefmt = %Y-%m-%dT%H:%M:%S

0
app/models.py Normal file
View File

File diff suppressed because one or more lines are too long

7
app/static/bootstrap/bootstrap.min.js vendored Normal file

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

40
app/static/datatables/datatables.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,10 @@
{
"name":"Headscale Manager",
"short_name":"hsman",
"icons":[
{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],
"theme_color":"#000000",
"background_color":"#000000",
"display":"standalone"
}

View File

@ -0,0 +1,22 @@
/*
* The Typekit service used to deliver this font or fonts for use on websites
* is provided by Adobe and is subject to these Terms of Use
* http://www.adobe.com/products/eulas/tou_typekit. For font license
* information, see the list below.
*
* century-gothic:
* - http://typekit.com/eulas/00000000000000003b9b1f23
*
* © 2009-2024 Adobe Systems Incorporated. All Rights Reserved.
*/
/*{"last_published":"2021-09-06 05:08:57 UTC"}*/
@import url("https://p.typekit.net/p.css?s=1&k=oov2wcw&ht=tk&f=39203&a=85994746&app=typekit&e=css");
@font-face {
font-family:"century-gothic";
src:url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff2"),url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff"),url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("opentype");
font-display:auto;font-style:normal;font-weight:400;font-stretch:normal;
}
.tk-century-gothic { font-family: "century-gothic",sans-serif; }

View File

@ -0,0 +1,21 @@
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkP8o18E.ttf) format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkM0o18E.ttf) format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkCEv18E.ttf) format('truetype');
}

BIN
app/static/hsman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

120
app/static/main.css Normal file
View File

@ -0,0 +1,120 @@
/* custom css */
html {
position: relative;
min-height: 100%;
font-size: 90%;
}
body.bootstrap,
body.bootstra-dark {
width: 100%;
height: 100%;
font-family: century-gothic, sans-serif;
/* font-family: 'Quicksand', sans-serif; */
min-height: 100%;
/* margin: 0 0 60px; */
margin-bottom: 60px;
}
body > .container,
body > .container-fluid {
margin-top: 30px;
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
/* Set the fixed height of the footer here */
height: 30px;
margin-top: 0 auto;
/* Vertically center the text there */
line-height: 10px;
padding-left: 1rem;
padding-right: 1rem;
}
a:link {
color: unset;
text-decoration-line: underline;
/* text-decoration-style: dotted; */
/* text-decoration-style: dashed; */
}
a:visited {
color: unset;
text-decoration-line: underline;
/* text-decoration-style: dotted; */
/* text-decoration-style: dashed; */
}
a:hover {
color: unset;
text-decoration-line: unset;
/* text-decoration: underline; */
/* text-decoration-style: dotted; */
}
a:active {
color: unset;
text-decoration: none;
}
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle {
text-decoration-line: none;
text-decoration-style: unset;
}
table.dataTable tbody tr:hover {
background-color: #444;
}
table.dataTable tbody tr:hover > .sorting_1 {
background-color: #444;
}
div.data:hover{
/* background-color: rgba(63, 140, 211, 0.808); */
background-color: #444;
}
.fancyTrue {
color: #10932f
}
.fancyFalse {
color: #9b0f0f
}
/* primary route */
a.route.primary {
font-weight: bold;
}
/* route enabled */
a.route.True {
font-style: italic;
color: #bbbbbb;
}
/* route disabled */
a.route.False {
font-style: italic;
text-decoration-line: line-through underline;
color: #888;
}
div.dt-container div.dt-scroll-body {
border-bottom: none !important;
}
tr.pka-hide {
visibility: collapse;
}
i.disabled {
color: #888;
}
span.expired {
color: #888;
}

67
app/static/main.js Normal file
View File

@ -0,0 +1,67 @@
function renameNode(nodeId) {
var newName = $("#newName").val();
var url = `${nodeId}/rename/${newName}`;
$.ajax({
url: url,
xhrFields: {
withCredentials: true,
},
success: function (data) {
$("#renameModal").modal("hide");
$("#givenName").html(data.newName);
},
});
}
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",
dataType: "json",
contentType: "application/json; charset=utf-8",
xhrFields: {
withCredentials: true,
},
data: JSON.stringify({
ephemeral: ephemereal,
reusable: reusable,
expiration: expiration,
}),
success: function (data) {
$("#createPKA").modal("hide");
location.reload();
},
});
}
function copyToClipboard(obj) {
var span = $(obj);
var value = span.attr("value");
var original = span.html();
try {
navigator.clipboard.writeText(value);
span.html("copied!");
setTimeout(function () {
span.html(original);
}, 500);
} catch (error) {
span.html("error");
console.error(error);
}
}
function toggleExpired(obj) {
var toggle = $(obj);
if (toggle.is(":checked")) {
$(".pka-expired").removeClass("pka-hide");
} else {
$(".pka-expired").addClass("pka-hide");
}
}

View File

@ -0,0 +1,18 @@
<!-- Put your reusable template code into macros here -->
{% macro theme_icon(theme) %}
<div class="theme-icon">
{% if theme == "dark" %}
<svg xmlns="http://www.w3.org/2000/svg" class="theme-icon" fill="none" viewBox="0 0 24 24" stroke="yellow"
stroke-opacity="0.6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" class="theme-icon" fill="none" viewBox="0 0 18 24" stroke="grey"
stroke-opacity="0.6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
{% endif %}
</div>
{% endmacro %}

111
app/templates/base.html Normal file
View File

@ -0,0 +1,111 @@
{% import '_macros.html.j2' as mc -%}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ config.APP_NAME }}</title>
<!-- meta -->
<meta name="description" content="">
<meta name="author" content="">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
{% block meta %}{% endblock %}
<!-- styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap-dark.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap-print.min.css') }}">
<!-- Century gothic font -->
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/century-gothic.css') }}">
<!-- fontawesome -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" integrity="sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ" crossorigin="anonymous">
<link href="{{ url_for('static', filename='main.css') }}" rel="stylesheet" media="screen">
<!-- Datatables -->
<link href="{{ url_for('static', filename='datatables/datatables.min.css') }}" rel="stylesheet">
{% block links %}{% endblock %}
<link rel="icon" type="image/png" sizes="180x180" href="{{ url_for('static', filename='favicon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon/favicon-16x16.png') }}">
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='favicon/android-chrome-192x192.png') }}">
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='favicon/android-chrome-512x512.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='favicon/site.webmanifest') }}">
</head>
<body class="bootstrap bootstrap-dark">
<!-- Header -->
<header>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-themed">
<!-- Navbar Brand -->
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<img src="{{ url_for('static', filename='/hsman.png') }}">
<!-- HSMAN -->
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.nodes') }}">nodes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.users') }}">users</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.routes') }}">routes</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item me-right">
<a href="{{url_for('main.logout') }}" id="themeSwitch">
<i class="fas fa-sign-out-alt"></i>
<!-- <i class="fas fa-plug-circle-xmark"></i> -->
</a>
</li>
</ul>
</div>
</nav>
</header>
{% if g.is_mobile %}
<div class="container-fluid">
{% else %}
<div class="container">
{% endif %}
<!-- Main Content -->
{% block content %}{% endblock %}
</div>
<!-- Footer-->
<footer class="footer text-center">
<div class="container">
<div class="row">
<!-- Copyrights -->
<div class="col-lg-12 text-center">
<p class="text-muted mb-0 py-2">
<!-- <img src="/static/hsman.png" height="20px"> -->
Headscale Manager |
ver. {{ config.APP_VERSION }} ({{ config.APP_SHA }})
</p>
</div>
</div>
</footer>
<!-- scripts -->
<script src="{{ url_for('static', filename='bootstrap/jquery-3.7.1.min.js') }}"></script>
<script src="{{ url_for('static', filename='bootstrap/popper-1.12.9.min.js') }}"></script>
<script src="{{ url_for('static', filename='bootstrap/bootstrap.min.js') }}"></script>
<script src="{{ url_for('static', filename='datatables/datatables.min.js') }}"></script>
<script src="{{ url_for('static', filename='main.js') }}" type="text/javascript"></script>
<script>
$(function () {
$('[data-toggle="tooltip"]').tooltip();
})
</script>
{% block scripts %}{% endblock %}
</body>
</html>

10
app/templates/error.html Normal file
View File

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

71
app/templates/index.html Normal file
View File

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block content %}
<h3>Welcome, {{ session.userinfo.name }}</h3>
<hr>
<h4>authentication info</h4>
<div class="row data">
<div class="col col-2">
<strong>email</strong>
</div>
<div class="col col-6">
{{ session.userinfo.email }}
<!-- {{ session.userinfo.email_verified | fancyBool | safe }} -->
</div>
</div>
<div class="row data">
<div class="col col-2">
<strong>username</strong>
</div>
<div class="col col-6">
{{ session.userinfo.preferred_username }}
</div>
</div>
<div class="row data">
<div class="col col-2">
<strong>groups</strong>
</div>
<div class="col col-6">
<i class="fas fa-angle-right"></i>
{{ session.userinfo.groups[0]}}
</div>
</div>
{% for group in session.userinfo.groups[1:] |sort %}
<div class="row data">
<div class="col col-2">
&nbsp;
</div>
<div class="col col-6">
<i class="fas fa-angle-right"></i> {{ group }}
</div>
</div>
{% endfor %}
<hr>
<h4>your devices</h4>
<div class="row strong">
<div class="col col-2"><strong></strong></div>
<div class="col col-2"><strong>registered</strong></div>
<div class="col col-2"><strong>last event</strong></div>
<div class="col col-2"><strong>online</strong></div>
</div>
{% for node in userNodeList %}
<div class="row data">
<div class="col col-2">
{{ node.givenName}}
</div>
<div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
{{node.createdAt | htime_dt }}
</span>
</div>
<div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="{{ node.lastSeen | fmt_datetime }}">
{{node.lastSeen | htime_dt }}
</span>
</div>
<div class="col col-2">
{{node.online | fancyBool | safe }}
</div>
</div>
{% endfor %}
{% endblock %}

209
app/templates/node.html Normal file
View File

@ -0,0 +1,209 @@
{% extends "base.html" %}
{% block content %}
<h3>
<span id="givenName">
{{node.givenName}}
</span>
<a href="#" data-toggle="modal" data-target="#renameModal">
<span data-toggle="tooltip"
data-placement="right"
title="rename node">
<i class="fas fa-edit h6"></i>
</span>
</a>
</h3>
<hr>
<p></p>
<div class="row">
<div class="col col-3 float-left">
<strong>status</strong>
</div>
<div class="col col-8 float-left">
{% if node.online %}
<span class="badge badge-pill badge-success">online</span>
{% else %}
<span class="badge badge-pill badge-danger">offline</span>
{% endif %}
</div>
</div>
<div class="row">
<div class="col col-3 float-left">
<strong>registered</strong>
</div>
<div class="col col-8 float-left">
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
{{ node.createdAt | htime_dt }}
</span>
</div>
</div>
<div class="row">
<div class="col col-3 float-left">
<strong>expire</strong>
</div>
<div class="col col-8 float-left">
<span data-toggle="tooltip" data-placement="right" title="{{ node.expiry | fmt_datetime }}">
{{ node.expiry | htime_dt }}
</span>
{% if node.expireDate and not node.expired %}
<a href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect node">
<i class="fas fa-plug"></i>
</span>
</a>
{% endif %}
</div>
</div>
<div class="row">
<div class="col col-3 float-left">
<strong>owner</strong>
</div>
<div class="col col-8 float-left">
<a href='{{ url_for("main.user", userName=node.user.name) }}' class="plain">{{ node.user.name }}</a>
</div>
</div>
<p></p>
<!-- ADDRESSES -->
<h5>addresses</h5>
{% for ip in node.ipAddresses %}
<div class="row data">
<div class="col col-3">
{{ ip }}
</div>
</div>
{% endfor %}
<p></p>
<!-- TAGS -->
<h5>tags</h5>
<div class="row data">
<div class="col col-3 float-left">
<strong>
announced
</strong>
</div>
<div class="col col-6 float-left">
{% if node.validTags %}
{% for tag in node.validTags %}
<span class="badge badge-pill badge-warning">
{{ tag }}
</span>
{% endfor %}
{% else %}
None
{% endif %}
</div>
</div>
<div class="row data">
<div class="col col-3 float-left">
<strong>forced</strong>
</div>
<div class="col col-6 float-left">
{% if node.forced %}
{% for tag in node.forcedTags %}
<h3><span class="badge badge-pill badge-primary">
{{ tag }}
</span></h3>
{% endfor %}
{% else %}
None
{% endif %}
</div>
</div>
<!-- KEYS -->
<p></p>
<h5>keys</h5>
<div class="row data">
<div class="col col-3 float-left">
<strong>machineKey</strong>
</div>
<div class="col col-8 float-left">
<code>{{ node.machineKey }}</code>
</div>
</div>
<div class="row data">
<div class="col col-3 float-left">
<strong>nodeKey</strong>
</div>
<div class="col col-8 float-left">
<code>{{ node.nodeKey }}</code>
</div>
</div>
<div class="row data">
<div class="col col-3 float-left">
<strong>discoKey</strong>
</div>
<div class="col col-8 float-left">
<code>{{ node.discoKey }}</code>
</div>
</div>
<p></p>
<!-- ROUTES -->
<h5>routes
{% if isExitNode %}
<span class="small badge-pill badge-primary">Exit Node</span>
{% endif %}
</h5>
{% if routes %}
<div class="row">
<div class="col col-3 float-left">
<strong>prefix</strong>
</div>
<div class="col col-3 float-left">
<strong>enabled</strong>
</div>
<div class="col col-3 float-left">
<strong>primary</strong>
</div>
</div>
{% for route in routes | sort(attribute='prefix') %}
<div class="row data">
<div class="col col-3 float-left">
{{ route.prefix }}
</div>
<div class="col col-3 float-left">
{{ route.enabled | fancyBool | safe }}
</div>
<div class="col col-3 float-left">
{{ route.isPrimary | fancyBool | safe }}
</div>
</div>
{% endfor %}
{% else %}
<div class="row">
<div class="col col-9 text-center">
<h3>No routes announced</h3>
</div>
</div>
{% endif %}
<!-- rename modal -->
<!-- Modal -->
<div class="modal fade" id="renameModal" tabindex="-1" role="dialog" aria-labelledby="renameModal" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">Rename node</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="text" name="newName" id="newName" value="{{ node.givenName}}">
</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="renameNode(id={{node.id}})">Save changes</button>
</div>
</div>
</div>
</div>
{% endblock %}

98
app/templates/nodes.html Normal file
View File

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block content %}
<h3>nodes</h3>
<hr>
<p></p>
<table id="nodes" class="display" style="width:100%">
<thead>
<tr>
<th>name</th>
<th>user</th>
<th>expire</th>
<th>last event</th>
<th>addresses</th>
<th>online</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for node in nodes %}
<tr>
<td>
<a class="plain" href="{{ url_for('main.node', nodeId=node.id)}}">
<span data-toggle="tooltip" data-placement="right" title="{{ node.ipAddresses | join('\n') }}">
{{node.givenName}}
</span>
</a>
</td>
<td>
<a class="plain" href="{{ url_for('main.user', userName=node.user.name)}}">
{{node.user.name}}
</a>
</td>
<td>
<span data-toggle="tooltip" data-placement="right"
title="{{ node.expiry | fmt_datetime }}"
class="{% if node.expired %}expired{% endif %}">
{{node.expireDate | htime_dt | safe}}
</span>
</td>
<td data-order="{{ node.lastSeen | fmt_datetime }}">
<span data-toggle="tooltip" data-placement="right" title="{{ node.lastSeen | fmt_datetime }}">
{{node.lastSeen | htime_dt }}
</span>
</td>
<td>
{{ node.ipAddresses | join(', ') }}
</td>
<td data-filter="{{ node.online | fancyOnline }}">
{{node.online | fancyBool | safe}}
</td>
<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) }}">
<i class="fas fa-plug"></i>
</a>
</span>
{% else %}
<i class="fas fa-plug disabled"></i>
{% endif %}
<span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
<i class="fas fa-trash"></i>
</a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block scripts %}
<script>
$(function () {
new DataTable('#nodes', {
paging: true,
lengthMenu: [15, 30, 50, 100, { label: 'All', value: -1 }],
pageLength: 30,
fixedHeader: false,
select: false,
keys: false,
aoColumnDefs: [
{ 'bSortable': false, 'aTargets': [ -1 ] }
],
columnDefs: [
{
target: 4,
visible: false,
searchable: true
}
]
});
})
</script>
{% endblock %}

93
app/templates/routes.html Normal file
View File

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block content %}
<h5>
Routing table
<small class="text-muted">
click on the icon in <em>enabled</em> column to toggle route status
</small>
</h5>
<hr>
<p></p>
<div class="row">
<div class="col col-12">
<h5>Exit nodes</h5>
</div>
</div>
{% for exitNode in exitNodes %}
<div class="row data">
<div class="col col-12">
<span data-toggle="tooltip" data-placement="right" title="{{ exitNode.ipAddresses | join('\n') }}">
&nbsp; {{ exitNode.givenName }}
</span>
</div>
</div>
{% endfor %}
<p></p>
{% for prefix, rts in routes.items() %}
<div class="row">
<div class="col col-4 float-left">
<strong>prefix</strong>
</div>
<div class="col col-4 float-left">
<strong>gateway</strong>
</div>
<div class="col col-2 float-left">
<strong>enabled</strong>
</div>
<div class="col col-2 float-left">
<strong>active</strong>
</div>
</div>
<div class="row data">
<div class="col col-4 float-left">
{{ prefix}}
</div>
<div class="col col-4 float-left">
<a class="plain route primary" href="{{ url_for('main.node', nodeId=rts[0].node.id) }}">
<span data-toggle="tooltip" data-placement="right"
title="{{ rts[0].node.ipAddresses | join('\n') }}">
{{ rts[0].node.givenName}}
</span>
</a>
</div>
<div class="col col-2 float-left">
<a class="routeToggle" href="{{ url_for('rest.routeToggle', routeId=rts[0].id) }}">
{{ rts[0].enabled | fancyBool | safe}}
</a>
</div>
<div class="col col-2 float-left">
{{ rts[0].isPrimary | fancyBool | safe}}
</div>
</div>
{% for rt in rts[1:] %}
<div class="row data">
<div class="col col-4 float-left" style="border: 1px red;">
<span>&nbsp;</span>
</div>
<div class="col col-4">
<a class="plain route {{rt.enabled}}" href="{{ url_for('main.node', nodeId=rt.node.id) }}">
<span data-toggle="tooltip" data-placement="right"
title="{{ rt.node.ipAddresses | join('\n') }}">
{{ rt.node.givenName}}
</span>
</a>
</div>
<div class="col col-2 float-left">
<a class="routeToggle" href="{{ url_for('rest.routeToggle', routeId=rt.id) }}">
{{ rt.enabled | fancyBool | safe}}
</a>
</div>
<div class="col col-2 float-left">
{{ rt.isPrimary | fancyBool | safe}}
</div>
</div>
{% endfor %}
<p></p>
{% endfor %}
{% endblock %}

208
app/templates/user.html Normal file
View File

@ -0,0 +1,208 @@
{% extends "base.html" %}
{% block content %}
<h3>{{ user.name }}</h3>
<hr>
<p></p>
<div class="row">
<div class="col col-3">
<strong>registered</strong>
</div>
<div class="col col-8">
<span data-toggle="tooltip"
data-placement="right"
title="{{ user.createdAt | fmt_datetime }}">
{{ user.createdAt | htime_dt }}
</span>
</div>
</div>
<p></p>
<!-- NODES -->
<h5>nodes</h5>
<table id="nodes" class="display" style="width:80%">
<thead>
<tr>
<th>&nbsp;</th>
<th>last connect</th>
<th>online</th>
</tr>
</thead>
<tbody>
{% for node in userNodeList %}
<tr>
<td>
<a href="{{ url_for('main.node', nodeId=node.id) }}" class="plain">
{{ node.givenName }}
</a>
</td>
<td>
<span data-toggle="tooltip"
data-placement="right"
title="{{ node.lastSeen | fmt_datetime }}">
{{node.lastSeen | htime_dt }}
</span>
</td>
<td>
{{node.online | fancyBool | safe}}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p></p>
<!-- PRE AUTH KEYS -->
<h5>
pre auth keys
&nbsp;
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#createPKA">create</button>
</h5>
{% if preauthKeys %}
<table id="paks" class="display" style="width:80%">
<thead>
<tr>
<th>
<div class="form-check form-check-inline">
<label class="form-check-label small" for="showExpired">
show expired&nbsp;
</label>
<input type="checkbox" class="form-check-input form-control-sm" id="showExpired">
</div>
</th>
<th>created</th>
<th>expiration</th>
<th>attributes</th>
<!-- <th>&nbsp;</th> -->
</tr>
</thead>
<tbody>
{% for key in preauthKeys %}
<tr class="pka{% if key.expired %} pka-expired pka-hide{% endif %}">
<td>
<span data-toggle="tooltip"
data-placement="right"
value="{{ key.key}}"
title="click to copy full value"
class="pak_copy">{{ key.key[:5] }}&hellip;{{ key.key[-5:] }}</span>
</td>
<td>
<span data-toggle="tooltip"
data-placement="right"
title="{{ key.createdAt | fmt_datetime }}">
{{key.createdAt | htime_dt }}
</span>
</td>
<td>
<span data-toggle="tooltip"
data-placement="right"
title="{{ key.expiration | fmt_datetime }}">
{{key.expiration | htime_dt }}
</span>
</td>
<td>
{% if key.ephemeral %}
<span class="badge badge-pill badge-primary">ephemereal</span>
{% endif %}
{% if key.reusable %}
<span class="badge badge-pill badge-primary">reusable</span>
{% endif %}
{% if key.used %}
<span class="badge badge-pill badge-primary">used</span>
{% endif %}
</td>
<!-- <td>
<span data-toggle="tooltip" data-placement="right" title="expire">
<a class="nodeco" href="/user/{{user.name}}/expire/{{key.key}}">
<i class="fas fa-trash"></i>
</a>
</span>
</td> -->
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="row">
<div class="col col-9 text-center">
<h3>No preauth keys</h3>
</div>
</div>
{% endif %}
<!-- new key modal -->
<div class="modal fade" id="createPKA" tabindex="-1" role="dialog" aria-labelledby="createPKA" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">create new pre auth key</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="datetime-local" name="expiration" id="expiration" value="{{ defaultExpiry}}">
<label class="form-check-label" for="ephemereal">expiration</label>
</div>
<p></p>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="reusable" id="reusable">
<label class="form-check-label" for="reusable">reusable</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="ephemereal" id="ephemereal">
<label class="form-check-label" for="ephemereal">ephemereal</label>
</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="createPKA(user='{{ user.name }}')">Save changes</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(function () {
$('.pak_copy').on('click', function() {
copyToClipboard(this)
})
$('#showExpired').on('change', function() {
toggleExpired(this)
})
new DataTable('#nodes', {
scrollY: 130,
scrollCollapse: true,
paging: false,
// lengthMenu: [5, 10, 30, 50, { label: 'All', value: -1 }],
// pageLength: 10,
fixedHeader: {
header: true,
footer: false
},
info: false,
searching: false,
select: false,
keys: false,
});
new DataTable('#paks', {
scrollY: 230,
scrollCollapse: true,
paging: false,
// lengthMenu: [5, 10, 30, 50, { label: 'All', value: -1 }],
// pageLength: 10,
fixedHeader: {
header: true,
footer: false
},
info: false,
searching: false,
select: false,
keys: false,
});
})
</script>
{% endblock %}

62
app/templates/users.html Normal file
View File

@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block content %}
<h3>users</h3>
<hr>
<p></p>
<table id="users" class="display" style="width:100%">
<thead>
<tr>
<th>name</th>
<th>registered on</th>
<th>online</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<a class="plain" href="{{ url_for('main.user', userName=user.name)}}">
{{user.name}}
</a>
</td>
<td data-order="{{ user.createdAt }}">
<span data-toggle="tooltip" data-placement="right" title="{{ user.createdAt | fmt_datetime }}">
{{user.createdAt | htime_dt }}
</span>
</td>
<td data-filter="{{ online[user.name] | fancyOnline }}">
{{online[user.name] | fancyBool | safe }}
</td>
<td class="no-sort">
<span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="/user/{{user.name}}/delete">
<i class="fas fa-trash"></i>
</a>
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block scripts %}
<script>
$(function () {
new DataTable('#users', {
paging: true,
lengthMenu: [15, 30, 50, 100, { label: 'All', value: -1 }],
pageLength: 30,
fixedHeader: false,
select: false,
keys: false,
aoColumnDefs: [
{ 'bSortable': false, 'aTargets': [ -1 ] }
],
});
})
</script>
{% endblock %}

2
app/views/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from .main import *
from .rest import *

112
app/views/rest.py Normal file
View File

@ -0,0 +1,112 @@
import logging
import os
from flask import Blueprint, request
from flask import redirect, url_for
from app import auth
from ..lib import username
from flask import jsonify
from hsapi_client import Node, User, Route, PreAuthKey
from hsapi_client.preauthkeys import (v1CreatePreAuthKeyRequest,
v1ExpirePreAuthKeyRequest)
log = logging.getLogger()
# REST calls
rest_blueprint = Blueprint(
'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/'))
@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]
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"))
@rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
@auth.authorize_admins('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>/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')
def deleteNode(nodeId: int):
Node().delete(nodeId)
return redirect(url_for("main.nodes"))
@rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
@auth.authorize_admins('default')
def renameNode(nodeId: int, newName: str):
Node().rename(nodeId, newName)
return jsonify(dict(newName=newName))
@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:
Node().expire(node.id)
Node().delete(node.id)
User().delete(userName)
return redirect(url_for("main.users"))
@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'],
ephemeral=data['ephemeral'],
expiration=expiration)
pak = PreAuthKey().create((req))
return jsonify(dict(key=pak.preAuthKey.key))
@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))