Minor bug fixes

This commit is contained in:
Andrea Mistrali 2024-07-05 14:44:18 +02:00
parent 42e2b884a1
commit fe5979aa5b
13 changed files with 138 additions and 87 deletions

View File

@ -7,49 +7,51 @@ import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def htime(ts): def htime(timestamp):
if ts: if timestamp:
dt = datetime.datetime.fromtimestamp(ts) dt = datetime.datetime.fromtimestamp(timestamp)
return humanize.naturaltime(dt) return humanize.naturaltime(dt)
def htime_dt(dt): def htime_dt(datetime):
if dt: if datetime:
try: try:
return humanize.naturaltime(dt) return humanize.naturaltime(datetime)
except ValueError: except ValueError:
return "Never" return "Never"
return "Never"
def hdate(ts): def hdate(timestamp):
if ts: if timestamp:
dt = datetime.datetime.fromtimestamp(ts) dt = datetime.datetime.fromtimestamp(timestamp)
return humanize.naturaldate(dt) return humanize.naturaldate(dt)
def hdate_dt(dt): def hdate_dt(datetime):
if dt: if datetime:
try: try:
return humanize.naturaldate(dt) return humanize.naturaldate(datetime)
except ValueError: except ValueError:
return "Never" return "Never"
return "Never"
def fmt_timestamp(ts): def fmt_timestamp(timestamp):
with current_app.app_context(): with current_app.app_context():
tz = ZoneInfo(current_app.config['APP_TZ']) tz = ZoneInfo(current_app.config['APP_TZ'])
if ts: if timestamp:
local_ts = datetime.datetime.fromtimestamp(ts, tz) local_ts = datetime.datetime.fromtimestamp(timestamp, tz)
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'), return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),
local_ts.tzname()) local_ts.tzname())
def fmt_datetime(dt): def fmt_datetime(datetime):
with current_app.app_context(): with current_app.app_context():
tz = ZoneInfo(current_app.config['APP_TZ']) tz = ZoneInfo(current_app.config['APP_TZ'])
if dt: if datetime:
try: try:
local_ts = dt.fromtimestamp(dt.timestamp(), tz) local_ts = datetime.fromtimestamp(datetime.timestamp(), tz)
except OverflowError: except OverflowError:
return "Never" return "Never"
return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'), return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'),

View File

@ -20,6 +20,13 @@ def remote_ip() -> str:
return str(request.environ.get('REMOTE_ADDR')) return str(request.environ.get('REMOTE_ADDR'))
def username() -> str:
userinfo = flask_session['userinfo']
if 'preferred_username' in userinfo:
return userinfo['preferred_username']
return userinfo['email']
def webMode() -> bool: def webMode() -> bool:
is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '') is_gunicorn = "gunicorn" in os.environ.get('SERVER_SOFTWARE', '')
is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true" is_werkzeug = os.environ.get('WERKZEUG_RUN_MAIN', False) == "true"

View File

@ -103,3 +103,10 @@ div.dt-container div.dt-scroll-body {
tr.pka-hide { tr.pka-hide {
visibility: collapse; visibility: collapse;
} }
i.disabled {
color: #888;
}
span.expired {
color: #888;
}

View File

@ -16,9 +16,3 @@
{% endif %} {% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
{% macro body_theme(theme) -%}
{% if theme == "dark" %}
bootstrap-dark
{% endif %}
{%- endmacro -%}

View File

@ -34,7 +34,6 @@
<link rel="manifest" href="/static/favicon/site.webmanifest"> <link rel="manifest" href="/static/favicon/site.webmanifest">
</head> </head>
<!-- <body class="bootstrap {{ mc.body_theme(g.theme) }}"> -->
<body class="bootstrap bootstrap-dark"> <body class="bootstrap bootstrap-dark">
<!-- Header --> <!-- Header -->
<header> <header>

View File

@ -14,6 +14,18 @@
</h3> </h3>
<hr> <hr>
<p></p> <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="row">
<div class="col col-3 float-left"> <div class="col col-3 float-left">
<strong>registered</strong> <strong>registered</strong>
@ -27,20 +39,23 @@
<div class="row"> <div class="row">
<div class="col col-3 float-left"> <div class="col col-3 float-left">
<strong>expiry</strong> <strong>expire</strong>
</div> </div>
<div class="col col-8 float-left"> <div class="col col-8 float-left">
<span data-toggle="tooltip" data-placement="right" title="{{ node.expiry | fmt_datetime }}"> <span data-toggle="tooltip" data-placement="right" title="{{ node.expiry | fmt_datetime }}">
{{ node.expiry | htime_dt }} {{ node.expiry | htime_dt }}
</span> </span>
{% if node.expireDate and not node.expired %}
<a href="/node/{{ node.id }}/expire"> <a href="/node/{{ node.id }}/expire">
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect node"> <span data-toggle="tooltip" data-placement="right" title="expire/disconnect node">
<i class="fas fa-plug"></i> <i class="fas fa-plug"></i>
</span> </span>
</a> </a>
{% endif %}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col col-3 float-left"> <div class="col col-3 float-left">
<strong>owner</strong> <strong>owner</strong>
@ -73,7 +88,7 @@
<div class="col col-6 float-left"> <div class="col col-6 float-left">
{% if node.validTags %} {% if node.validTags %}
{% for tag in node.validTags %} {% for tag in node.validTags %}
<span class="badge badge-pill badge-info"> <span class="badge badge-pill badge-warning">
{{ tag }} {{ tag }}
</span> </span>
{% endfor %} {% endfor %}
@ -89,7 +104,7 @@
<div class="col col-6 float-left"> <div class="col col-6 float-left">
{% if node.forced %} {% if node.forced %}
{% for tag in node.forcedTags %} {% for tag in node.forcedTags %}
<h3><span class="badge badge-pill badge-info"> <h3><span class="badge badge-pill badge-primary">
{{ tag }} {{ tag }}
</span></h3> </span></h3>
{% endfor %} {% endfor %}

View File

@ -13,7 +13,7 @@
<tr> <tr>
<th>name</th> <th>name</th>
<th>user</th> <th>user</th>
<th>registered</th> <th>expire</th>
<th>last event</th> <th>last event</th>
<th>online</th> <th>online</th>
<th>&nbsp;</th> <th>&nbsp;</th>
@ -32,9 +32,11 @@
{{node.user.name}} {{node.user.name}}
</a> </a>
</td> </td>
<td data-order="{{ node.createdAt }}"> <td>
<span data-toggle="tooltip" data-placement="right" title="{{ node.createdAt | fmt_datetime }}"> <span data-toggle="tooltip" data-placement="right"
{{node.createdAt | htime_dt }} title="{{ node.expiry | fmt_datetime }}"
class="{% if node.expired %}expired{% endif %}">
{{node.expireDate | htime_dt | safe}}
</span> </span>
</td> </td>
<td data-order="{{ node.lastSeen | fmt_datetime }}"> <td data-order="{{ node.lastSeen | fmt_datetime }}">
@ -46,11 +48,15 @@
{{node.online | fancyBool | safe}} {{node.online | fancyBool | safe}}
</td> </td>
<td class="no-sort"> <td class="no-sort">
{% if node.expireDate and not node.expired %}
<span data-toggle="tooltip" data-placement="right" title="expire/disconnect"> <span data-toggle="tooltip" data-placement="right" title="expire/disconnect">
<a class="nodeco" href="/node/{{node.id}}/list-expire"> <a class="nodeco" href="/node/{{node.id}}/list-expire" disabled>
<i class="fas fa-plug"></i> <i class="fas fa-plug"></i>
</a> </a>
</span> </span>
{% else %}
<i class="fas fa-plug disabled"></i>
{% endif %}
<span data-toggle="tooltip" data-placement="right" title="delete"> <span data-toggle="tooltip" data-placement="right" title="delete">
<a class="nodeco" href="/node/{{node.id}}/delete"> <a class="nodeco" href="/node/{{node.id}}/delete">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>

View File

@ -5,6 +5,8 @@ from flask import render_template, Blueprint, request
from flask import redirect, session, url_for from flask import redirect, session, url_for
from app import auth from app import auth
from .lib import username
from flask import jsonify from flask import jsonify
from flask_pyoidc.user_session import UserSession from flask_pyoidc.user_session import UserSession
@ -19,13 +21,13 @@ log = logging.getLogger()
main_blueprint = Blueprint('main', __name__) main_blueprint = Blueprint('main', __name__)
@ main_blueprint.route('/health', methods=['GET']) @main_blueprint.route('/health', methods=['GET'])
def health(): def health():
return jsonify(dict(status="OK", version=current_app.config['APP_VERSION'])) return jsonify(dict(status="OK", version=current_app.config['APP_VERSION']))
@ main_blueprint.route('/', methods=['GET', 'POST']) @main_blueprint.route('/', methods=['GET', 'POST'])
@ auth.access_control('default') @auth.access_control('default')
def index(): def index():
user_session = UserSession(session) user_session = UserSession(session)
hs_user = user_session.userinfo['email'].split('@')[0] hs_user = user_session.userinfo['email'].split('@')[0]
@ -35,29 +37,29 @@ def index():
session=user_session) session=user_session)
@ main_blueprint.route('/token', methods=['GET', 'POST']) @main_blueprint.route('/token', methods=['GET', 'POST'])
@ auth.access_control('default') @auth.access_control('default')
def token(): def token():
user_session = UserSession(session) user_session = UserSession(session)
return jsonify(user_session.userinfo) return jsonify(user_session.userinfo)
@ main_blueprint.route('/logout') @main_blueprint.route('/logout')
@ auth.oidc_logout @auth.oidc_logout
def logout(): def logout():
return redirect('/') return redirect('/')
@ main_blueprint.route('/nodes', methods=['GET']) @main_blueprint.route('/nodes', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def nodes(): def nodes():
nodelist = Node().list() nodelist = Node().list()
return render_template('nodes.html', return render_template('nodes.html',
nodes=nodelist.nodes) nodes=nodelist.nodes)
@ main_blueprint.route('/node/<int:nodeId>', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def node(nodeId): def node(nodeId):
# There is a bug in HS api with retrieving a single node # There is a bug in HS api with retrieving a single node
# and we added a workaround to hsapi, so node.get() returns a # and we added a workaround to hsapi, so node.get() returns a
@ -72,8 +74,8 @@ def node(nodeId):
node=node) node=node)
@ main_blueprint.route('/users', methods=['GET']) @main_blueprint.route('/users', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def users(): def users():
userList = User().list() userList = User().list()
# Get online status of devices of the user # Get online status of devices of the user
@ -88,8 +90,8 @@ def users():
online=online) online=online)
@ main_blueprint.route('/user/<userName>', methods=['GET']) @main_blueprint.route('/user/<userName>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def user(userName): def user(userName):
user = User().get(userName) user = User().get(userName)
userNodeList = [n for n in Node().list().nodes if n.user.name == userName] userNodeList = [n for n in Node().list().nodes if n.user.name == userName]
@ -107,8 +109,8 @@ def user(userName):
userNodeList=userNodeList) userNodeList=userNodeList)
@ main_blueprint.route('/routes', methods=['GET']) @main_blueprint.route('/routes', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def routes(): def routes():
routes = Route().list() routes = Route().list()
@ -126,52 +128,65 @@ def routes():
routes=final) routes=final)
@ main_blueprint.route('/routeToggle/<int:routeId>', methods=['GET']) @main_blueprint.route('/routeToggle/<int:routeId>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def routeToggle(routeId: int): def routeToggle(routeId: int):
routes = Route().list() routes = Route().list()
route = [r for r in routes.routes if r.id == routeId] route = [r for r in routes.routes if r.id == routeId]
if route: if route:
route = route[0] route = route[0]
if route.enabled: if route.enabled:
action = 'disabled'
Route().disable(routeId) Route().disable(routeId)
log.info(f"route {route.prefix}/{route.node.givenName} disabled")
else: else:
Route().enable(routeId) Route().enable(routeId)
log.info(f"route {route.prefix}/{route.node.givenName} enabled") action = 'enabled'
log.info(
f"route '{route.prefix}' via '{route.node.givenName}'"
f"{action} by '{username()}'")
return redirect(url_for("main.routes")) return redirect(url_for("main.routes"))
@ main_blueprint.route('/node/<int:nodeId>/expire', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>/expire', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def expireNode(nodeId: int): def expireNode(nodeId: int):
"""
This expires a node from the node page.
The difference from above is that it returns to the /node/nodeId page
"""
Node().expire(nodeId) Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'")
return redirect(url_for("main.node", nodeId=nodeId)) return redirect(url_for("main.node", nodeId=nodeId))
@ main_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>/list-expire', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def expireNodeList(nodeId: int): 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) Node().expire(nodeId)
log.info(f"node '{nodeId}' expired by '{username()}'")
return redirect(url_for("main.nodes")) return redirect(url_for("main.nodes"))
@ main_blueprint.route('/node/<int:nodeId>/delete', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>/delete', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def deleteNode(nodeId: int): def deleteNode(nodeId: int):
Node().delete(nodeId) Node().delete(nodeId)
return redirect(url_for("main.nodes")) return redirect(url_for("main.nodes"))
@ main_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET']) @main_blueprint.route('/node/<int:nodeId>/rename/<newName>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def renameNode(nodeId: int, newName: str): def renameNode(nodeId: int, newName: str):
Node().rename(nodeId, newName) Node().rename(nodeId, newName)
return jsonify(dict(newName=newName)) return jsonify(dict(newName=newName))
@ main_blueprint.route('/user/<userName>/delete', methods=['GET']) @main_blueprint.route('/user/<userName>/delete', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def deleteUser(userName: str): def deleteUser(userName: str):
nodes = Node().byUser(userName) nodes = Node().byUser(userName)
for node in nodes.nodes: for node in nodes.nodes:
@ -181,8 +196,8 @@ def deleteUser(userName: str):
return redirect(url_for("main.users")) return redirect(url_for("main.users"))
@ main_blueprint.route('/user/<userName>/pakcreate', methods=['POST']) @main_blueprint.route('/user/<userName>/pakcreate', methods=['POST'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def createPKA(userName: str): def createPKA(userName: str):
data = request.json data = request.json
log.debug(data) log.debug(data)
@ -195,8 +210,8 @@ def createPKA(userName: str):
return jsonify(dict(key=pak.preAuthKey.key)) return jsonify(dict(key=pak.preAuthKey.key))
@ main_blueprint.route('/user/<userName>/expire/<key>', methods=['GET']) @main_blueprint.route('/user/<userName>/expire/<key>', methods=['GET'])
@ auth.authorize_admins('default') @auth.authorize_admins('default')
def expirePKA(userName: str, key: str): def expirePKA(userName: str, key: str):
log.debug(key) log.debug(key)
req = v1ExpirePreAuthKeyRequest(user=userName, key=key) req = v1ExpirePreAuthKeyRequest(user=userName, key=key)

View File

@ -8,7 +8,8 @@ ARG BUILD_DATE
ENV APP_VERSION=${APP_VERSION:-alpha0} ENV APP_VERSION=${APP_VERSION:-alpha0}
ENV APP_SHA=${APP_SHA:-000000} ENV APP_SHA=${APP_SHA:-000000}
# useful in case we want to run in debug mode # useful in case we want to run in debug mode
ENV FLASK_APP /hsmon/wsgi.py ENV FLASK_APP /hsman/wsgi.py
ENV FLASK_ENV production
RUN apk --update --no-cache add \ RUN apk --update --no-cache add \
bash \ bash \
@ -18,16 +19,18 @@ RUN apk --update --no-cache add \
chmod g+w /run && \ chmod g+w /run && \
pip install poetry gunicorn pip install poetry gunicorn
COPY . /hsmon COPY . /hsman
RUN cd hsmon && \ RUN cd hsman && \
poetry install && \ poetry install && \
poetry export | pip install -r /dev/stdin poetry export | pip install -r /dev/stdin
WORKDIR /hsman
HEALTHCHECK --interval=20s --timeout=3s CMD curl -I -s -o /dev/null localhost:5000/health || exit 1 HEALTHCHECK --interval=20s --timeout=3s CMD curl -I -s -o /dev/null localhost:5000/health || exit 1
EXPOSE 5000 EXPOSE 5000
# exectute start up script # exectute start up script
ENTRYPOINT ["/hsmon/docker/entrypoint.sh"] ENTRYPOINT ["/hsman/docker/entrypoint.sh"]

View File

@ -5,11 +5,12 @@ if [ $# -gt 0 ]; then
fi fi
echo Starting ${APP_VERSION} echo Starting ${APP_VERSION}
if [ -e /debug ]; then
echo "Running app in debug mode!" if [ $FLASK_ENV == "development" ]; then
export PYTHONPATH=/app/site-packages echo "Running app in debug mode"
/hsmon/site-packages/bin/flask run --host 0.0.0.0 export FLASK_DEBUG=1
flask run --host 0.0.0.0
else else
echo "Running app in production mode!" echo "Running app in production mode"
gunicorn --chdir hsmon wsgi:app -w 1 --threads 2 -b 0.0.0.0:5000 gunicorn wsgi:app --config gunicorn.conf.py
fi fi

View File

@ -4,13 +4,15 @@ proc_name = "hsmon"
default_proc_name = "hsmon" default_proc_name = "hsmon"
bind = "0.0.0.0:5000" bind = "0.0.0.0:5000"
workers = 1 workers = 2
threads = 4 threads = 4
preload_app = True preload_app = True
worker_class = "uvicorn.workers.UvicornWorker" # worker_class = "uvicorn.workers.UvicornWorker"
chdir = "/hsman"
# logconfig = "app/logging/production.ini" # logconfig = "app/logging/production.ini"
logconfig = "/hsmon/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 %(l)s %(t)s %(r)s %(s)s %(b)s %(f)s %(a)s"
# Log to stdout. # Log to stdout.
accesslog = "-" accesslog = "-"

8
hsman/poetry.lock generated
View File

@ -457,13 +457,13 @@ files = [
[[package]] [[package]]
name = "hsapi-client" name = "hsapi-client"
version = "0.9.1" version = "0.9.2"
description = "Headscale API client" description = "Headscale API client"
optional = false optional = false
python-versions = "<4.0,>=3.11" python-versions = "<4.0,>=3.11"
files = [ files = [
{file = "hsapi_client-0.9.1-py3-none-any.whl", hash = "sha256:1b6531526ed476daaefa16eeb3a16e768372f4bd5d879da7bf4ed75a4bd36a5b"}, {file = "hsapi_client-0.9.2-py3-none-any.whl", hash = "sha256:bbda176e10b7ba0c1376eac6e8fcbd9027a41fab21585edad275cfbd21cce66d"},
{file = "hsapi_client-0.9.1.tar.gz", hash = "sha256:b047a21e642ae4ba108a17f6b68d71af8ba851270421261df92c97acdcfdc907"}, {file = "hsapi_client-0.9.2.tar.gz", hash = "sha256:902256e6240e47b3eecacde930f9d67e12a0d46d34d7dbcc86e95b2976013099"},
] ]
[package.dependencies] [package.dependencies]
@ -1186,4 +1186,4 @@ watchdog = ["watchdog (>=2.3)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.11,<4.0" python-versions = ">=3.11,<4.0"
content-hash = "462d0b3a2b307ddc04cb74309f7a1a85679a3a3bf05155a8022d0047d14d2241" content-hash = "b69a53708d986528f101f49ccb7e61fb1377d244511d9cd335b0ec200510b37b"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "hsman" name = "hsman"
version = "0.1.0" version = "0.9.0"
description = "Flask Admin webui for Headscale" description = "Flask Admin webui for Headscale"
authors = ["Andrea Mistrali <andrea@mistrali.pw>"] authors = ["Andrea Mistrali <andrea@mistrali.pw>"]
license = "BSD" license = "BSD"
@ -11,11 +11,11 @@ python = ">=3.11,<4.0"
Flask = "^3.0.3" Flask = "^3.0.3"
Flask-pyoidc = "^3.14.3" Flask-pyoidc = "^3.14.3"
gunicorn = "^22.0.0" gunicorn = "^22.0.0"
hsapi-client = "^0.9.1"
flask-mobility = "^2.0.1" flask-mobility = "^2.0.1"
humanize = "^4.9.0" humanize = "^4.9.0"
flask-pydantic = "^0.12.0" flask-pydantic = "^0.12.0"
uvicorn = "^0.30.1" uvicorn = "^0.30.1"
hsapi-client = "^0.9.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]