You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

541 lines
16 KiB

from datetime import timedelta as td
import time
from django.conf import settings
from django.db import connection
from django.http import (
HttpResponse,
HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseBadRequest,
JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from hc.accounts.models import Profile
from hc.api import schemas
from hc.api.decorators import authorize, authorize_read, cors, validate_json
from hc.api.forms import FlipsFiltersForm
from hc.api.models import MAX_DELTA, Flip, Channel, Check, Notification, Ping
from hc.lib.badges import check_signature, get_badge_svg, get_badge_url
class BadChannelException(Exception):
def __init__(self, message):
self.message = message
@csrf_exempt
@never_cache
def ping(request, code, check=None, action="success", exitstatus=None):
if check is None:
try:
check = Check.objects.get(code=code)
except Check.DoesNotExist:
return HttpResponseNotFound("not found")
if exitstatus is not None and exitstatus > 255:
return HttpResponseBadRequest("invalid url format")
headers = request.META
remote_addr = headers.get("HTTP_X_FORWARDED_FOR", headers["REMOTE_ADDR"])
remote_addr = remote_addr.split(",")[0]
scheme = headers.get("HTTP_X_FORWARDED_PROTO", "http")
method = headers["REQUEST_METHOD"]
ua = headers.get("HTTP_USER_AGENT", "")
body = request.body.decode(errors="replace")
if exitstatus is not None and exitstatus > 0:
action = "fail"
if check.methods == "POST" and method != "POST":
action = "ign"
check.ping(remote_addr, scheme, method, ua, body, action, exitstatus)
response = HttpResponse("OK")
response["Access-Control-Allow-Origin"] = "*"
return response
@csrf_exempt
def ping_by_slug(request, ping_key, slug, action="success", exitstatus=None):
try:
check = Check.objects.get(slug=slug, project__ping_key=ping_key)
except Check.DoesNotExist:
return HttpResponseNotFound("not found")
except Check.MultipleObjectsReturned:
return HttpResponse("ambiguous slug", status=409)
return ping(request, check.code, check, action, exitstatus)
def _lookup(project, spec):
unique_fields = spec.get("unique", [])
if unique_fields:
existing_checks = Check.objects.filter(project=project)
if "name" in unique_fields:
existing_checks = existing_checks.filter(name=spec.get("name"))
if "tags" in unique_fields:
existing_checks = existing_checks.filter(tags=spec.get("tags"))
if "timeout" in unique_fields:
timeout = td(seconds=spec["timeout"])
existing_checks = existing_checks.filter(timeout=timeout)
if "grace" in unique_fields:
grace = td(seconds=spec["grace"])
existing_checks = existing_checks.filter(grace=grace)
return existing_checks.first()
def _update(check, spec):
# First, validate the supplied channel codes/names
if "channels" not in spec:
# If the channels key is not present, don't update check's channels
new_channels = None
elif spec["channels"] == "*":
# "*" means "all project's channels"
new_channels = Channel.objects.filter(project=check.project)
elif spec.get("channels") == "":
# "" means "empty list"
new_channels = []
else:
# expect a comma-separated list of channel codes or names
new_channels = set()
available = list(Channel.objects.filter(project=check.project))
for s in spec["channels"].split(","):
if s == "":
raise BadChannelException("empty channel identifier")
matches = [c for c in available if str(c.code) == s or c.name == s]
if len(matches) == 0:
raise BadChannelException("invalid channel identifier: %s" % s)
elif len(matches) > 1:
raise BadChannelException("non-unique channel identifier: %s" % s)
new_channels.add(matches[0])
need_save = False
if check.pk is None:
# Empty pk means we're inserting a new check,
# and so do need to save() it:
need_save = True
if "name" in spec and check.name != spec["name"]:
check.set_name_slug(spec["name"])
need_save = True
if "tags" in spec and check.tags != spec["tags"]:
check.tags = spec["tags"]
need_save = True
if "desc" in spec and check.desc != spec["desc"]:
check.desc = spec["desc"]
need_save = True
if "manual_resume" in spec and check.manual_resume != spec["manual_resume"]:
check.manual_resume = spec["manual_resume"]
need_save = True
if "methods" in spec and check.methods != spec["methods"]:
check.methods = spec["methods"]
need_save = True
if "timeout" in spec and "schedule" not in spec:
new_timeout = td(seconds=spec["timeout"])
if check.kind != "simple" or check.timeout != new_timeout:
check.kind = "simple"
check.timeout = new_timeout
need_save = True
if "grace" in spec:
new_grace = td(seconds=spec["grace"])
if check.grace != new_grace:
check.grace = new_grace
need_save = True
if "schedule" in spec:
if check.kind != "cron" or check.schedule != spec["schedule"]:
check.kind = "cron"
check.schedule = spec["schedule"]
need_save = True
if "tz" in spec and check.tz != spec["tz"]:
check.tz = spec["tz"]
need_save = True
if need_save:
check.alert_after = check.going_down_after()
check.save()
# This needs to be done after saving the check, because of
# the M2M relation between checks and channels:
if new_channels is not None:
check.channel_set.set(new_channels)
@authorize_read
def get_checks(request):
q = Check.objects.filter(project=request.project)
if not request.readonly:
q = q.prefetch_related("channel_set")
tags = set(request.GET.getlist("tag"))
for tag in tags:
# approximate filtering by tags
q = q.filter(tags__contains=tag)
checks = []
for check in q:
# precise, final filtering
if not tags or check.matches_tag_set(tags):
checks.append(check.to_dict(readonly=request.readonly))
return JsonResponse({"checks": checks})
@validate_json(schemas.check)
@authorize
def create_check(request):
created = False
check = _lookup(request.project, request.json)
if check is None:
if request.project.num_checks_available() <= 0:
return HttpResponseForbidden()
check = Check(project=request.project)
created = True
try:
_update(check, request.json)
except BadChannelException as e:
return JsonResponse({"error": e.message}, status=400)
return JsonResponse(check.to_dict(), status=201 if created else 200)
@csrf_exempt
@cors("GET", "POST")
def checks(request):
if request.method == "POST":
return create_check(request)
return get_checks(request)
@cors("GET")
@csrf_exempt
@authorize
def channels(request):
q = Channel.objects.filter(project=request.project)
channels = [ch.to_dict() for ch in q]
return JsonResponse({"channels": channels})
@authorize_read
def get_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
return JsonResponse(check.to_dict(readonly=request.readonly))
@cors("GET")
@csrf_exempt
@authorize_read
def get_check_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id)
for check in checks:
if check.unique_key == unique_key:
return JsonResponse(check.to_dict(readonly=request.readonly))
return HttpResponseNotFound()
@validate_json(schemas.check)
@authorize
def update_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
try:
_update(check, request.json)
except BadChannelException as e:
return JsonResponse({"error": e.message}, status=400)
return JsonResponse(check.to_dict())
@authorize
def delete_check(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
response = check.to_dict()
check.delete()
return JsonResponse(response)
@csrf_exempt
@cors("POST", "DELETE", "GET")
def single(request, code):
if request.method == "POST":
return update_check(request, code)
if request.method == "DELETE":
return delete_check(request, code)
return get_check(request, code)
@cors("POST")
@csrf_exempt
@validate_json()
@authorize
def pause(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
check.status = "paused"
check.last_start = None
check.alert_after = None
check.save()
# After pausing a check we must check if all checks are up,
# and Profile.next_nag_date needs to be cleared out:
check.project.update_next_nag_dates()
return JsonResponse(check.to_dict())
@cors("GET")
@csrf_exempt
@validate_json()
@authorize
def pings(request, code):
check = get_object_or_404(Check, code=code)
if check.project_id != request.project.id:
return HttpResponseForbidden()
# Look up ping log limit from account's profile.
# There might be more pings in the database (depends on how pruning is handled)
# but we will not return more than the limit allows.
profile = Profile.objects.get(user__project=request.project)
limit = profile.ping_log_limit
# Query in descending order so we're sure to get the most recent
# pings, regardless of the limit restriction
pings = Ping.objects.filter(owner=check).order_by("-id")[:limit]
# Ascending order is more convenient for calculating duration, so use reverse()
prev, dicts = None, []
for ping in reversed(pings):
d = ping.to_dict()
if ping.kind != "start" and prev and prev.kind == "start":
delta = ping.created - prev.created
if delta < MAX_DELTA:
d["duration"] = delta.total_seconds()
dicts.insert(0, d)
prev = ping
return JsonResponse({"pings": dicts})
def flips(request, check):
if check.project_id != request.project.id:
return HttpResponseForbidden()
form = FlipsFiltersForm(request.GET)
if not form.is_valid():
return HttpResponseBadRequest()
flips = Flip.objects.filter(owner=check).order_by("-id")
if form.cleaned_data["start"]:
flips = flips.filter(created__gte=form.cleaned_data["start"])
if form.cleaned_data["end"]:
flips = flips.filter(created__lt=form.cleaned_data["end"])
if form.cleaned_data["seconds"]:
threshold = timezone.now() - td(seconds=form.cleaned_data["seconds"])
flips = flips.filter(created__gte=threshold)
return JsonResponse({"flips": [flip.to_dict() for flip in flips]})
@cors("GET")
@csrf_exempt
@authorize_read
def flips_by_uuid(request, code):
check = get_object_or_404(Check, code=code)
return flips(request, check)
@cors("GET")
@csrf_exempt
@authorize_read
def flips_by_unique_key(request, unique_key):
checks = Check.objects.filter(project=request.project.id)
for check in checks:
if check.unique_key == unique_key:
return flips(request, check)
return HttpResponseNotFound()
@cors("GET")
@csrf_exempt
@authorize_read
def badges(request):
tags = set(["*"])
for check in Check.objects.filter(project=request.project):
tags.update(check.tags_list())
key = request.project.badge_key
badges = {}
for tag in tags:
badges[tag] = {
"svg": get_badge_url(key, tag),
"svg3": get_badge_url(key, tag, with_late=True),
"json": get_badge_url(key, tag, fmt="json"),
"json3": get_badge_url(key, tag, fmt="json", with_late=True),
"shields": get_badge_url(key, tag, fmt="shields"),
"shields3": get_badge_url(key, tag, fmt="shields", with_late=True),
}
return JsonResponse({"badges": badges})
@never_cache
@cors("GET")
def badge(request, badge_key, signature, tag, fmt):
if fmt not in ("svg", "json", "shields"):
return HttpResponseNotFound()
with_late = True
if len(signature) == 10 and signature.endswith("-2"):
with_late = False
if not check_signature(badge_key, tag, signature):
return HttpResponseNotFound()
q = Check.objects.filter(project__badge_key=badge_key)
if tag != "*":
q = q.filter(tags__contains=tag)
label = tag
else:
label = settings.MASTER_BADGE_LABEL
status, total, grace, down = "up", 0, 0, 0
for check in q:
if tag != "*" and tag not in check.tags_list():
continue
total += 1
check_status = check.get_status()
if check_status == "down":
down += 1
status = "down"
if fmt == "svg":
# For SVG badges, we can leave the loop as soon as we
# find the first "down"
break
elif check_status == "grace":
grace += 1
if status == "up" and with_late:
status = "late"
if fmt == "shields":
color = "success"
if status == "down":
color = "critical"
elif status == "late":
color = "important"
return JsonResponse(
{"schemaVersion": 1, "label": label, "message": status, "color": color}
)
if fmt == "json":
return JsonResponse(
{"status": status, "total": total, "grace": grace, "down": down}
)
svg = get_badge_svg(label, status)
return HttpResponse(svg, content_type="image/svg+xml")
@csrf_exempt
@require_POST
def notification_status(request, code):
""" Handle notification delivery status callbacks. """
try:
cutoff = timezone.now() - td(hours=1)
notification = Notification.objects.get(code=code, created__gt=cutoff)
except Notification.DoesNotExist:
# If the notification does not exist, or is more than a hour old,
# return HTTP 200 so the other party doesn't retry over and over again:
return HttpResponse()
error, mark_not_verified = None, False
# Look for "error" and "mark_not_verified" keys:
if request.POST.get("error"):
error = request.POST["error"][:200]
mark_not_verified = request.POST.get("mark_not_verified")
# Handle "MessageStatus" key from Twilio
if request.POST.get("MessageStatus") in ("failed", "undelivered"):
status = request.POST["MessageStatus"]
error = f"Delivery failed (status={status})."
# Handle "CallStatus" key from Twilio
if request.POST.get("CallStatus") == "failed":
error = f"Delivery failed (status=failed)."
if error:
notification.error = error
notification.save(update_fields=["error"])
channel_q = Channel.objects.filter(id=notification.channel_id)
channel_q.update(last_error=error)
if mark_not_verified:
channel_q.update(email_verified=False)
return HttpResponse()
def metrics(request):
if not settings.METRICS_KEY:
return HttpResponseForbidden()
key = request.META.get("HTTP_X_METRICS_KEY")
if key != settings.METRICS_KEY:
return HttpResponseForbidden()
doc = {
"ts": int(time.time()),
"max_ping_id": Ping.objects.values_list("id", flat=True).last(),
"max_notification_id": Notification.objects.values_list("id", flat=True).last(),
"num_unprocessed_flips": Flip.objects.filter(processed__isnull=True).count(),
}
return JsonResponse(doc)
def status(request):
with connection.cursor() as c:
c.execute("SELECT 1")
c.fetchone()
return HttpResponse("OK")