diff --git a/hsman/app/filters.py b/hsman/app/filters.py index bfd5497..662a932 100644 --- a/hsman/app/filters.py +++ b/hsman/app/filters.py @@ -7,49 +7,51 @@ import logging log = logging.getLogger(__name__) -def htime(ts): - if ts: - dt = datetime.datetime.fromtimestamp(ts) +def htime(timestamp): + if timestamp: + dt = datetime.datetime.fromtimestamp(timestamp) return humanize.naturaltime(dt) -def htime_dt(dt): - if dt: +def htime_dt(datetime): + if datetime: try: - return humanize.naturaltime(dt) + return humanize.naturaltime(datetime) except ValueError: return "Never" + return "Never" -def hdate(ts): - if ts: - dt = datetime.datetime.fromtimestamp(ts) +def hdate(timestamp): + if timestamp: + dt = datetime.datetime.fromtimestamp(timestamp) return humanize.naturaldate(dt) -def hdate_dt(dt): - if dt: +def hdate_dt(datetime): + if datetime: try: - return humanize.naturaldate(dt) + return humanize.naturaldate(datetime) except ValueError: return "Never" + return "Never" -def fmt_timestamp(ts): +def fmt_timestamp(timestamp): with current_app.app_context(): tz = ZoneInfo(current_app.config['APP_TZ']) - if ts: - local_ts = datetime.datetime.fromtimestamp(ts, 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(dt): +def fmt_datetime(datetime): with current_app.app_context(): tz = ZoneInfo(current_app.config['APP_TZ']) - if dt: + if datetime: try: - local_ts = dt.fromtimestamp(dt.timestamp(), tz) + local_ts = datetime.fromtimestamp(datetime.timestamp(), tz) except OverflowError: return "Never" return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'), diff --git a/hsman/app/lib.py b/hsman/app/lib.py index 3f0439f..744dc07 100644 --- a/hsman/app/lib.py +++ b/hsman/app/lib.py @@ -20,6 +20,13 @@ def remote_ip() -> str: 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" diff --git a/hsman/app/static/main.css b/hsman/app/static/main.css index aacc4f6..ffed578 100644 --- a/hsman/app/static/main.css +++ b/hsman/app/static/main.css @@ -103,3 +103,10 @@ div.dt-container div.dt-scroll-body { tr.pka-hide { visibility: collapse; } + +i.disabled { + color: #888; +} +span.expired { + color: #888; +} diff --git a/hsman/app/templates/_macros.html.j2 b/hsman/app/templates/_macros.html.j2 index 7a75b32..240c816 100644 --- a/hsman/app/templates/_macros.html.j2 +++ b/hsman/app/templates/_macros.html.j2 @@ -16,9 +16,3 @@ {% endif %} {% endmacro %} - -{% macro body_theme(theme) -%} -{% if theme == "dark" %} -bootstrap-dark -{% endif %} -{%- endmacro -%} diff --git a/hsman/app/templates/base.html b/hsman/app/templates/base.html index 7ba2288..adb530b 100644 --- a/hsman/app/templates/base.html +++ b/hsman/app/templates/base.html @@ -34,7 +34,6 @@ -
diff --git a/hsman/app/templates/node.html b/hsman/app/templates/node.html index 0d9a02f..734f067 100644 --- a/hsman/app/templates/node.html +++ b/hsman/app/templates/node.html @@ -14,6 +14,18 @@

+
+
+ status +
+
+ {% if node.online %} + online + {% else %} + offline + {% endif %} +
+
registered @@ -27,20 +39,23 @@
- expiry + expire
{{ node.expiry | htime_dt }} + {% if node.expireDate and not node.expired %} + {% endif %}
+
owner @@ -73,7 +88,7 @@
{% if node.validTags %} {% for tag in node.validTags %} - + {{ tag }} {% endfor %} @@ -89,7 +104,7 @@
{% if node.forced %} {% for tag in node.forcedTags %} -

+

{{ tag }}

{% endfor %} diff --git a/hsman/app/templates/nodes.html b/hsman/app/templates/nodes.html index 4f91675..fb17ca2 100644 --- a/hsman/app/templates/nodes.html +++ b/hsman/app/templates/nodes.html @@ -13,7 +13,7 @@ name user - registered + expire last event online   @@ -32,9 +32,11 @@ {{node.user.name}} - - - {{node.createdAt | htime_dt }} + + + {{node.expireDate | htime_dt | safe}} @@ -46,11 +48,15 @@ {{node.online | fancyBool | safe}} + {% if node.expireDate and not node.expired %} - + + {% else %} + + {% endif %} diff --git a/hsman/app/views.py b/hsman/app/views.py index 7fe42c0..e4ee1ec 100644 --- a/hsman/app/views.py +++ b/hsman/app/views.py @@ -5,6 +5,8 @@ from flask import render_template, Blueprint, request from flask import redirect, session, url_for from app import auth +from .lib import username + from flask import jsonify from flask_pyoidc.user_session import UserSession @@ -19,13 +21,13 @@ log = logging.getLogger() main_blueprint = Blueprint('main', __name__) -@ main_blueprint.route('/health', methods=['GET']) +@main_blueprint.route('/health', methods=['GET']) def health(): return jsonify(dict(status="OK", version=current_app.config['APP_VERSION'])) -@ main_blueprint.route('/', methods=['GET', 'POST']) -@ auth.access_control('default') +@main_blueprint.route('/', methods=['GET', 'POST']) +@auth.access_control('default') def index(): user_session = UserSession(session) hs_user = user_session.userinfo['email'].split('@')[0] @@ -35,29 +37,29 @@ def index(): session=user_session) -@ main_blueprint.route('/token', methods=['GET', 'POST']) -@ auth.access_control('default') +@main_blueprint.route('/token', methods=['GET', 'POST']) +@auth.access_control('default') def token(): user_session = UserSession(session) return jsonify(user_session.userinfo) -@ main_blueprint.route('/logout') -@ auth.oidc_logout +@main_blueprint.route('/logout') +@auth.oidc_logout def logout(): return redirect('/') -@ main_blueprint.route('/nodes', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/nodes', methods=['GET']) +@auth.authorize_admins('default') def nodes(): nodelist = Node().list() return render_template('nodes.html', nodes=nodelist.nodes) -@ main_blueprint.route('/node/', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/node/', methods=['GET']) +@auth.authorize_admins('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 @@ -72,8 +74,8 @@ def node(nodeId): node=node) -@ main_blueprint.route('/users', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/users', methods=['GET']) +@auth.authorize_admins('default') def users(): userList = User().list() # Get online status of devices of the user @@ -88,8 +90,8 @@ def users(): online=online) -@ main_blueprint.route('/user/', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/user/', methods=['GET']) +@auth.authorize_admins('default') def user(userName): user = User().get(userName) userNodeList = [n for n in Node().list().nodes if n.user.name == userName] @@ -107,8 +109,8 @@ def user(userName): userNodeList=userNodeList) -@ main_blueprint.route('/routes', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/routes', methods=['GET']) +@auth.authorize_admins('default') def routes(): routes = Route().list() @@ -126,52 +128,65 @@ def routes(): routes=final) -@ main_blueprint.route('/routeToggle/', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/routeToggle/', 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) - log.info(f"route {route.prefix}/{route.node.givenName} disabled") else: 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")) -@ main_blueprint.route('/node//expire', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/node//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)) -@ main_blueprint.route('/node//list-expire', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/node//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")) -@ main_blueprint.route('/node//delete', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/node//delete', methods=['GET']) +@auth.authorize_admins('default') def deleteNode(nodeId: int): Node().delete(nodeId) return redirect(url_for("main.nodes")) -@ main_blueprint.route('/node//rename/', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/node//rename/', methods=['GET']) +@auth.authorize_admins('default') def renameNode(nodeId: int, newName: str): Node().rename(nodeId, newName) return jsonify(dict(newName=newName)) -@ main_blueprint.route('/user//delete', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/user//delete', methods=['GET']) +@auth.authorize_admins('default') def deleteUser(userName: str): nodes = Node().byUser(userName) for node in nodes.nodes: @@ -181,8 +196,8 @@ def deleteUser(userName: str): return redirect(url_for("main.users")) -@ main_blueprint.route('/user//pakcreate', methods=['POST']) -@ auth.authorize_admins('default') +@main_blueprint.route('/user//pakcreate', methods=['POST']) +@auth.authorize_admins('default') def createPKA(userName: str): data = request.json log.debug(data) @@ -195,8 +210,8 @@ def createPKA(userName: str): return jsonify(dict(key=pak.preAuthKey.key)) -@ main_blueprint.route('/user//expire/', methods=['GET']) -@ auth.authorize_admins('default') +@main_blueprint.route('/user//expire/', methods=['GET']) +@auth.authorize_admins('default') def expirePKA(userName: str, key: str): log.debug(key) req = v1ExpirePreAuthKeyRequest(user=userName, key=key) diff --git a/hsman/docker/Dockerfile b/hsman/docker/Dockerfile index 6be579f..ca5d84e 100644 --- a/hsman/docker/Dockerfile +++ b/hsman/docker/Dockerfile @@ -8,7 +8,8 @@ ARG BUILD_DATE ENV APP_VERSION=${APP_VERSION:-alpha0} ENV APP_SHA=${APP_SHA:-000000} # 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 \ bash \ @@ -18,16 +19,18 @@ RUN apk --update --no-cache add \ chmod g+w /run && \ pip install poetry gunicorn -COPY . /hsmon +COPY . /hsman -RUN cd hsmon && \ +RUN cd hsman && \ poetry install && \ 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 EXPOSE 5000 # exectute start up script -ENTRYPOINT ["/hsmon/docker/entrypoint.sh"] +ENTRYPOINT ["/hsman/docker/entrypoint.sh"] diff --git a/hsman/docker/entrypoint.sh b/hsman/docker/entrypoint.sh index 11e2a8f..acacf94 100755 --- a/hsman/docker/entrypoint.sh +++ b/hsman/docker/entrypoint.sh @@ -5,11 +5,12 @@ if [ $# -gt 0 ]; then fi echo Starting ${APP_VERSION} -if [ -e /debug ]; then - echo "Running app in debug mode!" - export PYTHONPATH=/app/site-packages - /hsmon/site-packages/bin/flask run --host 0.0.0.0 + +if [ $FLASK_ENV == "development" ]; then + echo "Running app in debug mode" + export FLASK_DEBUG=1 + flask run --host 0.0.0.0 else - echo "Running app in production mode!" - gunicorn --chdir hsmon wsgi:app -w 1 --threads 2 -b 0.0.0.0:5000 + echo "Running app in production mode" + gunicorn wsgi:app --config gunicorn.conf.py fi diff --git a/hsman/gunicorn.conf.py b/hsman/gunicorn.conf.py index 83cbe05..1c7f1be 100644 --- a/hsman/gunicorn.conf.py +++ b/hsman/gunicorn.conf.py @@ -4,13 +4,15 @@ proc_name = "hsmon" default_proc_name = "hsmon" bind = "0.0.0.0:5000" -workers = 1 +workers = 2 threads = 4 preload_app = True -worker_class = "uvicorn.workers.UvicornWorker" +# worker_class = "uvicorn.workers.UvicornWorker" + +chdir = "/hsman" # 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" # Log to stdout. accesslog = "-" diff --git a/hsman/poetry.lock b/hsman/poetry.lock index 89439d2..ec03ad7 100644 --- a/hsman/poetry.lock +++ b/hsman/poetry.lock @@ -457,13 +457,13 @@ files = [ [[package]] name = "hsapi-client" -version = "0.9.1" +version = "0.9.2" description = "Headscale API client" optional = false python-versions = "<4.0,>=3.11" files = [ - {file = "hsapi_client-0.9.1-py3-none-any.whl", hash = "sha256:1b6531526ed476daaefa16eeb3a16e768372f4bd5d879da7bf4ed75a4bd36a5b"}, - {file = "hsapi_client-0.9.1.tar.gz", hash = "sha256:b047a21e642ae4ba108a17f6b68d71af8ba851270421261df92c97acdcfdc907"}, + {file = "hsapi_client-0.9.2-py3-none-any.whl", hash = "sha256:bbda176e10b7ba0c1376eac6e8fcbd9027a41fab21585edad275cfbd21cce66d"}, + {file = "hsapi_client-0.9.2.tar.gz", hash = "sha256:902256e6240e47b3eecacde930f9d67e12a0d46d34d7dbcc86e95b2976013099"}, ] [package.dependencies] @@ -1186,4 +1186,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<4.0" -content-hash = "462d0b3a2b307ddc04cb74309f7a1a85679a3a3bf05155a8022d0047d14d2241" +content-hash = "b69a53708d986528f101f49ccb7e61fb1377d244511d9cd335b0ec200510b37b" diff --git a/hsman/pyproject.toml b/hsman/pyproject.toml index f3fa404..38818a6 100644 --- a/hsman/pyproject.toml +++ b/hsman/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hsman" -version = "0.1.0" +version = "0.9.0" description = "Flask Admin webui for Headscale" authors = ["Andrea Mistrali "] license = "BSD" @@ -11,11 +11,11 @@ python = ">=3.11,<4.0" Flask = "^3.0.3" Flask-pyoidc = "^3.14.3" gunicorn = "^22.0.0" -hsapi-client = "^0.9.1" flask-mobility = "^2.0.1" humanize = "^4.9.0" flask-pydantic = "^0.12.0" uvicorn = "^0.30.1" +hsapi-client = "^0.9.2" [tool.poetry.group.dev.dependencies]