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 = [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 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 = [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 = %(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) %} + +{% 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 -%} + + + + + +