From 7f3cc334873d693e13667de7b6ae3d34eca0217f Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 16 Apr 2020 02:51:29 +0200 Subject: [PATCH] Add a static method to call remote diagnosis and supports ipv4-only or ipv6-only check --- data/hooks/diagnosis/14-ports.py | 20 ++----- data/hooks/diagnosis/21-web.py | 98 +++++++++++++++++++------------- src/yunohost/diagnosis.py | 46 +++++++++++++++ 3 files changed, 110 insertions(+), 54 deletions(-) diff --git a/data/hooks/diagnosis/14-ports.py b/data/hooks/diagnosis/14-ports.py index f973a3275..05c28e8dc 100644 --- a/data/hooks/diagnosis/14-ports.py +++ b/data/hooks/diagnosis/14-ports.py @@ -1,7 +1,6 @@ #!/usr/bin/env python import os -import requests from yunohost.diagnosis import Diagnoser from yunohost.utils.error import YunohostError @@ -27,25 +26,16 @@ class PortsDiagnoser(Diagnoser): ports[port] = service try: - r = requests.post('https://diagnosis.yunohost.org/check-ports', json={'ports': ports.keys()}, timeout=30) - if r.status_code not in [200, 400, 418]: - raise Exception("Bad response from the server https://diagnosis.yunohost.org/check-ports : %s - %s" % (str(r.status_code), r.content)) - r = r.json() - if "status" not in r.keys(): - raise Exception("Bad syntax for response ? Raw json: %s" % str(r)) - elif r["status"] == "error": - if "content" in r.keys(): - raise Exception(r["content"]) - else: - raise Exception("Bad syntax for response ? Raw json: %s" % str(r)) - elif r["status"] != "ok" or "ports" not in r.keys() or not isinstance(r["ports"], dict): - raise Exception("Bad syntax for response ? Raw json: %s" % str(r)) + r = Diagnoser.remote_diagnosis('check-ports', + data={'ports': ports.keys()}, + ipversion=4) + results = r["ports"] except Exception as e: raise YunohostError("diagnosis_ports_could_not_diagnose", error=e) for port, service in sorted(ports.items()): category = services[service].get("category", "[?]") - if r["ports"].get(str(port), None) is not True: + if results.get(str(port), None) is not True: yield dict(meta={"port": str(port)}, data={"service": service, "category": category}, status="ERROR", diff --git a/data/hooks/diagnosis/21-web.py b/data/hooks/diagnosis/21-web.py index 2d0344abe..270c566cc 100644 --- a/data/hooks/diagnosis/21-web.py +++ b/data/hooks/diagnosis/21-web.py @@ -4,10 +4,14 @@ import os import random import requests +from moulinette.utils.filesystem import read_file + from yunohost.diagnosis import Diagnoser from yunohost.domain import domain_list from yunohost.utils.error import YunohostError +DIAGNOSIS_SERVER = "diagnosis.yunohost.org" + class WebDiagnoser(Diagnoser): @@ -17,52 +21,42 @@ class WebDiagnoser(Diagnoser): def run(self): - nonce_digits = "0123456789abcedf" - - at_least_one_domain_ok = False all_domains = domain_list()["domains"] + domains_to_check = [] for domain in all_domains: # If the diagnosis location ain't defined, can't do diagnosis, # probably because nginx conf manually modified... nginx_conf = "/etc/nginx/conf.d/%s.conf" % domain - if os.system("grep -q '^.*location .*/.well-known/ynh-diagnosis/' %s" % nginx_conf) != 0: + if ".well-known/ynh-diagnosis/" not in read_file(nginx_conf): yield dict(meta={"domain": domain}, status="WARNING", summary="diagnosis_http_nginx_conf_not_up_to_date", details=["diagnosis_http_nginx_conf_not_up_to_date_details"]) - - nonce = ''.join(random.choice(nonce_digits) for i in range(16)) - os.system("rm -rf /tmp/.well-known/ynh-diagnosis/") - os.system("mkdir -p /tmp/.well-known/ynh-diagnosis/") - os.system("touch /tmp/.well-known/ynh-diagnosis/%s" % nonce) - - try: - r = requests.post('https://diagnosis.yunohost.org/check-http', json={'domain': domain, "nonce": nonce}, timeout=30) - if r.status_code not in [200, 400, 418]: - raise Exception("Bad response from the server https://diagnosis.yunohost.org/check-http : %s - %s" % (str(r.status_code), r.content)) - r = r.json() - if "status" not in r.keys(): - raise Exception("Bad syntax for response ? Raw json: %s" % str(r)) - elif r["status"] == "error" and ("code" not in r.keys() or not r["code"].startswith("error_http_check_")): - if "content" in r.keys(): - raise Exception(r["content"]) - else: - raise Exception("Bad syntax for response ? Raw json: %s" % str(r)) - except Exception as e: - raise YunohostError("diagnosis_http_could_not_diagnose", error=e) - - if r["status"] == "ok": - at_least_one_domain_ok = True - yield dict(meta={"domain": domain}, - status="SUCCESS", - summary="diagnosis_http_ok") else: - detail = r["code"].replace("error_http_check", "diagnosis_http") if "code" in r else "diagnosis_http_unknown_error" - yield dict(meta={"domain": domain}, - status="ERROR", - summary="diagnosis_http_unreachable", - details=[detail]) + domains_to_check.append(domain) + + self.nonce = ''.join(random.choice("0123456789abcedf") for i in range(16)) + os.system("rm -rf /tmp/.well-known/ynh-diagnosis/") + os.system("mkdir -p /tmp/.well-known/ynh-diagnosis/") + os.system("touch /tmp/.well-known/ynh-diagnosis/%s" % self.nonce) + + if not domains_to_check: + return + + # To perform hairpinning test, we gotta make sure that port forwarding + # is working and therefore we'll do it only if at least one ipv4 domain + # works. + self.do_hairpinning_test = False + ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {} + if ipv4.get("status") == "SUCCESS": + for item in self.test_http(domains_to_check, ipversion=4): + yield item + + ipv6 = Diagnoser.get_cached_report("ip", item={"test": "ipv6"}) or {} + if ipv6.get("status") == "SUCCESS": + for item in self.test_http(domains_to_check, ipversion=6): + yield item # If at least one domain is correctly exposed to the outside, # attempt to diagnose hairpinning situations. On network with @@ -70,13 +64,12 @@ class WebDiagnoser(Diagnoser): # outside, but from the outside, it will be as if the port forwarding # was not configured... Hence, calling for example # "curl --head the.global.ip" will simply timeout... - if at_least_one_domain_ok: - ipv4 = Diagnoser.get_cached_report_item("ip", {"test": "ipv4"}) - global_ipv4 = ipv4.get("data", {}).get("global", {}) + if self.do_hairpinning_test: + global_ipv4 = ipv4.get("data", {}).get("global", None) if global_ipv4: try: requests.head("http://" + global_ipv4, timeout=5) - except requests.exceptions.Timeout as e: + except requests.exceptions.Timeout: yield dict(meta={"test": "hairpinning"}, status="WARNING", summary="diagnosis_http_hairpinning_issue", @@ -87,6 +80,33 @@ class WebDiagnoser(Diagnoser): # issue but something else super weird ... pass + def test_http(self, domains, ipversion): + + try: + r = Diagnoser.remote_diagnosis('check-http', + data={'domains': domains, + "nonce": self.nonce}, + ipversion=ipversion) + results = r["http"] + except Exception as e: + raise YunohostError("diagnosis_http_could_not_diagnose", error=e) + + assert set(results.keys()) == set(domains) + + for domain, result in results.items(): + + if result["status"] == "ok": + if ipversion == 4: + self.do_hairpinning_test = True + yield dict(meta={"domain": domain}, + status="SUCCESS", + summary="diagnosis_http_ok") + else: + yield dict(meta={"domain": domain}, + status="ERROR", + summary="diagnosis_http_unreachable", + details=[result["status"].replace("error_http_check", "diagnosis_http")]) + def main(args, env, loggers): return WebDiagnoser(args, env, loggers).diagnose() diff --git a/src/yunohost/diagnosis.py b/src/yunohost/diagnosis.py index c11cde566..fd7a37480 100644 --- a/src/yunohost/diagnosis.py +++ b/src/yunohost/diagnosis.py @@ -27,6 +27,8 @@ import re import os import time +import requests +import socket from moulinette import m18n, msettings from moulinette.utils import log @@ -39,6 +41,7 @@ logger = log.getActionLogger('yunohost.diagnosis') DIAGNOSIS_CACHE = "/var/cache/yunohost/diagnosis/" DIAGNOSIS_CONFIG_FILE = '/etc/yunohost/diagnosis.yml' +DIAGNOSIS_SERVER = "diagnosis.yunohost.org" def diagnosis_list(): all_categories_names = [h for h, _ in _list_diagnosis_categories()] @@ -492,6 +495,49 @@ class Diagnoser(): if "details" in item: item["details"] = [m18n_(info) for info in item["details"]] + @staticmethod + def remote_diagnosis(uri, data, ipversion, timeout=30): + + # Monkey patch socket.getaddrinfo to force request() to happen in ipv4 + # or 6 ... + # Inspired by https://stackoverflow.com/a/50044152 + old_getaddrinfo = socket.getaddrinfo + + def getaddrinfo_ipv4_only(*args, **kwargs): + responses = old_getaddrinfo(*args, **kwargs) + return [response + for response in responses + if response[0] == socket.AF_INET] + + def getaddrinfo_ipv6_only(*args, **kwargs): + responses = old_getaddrinfo(*args, **kwargs) + return [response + for response in responses + if response[0] == socket.AF_INET6] + + if ipversion == 4: + socket.getaddrinfo = getaddrinfo_ipv4_only + elif ipversion == 6: + socket.getaddrinfo = getaddrinfo_ipv6_only + + url = 'https://%s/%s' % (DIAGNOSIS_SERVER, uri) + try: + r = requests.post(url, json=data, timeout=timeout) + finally: + socket.getaddrinfo = old_getaddrinfo + + if r.status_code not in [200, 400]: + raise Exception("Bad response from diagnosis server.\nURL: %s\nStatus code: %s\nMessage: %s" % (url, r.status_code, r.content)) + if r.status_code == 400: + raise Exception("Diagnosis request was refused: %s" % r.content) + + try: + r = r.json() + except Exception as e: + raise Exception("Failed to parse json from diagnosis server response.\nError: %s\nOriginal content: %s" % (e, r.content)) + + return r + def _list_diagnosis_categories(): hooks_raw = hook_list("diagnosis", list_by="priority", show_info=True)["hooks"]