Reorganize repos
58
app/__init__.py
Normal file
@ -0,0 +1,58 @@
|
||||
from flask import Flask, render_template
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from flask_mobility import Mobility
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
|
||||
|
||||
|
||||
from . import filters
|
||||
from .lib import OIDCAuthentication
|
||||
import os
|
||||
|
||||
mobility = Mobility()
|
||||
|
||||
auth = OIDCAuthentication()
|
||||
|
||||
|
||||
def create_app(environment='development'):
|
||||
|
||||
from config import config
|
||||
|
||||
# Instantiate app.
|
||||
app_prefix = os.getenv('APPLICATION_ROOT', '')
|
||||
app = Flask(__name__,
|
||||
static_url_path=f"{app_prefix}/static")
|
||||
|
||||
# Set app config.
|
||||
env = os.environ.get('FLASK_ENV', environment)
|
||||
app.config.from_object(config[env])
|
||||
app.config.from_prefixed_env(prefix="HSMAN")
|
||||
config[env].configure(app)
|
||||
app.config['APP_TZ'] = os.environ.get('TZ', 'UTC')
|
||||
|
||||
app.logger.info("middleware init: mobility")
|
||||
mobility.init_app(app)
|
||||
|
||||
app.logger.info("middleware init: auth")
|
||||
auth.init_app(app)
|
||||
|
||||
# Register blueprints.
|
||||
from .views import main_blueprint, rest_blueprint
|
||||
app.logger.info(f"registering main blueprint with prefix '{
|
||||
main_blueprint.url_prefix}'")
|
||||
app.register_blueprint(main_blueprint)
|
||||
|
||||
app.logger.info(f"registering rest blueprint with prefix '{
|
||||
rest_blueprint.url_prefix}'")
|
||||
app.register_blueprint(rest_blueprint)
|
||||
|
||||
app.logger.info("jinja2 custom filters loaded")
|
||||
filters.init_app(app)
|
||||
|
||||
# Error handlers.
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_error(exc):
|
||||
return render_template('error.html', error=exc), exc.code
|
||||
|
||||
return app
|
87
app/filters.py
Normal file
@ -0,0 +1,87 @@
|
||||
import humanize
|
||||
import datetime
|
||||
from flask import current_app
|
||||
from zoneinfo import ZoneInfo
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def htime(timestamp):
|
||||
if timestamp:
|
||||
dt = datetime.datetime.fromtimestamp(timestamp)
|
||||
return humanize.naturaltime(dt)
|
||||
|
||||
|
||||
def htime_dt(datetime):
|
||||
if datetime:
|
||||
try:
|
||||
return humanize.naturaltime(datetime)
|
||||
except ValueError:
|
||||
return "Never"
|
||||
return "Never"
|
||||
|
||||
|
||||
def hdate(timestamp):
|
||||
if timestamp:
|
||||
dt = datetime.datetime.fromtimestamp(timestamp)
|
||||
return humanize.naturaldate(dt)
|
||||
|
||||
|
||||
def hdate_dt(datetime):
|
||||
if datetime:
|
||||
try:
|
||||
return humanize.naturaldate(datetime)
|
||||
except ValueError:
|
||||
return "Never"
|
||||
return "Never"
|
||||
|
||||
|
||||
def fmt_timestamp(timestamp):
|
||||
with current_app.app_context():
|
||||
tz = ZoneInfo(current_app.config['APP_TZ'])
|
||||
if timestamp:
|
||||
local_ts = datetime.datetime.fromtimestamp(timestamp, tz)
|
||||
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
local_ts.tzname())
|
||||
|
||||
|
||||
def fmt_datetime(datetime):
|
||||
with current_app.app_context():
|
||||
tz = ZoneInfo(current_app.config['APP_TZ'])
|
||||
if datetime:
|
||||
try:
|
||||
local_ts = datetime.fromtimestamp(datetime.timestamp(), tz)
|
||||
except OverflowError:
|
||||
return "Never"
|
||||
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
local_ts.tzname())
|
||||
|
||||
|
||||
def fancyBool(bool_):
|
||||
if bool_:
|
||||
return '<span class="fancyTrue">✔</span>'
|
||||
else:
|
||||
return '<span class="fancyFalse">✘</span>'
|
||||
|
||||
|
||||
def fancyOnline(online):
|
||||
return ["offline", "online"][online]
|
||||
|
||||
|
||||
# A list of all of the template_filter functions to add in `init_app`
|
||||
FILTERS = [hdate,
|
||||
hdate_dt,
|
||||
htime,
|
||||
htime_dt,
|
||||
fmt_timestamp,
|
||||
fmt_datetime,
|
||||
fancyBool,
|
||||
fancyOnline]
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Register the template filters with an app instance"""
|
||||
log.debug("filters init")
|
||||
for func in FILTERS:
|
||||
app.jinja_env.filters[func.__name__] = func
|
180
app/lib.py
Normal file
@ -0,0 +1,180 @@
|
||||
import os
|
||||
import functools
|
||||
|
||||
from flask import request, abort, current_app
|
||||
from flask import session as flask_session
|
||||
from flask_pyoidc import OIDCAuthentication as _OIDCAuth
|
||||
from flask_pyoidc.user_session import UserSession
|
||||
from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
import logging
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def remote_ip() -> str:
|
||||
if 'HTTP_X_FORWARDED_FOR' in request.environ:
|
||||
xff_parts = request.environ.get('HTTP_X_FORWARDED_FOR').split(',')
|
||||
return xff_parts[0]
|
||||
else:
|
||||
return str(request.environ.get('REMOTE_ADDR'))
|
||||
|
||||
|
||||
def username() -> str:
|
||||
userinfo = flask_session['userinfo']
|
||||
if 'preferred_username' in userinfo:
|
||||
return userinfo['preferred_username']
|
||||
return userinfo['email']
|
||||
|
||||
|
||||
def webMode() -> bool:
|
||||
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
|
||||
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"
|
||||
|
||||
return is_gunicorn or is_werkzeug
|
||||
|
||||
|
||||
class OIDCAuthentication(_OIDCAuth):
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def init_app(self, app):
|
||||
client_metadata = ClientMetadata(
|
||||
client_id=app.config['OIDC_CLIENT_ID'],
|
||||
client_secret=app.config['OIDC_CLIENT_SECRET'])
|
||||
|
||||
provider_config = ProviderConfiguration(
|
||||
issuer=app.config['OIDC_URL'],
|
||||
client_metadata=client_metadata,
|
||||
auth_request_params={
|
||||
'scope': ['openid',
|
||||
'profile',
|
||||
'groups',
|
||||
'email']},
|
||||
session_refresh_interval_seconds=1800)
|
||||
super().__init__({'default': provider_config})
|
||||
super().init_app(app)
|
||||
|
||||
def authorize(self, provider_name: str, authz_fn: Callable, **kwargs):
|
||||
if provider_name not in self._provider_configurations:
|
||||
raise ValueError(
|
||||
f"Provider name '{provider_name}' not in configured providers: {
|
||||
self._provider_configurations.keys()}."
|
||||
)
|
||||
|
||||
# We save args with which we have been called
|
||||
external_args = kwargs
|
||||
|
||||
# Decorator
|
||||
def oidc_decorator(view_func):
|
||||
@ functools.wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Retrieve session and client
|
||||
session = UserSession(flask_session, provider_name)
|
||||
client = self.clients[session.current_provider]
|
||||
|
||||
# Check session validity
|
||||
if session.should_refresh(client.session_refresh_interval_seconds):
|
||||
log.debug('user auth will be refreshed "silently"')
|
||||
return self._authenticate(client, interactive=False)
|
||||
elif session.is_authenticated():
|
||||
log.debug('user is already authenticated')
|
||||
else:
|
||||
log.debug('user not authenticated, start flow')
|
||||
return self._authenticate(client)
|
||||
|
||||
# Call authorization function that must return true or false
|
||||
authorized = authz_fn(session, **external_args)
|
||||
if authorized:
|
||||
return view_func(*args, **kwargs)
|
||||
else:
|
||||
return abort(403)
|
||||
return wrapper
|
||||
|
||||
return oidc_decorator
|
||||
|
||||
def authorize_domains(self,
|
||||
provider_name: str,
|
||||
domains: List[str]):
|
||||
"""
|
||||
Authorize a user if the email domain is in a list of domains
|
||||
"""
|
||||
|
||||
def _authz_fn(session, domains) -> bool:
|
||||
email = session.userinfo.get('email', "")
|
||||
domain = email.split('@')[-1]
|
||||
|
||||
if domain in domains:
|
||||
return True
|
||||
return False
|
||||
|
||||
return self.authorize(provider_name,
|
||||
authz_fn=_authz_fn,
|
||||
domains=domains)
|
||||
|
||||
def authorize_users(self, provider_name: str, users: List[str]):
|
||||
"""
|
||||
Authorize a user if the username of the user part of the email
|
||||
is in a list of usernames
|
||||
"""
|
||||
|
||||
def _authz_fn(session, users) -> bool:
|
||||
username = session.userinfo.get('preferred_username', "")
|
||||
email = session.userinfo.get('email', "")
|
||||
email_user = email.split('@')[0]
|
||||
|
||||
if username in users or email_user in users:
|
||||
return True
|
||||
return False
|
||||
|
||||
return self.authorize(provider_name,
|
||||
authz_fn=_authz_fn,
|
||||
users=users)
|
||||
|
||||
def authorize_groups(self, provider_name: str, groups: List[str]):
|
||||
"""
|
||||
Authorize members of a list of groups
|
||||
"""
|
||||
|
||||
def _authz_fn(session, groups) -> bool:
|
||||
user_groups = session.userinfo.get('groups', [])
|
||||
|
||||
if len(set(groups).intersection(user_groups)):
|
||||
return True
|
||||
return False
|
||||
|
||||
return self.authorize(provider_name,
|
||||
authz_fn=_authz_fn,
|
||||
groups=groups)
|
||||
|
||||
def authorize_admins(self, provider_name: str):
|
||||
"""
|
||||
Authorize admins.
|
||||
Admins are taken from the app config:
|
||||
- members of groups in ADMIN_GROUPS
|
||||
- users in ADMIN_USERS
|
||||
"""
|
||||
|
||||
def _authz_fn(session) -> bool:
|
||||
user_groups = session.userinfo.get('groups', [])
|
||||
username = session.userinfo.get('preferred_username', "")
|
||||
with current_app.app_context():
|
||||
admin_groups = current_app.config.get('ADMIN_GROUPS', [])
|
||||
admin_users = current_app.config.get('ADMIN_USERS', [])
|
||||
|
||||
authorized_groups = set(admin_groups).intersection(user_groups)
|
||||
|
||||
if len(authorized_groups):
|
||||
log.debug(f"'{username}' is a member of {
|
||||
authorized_groups}")
|
||||
return True
|
||||
|
||||
if username in admin_users:
|
||||
log.debug(f"'{username}' is an admin user")
|
||||
return True
|
||||
return False
|
||||
|
||||
return self.authorize(provider_name,
|
||||
authz_fn=_authz_fn)
|
95
app/logging/development.ini
Normal file
@ -0,0 +1,95 @@
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root, wsgi, app, views, lib, model, sqlalchemy, scheduler, werkzeug, gunicorn
|
||||
|
||||
[handlers]
|
||||
keys = console, accesslog
|
||||
|
||||
[formatters]
|
||||
keys = generic, colored, accesslog
|
||||
|
||||
[logger_root]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
|
||||
[logger_gunicorn]
|
||||
level = DEBUG
|
||||
handlers = accesslog
|
||||
qualname = gunicorn
|
||||
propagate = 0
|
||||
|
||||
[logger_werkzeug]
|
||||
level = INFO
|
||||
handlers = accesslog
|
||||
qualname = werkzeug
|
||||
propagate = 0
|
||||
|
||||
[logger_wsgi]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
qualname = wsgi
|
||||
propagate = 0
|
||||
|
||||
[logger_app]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
qualname = app
|
||||
propagate = 0
|
||||
|
||||
[logger_views]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
qualname = app.views
|
||||
propagate = 0
|
||||
|
||||
[logger_lib]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
qualname = app.lib
|
||||
propagate = 0
|
||||
|
||||
[logger_model]
|
||||
level = DEBUG
|
||||
handlers = console
|
||||
qualname = app.models
|
||||
propagate = 0
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname = sqlalchemy.engine.Engine
|
||||
propagate = 0
|
||||
; qualname = sqlalchemy.engine.Engine
|
||||
# "level = INFO" logs SQL queries.
|
||||
# "level = DEBUG" logs SQL queries and results.
|
||||
# "level = WARN" logs neither. (Recommended for production systems.)
|
||||
|
||||
[logger_scheduler]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname = app.scheduler, apscheduler
|
||||
propagate = 0
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = colored
|
||||
; formatter = generic
|
||||
|
||||
[handler_accesslog]
|
||||
class = StreamHandler
|
||||
args = (sys.stdout,)
|
||||
level = NOTSET
|
||||
formatter = accesslog
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s,%(msecs)03d %(levelname)-7.7s [%(name)s/%(filename)s:%(lineno)d]: %(message)s
|
||||
datefmt = %Y-%m-%d %H:%M:%S
|
||||
|
||||
[formatter_colored]
|
||||
format = [0;1m%(asctime)s,%(msecs)03d [1;31m%(levelname)-7.7s [1;34m[%(name)s/%(filename)s:%(lineno)d]: [0m%(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
||||
[formatter_accesslog]
|
||||
format = %(name)s %(message)s
|
60
app/logging/production.ini
Normal file
@ -0,0 +1,60 @@
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root, wsgi, app, views, lib, access, error
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = json
|
||||
|
||||
[logger_root]
|
||||
level = INFO
|
||||
handlers = console
|
||||
|
||||
[logger_access]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = gunicorn.access
|
||||
propagate = 0
|
||||
|
||||
[logger_error]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = gunicorn.error
|
||||
propagate = 0
|
||||
|
||||
[logger_wsgi]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = wsgi
|
||||
propagate = 0
|
||||
|
||||
[logger_app]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = app.
|
||||
propagate = 0
|
||||
|
||||
[logger_views]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = app.views
|
||||
propagate = 0
|
||||
|
||||
[logger_lib]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname = app.lib
|
||||
propagate = 0
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
; formatter = colored
|
||||
formatter = json
|
||||
|
||||
[formatter_json]
|
||||
format={"timestamp": "%(asctime)s.%(msecs)03d", "level": "%(levelname)s", "logger": "%(name)s", "message": "%(message)s"}
|
||||
datefmt = %Y-%m-%dT%H:%M:%S
|
0
app/models.py
Normal file
8
app/static/bootstrap/bootstrap.min.css
vendored
Normal file
7
app/static/bootstrap/bootstrap.min.js
vendored
Normal file
2
app/static/bootstrap/jquery-3.7.1.min.js
vendored
Normal file
5
app/static/bootstrap/popper-1.12.9.min.js
vendored
Normal file
1
app/static/bootstrap/toggle-bootstrap-dark.min.css
vendored
Normal file
1
app/static/bootstrap/toggle-bootstrap-print.min.css
vendored
Normal file
@ -0,0 +1 @@
|
||||
@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}}
|
1
app/static/bootstrap/toggle-bootstrap.min.css
vendored
Normal file
21
app/static/datatables/datatables.min.css
vendored
Normal file
40
app/static/datatables/datatables.min.js
vendored
Normal file
BIN
app/static/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
app/static/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
app/static/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
app/static/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 470 B |
BIN
app/static/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 871 B |
BIN
app/static/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
10
app/static/favicon/site.webmanifest
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name":"Headscale Manager",
|
||||
"short_name":"hsman",
|
||||
"icons":[
|
||||
{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},
|
||||
{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],
|
||||
"theme_color":"#000000",
|
||||
"background_color":"#000000",
|
||||
"display":"standalone"
|
||||
}
|
22
app/static/fonts/century-gothic.css
Normal file
@ -0,0 +1,22 @@
|
||||
/*
|
||||
* The Typekit service used to deliver this font or fonts for use on websites
|
||||
* is provided by Adobe and is subject to these Terms of Use
|
||||
* http://www.adobe.com/products/eulas/tou_typekit. For font license
|
||||
* information, see the list below.
|
||||
*
|
||||
* century-gothic:
|
||||
* - http://typekit.com/eulas/00000000000000003b9b1f23
|
||||
*
|
||||
* © 2009-2024 Adobe Systems Incorporated. All Rights Reserved.
|
||||
*/
|
||||
/*{"last_published":"2021-09-06 05:08:57 UTC"}*/
|
||||
|
||||
@import url("https://p.typekit.net/p.css?s=1&k=oov2wcw&ht=tk&f=39203&a=85994746&app=typekit&e=css");
|
||||
|
||||
@font-face {
|
||||
font-family:"century-gothic";
|
||||
src:url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff2"),url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff"),url("https://use.typekit.net/af/afc5c6/00000000000000003b9b1f23/27/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("opentype");
|
||||
font-display:auto;font-style:normal;font-weight:400;font-stretch:normal;
|
||||
}
|
||||
|
||||
.tk-century-gothic { font-family: "century-gothic",sans-serif; }
|
21
app/static/fonts/quicksand.css
Normal file
@ -0,0 +1,21 @@
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkP8o18E.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkM0o18E.ttf) format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Quicksand';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/quicksand/v31/6xK-dSZaM9iE8KbpRA_LJ3z8mH9BOJvgkCEv18E.ttf) format('truetype');
|
||||
}
|
BIN
app/static/hsman.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
120
app/static/main.css
Normal file
@ -0,0 +1,120 @@
|
||||
/* custom css */
|
||||
html {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
body.bootstrap,
|
||||
body.bootstra-dark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: century-gothic, sans-serif;
|
||||
/* font-family: 'Quicksand', sans-serif; */
|
||||
min-height: 100%;
|
||||
/* margin: 0 0 60px; */
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
body > .container,
|
||||
body > .container-fluid {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
/* Set the fixed height of the footer here */
|
||||
height: 30px;
|
||||
margin-top: 0 auto;
|
||||
/* Vertically center the text there */
|
||||
line-height: 10px;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
|
||||
a:link {
|
||||
color: unset;
|
||||
text-decoration-line: underline;
|
||||
/* text-decoration-style: dotted; */
|
||||
/* text-decoration-style: dashed; */
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: unset;
|
||||
text-decoration-line: underline;
|
||||
/* text-decoration-style: dotted; */
|
||||
/* text-decoration-style: dashed; */
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: unset;
|
||||
text-decoration-line: unset;
|
||||
/* text-decoration: underline; */
|
||||
/* text-decoration-style: dotted; */
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: unset;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.nodeco, a.nav-link, a.navbar-brand, a.routeToggle {
|
||||
text-decoration-line: none;
|
||||
text-decoration-style: unset;
|
||||
}
|
||||
|
||||
table.dataTable tbody tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
table.dataTable tbody tr:hover > .sorting_1 {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
div.data:hover{
|
||||
/* background-color: rgba(63, 140, 211, 0.808); */
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.fancyTrue {
|
||||
color: #10932f
|
||||
}
|
||||
|
||||
.fancyFalse {
|
||||
color: #9b0f0f
|
||||
}
|
||||
|
||||
/* primary route */
|
||||
a.route.primary {
|
||||
font-weight: bold;
|
||||
}
|
||||
/* route enabled */
|
||||
a.route.True {
|
||||
font-style: italic;
|
||||
color: #bbbbbb;
|
||||
}
|
||||
/* route disabled */
|
||||
a.route.False {
|
||||
font-style: italic;
|
||||
text-decoration-line: line-through underline;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
div.dt-container div.dt-scroll-body {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
tr.pka-hide {
|
||||
visibility: collapse;
|
||||
}
|
||||
|
||||
i.disabled {
|
||||
color: #888;
|
||||
}
|
||||
span.expired {
|
||||
color: #888;
|
||||
}
|
67
app/static/main.js
Normal file
@ -0,0 +1,67 @@
|
||||
function renameNode(nodeId) {
|
||||
var newName = $("#newName").val();
|
||||
var url = `${nodeId}/rename/${newName}`;
|
||||
$.ajax({
|
||||
url: url,
|
||||
xhrFields: {
|
||||
withCredentials: true,
|
||||
},
|
||||
success: function (data) {
|
||||
$("#renameModal").modal("hide");
|
||||
$("#givenName").html(data.newName);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPKA(username) {
|
||||
console.log(username);
|
||||
var url = `${username}/pakcreate`;
|
||||
var ephemereal = $("#ephemereal").is(":checked");
|
||||
var reusable = $("#reusable").is(":checked");
|
||||
var expiration = $("#expiration").val();
|
||||
console.log(expiration);
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: "POST",
|
||||
dataType: "json",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
xhrFields: {
|
||||
withCredentials: true,
|
||||
},
|
||||
data: JSON.stringify({
|
||||
ephemeral: ephemereal,
|
||||
reusable: reusable,
|
||||
expiration: expiration,
|
||||
}),
|
||||
|
||||
success: function (data) {
|
||||
$("#createPKA").modal("hide");
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function copyToClipboard(obj) {
|
||||
var span = $(obj);
|
||||
var value = span.attr("value");
|
||||
var original = span.html();
|
||||
try {
|
||||
navigator.clipboard.writeText(value);
|
||||
span.html("copied!");
|
||||
setTimeout(function () {
|
||||
span.html(original);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
span.html("error");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpired(obj) {
|
||||
var toggle = $(obj);
|
||||
if (toggle.is(":checked")) {
|
||||
$(".pka-expired").removeClass("pka-hide");
|
||||
} else {
|
||||
$(".pka-expired").addClass("pka-hide");
|
||||
}
|
||||
}
|
18
app/templates/_macros.html.j2
Normal file
@ -0,0 +1,18 @@
|
||||
<!-- Put your reusable template code into macros here -->
|
||||
{% macro theme_icon(theme) %}
|
||||
<div class="theme-icon">
|
||||
{% if theme == "dark" %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="theme-icon" fill="none" viewBox="0 0 24 24" stroke="yellow"
|
||||
stroke-opacity="0.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="theme-icon" fill="none" viewBox="0 0 18 24" stroke="grey"
|
||||
stroke-opacity="0.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
111
app/templates/base.html
Normal file
@ -0,0 +1,111 @@
|
||||
{% import '_macros.html.j2' as mc -%}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ config.APP_NAME }}</title>
|
||||
<!-- meta -->
|
||||
<meta name="description" content="">
|
||||
<meta name="author" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
{% block meta %}{% endblock %}
|
||||
<!-- styles -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap-dark.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/toggle-bootstrap-print.min.css') }}">
|
||||
<!-- Century gothic font -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='fonts/century-gothic.css') }}">
|
||||
<!-- fontawesome -->
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" integrity="sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ" crossorigin="anonymous">
|
||||
|
||||
<link href="{{ url_for('static', filename='main.css') }}" rel="stylesheet" media="screen">
|
||||
<!-- Datatables -->
|
||||
<link href="{{ url_for('static', filename='datatables/datatables.min.css') }}" rel="stylesheet">
|
||||
{% block links %}{% endblock %}
|
||||
<link rel="icon" type="image/png" sizes="180x180" href="{{ url_for('static', filename='favicon/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon/favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon/favicon-16x16.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='favicon/android-chrome-192x192.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='favicon/android-chrome-512x512.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='favicon/site.webmanifest') }}">
|
||||
</head>
|
||||
|
||||
<body class="bootstrap bootstrap-dark">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-themed">
|
||||
<!-- Navbar Brand -->
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">
|
||||
<img src="{{ url_for('static', filename='/hsman.png') }}">
|
||||
<!-- HSMAN -->
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav mr-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.nodes') }}">nodes</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.users') }}">users</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.routes') }}">routes</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item me-right">
|
||||
<a href="{{url_for('main.logout') }}" id="themeSwitch">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<!-- <i class="fas fa-plug-circle-xmark"></i> -->
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
{% if g.is_mobile %}
|
||||
<div class="container-fluid">
|
||||
{% else %}
|
||||
<div class="container">
|
||||
{% endif %}
|
||||
<!-- Main Content -->
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Footer-->
|
||||
<footer class="footer text-center">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<!-- Copyrights -->
|
||||
<div class="col-lg-12 text-center">
|
||||
<p class="text-muted mb-0 py-2">
|
||||
<!-- <img src="/static/hsman.png" height="20px"> -->
|
||||
Headscale Manager |
|
||||
ver. {{ config.APP_VERSION }} ({{ config.APP_SHA }})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- scripts -->
|
||||
<script src="{{ url_for('static', filename='bootstrap/jquery-3.7.1.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='bootstrap/popper-1.12.9.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='bootstrap/bootstrap.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='datatables/datatables.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='main.js') }}" type="text/javascript"></script>
|
||||
<script>
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
})
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
10
app/templates/error.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="jumbotron my-4">
|
||||
<div class="text-center">
|
||||
<h1>{{ '%s - %s' % (error.code, error.name) }}</h1>
|
||||
<p>{{ error.description }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
71
app/templates/index.html
Normal file
@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>Welcome, {{ session.userinfo.name }}</h3>
|
||||
<hr>
|
||||
<h4>authentication info</h4>
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>email</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ session.userinfo.email }}
|
||||
<!-- {{ session.userinfo.email_verified | fancyBool | safe }} -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>username</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
{{ session.userinfo.preferred_username }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
<strong>groups</strong>
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
<i class="fas fa-angle-right"></i>
|
||||
{{ session.userinfo.groups[0]}}
|
||||
</div>
|
||||
</div>
|
||||
{% for group in session.userinfo.groups[1:] |sort %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
|
||||
</div>
|
||||
<div class="col col-6">
|
||||
<i class="fas fa-angle-right"></i> {{ group }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr>
|
||||
<h4>your devices</h4>
|
||||
<div class="row strong">
|
||||
<div class="col col-2"><strong></strong></div>
|
||||
<div class="col col-2"><strong>registered</strong></div>
|
||||
<div class="col col-2"><strong>last event</strong></div>
|
||||
<div class="col col-2"><strong>online</strong></div>
|
||||
</div>
|
||||
{% for node in userNodeList %}
|
||||
<div class="row data">
|
||||
<div class="col col-2">
|
||||
{{ node.givenName}}
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
|
||||
{{node.createdAt | htime_dt }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.lastSeen | fmt_datetime }}">
|
||||
{{node.lastSeen | htime_dt }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="col col-2">
|
||||
{{node.online | fancyBool | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
209
app/templates/node.html
Normal file
@ -0,0 +1,209 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h3>
|
||||
<span id="givenName">
|
||||
{{node.givenName}}
|
||||
</span>
|
||||
<a href="#" data-toggle="modal" data-target="#renameModal">
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="rename node">
|
||||
<i class="fas fa-edit h6"></i>
|
||||
</span>
|
||||
</a>
|
||||
</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>status</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
{% if node.online %}
|
||||
<span class="badge badge-pill badge-success">online</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pill badge-danger">offline</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>registered</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}">
|
||||
{{ node.createdAt | htime_dt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>expire</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.expiry | fmt_datetime }}">
|
||||
{{ node.expiry | htime_dt }}
|
||||
</span>
|
||||
{% if node.expireDate and not node.expired %}
|
||||
<a href="{{ url_for('rest.expireNode', nodeId=node.id) }}">
|
||||
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect node">
|
||||
<i class="fas fa-plug"></i>
|
||||
</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>owner</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<a href='{{ url_for("main.user", userName=node.user.name) }}' class="plain">{{ node.user.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
|
||||
<!-- ADDRESSES -->
|
||||
<h5>addresses</h5>
|
||||
{% for ip in node.ipAddresses %}
|
||||
<div class="row data">
|
||||
<div class="col col-3">
|
||||
{{ ip }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p></p>
|
||||
|
||||
<!-- TAGS -->
|
||||
<h5>tags</h5>
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>
|
||||
announced
|
||||
</strong>
|
||||
</div>
|
||||
<div class="col col-6 float-left">
|
||||
{% if node.validTags %}
|
||||
{% for tag in node.validTags %}
|
||||
<span class="badge badge-pill badge-warning">
|
||||
{{ tag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>forced</strong>
|
||||
</div>
|
||||
<div class="col col-6 float-left">
|
||||
{% if node.forced %}
|
||||
{% for tag in node.forcedTags %}
|
||||
<h3><span class="badge badge-pill badge-primary">
|
||||
{{ tag }}
|
||||
</span></h3>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KEYS -->
|
||||
<p></p>
|
||||
<h5>keys</h5>
|
||||
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>machineKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<code>{{ node.machineKey }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>nodeKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<code>{{ node.nodeKey }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>discoKey</strong>
|
||||
</div>
|
||||
<div class="col col-8 float-left">
|
||||
<code>{{ node.discoKey }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
|
||||
<!-- ROUTES -->
|
||||
<h5>routes
|
||||
{% if isExitNode %}
|
||||
<span class="small badge-pill badge-primary">Exit Node</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
{% if routes %}
|
||||
<div class="row">
|
||||
<div class="col col-3 float-left">
|
||||
<strong>prefix</strong>
|
||||
</div>
|
||||
<div class="col col-3 float-left">
|
||||
<strong>enabled</strong>
|
||||
</div>
|
||||
<div class="col col-3 float-left">
|
||||
<strong>primary</strong>
|
||||
</div>
|
||||
</div>
|
||||
{% for route in routes | sort(attribute='prefix') %}
|
||||
<div class="row data">
|
||||
<div class="col col-3 float-left">
|
||||
{{ route.prefix }}
|
||||
</div>
|
||||
<div class="col col-3 float-left">
|
||||
{{ route.enabled | fancyBool | safe }}
|
||||
</div>
|
||||
<div class="col col-3 float-left">
|
||||
{{ route.isPrimary | fancyBool | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col col-9 text-center">
|
||||
<h3>No routes announced</h3>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- rename modal -->
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="renameModal" tabindex="-1" role="dialog" aria-labelledby="renameModal" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renameModalLabel">Rename node</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="text" name="newName" id="newName" value="{{ node.givenName}}">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onClick="renameNode(id={{node.id}})">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
98
app/templates/nodes.html
Normal file
@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>nodes</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<table id="nodes" class="display" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>user</th>
|
||||
<th>expire</th>
|
||||
<th>last event</th>
|
||||
<th>addresses</th>
|
||||
<th>online</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in nodes %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="plain" href="{{ url_for('main.node', nodeId=node.id)}}">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.ipAddresses | join('\n') }}">
|
||||
{{node.givenName}}
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="plain" href="{{ url_for('main.user', userName=node.user.name)}}">
|
||||
{{node.user.name}}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span data-toggle="tooltip" data-placement="right"
|
||||
title="{{ node.expiry | fmt_datetime }}"
|
||||
class="{% if node.expired %}expired{% endif %}">
|
||||
{{node.expireDate | htime_dt | safe}}
|
||||
</span>
|
||||
</td>
|
||||
<td data-order="{{ node.lastSeen | fmt_datetime }}">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ node.lastSeen | fmt_datetime }}">
|
||||
{{node.lastSeen | htime_dt }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ node.ipAddresses | join(', ') }}
|
||||
</td>
|
||||
<td data-filter="{{ node.online | fancyOnline }}">
|
||||
{{node.online | fancyBool | safe}}
|
||||
</td>
|
||||
<td class="no-sort">
|
||||
{% if node.expireDate and not node.expired %}
|
||||
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
|
||||
<a class="nodeco" href="{{ url_for('rest.expireNodeList', nodeId=node.id) }}">
|
||||
<i class="fas fa-plug"></i>
|
||||
</a>
|
||||
</span>
|
||||
{% else %}
|
||||
<i class="fas fa-plug disabled"></i>
|
||||
{% endif %}
|
||||
<span data-toggle="tooltip" data-placement="right" title="delete">
|
||||
<a class="nodeco" href="{{ url_for('rest.deleteNode', nodeId=node.id) }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(function () {
|
||||
new DataTable('#nodes', {
|
||||
paging: true,
|
||||
lengthMenu: [15, 30, 50, 100, { label: 'All', value: -1 }],
|
||||
pageLength: 30,
|
||||
fixedHeader: false,
|
||||
select: false,
|
||||
keys: false,
|
||||
aoColumnDefs: [
|
||||
{ 'bSortable': false, 'aTargets': [ -1 ] }
|
||||
],
|
||||
columnDefs: [
|
||||
{
|
||||
target: 4,
|
||||
visible: false,
|
||||
searchable: true
|
||||
}
|
||||
]
|
||||
});
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
93
app/templates/routes.html
Normal file
@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h5>
|
||||
Routing table
|
||||
<small class="text-muted">
|
||||
click on the icon in <em>enabled</em> column to toggle route status
|
||||
</small>
|
||||
</h5>
|
||||
<hr>
|
||||
<p></p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-12">
|
||||
<h5>Exit nodes</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for exitNode in exitNodes %}
|
||||
<div class="row data">
|
||||
<div class="col col-12">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ exitNode.ipAddresses | join('\n') }}">
|
||||
{{ exitNode.givenName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<p></p>
|
||||
{% for prefix, rts in routes.items() %}
|
||||
<div class="row">
|
||||
<div class="col col-4 float-left">
|
||||
<strong>prefix</strong>
|
||||
</div>
|
||||
<div class="col col-4 float-left">
|
||||
<strong>gateway</strong>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
<strong>enabled</strong>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
<strong>active</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row data">
|
||||
<div class="col col-4 float-left">
|
||||
{{ prefix}}
|
||||
</div>
|
||||
<div class="col col-4 float-left">
|
||||
<a class="plain route primary" href="{{ url_for('main.node', nodeId=rts[0].node.id) }}">
|
||||
<span data-toggle="tooltip" data-placement="right"
|
||||
title="{{ rts[0].node.ipAddresses | join('\n') }}">
|
||||
{{ rts[0].node.givenName}}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
<a class="routeToggle" href="{{ url_for('rest.routeToggle', routeId=rts[0].id) }}">
|
||||
{{ rts[0].enabled | fancyBool | safe}}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
{{ rts[0].isPrimary | fancyBool | safe}}
|
||||
</div>
|
||||
</div>
|
||||
{% for rt in rts[1:] %}
|
||||
<div class="row data">
|
||||
<div class="col col-4 float-left" style="border: 1px red;">
|
||||
<span> </span>
|
||||
</div>
|
||||
<div class="col col-4">
|
||||
<a class="plain route {{rt.enabled}}" href="{{ url_for('main.node', nodeId=rt.node.id) }}">
|
||||
<span data-toggle="tooltip" data-placement="right"
|
||||
title="{{ rt.node.ipAddresses | join('\n') }}">
|
||||
{{ rt.node.givenName}}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
<a class="routeToggle" href="{{ url_for('rest.routeToggle', routeId=rt.id) }}">
|
||||
{{ rt.enabled | fancyBool | safe}}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col col-2 float-left">
|
||||
{{ rt.isPrimary | fancyBool | safe}}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<p></p>
|
||||
{% endfor %}
|
||||
|
||||
{% endblock %}
|
208
app/templates/user.html
Normal file
@ -0,0 +1,208 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<h3>{{ user.name }}</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<div class="row">
|
||||
<div class="col col-3">
|
||||
<strong>registered</strong>
|
||||
</div>
|
||||
<div class="col col-8">
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ user.createdAt | fmt_datetime }}">
|
||||
{{ user.createdAt | htime_dt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<!-- NODES -->
|
||||
<h5>nodes</h5>
|
||||
<table id="nodes" class="display" style="width:80%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>last connect</th>
|
||||
<th>online</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for node in userNodeList %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('main.node', nodeId=node.id) }}" class="plain">
|
||||
{{ node.givenName }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ node.lastSeen | fmt_datetime }}">
|
||||
{{node.lastSeen | htime_dt }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{node.online | fancyBool | safe}}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<p></p>
|
||||
<!-- PRE AUTH KEYS -->
|
||||
<h5>
|
||||
pre auth keys
|
||||
|
||||
<button class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#createPKA">create</button>
|
||||
|
||||
</h5>
|
||||
{% if preauthKeys %}
|
||||
<table id="paks" class="display" style="width:80%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="form-check form-check-inline">
|
||||
<label class="form-check-label small" for="showExpired">
|
||||
show expired
|
||||
</label>
|
||||
<input type="checkbox" class="form-check-input form-control-sm" id="showExpired">
|
||||
</div>
|
||||
</th>
|
||||
<th>created</th>
|
||||
<th>expiration</th>
|
||||
<th>attributes</th>
|
||||
<!-- <th> </th> -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in preauthKeys %}
|
||||
<tr class="pka{% if key.expired %} pka-expired pka-hide{% endif %}">
|
||||
<td>
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
value="{{ key.key}}"
|
||||
title="click to copy full value"
|
||||
class="pak_copy">{{ key.key[:5] }}…{{ key.key[-5:] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ key.createdAt | fmt_datetime }}">
|
||||
{{key.createdAt | htime_dt }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span data-toggle="tooltip"
|
||||
data-placement="right"
|
||||
title="{{ key.expiration | fmt_datetime }}">
|
||||
{{key.expiration | htime_dt }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if key.ephemeral %}
|
||||
<span class="badge badge-pill badge-primary">ephemereal</span>
|
||||
{% endif %}
|
||||
{% if key.reusable %}
|
||||
<span class="badge badge-pill badge-primary">reusable</span>
|
||||
{% endif %}
|
||||
{% if key.used %}
|
||||
<span class="badge badge-pill badge-primary">used</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<!-- <td>
|
||||
<span data-toggle="tooltip" data-placement="right" title="expire">
|
||||
<a class="nodeco" href="/user/{{user.name}}/expire/{{key.key}}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
</td> -->
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col col-9 text-center">
|
||||
<h3>No preauth keys</h3>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- new key modal -->
|
||||
<div class="modal fade" id="createPKA" tabindex="-1" role="dialog" aria-labelledby="createPKA" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renameModalLabel">create new pre auth key</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="datetime-local" name="expiration" id="expiration" value="{{ defaultExpiry}}">
|
||||
<label class="form-check-label" for="ephemereal">expiration</label>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="reusable" id="reusable">
|
||||
<label class="form-check-label" for="reusable">reusable</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="ephemereal" id="ephemereal">
|
||||
<label class="form-check-label" for="ephemereal">ephemereal</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onClick="createPKA(user='{{ user.name }}')">Save changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(function () {
|
||||
$('.pak_copy').on('click', function() {
|
||||
copyToClipboard(this)
|
||||
})
|
||||
$('#showExpired').on('change', function() {
|
||||
toggleExpired(this)
|
||||
})
|
||||
|
||||
new DataTable('#nodes', {
|
||||
scrollY: 130,
|
||||
scrollCollapse: true,
|
||||
paging: false,
|
||||
// lengthMenu: [5, 10, 30, 50, { label: 'All', value: -1 }],
|
||||
// pageLength: 10,
|
||||
fixedHeader: {
|
||||
header: true,
|
||||
footer: false
|
||||
},
|
||||
info: false,
|
||||
searching: false,
|
||||
select: false,
|
||||
keys: false,
|
||||
});
|
||||
new DataTable('#paks', {
|
||||
scrollY: 230,
|
||||
scrollCollapse: true,
|
||||
paging: false,
|
||||
// lengthMenu: [5, 10, 30, 50, { label: 'All', value: -1 }],
|
||||
// pageLength: 10,
|
||||
fixedHeader: {
|
||||
header: true,
|
||||
footer: false
|
||||
},
|
||||
info: false,
|
||||
searching: false,
|
||||
select: false,
|
||||
keys: false,
|
||||
});
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
62
app/templates/users.html
Normal file
@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h3>users</h3>
|
||||
<hr>
|
||||
<p></p>
|
||||
<table id="users" class="display" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>name</th>
|
||||
<th>registered on</th>
|
||||
<th>online</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<a class="plain" href="{{ url_for('main.user', userName=user.name)}}">
|
||||
{{user.name}}
|
||||
</a>
|
||||
</td>
|
||||
<td data-order="{{ user.createdAt }}">
|
||||
<span data-toggle="tooltip" data-placement="right" title="{{ user.createdAt | fmt_datetime }}">
|
||||
{{user.createdAt | htime_dt }}
|
||||
</span>
|
||||
</td>
|
||||
<td data-filter="{{ online[user.name] | fancyOnline }}">
|
||||
{{online[user.name] | fancyBool | safe }}
|
||||
</td>
|
||||
<td class="no-sort">
|
||||
<span data-toggle="tooltip" data-placement="right" title="delete">
|
||||
<a class="nodeco" href="/user/{{user.name}}/delete">
|
||||
<i class="fas fa-trash"></i>
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(function () {
|
||||
new DataTable('#users', {
|
||||
paging: true,
|
||||
lengthMenu: [15, 30, 50, 100, { label: 'All', value: -1 }],
|
||||
pageLength: 30,
|
||||
fixedHeader: false,
|
||||
select: false,
|
||||
keys: false,
|
||||
aoColumnDefs: [
|
||||
{ 'bSortable': false, 'aTargets': [ -1 ] }
|
||||
],
|
||||
});
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
2
app/views/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .main import *
|
||||
from .rest import *
|
112
app/views/rest.py
Normal file
@ -0,0 +1,112 @@
|
||||
import logging
|
||||
import os
|
||||
from flask import Blueprint, request
|
||||
from flask import redirect, url_for
|
||||
from app import auth
|
||||
|
||||
from ..lib import username
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
from hsapi_client import Node, User, Route, PreAuthKey
|
||||
from hsapi_client.preauthkeys import (v1CreatePreAuthKeyRequest,
|
||||
v1ExpirePreAuthKeyRequest)
|
||||
|
||||
|
||||
log = logging.getLogger()
|
||||
# REST calls
|
||||
|
||||
rest_blueprint = Blueprint(
|
||||
'rest', __name__, url_prefix=os.getenv('APPLICATION_ROOT', '/'))
|
||||
|
||||
|
||||
@rest_blueprint.route('/routeToggle/<int:routeId>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def routeToggle(routeId: int):
|
||||
routes = Route().list()
|
||||
route = [r for r in routes.routes if r.id == routeId]
|
||||
if route:
|
||||
route = route[0]
|
||||
if route.enabled:
|
||||
action = 'disabled'
|
||||
Route().disable(routeId)
|
||||
else:
|
||||
Route().enable(routeId)
|
||||
action = 'enabled'
|
||||
log.info(
|
||||
f"route '{route.prefix}' via '{route.node.givenName}'"
|
||||
f"{action} by '{username()}'")
|
||||
return redirect(url_for("main.routes"))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expireNode(nodeId: int):
|
||||
"""
|
||||
This expires a node from the node page.
|
||||
The difference from above is that it returns to the /node/nodeId page
|
||||
"""
|
||||
Node().expire(nodeId)
|
||||
log.info(f"node '{nodeId}' expired by '{username()}'")
|
||||
return redirect(url_for("main.node", nodeId=nodeId))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expireNodeList(nodeId: int):
|
||||
"""
|
||||
This expires a node from the node list.
|
||||
The difference from above is that it returns to the /nodes page
|
||||
"""
|
||||
Node().expire(nodeId)
|
||||
log.info(f"node '{nodeId}' expired by '{username()}'")
|
||||
return redirect(url_for("main.nodes"))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def deleteNode(nodeId: int):
|
||||
Node().delete(nodeId)
|
||||
return redirect(url_for("main.nodes"))
|
||||
|
||||
|
||||
@rest_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def renameNode(nodeId: int, newName: str):
|
||||
Node().rename(nodeId, newName)
|
||||
return jsonify(dict(newName=newName))
|
||||
|
||||
|
||||
@rest_blueprint.route('/user/<userName>/delete', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def deleteUser(userName: str):
|
||||
nodes = Node().byUser(userName)
|
||||
for node in nodes.nodes:
|
||||
Node().expire(node.id)
|
||||
Node().delete(node.id)
|
||||
User().delete(userName)
|
||||
return redirect(url_for("main.users"))
|
||||
|
||||
|
||||
@rest_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
|
||||
@auth.authorize_admins('default')
|
||||
def createPKA(userName: str):
|
||||
data = request.json
|
||||
log.debug(data)
|
||||
expiration = f"{data['expiration']}:00Z"
|
||||
req = v1CreatePreAuthKeyRequest(user=userName,
|
||||
reusable=data['reusable'],
|
||||
ephemeral=data['ephemeral'],
|
||||
expiration=expiration)
|
||||
pak = PreAuthKey().create((req))
|
||||
return jsonify(dict(key=pak.preAuthKey.key))
|
||||
|
||||
|
||||
@rest_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
|
||||
@auth.authorize_admins('default')
|
||||
def expirePKA(userName: str, key: str):
|
||||
log.debug(key)
|
||||
req = v1ExpirePreAuthKeyRequest(user=userName, key=key)
|
||||
|
||||
PreAuthKey().expire(req)
|
||||
return redirect(url_for('main.user', userName=userName))
|