27 Commits

Author SHA1 Message Date
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
07ac2edb53 Fix admin groups parsing 2024-07-26 16:40:36 +02:00
50097ce5b3 Bump version 2024-07-26 15:52:09 +02:00
4b28db6a13 Sort routes 2024-07-26 15:51:54 +02:00
24ca9d59f6 Bump version 2024-07-26 12:16:50 +02:00
0500a468a1 Quick fix for exit node 2024-07-26 12:16:32 +02:00
28 changed files with 1203 additions and 681 deletions

View File

@@ -16,17 +16,17 @@ You can run the Flask application as any other Flask app, using `flask run` insi
There are some settings that must/can be provided to the application: There are some settings that must/can be provided to the application:
| Variable | Usage | Default | | Variable | Usage | Default |
| -------------------------- | ---------------------------------------- | :-----: | | -------------------------- | -------------------------------------------------------------- | :-----: |
| `APPLICATION_ROOT` | Base URI path for the app | `/` | | `APPLICATION_ROOT` | Base URI path for the app | `/` |
| `HSMAN_SECRET_KEY` | Flask app secret key | | | `HSMAN_SECRET_KEY` | Flask app secret key | |
| `HSMAN_ADMIN_GROUPS` | User groups that are considered admins | | | `HSMAN_ADMIN_GROUPS` | Comma separated list of user groups that are considered admins | |
| `HSMAN_OIDC_CLIENT_ID` | OIDC client ID | | | `HSMAN_OIDC_CLIENT_ID` | OIDC client ID | |
| `HSMAN_OIDC_CLIENT_SECRET` | OIDC clietn secret | | | `HSMAN_OIDC_CLIENT_SECRET` | OIDC clietn secret | |
| `HSMAN_OIDC_URL` | OIDC server URL | | | `HSMAN_OIDC_URL` | OIDC server URL | |
| `HSMAN_OIDC_REDIRECT_URI` | OIDC redirect URI | | | `HSMAN_OIDC_REDIRECT_URI` | OIDC redirect URI | |
| `HSAPI_SERVER` | Headscale server URL | | | `HSAPI_SERVER` | Headscale server URL | |
| `HSAPI_API_TOKEN` | API token/key to access headscale server | | | `HSAPI_API_TOKEN` | API token/key to access headscale server | |
The last two variables are then fed to `hsapi-client`, the module that we use to interact with Headscale APIs. The last two variables are then fed to `hsapi-client`, the module that we use to interact with Headscale APIs.

View File

@@ -1,9 +1,7 @@
from flask import Flask, render_template 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_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
from . import filters from . import filters
from .lib import OIDCAuthentication from .lib import OIDCAuthentication
@@ -29,6 +27,9 @@ def create_app(environment='development'):
app.config.from_prefixed_env(prefix="HSMAN") app.config.from_prefixed_env(prefix="HSMAN")
config[env].configure(app) config[env].configure(app)
app.config['APP_TZ'] = os.environ.get('TZ', 'UTC') app.config['APP_TZ'] = os.environ.get('TZ', 'UTC')
app.config['ADMIN_GROUPS'] = list(
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") app.logger.info("middleware init: mobility")
mobility.init_app(app) mobility.init_app(app)
@@ -38,21 +39,24 @@ def create_app(environment='development'):
# Register blueprints. # Register blueprints.
from .views import main_blueprint, rest_blueprint from .views import main_blueprint, rest_blueprint
app.logger.info(f"registering main blueprint with prefix '{ app.logger.info(f"register blueprint: 'main' [prefix '{
main_blueprint.url_prefix}'") main_blueprint.url_prefix}']")
app.register_blueprint(main_blueprint) app.register_blueprint(main_blueprint)
app.logger.info(f"registering rest blueprint with prefix '{ app.logger.info(f"register blueprint: 'rest' [prefix '{
rest_blueprint.url_prefix}'") rest_blueprint.url_prefix}']")
app.register_blueprint(rest_blueprint) app.register_blueprint(rest_blueprint)
app.logger.info("jinja2 custom filters loaded") app.logger.info("jinja2 custom filters loaded")
filters.init_app(app) filters.init_app(app)
# Error handlers. # Error handlers.
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def handle_http_error(exc): def handle_http_error(exc):
return render_template('error.html', error=exc), exc.code return render_template('error.html', error=exc), exc.code
@app.context_processor
def inject_auth():
return dict(auth=auth)
return app return app

View File

@@ -2,7 +2,7 @@ import os
import functools import functools
from flask import request, abort, current_app 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 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
@@ -21,19 +21,6 @@ def remote_ip() -> str:
return str(request.environ.get('REMOTE_ADDR')) 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: def webMode() -> bool:
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '') is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true" is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"
@@ -64,6 +51,68 @@ class OIDCAuthentication(_OIDCAuth):
super().init_app(app) super().init_app(app)
app.auth = self app.auth = self
@property
def username(self) -> str:
userinfo = flask_session['userinfo']
return userinfo['email'].split('@')[0]
@property
def email(self) -> str:
userinfo = flask_session['userinfo']
return userinfo['email']
@property
def login_name(self) -> str:
userinfo = flask_session['userinfo']
return userinfo.get('preferred_username', self.username)
@property
def full_name(self) -> str:
userinfo = flask_session['userinfo']
return userinfo.get('name')
@property
def groups(self) -> list:
userinfo = flask_session['userinfo']
return userinfo.get('groups') or []
@property
def isAdmin(self) -> bool:
userinfo = flask_session['userinfo']
user_groups = 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): def authorize(self, provider_name: str, authz_fn: Callable, **kwargs):
if provider_name not in self._provider_configurations: if provider_name not in self._provider_configurations:
raise ValueError( raise ValueError(
@@ -76,7 +125,7 @@ class OIDCAuthentication(_OIDCAuth):
# Decorator # Decorator
def oidc_decorator(view_func): def oidc_decorator(view_func):
@ functools.wraps(view_func) @functools.wraps(view_func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
# Retrieve session and client # Retrieve session and client
session = UserSession(flask_session, provider_name) session = UserSession(flask_session, provider_name)
@@ -165,23 +214,7 @@ class OIDCAuthentication(_OIDCAuth):
""" """
def _authz_fn(session) -> bool: def _authz_fn(session) -> bool:
user_groups = session.userinfo.get('groups', []) return self.isAdmin
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, return self.authorize(provider_name,
authz_fn=_authz_fn) authz_fn=_authz_fn)

View File

@@ -13,7 +13,7 @@ level = INFO
handlers = console handlers = console
[logger_access] [logger_access]
level = INFO level = ERROR
handlers = console handlers = console
qualname = gunicorn.access qualname = gunicorn.access
propagate = 0 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 { body > .container-fluid {
margin-top: 30px; margin-top: 30px;
margin-bottom: 30px; margin-bottom: 30px;
margin-left: 200px;
margin-right: 200px;
} }
.footer { .footer {
@@ -62,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;
} }
@@ -118,3 +120,15 @@ i.disabled {
span.expired { span.expired {
color: #888; 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) { function createPKA(username) {
console.log(username);
var url = `${username}/pakcreate`; var url = `${username}/pakcreate`;
var ephemereal = $("#ephemereal").is(":checked"); var ephemereal = $("#ephemereal").is(":checked");
var reusable = $("#reusable").is(":checked"); var reusable = $("#reusable").is(":checked");
var expiration = $("#expiration").val(); var expiration = $("#expiration").val();
console.log(expiration);
$.ajax({ $.ajax({
url: url, url: url,
method: "POST", method: "POST",
@@ -65,3 +63,65 @@ function toggleExpired(obj) {
$(".pka-expired").addClass("pka-hide"); $(".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"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mr-auto"> <ul class="navbar-nav mr-auto">
{% if auth.isAdmin %}
<li class="nav-item"> <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>
<li class="nav-item"> <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>
<li class="nav-item"> <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>
<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>
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item me-right"> <li class="nav-item me-right">
@@ -74,8 +79,9 @@
{% if g.is_mobile %} {% if g.is_mobile %}
<div class="container-fluid"> <div class="container-fluid">
{% else %} {% else %}
<div class="container"> <div class="container-lg">
{% endif %} {% endif %}
<p></p>
<!-- Main Content --> <!-- Main Content -->
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>

View File

@@ -1,10 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="jumbotron my-4"> <div class="jumbotron jumbotron-fluid my-4">
<div class="text-center"> <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> <p>{{ error.description }}.</p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,16 +1,20 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<h3>Welcome, {{ session.userinfo.name }}</h3> <h3>
<!-- Welcome, {{ auth.full_name }} -->
authentication info
</h3>
<hr> <hr>
<h4>authentication info</h4> <!-- <h4>authentication info</h4> -->
<div class="row data"> <div class="row data">
<div class="col col-2"> <div class="col col-2">
<strong>email</strong> <strong>full name</strong>
</div> </div>
<div class="col col-6"> <div class="col col-6">
{{ session.userinfo.email }} <span data-toggle="tooltip" data-placement="right" title="OIDC username: {{ auth.login_name }}">
<!-- {{ session.userinfo.email_verified | fancyBool | safe }} --> {{ auth.full_name }}
</span>
</div> </div>
</div> </div>
<div class="row data"> <div class="row data">
@@ -18,7 +22,17 @@
<strong>username</strong> <strong>username</strong>
</div> </div>
<div class="col col-6"> <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> </div>
<div class="row data"> <div class="row data">
@@ -27,19 +41,44 @@
</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>
{{ session.userinfo.groups[0]}} {% if not auth.groups[0] or auth.groups[0] in config['ADMIN_GROUPS'] %}
<span class="badge badge-pill badge-warning">
{% else %}
<span class="badge badge-pill badge-dark">
{% endif %}
{{ auth.groups[0] | default('no group')}}
</span>
</div> </div>
</div> </div>
{% for group in session.userinfo.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;
</div> </div>
<div class="col col-6"> <div class="col col-6">
<i class="fas fa-angle-right"></i> {{ group }} <i class="fas fa-angle-right"></i>
{% if group in config['ADMIN_GROUPS'] %}
<span class="badge badge-pill badge-warning">
{% else %}
<span class="badge badge-pill badge-dark">
{% endif %}
{{ group }}
</span>
</div> </div>
</div> </div>
{% endfor %} {% 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> <hr>
<h4>your devices</h4> <h4>your devices</h4>
<div class="row strong"> <div class="row strong">
@@ -52,7 +91,7 @@
{% for node in userNodeList %} {% for node in userNodeList %}
<div class="row data"> <div class="row data">
<div class="col col-2"> <div class="col col-2">
{{ node.givenName}} <a href="{{url_for('main.node', nodeId=node.id) }}">{{ node.givenName}}</a>
</div> </div>
<div class="col col-2"> <div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}"> <span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
@@ -69,7 +108,7 @@
</div> </div>
<div class="col col-2"> <div class="col col-2">
<span data-toggle="tooltip" data-placement="right" title="delete"> <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> <i class="fas fa-trash"></i>
</a> </a>
</span> </span>

View File

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

View File

@@ -1,9 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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> <hr>
<p></p>
<table id="nodes" class="display" style="width:100%"> <table id="nodes" class="display" style="width:100%">
<thead> <thead>
<tr> <tr>
@@ -52,7 +62,7 @@
<td class="no-sort"> <td class="no-sort">
{% if node.expireDate and not node.expired %} {% if node.expireDate and not node.expired %}
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect"> <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> <i class="fas fa-plug"></i>
</a> </a>
</span> </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="/policy/view" target="_blank">View</a></button>
<button type="button" class="btn btn-outline-primary btn-sm plain"><a class="plain" href="/policy/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 %} {% block content %}
<h5> <h3>
Routing table routing table
<small class="text-muted"> <span class="text-muted" style="font-size: 60%;">
click on the icon in <em>enabled</em> column to toggle route status </span>
</small> </h3>
</h5> <footer class="blockquote-footer">
click on the icon in <em>enabled</em> column to toggle route status
</footer>
<hr> <hr>
<p></p>
<div class="row"> <div class="row">
<div class="col col-12"> <div class="col col-12">

View File

@@ -3,7 +3,6 @@
<h3>{{ user.name }}</h3> <h3>{{ user.name }}</h3>
<hr> <hr>
<p></p>
<div class="row"> <div class="row">
<div class="col col-3"> <div class="col col-3">
<strong>registered</strong> <strong>registered</strong>
@@ -49,7 +48,7 @@
<td class="no-sort"> <td class="no-sort">
{% if node.expireDate and not node.expired %} {% if node.expireDate and not node.expired %}
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect"> <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> <i class="fas fa-plug"></i>
</a> </a>
</span> </span>
@@ -57,7 +56,7 @@
<i class="fas fa-plug disabled"></i> <i class="fas fa-plug disabled"></i>
{% endif %} {% endif %}
<span data-toggle="tooltip" data-placement="right" title="delete"> <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> <i class="fas fa-trash"></i>
</a> </a>
</span> </span>

View File

@@ -3,7 +3,6 @@
{% block content %} {% block content %}
<h3>users</h3> <h3>users</h3>
<hr> <hr>
<p></p>
<table id="users" class="display" style="width:100%"> <table id="users" class="display" style="width:100%">
<thead> <thead>
<tr> <tr>
@@ -17,7 +16,7 @@
{% for user in users %} {% for user in users %}
<tr> <tr>
<td> <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}} {{user.name}}
</a> </a>
</td> </td>
@@ -31,7 +30,7 @@
</td> </td>
<td class="no-sort"> <td class="no-sort">
<span data-toggle="tooltip" data-placement="right" title="delete"> <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> <i class="fas fa-trash"></i>
</a> </a>
</span> </span>

View File

@@ -2,16 +2,16 @@ import logging
import datetime import datetime
import os import os
from flask import current_app 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 flask import redirect, session, url_for
from app import auth from app import auth
# from ..lib import username # from ..lib import username
from flask import jsonify from flask import jsonify, make_response
from flask_pyoidc.user_session import UserSession 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 from hsapi_client.preauthkeys import v1ListPreAuthKeyRequest
@@ -26,27 +26,24 @@ def health():
return jsonify(dict(status="OK", version=current_app.config['APP_VERSION'])) 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']) @main_blueprint.route('/token', methods=['GET', 'POST'])
@auth.authorize_admins('default') @auth.authorize_admins('default')
def token(): def token():
user_session = UserSession(session) user_session = UserSession(session)
# return jsonify(user_session.userinfo)
return jsonify(access_token=user_session.access_token, return jsonify(access_token=user_session.access_token,
id_token=user_session.id_token, id_token=user_session.id_token,
userinfo=user_session.userinfo) 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') @main_blueprint.route('/logout')
@auth.oidc_logout @auth.oidc_logout
def logout(): def logout():
@@ -62,16 +59,18 @@ def nodes():
@main_blueprint.route('/node/<int:nodeId>', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>', methods=['GET'])
@auth.authorize_admins('default') @auth.access_control('default')
def node(nodeId): def node(nodeId):
# There is a bug in HS api with retrieving a single node # There is a bug in HS api with retrieving a single node
# and we added a workaround to hsapi, so node.get() returns a # and we added a workaround to hsapi, so node.get() returns a
# v1Node object instead of v1NodeResponse, so we access directly # v1Node object instead of v1NodeResponse, so we access directly
# `node`, instead of `node.node` # `node`, instead of `node.node`
if not auth.userOrAdmin(auth.username):
return auth.unathorized
node = Node().get(nodeId) node = Node().get(nodeId)
routes = Node().routes(nodeId) routes = Node().routes(nodeId)
isExitNode = any( isExitNode = any(
(r for r in routes.routes if r.prefix.endswith('/0') and r.enabled)) (r for r in routes.routes if r.prefix.endswith('0/0') and r.enabled))
return render_template("node.html", return render_template("node.html",
routes=routes.routes, routes=routes.routes,
isExitNode=isExitNode, isExitNode=isExitNode,
@@ -118,11 +117,11 @@ def user(userName):
def routes(): def routes():
routes = Route().list() routes = Route().list()
prefixes = set( prefixes = sorted(set(
(r.prefix for r in routes.routes if not r.prefix.endswith('/0'))) (r.prefix for r in routes.routes if not r.prefix.endswith('/0'))))
exitNodes = [r.node for r in routes.routes if r.prefix.endswith( exitNodes = [r.node for r in routes.routes if r.prefix.endswith(
'/0') and r.enabled] '0/0') and r.enabled]
final = {} final = {}
for prefix in prefixes: for prefix in prefixes:
@@ -131,3 +130,26 @@ def routes():
return render_template("routes.html", return render_template("routes.html",
exitNodes=exitNodes, exitNodes=exitNodes,
routes=final) 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):
log.debug(f"action: {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 flask import redirect, url_for
from app import auth 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, Policy
from hsapi_client import Node, User, Route, PreAuthKey
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
log = logging.getLogger() log = logging.getLogger()
# REST calls
# REST calls
rest_blueprint = Blueprint( rest_blueprint = Blueprint(
'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/')) 'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/'))
@@ -23,98 +26,58 @@ rest_blueprint = Blueprint(
@rest_blueprint.route('/routeToggle/<int:routeId>', methods=['GET']) @rest_blueprint.route('/routeToggle/<int:routeId>', methods=['GET'])
@auth.authorize_admins('default') @auth.authorize_admins('default')
def routeToggle(routeId: int): def routeToggle(routeId: int):
routes = Route().list() route = Route().get(routeId)
route = [r for r in routes.routes if r.id == routeId]
if route: if route:
route = route[0]
if route.enabled: if route.enabled:
action = 'disabled' action = 'disabled'
Route().disable(routeId)
else: else:
Route().enable(routeId)
action = 'enabled' action = 'enabled'
log.info( log.info(
f"route '{route.prefix}' via '{route.node.givenName}'" f"route '{route.prefix}' via '{route.node.givenName}' "
f"{action} by '{username()}'") f"{action} by '{auth.username}@{remote_ip()}'")
return redirect(url_for("main.routes")) Route().toggle(routeId)
return redirect(request.referrer)
@rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET']) @rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
@auth.authorize_admins('default') @auth.access_control('default')
def expireNode(nodeId: int): 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. This expires a node from the node page.
The difference from above is that it returns to the /node/nodeId page The difference from above is that it returns to the /node/nodeId page
""" """
node = Node().get(nodeId) node = Node().get(nodeId)
userName = node.user.name if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().expire(nodeId) Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'") log.info(f"node '{nodeId}' expired by '{auth.username}@{remote_ip()}'")
return redirect(url_for("main.user", userName=userName)) return redirect(request.referrer)
@rest_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET']) @rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
@auth.authorize_admins('default') @auth.access_control('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): 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) node = Node().get(nodeId)
if node.user.name != username(): if not auth.userOrAdmin(node.user.name):
response = jsonify({'message': 'not authorized'}) return auth.unathorized
return response, 401 Node().expire(nodeId)
Node().delete(nodeId) Node().delete(nodeId)
log.info(f"'{username()}' delete their own node '{nodeId}'") log.info(f"node '{nodeId}' deleted by '{auth.username}@{remote_ip()}'")
return redirect(url_for("main.index")) 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') @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): def renameNode(nodeId: int, newName: str):
node = Node().get(nodeId)
if not auth.userOrAdmin(node.user.name):
return auth.unathorized
Node().rename(nodeId, newName) Node().rename(nodeId, newName)
return jsonify(dict(newName=newName)) return jsonify(dict(newName=newName))
@ rest_blueprint.route('/user/<userName>/delete', methods=['GET']) @rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def deleteUser(userName: str): def deleteUser(userName: str):
nodes = Node().byUser(userName) nodes = Node().byUser(userName)
for node in nodes.nodes: for node in nodes.nodes:
@@ -124,11 +87,10 @@ def deleteUser(userName: str):
return redirect(url_for("main.users")) return redirect(url_for("main.users"))
@ rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST']) @rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def createPKA(userName: str): def createPKA(userName: str):
data = request.json data = request.json
log.debug(data)
expiration = f"{data['expiration']}:00Z" expiration = f"{data['expiration']}:00Z"
req = v1CreatePreAuthKeyRequest(user=userName, req = v1CreatePreAuthKeyRequest(user=userName,
reusable=data['reusable'], reusable=data['reusable'],
@@ -138,11 +100,35 @@ def createPKA(userName: str):
return jsonify(dict(key=pak.preAuthKey.key)) return jsonify(dict(key=pak.preAuthKey.key))
@ rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET']) @rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def expirePKA(userName: str, key: str): def expirePKA(userName: str, key: str):
log.debug(key)
req = v1ExpirePreAuthKeyRequest(user=userName, key=key) req = v1ExpirePreAuthKeyRequest(user=userName, key=key)
PreAuthKey().expire(req) PreAuthKey().expire(req)
return redirect(url_for('main.user', userName=userName)) 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

@@ -17,7 +17,7 @@ class BaseConfig(object):
# All the followinf vars can be overriden # All the followinf 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"
OIDC_CLIENT_ID = 'client-id' OIDC_CLIENT_ID = 'client-id'
OIDC_CLIENT_SECRET = 'client-secreto' OIDC_CLIENT_SECRET = 'client-secreto'
OIDC_URL = "https://myidp.example.com/auth" OIDC_URL = "https://myidp.example.com/auth"
@@ -38,6 +38,7 @@ class DevelopmentConfig(BaseConfig):
DEBUG = True DEBUG = True
ENVIRONMENT = "develop" ENVIRONMENT = "develop"
TEMPLATES_AUTO_RELOAD = True
class TestingConfig(BaseConfig): class TestingConfig(BaseConfig):

View File

@@ -11,7 +11,7 @@ preload_app = True
# logconfig = "app/logging/production.ini" # logconfig = "app/logging/production.ini"
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. # Log to stdout.
accesslog = "-" accesslog = "-"
errorlog = "-" errorlog = "-"

1135
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hsman" name = "hsman"
version = "0.9.4" version = "0.9.21"
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"
@@ -15,7 +15,8 @@ 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.2" hsapi-client = "^0.9.9"
# hsapi_client = { path = "../hsapi-client", develop = true }
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]

View File

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