diff --git a/hsapi/hsapi/nodes.py b/hsapi/hsapi/nodes.py index d37efd7..72d6390 100644 --- a/hsapi/hsapi/nodes.py +++ b/hsapi/hsapi/nodes.py @@ -45,9 +45,25 @@ class Node(HSAPICall): response = self.call('get') return v1ListNodesResponse(**response.json()) - def get(self, nodeId: str) -> v1NodeResponse: - response = self.call('get', call_path=nodeId) - return v1NodeResponse(**response.json()) + def get(self, nodeId: str) -> v1Node: + # There is a bug in headscale API + # retrieving a specific node does not return the tags + # so we get the full list of nodes and extract the node with the + # ID we want + # response = self.call('get', call_path=nodeId) + nodelist = self.list() + node = [n for n in nodelist.nodes if n.id == nodeId] + if node: + return node[0] # type: ignore + else: + return v1Node() + + def byUser(self, username: str) -> v1ListNodesResponse: + nodelist = self.list() + + byUser = [n for n in nodelist.nodes if n.user.name == username] + + return v1ListNodesResponse(nodes=byUser) def delete(self, nodeId: str) -> None: self.call('delete', call_path=nodeId) diff --git a/hsapi/poetry.lock b/hsapi/poetry.lock index cb7d19b..cc8c3af 100644 --- a/hsapi/poetry.lock +++ b/hsapi/poetry.lock @@ -327,4 +327,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d6cbad8cd64ba63c62d1f3ab2dde75682d6a42c5b40808fd35615bccdf5fdd07" +content-hash = "28f675747b0ee9850925befb61509ac513e0acc429d7efc1abe2a0fea6d8f97d" diff --git a/hsapi/pyproject.toml b/hsapi/pyproject.toml index eb3f0da..1c235fa 100644 --- a/hsapi/pyproject.toml +++ b/hsapi/pyproject.toml @@ -13,6 +13,9 @@ pydantic = "^2.7.4" pydantic-settings = "^2.3.4" +[tool.poetry.group.dev.dependencies] +python-dotenv = "^1.0.1" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/hsman/.python-version b/hsman/.python-version deleted file mode 100644 index 051a30c..0000000 --- a/hsman/.python-version +++ /dev/null @@ -1 +0,0 @@ -headscale diff --git a/hsman/app/__init__.py b/hsman/app/__init__.py new file mode 100644 index 0000000..73c4b19 --- /dev/null +++ b/hsman/app/__init__.py @@ -0,0 +1,70 @@ +from flask import Flask, render_template +from werkzeug.exceptions import HTTPException + +from flask_mobility import Mobility +from flask_pyoidc import OIDCAuthentication +from flask_pyoidc.provider_configuration import ProviderConfiguration, ClientMetadata + + +from . import filters +import os + +mobility = Mobility() + +client_metadata = ClientMetadata( + client_id='***REMOVED***', + client_secret='***REMOVED***', + post_logout_redirect_uris=['https://example.com/logout']) + + +provider_config = ProviderConfiguration(issuer='***REMOVED***', + client_metadata=client_metadata, + auth_request_params={ + 'scope': ['openid', + 'profile', + 'groups', + 'email']}, + session_refresh_interval_seconds=1800) + +auth = OIDCAuthentication({'default': provider_config}) + + +def create_app(environment='development'): + + from config import config + from .views import main_blueprint + + # Instantiate app. + app = Flask(__name__) + + # 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) + + # Register blueprints. + app.logger.info("registering main blueprint") + app.register_blueprint(main_blueprint) + + app.logger.info("jinja2 custom filters loaded") + filters.init_app(app) + + app.config.update( + OIDC_REDIRECT_URI='http://localhost:5000/redirect', + SECRET_KEY="secreto" + ) + + auth.init_app(app) + + # Error handlers. + + @app.errorhandler(HTTPException) + def handle_http_error(exc): + return render_template('error.html', error=exc), exc.code + + return app diff --git a/hsman/app/filters.py b/hsman/app/filters.py new file mode 100644 index 0000000..bfd5497 --- /dev/null +++ b/hsman/app/filters.py @@ -0,0 +1,85 @@ +import humanize +import datetime +from flask import current_app +from zoneinfo import ZoneInfo +import logging + +log = logging.getLogger(__name__) + + +def htime(ts): + if ts: + dt = datetime.datetime.fromtimestamp(ts) + return humanize.naturaltime(dt) + + +def htime_dt(dt): + if dt: + try: + return humanize.naturaltime(dt) + except ValueError: + return "Never" + + +def hdate(ts): + if ts: + dt = datetime.datetime.fromtimestamp(ts) + return humanize.naturaldate(dt) + + +def hdate_dt(dt): + if dt: + try: + return humanize.naturaldate(dt) + except ValueError: + return "Never" + + +def fmt_timestamp(ts): + with current_app.app_context(): + tz = ZoneInfo(current_app.config['APP_TZ']) + if ts: + local_ts = datetime.datetime.fromtimestamp(ts, tz) + return "%s %s" % (local_ts.strftime('%Y-%m-%d %H:%M:%S'), + local_ts.tzname()) + + +def fmt_datetime(dt): + with current_app.app_context(): + tz = ZoneInfo(current_app.config['APP_TZ']) + if dt: + try: + local_ts = dt.fromtimestamp(dt.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 '' + else: + return '' + + +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 diff --git a/hsman/app/lib.py b/hsman/app/lib.py new file mode 100644 index 0000000..2c5375a --- /dev/null +++ b/hsman/app/lib.py @@ -0,0 +1,21 @@ +from app import models +import os +from flask import request + +import logging +log = logging.getLogger(__name__) + + +def remote_ip(): + if 'HTTP_X_FORWARDED_FOR' in request.environ: + xff_parts = request.environ.get('HTTP_X_FORWARDED_FOR').split(',') + return xff_parts[0] + else: + return request.environ.get('REMOTE_ADDR') + + +def webMode(): + 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 diff --git a/hsman/app/logging/development.ini b/hsman/app/logging/development.ini new file mode 100644 index 0000000..9ea9595 --- /dev/null +++ b/hsman/app/logging/development.ini @@ -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 = %(asctime)s,%(msecs)03d %(levelname)-7.7s [%(name)s/%(filename)s:%(lineno)d]: %(message)s +datefmt = %H:%M:%S + +[formatter_accesslog] +format = %(name)s %(message)s diff --git a/hsman/app/logging/production.ini b/hsman/app/logging/production.ini new file mode 100644 index 0000000..4341e43 --- /dev/null +++ b/hsman/app/logging/production.ini @@ -0,0 +1,94 @@ +# 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 = INFO +handlers = console + +[logger_gunicorn] +level = INFO +handlers = accesslog +qualname = gunicorn +propagate = 0 + +[logger_werkzeug] +level = INFO +handlers = accesslog +qualname = werkzeug +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 + +[logger_model] +level = INFO +handlers = console +qualname = app.models +propagate = 0 + +[logger_sqlalchemy] +level = WARN +handlers = console +qualname = sqlalchemy.engine.Engine +propagate = 0 +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[logger_scheduler] +level = INFO +handlers = console +qualname = app.scheduler +propagate = 0 + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +; formatter = colored +formatter = generic + +[handler_accesslog] +class = StreamHandler +args = (sys.stderr,) +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 = %(asctime)s,%(msecs)03d %(levelname)-7.7s [%(name)s/%(filename)s:%(lineno)d]: %(message)s +datefmt = %H:%M:%S + +[formatter_accesslog] +format = %(message)s diff --git a/hsman/app/models.py b/hsman/app/models.py new file mode 100644 index 0000000..e69de29 diff --git a/hsman/app/static/main.css b/hsman/app/static/main.css new file mode 100644 index 0000000..d136ecf --- /dev/null +++ b/hsman/app/static/main.css @@ -0,0 +1,77 @@ +/* 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; +} + +.theme-icon { + width: 1.3em; + height: 1.3em; + margin-right: 10px; +} + +a.plain:link { + color: unset; + text-decoration-line: underline; + text-decoration-style: dotted; +} + +a.plain:visited { + color: unset; + text-decoration-line: underline; + text-decoration-style: dotted; +} + +a.plain:hover { + color: unset; + text-decoration: underline; +} + +a.plain:active { + color: unset; + text-decoration: none; +} + +table.dataTable tbody tr:hover { + background-color: #444; +} + +table.dataTable tbody tr:hover > .sorting_1 { + background-color: #444; +} + +.data:hover{ + /* background-color: rgba(63, 140, 211, 0.808); */ + background-color: #444; +} diff --git a/hsman/app/static/main.js b/hsman/app/static/main.js new file mode 100644 index 0000000..765298f --- /dev/null +++ b/hsman/app/static/main.js @@ -0,0 +1,55 @@ +// custom javascript +var sun_icon = ` +
+ + + +
+`; + +var moon_icon = ` +
+ + + +
+`; + +function getCookie(cookie) { + let name = cookie + "="; + let ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) == " ") { + c = c.substring(1); + } + if (c.indexOf(name) == 0) { + return c.substring(name.length, c.length); + } + } + return ""; +} + +function setCookie(cname, cvalue) { + const d = new Date(); + d.setTime(d.getTime() + 3650 * 24 * 60 * 60 * 1000); // 10 years cookie + let expires = "expires=" + d.toUTCString(); + document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/"; +} + +function toggleTheme(obj) { + console.log(getCookie("theme")); + console.log(document.cookie); + if (getCookie("theme") == "light") { + setCookie("theme", "dark"); + obj.html(sun_icon); + $("body").addClass("bootstrap-dark"); + } else { + // switch to light mode + setCookie("theme", "light"); + obj.html(moon_icon); + $("body").removeClass("bootstrap-dark"); + } +} diff --git a/hsman/app/templates/_macros.html.j2 b/hsman/app/templates/_macros.html.j2 new file mode 100644 index 0000000..7a75b32 --- /dev/null +++ b/hsman/app/templates/_macros.html.j2 @@ -0,0 +1,24 @@ + +{% macro theme_icon(theme) %} +
+ {% if theme == "dark" %} + + + + {% else %} + + + + {% 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 new file mode 100644 index 0000000..16a28a5 --- /dev/null +++ b/hsman/app/templates/base.html @@ -0,0 +1,118 @@ +{% import '_macros.html.j2' as mc -%} + + + + + + {{ config.APP_NAME }} + + + + + + {% block meta %}{% endblock %} + + + + + + + + + + + + {% block links %}{% endblock %} + + + + + + + + + + + +
+ + +
+ {% if g.is_mobile %} +
+ {% else %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + + + + + + + + {% block scripts %}{% endblock %} + + + diff --git a/hsman/app/templates/error.html b/hsman/app/templates/error.html new file mode 100644 index 0000000..df46834 --- /dev/null +++ b/hsman/app/templates/error.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ '%s - %s' % (error.code, error.name) }}

+

{{ error.description }}.

+
+
+{% endblock %} \ No newline at end of file diff --git a/hsman/app/templates/index.html b/hsman/app/templates/index.html new file mode 100644 index 0000000..ed26721 --- /dev/null +++ b/hsman/app/templates/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} +

+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Egestas tellus rutrum tellus pellentesque eu tincidunt. Aenean et tortor at risus viverra adipiscing. Et malesuada fames ac turpis egestas sed tempus. Amet commodo nulla facilisi nullam vehicula ipsum a arcu. Quam viverra orci sagittis eu volutpat odio. Elit ut aliquam purus sit amet luctus. Vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt ornare massa. Ultrices gravida dictum fusce ut placerat orci nulla. Sed faucibus turpis in eu mi bibendum. Vel facilisis volutpat est velit egestas dui id ornare arcu. Scelerisque eu ultrices vitae auctor. Sem nulla pharetra diam sit amet. Lectus proin nibh nisl condimentum id venenatis a condimentum vitae. +

+

+
+

+Auctor elit sed vulputate mi sit amet mauris. Tincidunt eget nullam non nisi est. Leo vel orci porta non pulvinar neque laoreet suspendisse interdum. Volutpat commodo sed egestas egestas fringilla phasellus faucibus scelerisque. Ipsum dolor sit amet consectetur adipiscing elit pellentesque habitant morbi. Ultricies mi quis hendrerit dolor magna. Porta nibh venenatis cras sed felis eget. Quam vulputate dignissim suspendisse in. Pellentesque dignissim enim sit amet venenatis urna cursus eget nunc. Nunc lobortis mattis aliquam faucibus purus in. +

+{% endblock %} diff --git a/hsman/app/templates/node.html b/hsman/app/templates/node.html new file mode 100644 index 0000000..562f5f3 --- /dev/null +++ b/hsman/app/templates/node.html @@ -0,0 +1,168 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ name +
+
+ {{ node.givenName }} +
+
+
+
+ registered +
+
+ + {{ node.createdAt | htime_dt }} + +
+
+ +
+
+ expiry +
+
+ + {{ node.expiry | htime_dt }} + +
+
+ +
+
+ user +
+ +
+

+
+
+
addresses
+
+
+{% for ip in node.ipAddresses %} +
+
+ {{ ip }} +
+
+{% endfor %} +

+ +
+
+
tags
+
+
+
+
+ + announced + +
+
+ {% if node.validTags %} + {% for tag in node.validTags %} + + {{ tag }} + + {% endfor %} + {% else %} + None + {% endif %} +
+
+
+
+ forced +
+
+ {% if node.forced %} + {% for tag in node.forcedTags %} +

+ {{ tag }} +

+ {% endfor %} + {% else %} + None + {% endif %} +
+
+ + +

+
+
+
keys
+
+
+ +
+
+ machineKey +
+
+ {{ node.machineKey }} +
+
+ +
+
+ nodeKey +
+
+ {{ node.nodeKey }} +
+
+ +
+
+ discoKey +
+
+ {{ node.discoKey }} +
+
+ +

+
+
+
+ routes + {% if isExitNode %} + Exit Node + {% endif %} +
+
+
+ +
+
+ prefix +
+
+ enabled +
+
+ primary +
+
+{% for route in routes | sort(attribute='prefix') %} +
+
+ {{ route.prefix }} +
+
+ {{ route.enabled | fancyBool | safe }} +
+
+ {{ route.isPrimary | fancyBool | safe }} +
+
+{% endfor %} +{% endblock %} diff --git a/hsman/app/templates/nodes.html b/hsman/app/templates/nodes.html new file mode 100644 index 0000000..e31bf8f --- /dev/null +++ b/hsman/app/templates/nodes.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block links %} + + +{% endblock %} + +{% block content %} + + + + + + + + + + + +{% for node in nodes %} + + + + + + + +{% endfor %} + +
nameuserregistered onlast connectonline
+ + {{node.givenName}} + + {{node.user.name}} + + {{node.createdAt | htime_dt }} + + + + {{node.lastSeen | htime_dt }} + + + {{node.online | fancyBool | safe}} +
+ +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/hsman/app/templates/routes.html b/hsman/app/templates/routes.html new file mode 100644 index 0000000..683c22f --- /dev/null +++ b/hsman/app/templates/routes.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+
Exit nodes
+
+
+ +{% for exitNode in exitNodes %} +
+
+ +   {{ exitNode.givenName }} + +
+
+{% endfor %} + +

+{% for prefix, rts in routes.items() %} +
+
+ prefix +
+
+ gateway +
+
+ enabled +
+
+ primary +
+
+
+
+ {{ prefix}} +
+ +
+ {{ rts[0].isPrimary | fancyBool | safe}} +
+
+ {{ rts[0].enabled | fancyBool | safe}} +
+
+ {% for rt in rts[1:] %} +
+
+   +
+
+ {% if not rt.enabled %} + + + + {{ rt.node.givenName}} + + + + {% else %} + + + + {{ rt.node.givenName}} + + + {% endif %} +
+
+ {{ rt.enabled | fancyBool | safe}} +
+
+ {{ rt.isPrimary | fancyBool | safe}} +
+
+ {% endfor %} +

+{% endfor %} + +{% endblock %} diff --git a/hsman/app/templates/user.html b/hsman/app/templates/user.html new file mode 100644 index 0000000..a27c847 --- /dev/null +++ b/hsman/app/templates/user.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+ name +
+
+ {{ user.name }} +
+
+
+
+ registered +
+
+ + {{ user.createdAt | htime_dt }} + +
+
+

+
+
+
nodes
+
+
+
+
+ name +
+
+ last connect +
+
+ online +
+
+{% for node in userNodeList %} +
+ +
+ + {{node.lastSeen | htime_dt }} + +
+
+ {{node.online | fancyBool | safe}} +
+
+{% endfor %} + + + +{% endblock %} diff --git a/hsman/app/templates/users.html b/hsman/app/templates/users.html new file mode 100644 index 0000000..f2079e6 --- /dev/null +++ b/hsman/app/templates/users.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block links %} + + +{% endblock %} + +{% block content %} + + + + + + + + + +{% for user in users %} + + + + + +{% endfor %} + +
nameregistered ononline
+ + {{user.name}} + + + + {{user.createdAt | htime_dt }} + + + {{online[user.name] | fancyBool | safe }} +
+ +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/hsman/app/views.py b/hsman/app/views.py new file mode 100644 index 0000000..8dbe380 --- /dev/null +++ b/hsman/app/views.py @@ -0,0 +1,111 @@ +from flask import render_template, Blueprint, current_app, g +from flask import request, after_this_request, redirect, session +from app import auth + +from flask import jsonify +from flask_pyoidc.user_session import UserSession + +from hsapi import Node, User, Route + +import logging +log = logging.getLogger() + +main_blueprint = Blueprint('main', __name__) + + +# @main_blueprint.before_request +# def set_theme(): +# g.theme = request.cookies.get('theme', 'light') + + +@main_blueprint.route('/', methods=['GET', 'POST']) +@auth.oidc_auth('default') +def index(): + user_session = UserSession(session) + return jsonify(access_token=user_session.access_token, + id_token=user_session.id_token, + userinfo=user_session.userinfo) + + +@main_blueprint.route('/logout') +@auth.oidc_logout +def logout(): + return redirect('/') + + +@main_blueprint.route('/nodes', methods=['GET']) +def nodes(): + + nodelist = Node().list() + log.debug(nodelist) + + return render_template('nodes.html', + nodes=nodelist.nodes) + + +@main_blueprint.route('/node/', methods=['GET']) +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` + node = Node().get(nodeId) + routes = Node().routes(nodeId) + isExitNode = any((r for r in routes.routes if r.prefix.endswith('/0'))) + log.debug(node) + return render_template("node.html", + routes=routes.routes, + isExitNode=isExitNode, + node=node.node) + + +@main_blueprint.route('/users', methods=['GET']) +def users(): + + userList = User().list() + # Get online status of devices of the user + online = {} + nodeList = Node().list() + for user in userList.users: + log.debug(user) + userNodeList = [n for n in nodeList.nodes if n.user.name == user.name] + log.debug(userNodeList) + online[user.name] = any(map(lambda x: x.online, userNodeList)) + + return render_template('users.html', + users=userList.users, + online=online) + + +@main_blueprint.route('/user/', methods=['GET']) +def user(userName): + # 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` + user = User().get(userName) + log.debug(user) + userNodeList = [n for n in Node().list().nodes if n.user.name == userName] + return render_template("user.html", + user=user.user, + userNodeList=userNodeList) + + +@main_blueprint.route('/routes', methods=['GET']) +def routes(): + routes = Route().list() + log.debug(routes) + + prefixes = 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/0'))] + + final = {} + for prefix in prefixes: + rrp = [x for x in routes.routes if x.prefix == prefix] + final[prefix] = sorted(rrp, key=lambda x: x.isPrimary, reverse=True) + log.debug(final) + return render_template("routes.html", + exitNodes=exitNodes, + routes=final) diff --git a/hsman/config.py b/hsman/config.py new file mode 100644 index 0000000..d3d145c --- /dev/null +++ b/hsman/config.py @@ -0,0 +1,47 @@ +import os +import importlib.metadata + +base_dir = os.path.dirname(os.path.abspath(__file__)) + + +class BaseConfig(object): + """Base configuration.""" + + APP_NAME = "HSMAN" + APP_VERSION = os.getenv('APP_VERSION', "0.0alpha1") + APP_SHA = os.getenv('APP_SHA', "000000") + DEBUG_TB_ENABLED = False + WTF_CSRF_ENABLED = False + + @staticmethod + def configure(app): + # Implement this method to do further configuration on your app. + pass + + +class DevelopmentConfig(BaseConfig): + """Development configuration.""" + + DEBUG = True + ENVIRONMENT = "develop" + + +class TestingConfig(BaseConfig): + """Testing configuration.""" + + TESTING = True + PRESERVE_CONTEXT_ON_EXCEPTION = False + ENVIRONMENT = "test" + + +class ProductionConfig(BaseConfig): + """Production configuration.""" + + ENVIRONMENT = "production" + WTF_CSRF_ENABLED = True + + +config = dict( + development=DevelopmentConfig, + testing=TestingConfig, + production=ProductionConfig) diff --git a/hsman/poetry.lock b/hsman/poetry.lock index e5c6edf..f1d21dd 100644 --- a/hsman/poetry.lock +++ b/hsman/poetry.lock @@ -11,6 +11,24 @@ files = [ {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + [[package]] name = "blinker" version = "1.8.2" @@ -275,6 +293,17 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -286,6 +315,20 @@ files = [ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] +[[package]] +name = "executing" +version = "2.0.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.5" +files = [ + {file = "executing-2.0.1-py2.py3-none-any.whl", hash = "sha256:eac49ca94516ccc753f9fb5ce82603156e590b27525a8bc32cce8ae302eb61bc"}, + {file = "executing-2.0.1.tar.gz", hash = "sha256:35afe2ce3affba8ee97f2d69927fa823b08b472b7b994e36a52a964b93d16147"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + [[package]] name = "flask" version = "3.0.3" @@ -308,6 +351,35 @@ Werkzeug = ">=3.0.0" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-mobility" +version = "2.0.1" +description = "A Flask extension to simplify building mobile-friendly sites." +optional = false +python-versions = "*" +files = [ + {file = "Flask-Mobility-2.0.1.tar.gz", hash = "sha256:d05835e2e92f467ce2ddaed993bda21cf4ca54f73f39c574fad900f825715574"}, + {file = "Flask_Mobility-2.0.1-py3-none-any.whl", hash = "sha256:e2154f2830eb8c1b3d069b67f65ebb7a0216506f08a52f58cde80fedc7763175"}, +] + +[package.dependencies] +Flask = "*" + +[[package]] +name = "flask-pydantic" +version = "0.12.0" +description = "Flask extension for integration with Pydantic library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "Flask-Pydantic-0.12.0.tar.gz", hash = "sha256:b80f18cc7efe332b47eafbae141541e9875777132ba30d7c95f9fef9bf2a6977"}, + {file = "Flask_Pydantic-0.12.0-py3-none-any.whl", hash = "sha256:c80825d74eefa137d8d7f2396f6b376ee128329196d79eff1442bcfd816a6228"}, +] + +[package.dependencies] +Flask = "*" +pydantic = ">=2.0" + [[package]] name = "flask-pyoidc" version = "3.14.3" @@ -325,6 +397,21 @@ importlib-resources = "*" oic = "1.6.1" requests = "*" +[[package]] +name = "flask-shell-ipython" +version = "0.5.1" +description = "Replace default `flask shell` command by similar command running IPython." +optional = false +python-versions = ">=3.6, <4" +files = [ + {file = "flask_shell_ipython-0.5.1-py2.py3-none-any.whl", hash = "sha256:8c948c0721bcc5b8eb274e1831c8a428dfc693099609c33d2f330091782cce10"}, +] + +[package.dependencies] +click = "*" +Flask = ">=1.0" +IPython = ">=5.0.0" + [[package]] name = "future" version = "1.0.0" @@ -376,6 +463,20 @@ requests = ">=2.32.3,<3.0.0" type = "file" url = "../hsapi/dist/hsapi-0.9.0-py3-none-any.whl" +[[package]] +name = "humanize" +version = "4.9.0" +description = "Python humanize utilities" +optional = false +python-versions = ">=3.8" +files = [ + {file = "humanize-4.9.0-py3-none-any.whl", hash = "sha256:ce284a76d5b1377fd8836733b983bfb0b76f1aa1c090de2566fcf008d7f6ab16"}, + {file = "humanize-4.9.0.tar.gz", hash = "sha256:582a265c931c683a7e9b8ed9559089dea7edcf6cc95be39a3cbc2c5d5ac2bcfa"}, +] + +[package.extras] +tests = ["freezegun", "pytest", "pytest-cov"] + [[package]] name = "idna" version = "3.7" @@ -402,6 +503,43 @@ files = [ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["jaraco.test (>=5.4)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)", "zipp (>=3.17)"] +[[package]] +name = "ipython" +version = "8.26.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "ipython-8.26.0-py3-none-any.whl", hash = "sha256:e6b347c27bdf9c32ee9d31ae85defc525755a1869f14057e900675b9e8d6e6ff"}, + {file = "ipython-8.26.0.tar.gz", hash = "sha256:1cec0fbba8404af13facebe83d04436a7434c7400e59f47acf467c64abd0956c"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5.13.0" +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} + +[package.extras] +all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "intersphinx-registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing-extensions"] +kernel = ["ipykernel"] +matplotlib = ["matplotlib"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["packaging", "pickleshare", "pytest", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "ipython[test]", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.23)", "pandas", "trio"] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -413,6 +551,25 @@ files = [ {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, ] +[[package]] +name = "jedi" +version = "0.19.1" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.1-py2.py3-none-any.whl", hash = "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"}, + {file = "jedi-0.19.1.tar.gz", hash = "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd"}, +] + +[package.dependencies] +parso = ">=0.8.3,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] + [[package]] name = "jinja2" version = "3.1.4" @@ -518,6 +675,20 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "oic" version = "1.6.1" @@ -558,6 +729,74 @@ files = [ {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "prompt-toolkit" +version = "3.0.47" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.47-py3-none-any.whl", hash = "sha256:0d7bfa67001d5e39d02c224b663abc33687405033a8c422d0d675a5a13361d10"}, + {file = "prompt_toolkit-3.0.47.tar.gz", hash = "sha256:1e1b29cb58080b1e69f207c893a1a7bf16d127a5c30c9d17a25a5d77792e5360"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.2" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.2-py3-none-any.whl", hash = "sha256:01eaab343580944bc56080ebe0a674b39ec44a945e6d09ba7db3cb8cec289350"}, + {file = "pure_eval-0.2.2.tar.gz", hash = "sha256:2b45320af6dfaa1750f543d714b6d1c520a1688dec6fd24d339063ce0aaa9ac3"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycparser" version = "2.22" @@ -739,6 +978,20 @@ python-dotenv = ">=0.21.0" toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyjwkest" version = "1.4.2" @@ -801,6 +1054,40 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -829,6 +1116,17 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "werkzeug" version = "3.0.3" @@ -849,4 +1147,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = ">=3.11,<4.0" -content-hash = "be026a1506b86db9bcdd3d965e4383396fcd074370a026dd80bc89f186e0ecdc" +content-hash = "57399db8700fb5bbcdee00cb67fb178f1e7d5bd0b0ef700225ef192bea3a58b6" diff --git a/hsman/pyproject.toml b/hsman/pyproject.toml index ef7ceaa..daf8cbd 100644 --- a/hsman/pyproject.toml +++ b/hsman/pyproject.toml @@ -12,8 +12,15 @@ Flask = "^3.0.3" Flask-pyoidc = "^3.14.3" gunicorn = "^22.0.0" hsapi = {path = "../hsapi/dist/hsapi-0.9.0-py3-none-any.whl"} +flask-mobility = "^2.0.1" +humanize = "^4.9.0" +flask-pydantic = "^0.12.0" +[tool.poetry.group.dev.dependencies] +flask-shell-ipython = "^0.5.1" +python-dotenv = "^1.0.1" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/hsman/wsgi.py b/hsman/wsgi.py new file mode 100644 index 0000000..6404fa0 --- /dev/null +++ b/hsman/wsgi.py @@ -0,0 +1,29 @@ +from app import create_app +from app import lib +from app import models +import logging +import logging.config +import os + +logconffile = os.path.join('app/logging', '%s.ini' % + os.environ.get('FLASK_ENV', 'development')) +logging.config.fileConfig(logconffile, disable_existing_loggers=True) + +log = logging.getLogger(__name__) + + +app = create_app() + +log.debug(f"Running in web mode: {lib.webMode()}") + + +@app.shell_context_processor +def get_context(): + # flask cli context setup + """Objects exposed here will be automatically available from the shell.""" + return dict(app=app, models=models) + + +if __name__ == '__main__': + log.info("direct run") + app.run()