diff --git a/hyperglass/hyperglass.py b/hyperglass/hyperglass.py index 3379648..7cdf897 100644 --- a/hyperglass/hyperglass.py +++ b/hyperglass/hyperglass.py @@ -18,6 +18,7 @@ from sanic import response from sanic.exceptions import NotFound from sanic.exceptions import ServerError from sanic.exceptions import InvalidUsage +from sanic.exceptions import ServiceUnavailable from sanic_limiter import Limiter from sanic_limiter import RateLimitExceeded from sanic_limiter import get_remote_address @@ -29,7 +30,6 @@ from hyperglass.configuration import devices from hyperglass.configuration import logzero_config # noqa: F401 from hyperglass.configuration import params from hyperglass.constants import Supported -from hyperglass.constants import code from hyperglass.exceptions import ( HyperglassError, AuthError, @@ -97,7 +97,7 @@ count_data = Counter( count_errors = Counter( "count_errors", "Error Counter", - ["code", "reason", "source", "query_type", "loc_id", "target"], + ["reason", "source", "query_type", "loc_id", "target"], ) count_ratelimit = Counter( @@ -126,32 +126,45 @@ async def metrics(request): @app.exception(InvalidUsage) -async def handle_ui_errors(request, exception): - """Renders full error page for invalid URI""" +async def handle_frontend_errors(request, exception): + """Handles user-facing feedback related to frontend/input errors""" client_addr = get_remote_address(request) error = exception.args[0] - status = error["status"] + alert = error["alert"] logger.info(error) count_errors.labels( - status, - code.get_reason(status), + "Front End Error", client_addr, - request.json["query_type"], - request.json["location"], - request.json["target"], + request.json.get("query_type"), + request.json.get("location"), + request.json.get("target"), ).inc() logger.error(f'Error: {error["message"]}, Source: {client_addr}') return response.json( - {"output": error["message"], "status": status, "keywords": error["keywords"]}, - status=status, + {"output": error["message"], "alert": alert, "keywords": error["keywords"]}, + status=400, ) -@app.exception(ServerError) -async def handle_missing(request, exception): - """Renders full error page for invalid URI""" - logger.error(f"Error: {exception}") - return response.json(exception, status=code.invalid) +@app.exception(ServiceUnavailable) +async def handle_backend_errors(request, exception): + """Handles user-facing feedback related to backend errors""" + client_addr = get_remote_address(request) + error = exception.args[0] + alert = error["alert"] + logger.info(error) + count_errors.labels( + "Back End Error", + client_addr, + request.json.get("query_type"), + request.json.get("location"), + request.json.get("target"), + ).inc() + logger.error(f'Error: {error["message"]}, Source: {client_addr}') + return response.json( + {"output": error["message"], "alert": alert, "keywords": error["keywords"]}, + status=503, + ) @app.exception(NotFound) @@ -205,12 +218,19 @@ async def site(request): @app.route("/test", methods=["GET"]) async def test_route(request): """Test route for various tests""" - html = render_html("results") + html = render_html("500") return response.html(html, status=500) @app.route("/query", methods=["POST"]) -@limiter.limit(rate_limit_query, error_message="Query") +@limiter.limit( + rate_limit_query, + error_message={ + "output": params.features.rate_limit.query.message, + "alert": "danger", + "keywords": [], + }, +) async def hyperglass_main(request): """ Main backend application initiator. Ingests Ajax POST data from @@ -221,27 +241,58 @@ async def hyperglass_main(request): lg_data = request.json logger.debug(f"Unvalidated input: {lg_data}") + query_location = lg_data.get("location") + query_type = lg_data.get("query_type") + query_target = lg_data.get("target") + # Return error if no target is specified - if not lg_data["target"]: + if not query_target: logger.debug("No input specified") - raise handle_missing(request, params.messages.no_input) + raise InvalidUsage( + { + "message": params.messages.no_input.format( + query_type=params.branding.text.query_target + ), + "alert": "warning", + "keywords": [params.branding.text.query_target], + } + ) # Return error if no location is selected - if lg_data["location"] not in devices.hostnames: + if query_location not in devices.hostnames: logger.debug("No selection specified") - raise handle_missing(request, params.messages.no_input) + raise InvalidUsage( + { + "message": params.messages.no_input.format( + query_type=params.branding.text.query_location + ), + "alert": "warning", + "keywords": [params.branding.text.query_location], + } + ) # Return error if no query type is selected - if not Supported.is_supported_query(lg_data["query_type"]): + if not Supported.is_supported_query(query_type): logger.debug("No query specified") - raise handle_missing(request, params.messages.no_input) + raise InvalidUsage( + { + "message": params.messages.no_input.format( + query_type=params.branding.text.query_type + ), + "alert": "warning", + "keywords": [params.branding.text.query_location], + } + ) # Get client IP address for Prometheus logging & rate limiting client_addr = get_remote_address(request) # Increment Prometheus counter count_data.labels( - client_addr, lg_data["query_type"], lg_data["location"], lg_data["target"] + client_addr, + lg_data.get("query_type"), + lg_data.get("location"), + lg_data.get("target"), ).inc() logger.debug(f"Client Address: {client_addr}") @@ -268,18 +319,15 @@ async def hyperglass_main(request): endtime = time.time() elapsedtime = round(endtime - starttime, 4) logger.debug(f"Query {cache_key} took {elapsedtime} seconds to run.") - except ( - AuthError, - RestError, - ScrapeError, - InputInvalid, - InputNotAllowed, - DeviceTimeout, - ) as backend_error: - raise InvalidUsage(backend_error.__dict__()) + except (InputInvalid, InputNotAllowed) as frontend_error: + raise InvalidUsage(frontend_error.__dict__()) + except (AuthError, RestError, ScrapeError, DeviceTimeout) as backend_error: + raise ServiceUnavailable(backend_error.__dict__()) if not cache_value: - raise handle_ui_errors(request, params.messages.request_timeout) + raise ServerError( + {"message": params.messages.general, "alert": "danger", "keywords": []} + ) # Create a cache entry await r_cache.set(cache_key, str(cache_value)) diff --git a/hyperglass/render/__init__.py b/hyperglass/render/__init__.py index 069fea9..fa1f1e3 100644 --- a/hyperglass/render/__init__.py +++ b/hyperglass/render/__init__.py @@ -4,221 +4,3 @@ Renders Jinja2 & Sass templates for use by the front end application from hyperglass.render.html import render_html from hyperglass.render.webassets import render_assets - -''' -# Standard Library Imports -from pathlib import Path - -# Third Party Imports -import jinja2 -import sass -import yaml -from logzero import logger -from markdown2 import Markdown - -# Project Imports -from hyperglass.configuration import devices -from hyperglass.configuration import logzero_config # noqa: F401 -from hyperglass.configuration import params, networks -from hyperglass.exceptions import HyperglassError - -# Module Directories -working_directory = Path(__file__).resolve().parent -hyperglass_root = working_directory.parent -file_loader = jinja2.FileSystemLoader(str(working_directory)) -env = jinja2.Environment( - loader=file_loader, autoescape=True, extensions=["jinja2.ext.autoescape"] -) - -default_details = { - "footer": """ ---- -template: footer ---- -By using {{ branding.site_name }}, you agree to be bound by the following terms of \ -use: All queries executed on this page are logged for analysis and troubleshooting. \ -Users are prohibited from automating queries, or attempting to process queries in \ -bulk. This service is provided on a best effort basis, and {{ general.org_name }} \ -makes no availability or performance warranties or guarantees whatsoever. -""", - "bgp_aspath": r""" ---- -template: bgp_aspath -title: Supported AS Path Patterns ---- -{{ branding.site_name }} accepts the following `AS_PATH` regular expression patterns: - -| Expression | Match | -| :------------------- | :-------------------------------------------- | -| `_65000$` | Originated by 65000 | -| `^65000_` | Received from 65000 | -| `_65000_` | Via 65000 | -| `_65000_65001_` | Via 65000 and 65001 | -| `_65000(_.+_)65001$` | Anything from 65001 that passed through 65000 | -""", - "bgp_community": """ ---- -template: bgp_community -title: BGP Communities ---- -{{ branding.site_name }} makes use of the following BGP communities: - -| Community | Description | -| :-------- | :---------- | -| `65000:1` | Example 1 | -| `65000:2` | Example 2 | -| `65000:3` | Example 3 | -""", -} - -default_info = { - "bgp_route": """ ---- -template: bgp_route ---- -Performs BGP table lookup based on IPv4/IPv6 prefix. -""", - "bgp_community": """ ---- -template: bgp_community -link: {{ general.org_name }} BGP Communities ---- -Performs BGP table lookup based on Extended or Large community value. - - -""", - "bgp_aspath": """ ---- -template: bgp_aspath -link: Supported BGP AS Path Expressions ---- -Performs BGP table lookup based on `AS_PATH` regular expression. - - -""", - "ping": """ ---- -template: ping ---- -Sends 5 ICMP echo requests to the target. -""", - "traceroute": """ ---- -template: traceroute ---- -Performs UDP Based traceroute to the target.
For information about how to \ -interpret traceroute results, click here. -""", -} - - -def generate_markdown(section, file_name): - """ - Renders markdown as HTML. If file_name exists in appropriate - directory, it will be imported and used. If not, the default values - will be used. Also renders the Front Matter values within each - template. - """ - if section == "info": - file = working_directory.joinpath(f"templates/info/{file_name}.md") - defaults = default_info - elif section == "details": - file = working_directory.joinpath(f"templates/info/details/{file_name}.md") - defaults = default_details - if file.exists(): - with file.open(mode="r") as file_raw: - yaml_raw = file_raw.read() - else: - yaml_raw = defaults[file_name] - _, frontmatter, content = yaml_raw.split("---", 2) - html_classes = {"table": "ui compact table"} - markdown = Markdown( - extras={ - "break-on-newline": True, - "code-friendly": True, - "tables": True, - "html-classes": html_classes, - } - ) - frontmatter_rendered = ( - jinja2.Environment( - loader=jinja2.BaseLoader, - autoescape=True, - extensions=["jinja2.ext.autoescape"], - ) - .from_string(frontmatter) - .render(params) - ) - if frontmatter_rendered: - frontmatter_loaded = yaml.safe_load(frontmatter_rendered) - elif not frontmatter_rendered: - frontmatter_loaded = {"frontmatter": None} - content_rendered = ( - jinja2.Environment( - loader=jinja2.BaseLoader, - autoescape=True, - extensions=["jinja2.ext.autoescape"], - ) - .from_string(content) - .render(params, info=frontmatter_loaded) - ) - help_dict = dict(content=markdown.convert(content_rendered), **frontmatter_loaded) - if not help_dict: - raise HyperglassError(f"Error reading YAML frontmatter for {file_name}") - return help_dict - - -def html(template_name, **kwargs): - """Renders Jinja2 HTML templates""" - details_name_list = ["footer", "bgp_aspath", "bgp_community"] - details_dict = {} - for details_name in details_name_list: - details_data = generate_markdown("details", details_name) - details_dict.update({details_name: details_data}) - info_list = ["bgp_route", "bgp_aspath", "bgp_community", "ping", "traceroute"] - info_dict = {} - for info_name in info_list: - info_data = generate_markdown("info", info_name) - info_dict.update({info_name: info_data}) - try: - template_file = f"templates/{template_name}.html.j2" - template = env.get_template(template_file) - return template.render( - params, info=info_dict, details=details_dict, networks=networks, **kwargs - ) - except jinja2.TemplateNotFound as template_error: - logger.error( - f"Error rendering Jinja2 template {Path(template_file).resolve()}." - ) - raise HyperglassError(template_error) - - -def css(): - """Renders Jinja2 template to Sass file, then compiles Sass as CSS""" - scss_file = hyperglass_root.joinpath("static/sass/hyperglass.scss") - css_file = hyperglass_root.joinpath("static/css/hyperglass.css") - # Renders Jinja2 template as Sass file - try: - template_file = "templates/hyperglass.scss.j2" - template = env.get_template(template_file) - rendered_output = template.render(params) - with scss_file.open(mode="w") as scss_output: - scss_output.write(rendered_output) - except jinja2.TemplateNotFound as template_error: - logger.error( - f"Error rendering Jinja2 template {Path(template_file).resolve()}." - ) - raise HyperglassError(template_error) - # Compiles Sass to CSS - try: - generated_sass = sass.compile(filename=str(scss_file)) - with css_file.open(mode="w") as css_output: - css_output.write(generated_sass) - logger.debug(f"Compiled Sass file {scss_file} to CSS file {css_file}.") - except sass.CompileError as sassy: - logger.error(f"Error compiling Sass in file {scss_file}.") - raise HyperglassError(sassy) -''' diff --git a/hyperglass/render/templates/errortext.html.j2 b/hyperglass/render/templates/errortext.html.j2 index 2087f90..86c7b90 100644 --- a/hyperglass/render/templates/errortext.html.j2 +++ b/hyperglass/render/templates/errortext.html.j2 @@ -1,6 +1,6 @@ {% macro errortext(title, subtitle, button) -%} -
+

{{ title }}

{{ subtitle }}

diff --git a/hyperglass/render/templates/form.html.j2 b/hyperglass/render/templates/form.html.j2 index 79c7148..8f9b7cb 100644 --- a/hyperglass/render/templates/form.html.j2 +++ b/hyperglass/render/templates/form.html.j2 @@ -13,7 +13,7 @@
-
#}