Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fcae05d20 | |||
|
0409ac3d08
|
|||
|
425a1cd094
|
|||
|
31910dc034
|
|||
|
7fa17adfb1
|
|||
|
3301a36b5f
|
|||
|
da542f0bc2
|
|||
|
2cfbafbda5
|
|||
|
9b7b903c57
|
|||
|
379fef4b00
|
|||
|
bdba6db42d
|
|||
|
4fb45c41bd
|
|||
|
a1c66152ae
|
|||
|
2a38fb14dd
|
|||
|
d86b2b58c2
|
|||
|
b91e73f3a5
|
|||
|
71a3413cbe
|
|||
|
a1dadcd709
|
|||
|
b9fd722016
|
|||
|
c780b12b74
|
|||
|
2719ecadf0
|
|||
|
fe9b42e8a2
|
|||
| 14b09a0fcf | |||
|
c39c3a0ab6
|
|||
|
07ac2edb53
|
|||
|
50097ce5b3
|
|||
|
4b28db6a13
|
|||
|
24ca9d59f6
|
|||
|
0500a468a1
|
22
README.md
22
README.md
@@ -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:
|
||||
|
||||
| Variable | Usage | Default |
|
||||
| -------------------------- | ---------------------------------------- | :-----: |
|
||||
| `APPLICATION_ROOT` | Base URI path for the app | `/` |
|
||||
| `HSMAN_SECRET_KEY` | Flask app secret key | |
|
||||
| `HSMAN_ADMIN_GROUPS` | User groups that are considered admins | |
|
||||
| `HSMAN_OIDC_CLIENT_ID` | OIDC client ID | |
|
||||
| `HSMAN_OIDC_CLIENT_SECRET` | OIDC clietn secret | |
|
||||
| `HSMAN_OIDC_URL` | OIDC server URL | |
|
||||
| `HSMAN_OIDC_REDIRECT_URI` | OIDC redirect URI | |
|
||||
| `HSAPI_SERVER` | Headscale server URL | |
|
||||
| `HSAPI_API_TOKEN` | API token/key to access headscale server | |
|
||||
| Variable | Usage | Default |
|
||||
| -------------------------- | -------------------------------------------------------------- | :-----: |
|
||||
| `APPLICATION_ROOT` | Base URI path for the app | `/` |
|
||||
| `HSMAN_SECRET_KEY` | Flask app secret key | |
|
||||
| `HSMAN_ADMIN_GROUPS` | Comma separated list of user groups that are considered admins | |
|
||||
| `HSMAN_OIDC_CLIENT_ID` | OIDC client ID | |
|
||||
| `HSMAN_OIDC_CLIENT_SECRET` | OIDC clietn secret | |
|
||||
| `HSMAN_OIDC_URL` | OIDC server URL | |
|
||||
| `HSMAN_OIDC_REDIRECT_URI` | OIDC redirect URI | |
|
||||
| `HSAPI_SERVER` | Headscale server URL | |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from flask import Flask, render_template
|
||||
from flask import Flask, render_template, g
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from flask_mobility import Mobility
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
|
||||
|
||||
|
||||
from . import filters
|
||||
from .lib import OIDCAuthentication
|
||||
@@ -29,6 +27,9 @@ def create_app(environment='development'):
|
||||
app.config.from_prefixed_env(prefix="HSMAN")
|
||||
config[env].configure(app)
|
||||
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")
|
||||
mobility.init_app(app)
|
||||
@@ -38,21 +39,24 @@ def create_app(environment='development'):
|
||||
|
||||
# Register blueprints.
|
||||
from .views import main_blueprint, rest_blueprint
|
||||
app.logger.info(f"registering main blueprint with prefix '{
|
||||
main_blueprint.url_prefix}'")
|
||||
app.logger.info(f"register blueprint: 'main' [prefix '{
|
||||
main_blueprint.url_prefix}']")
|
||||
app.register_blueprint(main_blueprint)
|
||||
|
||||
app.logger.info(f"registering rest blueprint with prefix '{
|
||||
rest_blueprint.url_prefix}'")
|
||||
app.logger.info(f"register blueprint: 'rest' [prefix '{
|
||||
rest_blueprint.url_prefix}']")
|
||||
app.register_blueprint(rest_blueprint)
|
||||
|
||||
app.logger.info("jinja2 custom filters loaded")
|
||||
filters.init_app(app)
|
||||
|
||||
# Error handlers.
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_error(exc):
|
||||
return render_template('error.html', error=exc), exc.code
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth():
|
||||
return dict(auth=auth)
|
||||
|
||||
return app
|
||||
|
||||
97
app/lib.py
97
app/lib.py
@@ -2,7 +2,7 @@ import os
|
||||
import functools
|
||||
|
||||
from flask import request, abort, current_app
|
||||
from flask import session as flask_session
|
||||
from flask import session as flask_session, jsonify
|
||||
from flask_pyoidc import OIDCAuthentication as _OIDCAuth
|
||||
from flask_pyoidc.user_session import UserSession
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
|
||||
@@ -21,19 +21,6 @@ def remote_ip() -> str:
|
||||
return str(request.environ.get('REMOTE_ADDR'))
|
||||
|
||||
|
||||
def username() -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
return userinfo['email'].split('@')[0]
|
||||
|
||||
|
||||
def login_name() -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
if 'preferred_username' in userinfo:
|
||||
return userinfo['preferred_username']
|
||||
else:
|
||||
return username()
|
||||
|
||||
|
||||
def webMode() -> bool:
|
||||
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
|
||||
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"
|
||||
@@ -64,6 +51,68 @@ class OIDCAuthentication(_OIDCAuth):
|
||||
super().init_app(app)
|
||||
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):
|
||||
if provider_name not in self._provider_configurations:
|
||||
raise ValueError(
|
||||
@@ -76,7 +125,7 @@ class OIDCAuthentication(_OIDCAuth):
|
||||
|
||||
# Decorator
|
||||
def oidc_decorator(view_func):
|
||||
@ functools.wraps(view_func)
|
||||
@functools.wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Retrieve session and client
|
||||
session = UserSession(flask_session, provider_name)
|
||||
@@ -165,23 +214,7 @@ class OIDCAuthentication(_OIDCAuth):
|
||||
"""
|
||||
|
||||
def _authz_fn(session) -> bool:
|
||||
user_groups = session.userinfo.get('groups', [])
|
||||
username = session.userinfo.get('preferred_username', "")
|
||||
with current_app.app_context():
|
||||
admin_groups = current_app.config.get('ADMIN_GROUPS', [])
|
||||
admin_users = current_app.config.get('ADMIN_USERS', [])
|
||||
|
||||
authorized_groups = set(admin_groups).intersection(user_groups)
|
||||
|
||||
if len(authorized_groups):
|
||||
log.debug(f"'{username}' is a member of {
|
||||
authorized_groups}")
|
||||
return True
|
||||
|
||||
if username in admin_users:
|
||||
log.debug(f"'{username}' is an admin user")
|
||||
return True
|
||||
return False
|
||||
return self.isAdmin
|
||||
|
||||
return self.authorize(provider_name,
|
||||
authz_fn=_authz_fn)
|
||||
|
||||
@@ -13,7 +13,7 @@ level = INFO
|
||||
handlers = console
|
||||
|
||||
[logger_access]
|
||||
level = INFO
|
||||
level = ERROR
|
||||
handlers = console
|
||||
qualname = gunicorn.access
|
||||
propagate = 0
|
||||
|
||||
7
app/static/bootstrap/bootstrap-grid.min.css
vendored
Normal file
7
app/static/bootstrap/bootstrap-grid.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
app/static/bootstrap/bootstrap-reboot.min.css
vendored
Normal file
8
app/static/bootstrap/bootstrap-reboot.min.css
vendored
Normal 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 */
|
||||
7
app/static/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
7
app/static/bootstrap/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
app/static/bootstrap/bootstrap.min.css
vendored
11
app/static/bootstrap/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
8
app/static/bootstrap/bootstrap.min.js
vendored
8
app/static/bootstrap/bootstrap.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -20,6 +20,8 @@ body > .container,
|
||||
body > .container-fluid {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
margin-left: 200px;
|
||||
margin-right: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -62,7 +64,7 @@ a:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle {
|
||||
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle, a.plain {
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: unset;
|
||||
}
|
||||
@@ -118,3 +120,15 @@ i.disabled {
|
||||
span.expired {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.copy:hover {
|
||||
transform: scale(1.5, 1.5);
|
||||
-ms-transform: scale(1.5, 1.5)); /* IE 9 */
|
||||
-webkit-transform: scale(1.5, 1.5);
|
||||
}
|
||||
.copy:hover::after {
|
||||
content: "📄 click to copy";
|
||||
font-size: 80%;
|
||||
/* font-style:oblique; */
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,10 @@ function renameNode(nodeId) {
|
||||
}
|
||||
|
||||
function createPKA(username) {
|
||||
console.log(username);
|
||||
var url = `${username}/pakcreate`;
|
||||
var ephemereal = $("#ephemereal").is(":checked");
|
||||
var reusable = $("#reusable").is(":checked");
|
||||
var expiration = $("#expiration").val();
|
||||
console.log(expiration);
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "POST",
|
||||
@@ -65,3 +63,65 @@ function toggleExpired(obj) {
|
||||
$(".pka-expired").addClass("pka-hide");
|
||||
}
|
||||
}
|
||||
|
||||
function backfillips(obj) {
|
||||
var url = "backfillips";
|
||||
var button = $(obj);
|
||||
var original = button.html();
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
xhrFields: {
|
||||
withCredentials: true,
|
||||
},
|
||||
data: {},
|
||||
success: function (data) {
|
||||
if (data.length) {
|
||||
button.html("Updated");
|
||||
} else {
|
||||
button.html("Done");
|
||||
}
|
||||
setTimeout(function () {
|
||||
button.html(original);
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function uploadACL(obj) {
|
||||
var fd = new FormData();
|
||||
var files = $("#upload")[0].files[0];
|
||||
fd.append("file", files);
|
||||
|
||||
// When we close the modal, we reload the page
|
||||
$("#uploadACL").on("hidden.bs.modal", function (event) {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "policy/upload",
|
||||
type: "POST",
|
||||
xhrFields: {
|
||||
withCredentials: true,
|
||||
},
|
||||
data: fd,
|
||||
contentType: false,
|
||||
processData: false,
|
||||
success: function (response) {
|
||||
if (response != 0) {
|
||||
$("#output").html("acl updated");
|
||||
} else {
|
||||
$("#output").html("acl not updated");
|
||||
}
|
||||
setTimeout(() => {
|
||||
$("#uploadACL").modal("hide");
|
||||
}, 5000);
|
||||
},
|
||||
error: function (response) {
|
||||
console.log(response.responseJSON.message);
|
||||
$("#output").html(response.responseJSON.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,15 +50,20 @@
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
{% if auth.isAdmin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.nodes') }}">nodes</a>
|
||||
<a class="nav-link" href="{{ url_for('main.nodes') }}"><i class="fas fa-desktop"></i> nodes</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.users') }}">users</a>
|
||||
<a class="nav-link" href="{{ url_for('main.users') }}"><i class="fas fa-address-card"></i> users</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.routes') }}">routes</a>
|
||||
<a class="nav-link" href="{{ url_for('main.routes') }}"><i class="fas fa-satellite-dish"></i> routes</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.policy') }}"><i class="fas fa-user-shield"></i> policy</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item me-right">
|
||||
@@ -74,8 +79,9 @@
|
||||
{% if g.is_mobile %}
|
||||
<div class="container-fluid">
|
||||
{% else %}
|
||||
<div class="container">
|
||||
<div class="container-lg">
|
||||
{% endif %}
|
||||
<p></p>
|
||||
<!-- Main Content -->
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="jumbotron my-4">
|
||||
<div class="jumbotron jumbotron-fluid my-4">
|
||||
<div class="text-center">
|
||||
<h1>{{ '%s - %s' % (error.code, error.name) }}</h1>
|
||||
<h1>Oops, something went wrong</h1>
|
||||
<h1>{{ '%s - %s' % (error.code, error.name) }}</h2>
|
||||
<p>{{ error.description }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>Welcome, {{ session.userinfo.name }}</h3>
|
||||
<h3>
|
||||
<!-- Welcome, {{ auth.full_name }} -->
|
||||
authentication info
|
||||
</h3>
|
||||
<hr>
|
||||
<h4>authentication info</h4>
|
||||
<!-- <h4>authentication info</h4> -->
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>email</strong>
|
||||
<strong>full name</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ session.userinfo.email }}
|
||||
<!-- {{ session.userinfo.email_verified | fancyBool | safe }} -->
|
||||
<span data-toggle="tooltip" data-placement="right" title="OIDC username: {{ auth.login_name }}">
|
||||
{{ auth.full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
@@ -18,7 +22,17 @@
|
||||
<strong>username</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ session.userinfo.preferred_username }}
|
||||
<span data-toggle="tooltip" data-placement="right" title="OIDC username: {{ auth.login_name }}">
|
||||
{{ auth.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>email</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ auth.email }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
@@ -27,19 +41,44 @@
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
<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>
|
||||
{% for group in session.userinfo.groups[1:] |sort %}
|
||||
{% for group in auth.groups[1:] | default([]) |sort %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
|
||||
</div>
|
||||
<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>
|
||||
{% endfor %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>access level</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{% if auth.isAdmin %}
|
||||
<span class="badge badge-pill badge-danger">ADMIN</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pill badge-info">USER</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<h4>your devices</h4>
|
||||
<div class="row strong">
|
||||
@@ -52,7 +91,7 @@
|
||||
{% for node in userNodeList %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
{{ node.givenName}}
|
||||
<a href="{{url_for('main.node', nodeId=node.id) }}">{{ node.givenName}}</a>
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
|
||||
@@ -69,7 +108,7 @@
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
<span data-toggle="tooltip" data-placement="right" title="delete">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteOwnNode', nodeId=node.id) }}">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
</a>
|
||||
</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>status</strong>
|
||||
@@ -36,6 +35,9 @@
|
||||
<div class="col col-8 float-left">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
|
||||
{{ node.createdAt | htime_dt }}
|
||||
<span class="badge badge-pill badge-warning">
|
||||
{{ node.registerMethod.name }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,8 +75,11 @@
|
||||
<h5>addresses</h5>
|
||||
{% for ip in node.ipAddresses %}
|
||||
<div class="row data">
|
||||
<div class="col col-3">
|
||||
{{ ip }}
|
||||
<div class="col col-6">
|
||||
<span class="address copy"
|
||||
value="{{ ip }}">
|
||||
{{ ip }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -126,7 +131,9 @@
|
||||
<strong>machineKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<code>{{ node.machineKey }}</code>
|
||||
<span class="copy" value="{{ node.machineKey }}">
|
||||
<code>{{ node.machineKey }}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +142,9 @@
|
||||
<strong>nodeKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<code>{{ node.nodeKey }}</code>
|
||||
<span class="copy" value="{{ node.nodeKey }}">
|
||||
<code>{{ node.nodeKey }}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,7 +153,9 @@
|
||||
<strong>discoKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<span class="copy" value="{{ node.discoKey }}">
|
||||
<code>{{ node.discoKey }}</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
@@ -210,3 +221,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(function () {
|
||||
$('.copy').on('click', function() {
|
||||
copyToClipboard(this)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>nodes</h3>
|
||||
<div class="row data justify-content-between">
|
||||
<div class="col col-4">
|
||||
<h3>nodes</h3>
|
||||
</div>
|
||||
<div class="col col-4">
|
||||
<div class="float-right">
|
||||
<span data-toggle="tooltip" data-placement="right" title="Recheck all IP addresses of all nodes">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" onClick="backfillips(this);">Backfill IP addresses</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<p></p>
|
||||
<table id="nodes" class="display" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -52,7 +62,7 @@
|
||||
<td class="no-sort">
|
||||
{% if node.expireDate and not node.expired %}
|
||||
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
|
||||
<a class="nodeco" href="{{ url_for('rest.expireNodeList', nodeId=node.id) }}">
|
||||
<a class="nodeco" href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
|
||||
<i class="fas fa-plug"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
124
app/templates/policy.html
Normal file
124
app/templates/policy.html
Normal file
@@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row data justify-content-between">
|
||||
<div class="col col-4">
|
||||
<h3>policy</h3>
|
||||
<footer class="blockquote-footer">
|
||||
for more info see <a href="https://tailscale.com/kb/1337/acl-syntax" target="_blank">tailscale docs</a>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="col col-4">
|
||||
<div class="float-right">
|
||||
last update:
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ policy.updatedAt | fmt_datetime }}">
|
||||
{{policy.updatedAt | htime_dt }}
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain"><a class="plain" href="{{ url_for('main.policy', action='view') }}" target="_blank">View</a></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain"><a class="plain" href="{{ url_for('main.policy', action='download') }}">Download</a></button>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm plain" data-toggle="modal" data-target="#uploadACL">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col col-6">
|
||||
<div class="row">
|
||||
<div class="col col-4"><h5>source</h5></div>
|
||||
<div class="col col-8"><h5>destination</h5></div>
|
||||
</div>
|
||||
{% for acl in policy.policy['acls'] %}
|
||||
<div class="row data">
|
||||
<div class="col col-4">
|
||||
{{ ','.join(acl['src']) }}</div>
|
||||
<div class="col col-8">{{ ',<br />'.join(acl['dst']) | safe }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="col col-6">
|
||||
<!-- groups -->
|
||||
<div id="groups">
|
||||
<div class="card">
|
||||
<div class="card-header" id="groupsHeading">
|
||||
<h5 class="mb-0">
|
||||
<button class="btn btn-link" data-toggle="collapse" data-target="#groupsContent" aria-expanded="true" aria-controls="groupsContent">
|
||||
groups
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="groupsContent" class="collapse show" aria-labelledby="groupsHeading" data-parent="#groups">
|
||||
<div class="card-body">
|
||||
{% for group,users in policy.policy['groups'].items() %}
|
||||
<div class="row data">
|
||||
<div class="col col-6">
|
||||
{{ group }}
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ ", ".join(users) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- hosts -->
|
||||
<div class="hosts">
|
||||
<div class="card">
|
||||
<div class="card-header" id="hostsHeading">
|
||||
<h5 class="mb-0">
|
||||
<button class="btn btn-link" data-toggle="collapse" data-target="#hostsContent" aria-expanded="true" aria-controls="hostsContent">
|
||||
hosts
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="hostsContent" class="collapse" aria-labelledby="hostsHeading" data-parent="#hosts">
|
||||
<div class="card-body">
|
||||
{% for host, value in policy.policy['hosts'].items() %}
|
||||
<div class="row data">
|
||||
<div class="col col3">
|
||||
{{ host }}
|
||||
</div>
|
||||
<div class="col col3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- upload acl modal -->
|
||||
<div class="modal fade" id="uploadACL" tabindex="-1" role="dialog" aria-labelledby="uploadACL" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renameModalLabel">upload ACL</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</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 %}
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h5>
|
||||
Routing table
|
||||
<small class="text-muted">
|
||||
click on the icon in <em>enabled</em> column to toggle route status
|
||||
</small>
|
||||
</h5>
|
||||
<h3>
|
||||
routing table
|
||||
<span class="text-muted" style="font-size: 60%;">
|
||||
</span>
|
||||
</h3>
|
||||
<footer class="blockquote-footer">
|
||||
click on the icon in <em>enabled</em> column to toggle route status
|
||||
</footer>
|
||||
<hr>
|
||||
<p></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<h3>{{ user.name }}</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<div class="row">
|
||||
<div class="col col-3">
|
||||
<strong>registered</strong>
|
||||
@@ -49,7 +48,7 @@
|
||||
<td class="no-sort">
|
||||
{% if node.expireDate and not node.expired %}
|
||||
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
|
||||
<a class="nodeco" href="{{ url_for('rest.expireNodeUser', nodeId=node.id) }}">
|
||||
<a class="nodeco" href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
|
||||
<i class="fas fa-plug"></i>
|
||||
</a>
|
||||
</span>
|
||||
@@ -57,7 +56,7 @@
|
||||
<i class="fas fa-plug disabled"></i>
|
||||
{% endif %}
|
||||
<span data-toggle="tooltip" data-placement="right" title="delete">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteNodeUser', nodeId=node.id) }}">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
{% block content %}
|
||||
<h3>users</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<table id="users" class="display" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -17,7 +16,7 @@
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="plain" href="{{ url_for('main.user', userName=user.name)}}">
|
||||
<a class="plain" href="{{ url_for('main.user', userName=user.name) }}">
|
||||
{{user.name}}
|
||||
</a>
|
||||
</td>
|
||||
@@ -31,7 +30,7 @@
|
||||
</td>
|
||||
<td class="no-sort">
|
||||
<span data-toggle="tooltip" data-placement="right" title="delete">
|
||||
<a class="nodeco" href="/user/{{user.name}}/delete">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteUser', userName=user.name) }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -2,16 +2,16 @@ import logging
|
||||
import datetime
|
||||
import os
|
||||
from flask import current_app
|
||||
from flask import render_template, Blueprint, request
|
||||
from flask import render_template, Blueprint
|
||||
from flask import redirect, session, url_for
|
||||
from app import auth
|
||||
|
||||
# from ..lib import username
|
||||
|
||||
from flask import jsonify
|
||||
from flask import jsonify, make_response
|
||||
from flask_pyoidc.user_session import UserSession
|
||||
|
||||
from hsapi_client import Node, User, Route, PreAuthKey
|
||||
from hsapi_client import Node, User, Route, PreAuthKey, Policy
|
||||
from hsapi_client.preauthkeys import v1ListPreAuthKeyRequest
|
||||
|
||||
|
||||
@@ -26,27 +26,24 @@ def health():
|
||||
return jsonify(dict(status="OK", version=current_app.config['APP_VERSION']))
|
||||
|
||||
|
||||
@main_blueprint.route('/', methods=['GET', 'POST'])
|
||||
@auth.access_control('default')
|
||||
def index():
|
||||
user_session = UserSession(session)
|
||||
hs_user = user_session.userinfo['email'].split('@')[0]
|
||||
userNodeList = [n for n in Node().list().nodes if n.user.name == hs_user]
|
||||
return render_template('index.html',
|
||||
userNodeList=userNodeList,
|
||||
session=user_session)
|
||||
|
||||
|
||||
@main_blueprint.route('/token', methods=['GET', 'POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def token():
|
||||
user_session = UserSession(session)
|
||||
# return jsonify(user_session.userinfo)
|
||||
return jsonify(access_token=user_session.access_token,
|
||||
id_token=user_session.id_token,
|
||||
userinfo=user_session.userinfo)
|
||||
|
||||
|
||||
@main_blueprint.route('/', methods=['GET', 'POST'])
|
||||
@auth.access_control('default')
|
||||
def index():
|
||||
hs_user = auth.username
|
||||
userNodeList = [n for n in Node().list().nodes if n.user.name == hs_user]
|
||||
return render_template('index.html',
|
||||
userNodeList=userNodeList)
|
||||
|
||||
|
||||
@main_blueprint.route('/logout')
|
||||
@auth.oidc_logout
|
||||
def logout():
|
||||
@@ -62,16 +59,18 @@ def nodes():
|
||||
|
||||
|
||||
@main_blueprint.route('/node/<int:nodeId>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
@auth.access_control('default')
|
||||
def node(nodeId):
|
||||
# There is a bug in HS api with retrieving a single node
|
||||
# and we added a workaround to hsapi, so node.get() returns a
|
||||
# v1Node object instead of v1NodeResponse, so we access directly
|
||||
# `node`, instead of `node.node`
|
||||
if not auth.userOrAdmin(auth.username):
|
||||
return auth.unathorized
|
||||
node = Node().get(nodeId)
|
||||
routes = Node().routes(nodeId)
|
||||
isExitNode = any(
|
||||
(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",
|
||||
routes=routes.routes,
|
||||
isExitNode=isExitNode,
|
||||
@@ -118,11 +117,11 @@ def user(userName):
|
||||
def routes():
|
||||
routes = Route().list()
|
||||
|
||||
prefixes = set(
|
||||
(r.prefix for r in routes.routes if not r.prefix.endswith('/0')))
|
||||
prefixes = sorted(set(
|
||||
(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(
|
||||
'/0') and r.enabled]
|
||||
'0/0') and r.enabled]
|
||||
|
||||
final = {}
|
||||
for prefix in prefixes:
|
||||
@@ -131,3 +130,25 @@ def routes():
|
||||
return render_template("routes.html",
|
||||
exitNodes=exitNodes,
|
||||
routes=final)
|
||||
|
||||
|
||||
@main_blueprint.route('/policy', defaults={'action': None}, methods=['GET'])
|
||||
@main_blueprint.route('/policy/<action>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def policy(action):
|
||||
policy = Policy().get()
|
||||
if action == "view":
|
||||
return policy.json
|
||||
|
||||
elif action == "download":
|
||||
updateStr = policy.updatedAt.strftime(format='%Y%m%d-%H%M')
|
||||
log.debug(updateStr)
|
||||
filename = f"acl-{updateStr}.json"
|
||||
response = make_response(policy.json)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename={
|
||||
filename}'
|
||||
|
||||
return response
|
||||
else:
|
||||
return render_template("policy.html",
|
||||
policy=policy)
|
||||
|
||||
@@ -4,18 +4,21 @@ from flask import Blueprint, request
|
||||
from flask import redirect, url_for
|
||||
from app import auth
|
||||
|
||||
from ..lib import login_name, username
|
||||
from flask import jsonify, make_response
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
from hsapi_client import Node, User, Route, PreAuthKey
|
||||
from hsapi_client import Node, User, Route, PreAuthKey, Policy
|
||||
from hsapi_client.preauthkeys import (v1CreatePreAuthKeyRequest,
|
||||
v1ExpirePreAuthKeyRequest)
|
||||
from hsapi_client.policies import v1Policy
|
||||
|
||||
from hsapi_client.config import HTTPException
|
||||
|
||||
from app.lib import remote_ip
|
||||
|
||||
|
||||
log = logging.getLogger()
|
||||
# REST calls
|
||||
|
||||
# REST calls
|
||||
rest_blueprint = Blueprint(
|
||||
'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/'))
|
||||
|
||||
@@ -23,98 +26,58 @@ rest_blueprint = Blueprint(
|
||||
@rest_blueprint.route('/routeToggle/<int:routeId>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def routeToggle(routeId: int):
|
||||
routes = Route().list()
|
||||
route = [r for r in routes.routes if r.id == routeId]
|
||||
route = Route().get(routeId)
|
||||
if route:
|
||||
route = route[0]
|
||||
if route.enabled:
|
||||
action = 'disabled'
|
||||
Route().disable(routeId)
|
||||
else:
|
||||
Route().enable(routeId)
|
||||
action = 'enabled'
|
||||
log.info(
|
||||
f"route '{route.prefix}' via '{route.node.givenName}'"
|
||||
f"{action} by '{username()}'")
|
||||
return redirect(url_for("main.routes"))
|
||||
f"route '{route.prefix}' via '{route.node.givenName}' "
|
||||
f"{action} by '{auth.username}@{remote_ip()}'")
|
||||
Route().toggle(routeId)
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
@auth.access_control('default')
|
||||
def expireNode(nodeId: int):
|
||||
"""
|
||||
This expires a node from the node page.
|
||||
The difference from above is that it returns to the /node/nodeId page
|
||||
"""
|
||||
Node().expire(nodeId)
|
||||
log.info(f"node '{nodeId}' expired by '{username()}'")
|
||||
return redirect(url_for("main.node", nodeId=nodeId))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/user-expire', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expireNodeUser(nodeId: int):
|
||||
"""
|
||||
This expires a node from the node page.
|
||||
The difference from above is that it returns to the /node/nodeId page
|
||||
"""
|
||||
node = Node().get(nodeId)
|
||||
userName = node.user.name
|
||||
if not auth.userOrAdmin(node.user.name):
|
||||
return auth.unathorized
|
||||
Node().expire(nodeId)
|
||||
log.info(f"node '{nodeId}' expired by '{username()}'")
|
||||
return redirect(url_for("main.user", userName=userName))
|
||||
log.info(f"node '{nodeId}' expired by '{auth.username}@{remote_ip()}'")
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expireNodeList(nodeId: int):
|
||||
"""
|
||||
This expires a node from the node list.
|
||||
The difference from above is that it returns to the /nodes page
|
||||
"""
|
||||
Node().expire(nodeId)
|
||||
log.info(f"node '{nodeId}' expired by '{username()}'")
|
||||
return redirect(url_for("main.nodes"))
|
||||
|
||||
|
||||
@ rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
|
||||
@ auth.authorize_admins('default')
|
||||
@rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
|
||||
@auth.access_control('default')
|
||||
def deleteNode(nodeId: int):
|
||||
Node().delete(nodeId)
|
||||
log.info(f"node '{nodeId}' deleted by '{username()}'")
|
||||
return redirect(url_for("main.nodes"))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/delete-own', methods=['GET'])
|
||||
@auth.access_control('default')
|
||||
def deleteOwnNode(nodeId: int):
|
||||
node = Node().get(nodeId)
|
||||
if node.user.name != username():
|
||||
response = jsonify({'message': 'not authorized'})
|
||||
return response, 401
|
||||
if not auth.userOrAdmin(node.user.name):
|
||||
return auth.unathorized
|
||||
Node().expire(nodeId)
|
||||
Node().delete(nodeId)
|
||||
log.info(f"'{username()}' delete their own node '{nodeId}'")
|
||||
return redirect(url_for("main.index"))
|
||||
log.info(f"node '{nodeId}' deleted by '{auth.username}@{remote_ip()}'")
|
||||
return redirect(request.referrer)
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/delete-user', methods=['GET'])
|
||||
@rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
|
||||
@auth.access_control('default')
|
||||
def deleteNodeUser(nodeId: int):
|
||||
node = Node().get(nodeId)
|
||||
Node().delete(nodeId)
|
||||
log.info(f"'{username()}' delete their own node '{nodeId}'")
|
||||
return redirect(url_for("main.user", userName=node.user.name))
|
||||
|
||||
|
||||
@ rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
|
||||
@ auth.authorize_admins('default')
|
||||
def renameNode(nodeId: int, newName: str):
|
||||
node = Node().get(nodeId)
|
||||
if not auth.userOrAdmin(node.user.name):
|
||||
return auth.unathorized
|
||||
Node().rename(nodeId, newName)
|
||||
return jsonify(dict(newName=newName))
|
||||
|
||||
|
||||
@ rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
|
||||
@ auth.authorize_admins('default')
|
||||
@rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def deleteUser(userName: str):
|
||||
nodes = Node().byUser(userName)
|
||||
for node in nodes.nodes:
|
||||
@@ -124,11 +87,10 @@ def deleteUser(userName: str):
|
||||
return redirect(url_for("main.users"))
|
||||
|
||||
|
||||
@ rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
|
||||
@ auth.authorize_admins('default')
|
||||
@rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def createPKA(userName: str):
|
||||
data = request.json
|
||||
log.debug(data)
|
||||
expiration = f"{data['expiration']}:00Z"
|
||||
req = v1CreatePreAuthKeyRequest(user=userName,
|
||||
reusable=data['reusable'],
|
||||
@@ -138,11 +100,35 @@ def createPKA(userName: str):
|
||||
return jsonify(dict(key=pak.preAuthKey.key))
|
||||
|
||||
|
||||
@ rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
|
||||
@ auth.authorize_admins('default')
|
||||
@rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expirePKA(userName: str, key: str):
|
||||
log.debug(key)
|
||||
req = v1ExpirePreAuthKeyRequest(user=userName, key=key)
|
||||
|
||||
PreAuthKey().expire(req)
|
||||
return redirect(url_for('main.user', userName=userName))
|
||||
|
||||
|
||||
@rest_blueprint.route('/backfillips', methods=['POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def backfillips():
|
||||
response = Node().backfillips()
|
||||
return jsonify(response.changes)
|
||||
|
||||
|
||||
@rest_blueprint.route('/policy/upload', methods=['POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def policyUpload():
|
||||
file = request.files['file']
|
||||
try:
|
||||
acl = ''.join(map(bytes.decode, file.readlines()))
|
||||
except UnicodeDecodeError as e:
|
||||
log.debug(e)
|
||||
return jsonify(message=str(e)), 422
|
||||
|
||||
policy = Policy()
|
||||
try:
|
||||
policy.put(acl)
|
||||
except HTTPException as e:
|
||||
return jsonify(message=str(e.message)), 422
|
||||
return jsonify(message="acl updated"), 200
|
||||
|
||||
@@ -17,7 +17,7 @@ class BaseConfig(object):
|
||||
# All the followinf vars can be overriden
|
||||
# in the environment, using `HSMAN_` prefix
|
||||
SECRET_KEY = "secreto"
|
||||
ADMIN_GROUPS = ["adminGroup"]
|
||||
ADMIN_GROUPS = "adminGroup"
|
||||
OIDC_CLIENT_ID = 'client-id'
|
||||
OIDC_CLIENT_SECRET = 'client-secreto'
|
||||
OIDC_URL = "https://myidp.example.com/auth"
|
||||
@@ -38,6 +38,7 @@ class DevelopmentConfig(BaseConfig):
|
||||
|
||||
DEBUG = True
|
||||
ENVIRONMENT = "develop"
|
||||
TEMPLATES_AUTO_RELOAD = True
|
||||
|
||||
|
||||
class TestingConfig(BaseConfig):
|
||||
|
||||
@@ -11,7 +11,7 @@ preload_app = True
|
||||
|
||||
# logconfig = "app/logging/production.ini"
|
||||
logconfig = "app/logging/production.ini"
|
||||
# access_log_format = "%(h)s %(l)s %(t)s %(r)s %(s)s %(b)s %(f)s %(a)s"
|
||||
access_log_format = "%(h)s %({x-forwarded-for}i)s %(r)s %(s)s %(b)s %(L)s"
|
||||
# Log to stdout.
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
|
||||
1135
poetry.lock
generated
1135
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "hsman"
|
||||
version = "0.9.4"
|
||||
version = "0.9.21"
|
||||
description = "Flask Admin webui for Headscale"
|
||||
authors = ["Andrea Mistrali <andrea@mistrali.pw>"]
|
||||
license = "BSD"
|
||||
@@ -15,7 +15,8 @@ flask-mobility = "^2.0.1"
|
||||
humanize = "^4.9.0"
|
||||
flask-pydantic = "^0.12.0"
|
||||
uvicorn = "^0.30.1"
|
||||
hsapi-client = "^0.9.2"
|
||||
hsapi-client = "^0.9.9"
|
||||
# hsapi_client = { path = "../hsapi-client", develop = true }
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
|
||||
3
wsgi.py
3
wsgi.py
@@ -1,6 +1,5 @@
|
||||
from app import create_app
|
||||
from app import lib
|
||||
from app import models
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
@@ -20,7 +19,7 @@ log.debug(f"Running in web mode: {lib.webMode()}")
|
||||
def get_context():
|
||||
# flask cli context setup
|
||||
"""Objects exposed here will be automatically available from the shell."""
|
||||
return dict(app=app, models=models)
|
||||
return dict(app=app)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user