initial commit

This commit is contained in:
Raal Goff 2021-09-14 17:20:33 +08:00
commit 7d185d1b44
34 changed files with 1865 additions and 0 deletions

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.7
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install gunicorn
EXPOSE 8000
VOLUME /persist
RUN chmod a+x docker_entrypoint.sh
CMD ["/app/docker_entrypoint.sh"]

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Raal Goff
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

35
README.md Normal file
View file

@ -0,0 +1,35 @@
Nebula Mesh Admin
-----------------
Nebula Mesh Admin is a simple controller for [Nebula](https://github.com/slackhq/nebula). It allows you to issue short-lived certificates to users using OpenID authentication to give a traditional 'sign on' flow to users, similar to traditional VPNs.
### Quick Start
```commandline
git clone https://github.com/unreality/nebula-mesh-admin.git
docker build -t nebula-mesh-admin:latest nebula-mesh-admin/
docker volume create nebula-vol
docker run -d -p 8000:8000 -e OIDC_CONFIG_URL=your_oidc_config_url -e OIDC_CLIENT_ID=your_oidc_client_id -v nebula-vol:/persist nebula-mesh-admin:latest
```
### Environment settings
Required variables:
* ``OIDC_CONFIG_URL`` - URL for the .well-known configuration endpoint. For Keycloak installs this will be in the format http://**your-keycloak-host**/auth/realms/**your-realm-name**/.well-known/openid-configuration
* ``OIDC_CLIENT_ID`` - The OIDC client ID you have created for the Mesh Admin
* ``OIDC_JWT_AUDIENCE`` (default is 'account') - The OIDC server will return a JWT with a specific ``audience`` - for Keycloak installs this is 'account', other OIDC providers may specify something different
* ``OIDC_ADMIN_GROUP`` (default is 'admin') - The OIDC server must have a 'groups' element in the ``userinfo``. If this value is in the groups list, the user can log into the admin area. For keycloak installs this means adding a Groups Mapper to your client in the Keycloak admin area (when in your client, click on the mappers tab, and add a new mapper - choosing the User Group Membership as the type)
Optional variables:
* ``OIDC_SESSION_DURATION`` (default 1 hr) - How long a user session stays active in the admin console
* ``DEFAULT_DURATION`` (default 8 hrs) - default time for a short-lived certificate
* ``MAX_DURATION`` (default 10 hrs) - maximum time for a short-lived certificate
* ``MESH_SUBNET`` (default 192.168.11.0/24) - mesh subnet
* ``USER_SUBNET`` (default 192.168.11.192/26) - ip pool for short-lived (user) certificates
* ``CA_KEY`` - path to CA key. If not specified one is generated
* ``CA_CERT`` - path to CA cert. If not specified one is generated
* ``CA_NAME`` (default 'Nebula CA') - If a CA cert/keypair is generated, this is the name specified when generating
* ``CA_EXPIRY`` (default 2 years) - If a CA cert/keypair is generated, this is expiry time used when generating
* ``TIME_ZONE`` (default UTC) - timezone for rendering expiry times
* ``SECRET_KEY_FILE`` - secret key file for holding a Django SECRET_KEY. If not specified one is generated

4
docker_entrypoint.sh Normal file
View file

@ -0,0 +1,4 @@
#!/bin/bash
python manage.py migrate
python manage.py collectstatic --noinput
exec gunicorn -b 0.0.0.0:8000 -t 90 -w 4 nebula_mesh_admin.wsgi

22
manage.py Normal file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
mesh/__init__.py Normal file
View file

3
mesh/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

272
mesh/api.py Normal file
View file

@ -0,0 +1,272 @@
import ipaddress
import json
import time
from datetime import datetime, timedelta
import pytz
import requests
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from jose import jwt, JWTError, JWSError, jwk
from mesh.lib.nebulacert import NebulaCertificate
from mesh.models import Host, Lighthouse, BlocklistHost, OTTEnroll
def get_oidc_config():
oidc_config_request = requests.get(settings.OIDC_CONFIG_URL)
if oidc_config_request.status_code == 200:
oidc_config = oidc_config_request.json()
return oidc_config
else:
return None
@csrf_exempt
def ott_enroll(request):
if request.method == 'POST':
try:
sign_request = json.loads(request.body)
except ValueError:
resp = JsonResponse({'status': 'error', 'message': 'Invalid JSON payload'})
resp.status_code = 400
return resp
ott_str = sign_request.get('ott')
if not ott_str:
resp = JsonResponse({'status': 'error', 'message': 'No OTT'})
resp.status_code = 400
return resp
public_key = sign_request.get('public_key')
if not public_key:
resp = JsonResponse({'status': 'error', 'message': 'No public_key'})
resp.status_code = 400
return resp
try:
ott = OTTEnroll.objects.get(ott=ott_str, ott_expires__gt=datetime.utcnow().replace(tzinfo=pytz.utc))
nc = NebulaCertificate()
nc.Name = ott.name
nc.Groups = ott.groups.split(",")
nc.NotBefore = int(time.time())
nc.NotAfter = ott.expires
nc.set_public_key_pem(public_key)
nc.IsCA = False
nc.Ips = [ott.ip]
nc.Subnets = []
f = open(settings.CA_KEY)
signing_key_pem = "".join(f.readlines())
f.close()
f = open(settings.CA_CERT)
signing_cert_pem = "".join(f.readlines())
f.close()
s = nc.sign_to_pem(signing_key_pem=signing_key_pem,
signing_cert_pem=signing_cert_pem)
host = Host(
ip=ott.ip,
fingerprint=nc.fingerprint().hexdigest(),
name=ott.name,
expires=datetime.fromtimestamp(ott.expires, tz=pytz.utc)
)
host.save()
static_host_map = {}
lighthouses = []
blocklist = []
for lighthouse in Lighthouse.objects.all():
static_host_map[lighthouse.ip] = lighthouse.external_ip.split(",")
lighthouses.append(lighthouse.ip)
for b in BlocklistHost.objects.all():
blocklist.append(b.fingerprint)
ott.delete()
return JsonResponse({
'certificate': s,
'static_host_map': static_host_map,
'lighthouses': lighthouses,
'blocklist': blocklist
})
except OTTEnroll.DoesNotExist:
resp = JsonResponse({'status': 'error', 'message': 'Invalid enrollment token'})
resp.status_code = 401
return resp
return HttpResponse("")
@csrf_exempt
def sign(request):
if request.method == 'POST':
try:
sign_request = json.loads(request.body)
except ValueError:
resp = JsonResponse({'status': 'error', 'message': 'Invalid JSON payload'})
resp.status_code = 400
return resp
auth_header = request.headers.get("Authorization")
auth_tokens = auth_header.split(" ", 2)
if len(auth_tokens) == 2:
auth_jwt = auth_tokens[1]
oidc_config = get_oidc_config()
oidc_jwks_request = requests.get(oidc_config['jwks_uri'])
if oidc_jwks_request.status_code == 200:
jwks_config = oidc_jwks_request.json()
unverified_header = jwt.get_unverified_header(auth_jwt)
for k in jwks_config['keys']:
if k['kid'] == unverified_header['kid']:
constructed_key = jwk.construct(k)
try:
verified_token = jwt.decode(auth_jwt,
constructed_key,
k['alg'],
audience=settings.OIDC_JWT_AUDIENCE)
if not sign_request.get("public_key"):
resp = JsonResponse({'status': 'error', 'message': "invalid signing request: no public_key"})
resp.status_code = 401
return resp
duration = int(sign_request.get("duration", settings.DEFAULT_DURATION))
duration = min(duration, settings.MAX_DURATION)
nc = NebulaCertificate()
nc.Name = verified_token.get("email")
nc.Groups = verified_token.get("groups", [])
nc.NotBefore = int(time.time())
nc.NotAfter = int(time.time() + duration)
nc.set_public_key_pem(sign_request.get("public_key"))
nc.IsCA = False
subnet_iface = ipaddress.ip_interface(settings.MESH_SUBNET)
iface = ipaddress.ip_interface(settings.USER_SUBNET)
host = None
for ip in iface.network:
if ip == iface.network.network_address:
continue
if ip == iface.network.broadcast_address:
continue
ip_str = f"{str(ip)}/{subnet_iface.network.prefixlen}"
try:
host = Host.objects.get(ip=ip_str)
if host.expired: # if the host is expired, re-use it
host.name = verified_token.get("email")
host.expires = (datetime.utcnow() + timedelta(seconds=duration)).replace(tzinfo=pytz.utc)
host.save()
break
except Host.DoesNotExist:
host = Host(
ip=ip_str,
fingerprint=nc.fingerprint().hexdigest(),
name=verified_token.get("email"),
expires=(datetime.utcnow() + timedelta(seconds=duration)).replace(tzinfo=pytz.utc)
)
host.save()
break
if not host:
resp = JsonResponse({'status': 'error', 'message': "no free ip in subnet"})
resp.status_code = 500
return resp
nc.Ips = [host.ip]
nc.Subnets = []
f = open(settings.CA_KEY)
signing_key_pem = "".join(f.readlines())
f.close()
f = open(settings.CA_CERT)
signing_cert_pem = "".join(f.readlines())
f.close()
s = nc.sign_to_pem(signing_key_pem=signing_key_pem,
signing_cert_pem=signing_cert_pem)
host.fingerprint = nc.fingerprint().hexdigest()
host.save()
static_host_map = {}
lighthouses = []
blocklist = []
for lighthouse in Lighthouse.objects.all():
static_host_map[lighthouse.ip] = lighthouse.external_ip.split(",")
lighthouses.append(lighthouse.ip)
for b in BlocklistHost.objects.all():
blocklist.append(b.fingerprint)
return JsonResponse({
'certificate': s,
'static_host_map': static_host_map,
'lighthouses': lighthouses,
'blocklist': blocklist
})
except JWTError:
resp = JsonResponse({'status': 'error', 'message': "Token verification error"})
resp.status_code = 401
return resp
else:
resp = JsonResponse({'status': 'error', 'message': "Could not retrieve jwks info"})
resp.status_code = 500
return resp
def certs(request):
f = open(settings.CA_CERT)
signing_cert_pem = f.readlines()
f.close()
return HttpResponse(signing_cert_pem)
def config(request):
scheme = "https" if request.is_secure() else "http"
callback_path = reverse("sign")
sign_endpoint = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}"
callback_path = reverse("certs")
certs_endpoint = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}"
f = open(settings.CA_CERT)
signing_cert_pem = f.readlines()
f.close()
return JsonResponse({
"oidcConfigURL": settings.OIDC_CONFIG_URL,
"oidcClientID": settings.OIDC_CLIENT_ID,
"signEndpoint": sign_endpoint,
"certEndpoint": certs_endpoint,
"ca": "".join(signing_cert_pem),
})

6
mesh/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MeshConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mesh'

0
mesh/lib/__init__.py Normal file
View file

175
mesh/lib/cert_pb2.py Normal file
View file

@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: cert.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor.FileDescriptor(
name='cert.proto',
package='cert',
syntax='proto3',
serialized_options=b'Z\036github.com/slackhq/nebula/cert',
create_key=_descriptor._internal_create_key,
serialized_pb=b'\n\ncert.proto\x12\x04\x63\x65rt\"]\n\x14RawNebulaCertificate\x12\x32\n\x07\x44\x65tails\x18\x01 \x01(\x0b\x32!.cert.RawNebulaCertificateDetails\x12\x11\n\tSignature\x18\x02 \x01(\x0c\"\xaf\x01\n\x1bRawNebulaCertificateDetails\x12\x0c\n\x04Name\x18\x01 \x01(\t\x12\x0b\n\x03Ips\x18\x02 \x03(\r\x12\x0f\n\x07Subnets\x18\x03 \x03(\r\x12\x0e\n\x06Groups\x18\x04 \x03(\t\x12\x11\n\tNotBefore\x18\x05 \x01(\x03\x12\x10\n\x08NotAfter\x18\x06 \x01(\x03\x12\x11\n\tPublicKey\x18\x07 \x01(\x0c\x12\x0c\n\x04IsCA\x18\x08 \x01(\x08\x12\x0e\n\x06Issuer\x18\t \x01(\x0c\x42 Z\x1egithub.com/slackhq/nebula/certb\x06proto3'
)
_RAWNEBULACERTIFICATE = _descriptor.Descriptor(
name='RawNebulaCertificate',
full_name='cert.RawNebulaCertificate',
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name='Details', full_name='cert.RawNebulaCertificate.Details', index=0,
number=1, type=11, cpp_type=10, label=1,
has_default_value=False, default_value=None,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='Signature', full_name='cert.RawNebulaCertificate.Signature', index=1,
number=2, type=12, cpp_type=9, label=1,
has_default_value=False, default_value=b"",
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=20,
serialized_end=113,
)
_RAWNEBULACERTIFICATEDETAILS = _descriptor.Descriptor(
name='RawNebulaCertificateDetails',
full_name='cert.RawNebulaCertificateDetails',
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name='Name', full_name='cert.RawNebulaCertificateDetails.Name', index=0,
number=1, type=9, cpp_type=9, label=1,
has_default_value=False, default_value=b"".decode('utf-8'),
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='Ips', full_name='cert.RawNebulaCertificateDetails.Ips', index=1,
number=2, type=13, cpp_type=3, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='Subnets', full_name='cert.RawNebulaCertificateDetails.Subnets', index=2,
number=3, type=13, cpp_type=3, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='Groups', full_name='cert.RawNebulaCertificateDetails.Groups', index=3,
number=4, type=9, cpp_type=9, label=3,
has_default_value=False, default_value=[],
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='NotBefore', full_name='cert.RawNebulaCertificateDetails.NotBefore', index=4,
number=5, type=3, cpp_type=2, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='NotAfter', full_name='cert.RawNebulaCertificateDetails.NotAfter', index=5,
number=6, type=3, cpp_type=2, label=1,
has_default_value=False, default_value=0,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='PublicKey', full_name='cert.RawNebulaCertificateDetails.PublicKey', index=6,
number=7, type=12, cpp_type=9, label=1,
has_default_value=False, default_value=b"",
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='IsCA', full_name='cert.RawNebulaCertificateDetails.IsCA', index=7,
number=8, type=8, cpp_type=7, label=1,
has_default_value=False, default_value=False,
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
_descriptor.FieldDescriptor(
name='Issuer', full_name='cert.RawNebulaCertificateDetails.Issuer', index=8,
number=9, type=12, cpp_type=9, label=1,
has_default_value=False, default_value=b"",
message_type=None, enum_type=None, containing_type=None,
is_extension=False, extension_scope=None,
serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key),
],
extensions=[
],
nested_types=[],
enum_types=[
],
serialized_options=None,
is_extendable=False,
syntax='proto3',
extension_ranges=[],
oneofs=[
],
serialized_start=116,
serialized_end=291,
)
_RAWNEBULACERTIFICATE.fields_by_name['Details'].message_type = _RAWNEBULACERTIFICATEDETAILS
DESCRIPTOR.message_types_by_name['RawNebulaCertificate'] = _RAWNEBULACERTIFICATE
DESCRIPTOR.message_types_by_name['RawNebulaCertificateDetails'] = _RAWNEBULACERTIFICATEDETAILS
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
RawNebulaCertificate = _reflection.GeneratedProtocolMessageType('RawNebulaCertificate', (_message.Message,), {
'DESCRIPTOR' : _RAWNEBULACERTIFICATE,
'__module__' : 'cert_pb2'
# @@protoc_insertion_point(class_scope:cert.RawNebulaCertificate)
})
_sym_db.RegisterMessage(RawNebulaCertificate)
RawNebulaCertificateDetails = _reflection.GeneratedProtocolMessageType('RawNebulaCertificateDetails', (_message.Message,), {
'DESCRIPTOR' : _RAWNEBULACERTIFICATEDETAILS,
'__module__' : 'cert_pb2'
# @@protoc_insertion_point(class_scope:cert.RawNebulaCertificateDetails)
})
_sym_db.RegisterMessage(RawNebulaCertificateDetails)
DESCRIPTOR._options = None
# @@protoc_insertion_point(module_scope)

182
mesh/lib/nebulacert.py Normal file
View file

@ -0,0 +1,182 @@
import binascii
import hashlib
import ipaddress
import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
import base64
import mesh.lib.cert_pb2 as cert_proto
class NebulaCertificate(object):
def __init__(self):
self.Name = ""
self.Groups = []
self.Ips = []
self.Subnets = []
self.IsCA = False
self.NotBefore = int(time.time())
self.NotAfter = int(time.time() + 3600)
self.PublicKey = ""
self.Fingerprint = ""
def _decode_pem(self, pem):
s = pem.split("-----")
try:
return base64.b64decode(s[2].strip())
except KeyError:
print("Bad key")
return False
except binascii.Error as err:
print(f"Bad b64 {err}")
return False
def fingerprint(self):
return hashlib.sha256(self.PublicKey)
def set_public_key_pem(self, pk):
public_key = self._decode_pem(pk)
if public_key:
self.PublicKey = public_key
return public_key is not False
def sign_to_pem(self, signing_key_pem, signing_cert_pem):
signing_key_bytes = self._decode_pem(signing_key_pem)
if signing_key_bytes is False:
return False
if len(signing_key_bytes) == 64:
signing_key_bytes = signing_key_bytes[0:32]
signing_key = Ed25519PrivateKey.from_private_bytes(signing_key_bytes)
signing_cert_bytes = self._decode_pem(signing_cert_pem)
fingerprint = hashlib.sha256(signing_cert_bytes)
cert_details = cert_proto.RawNebulaCertificateDetails()
cert_details.Name = self.Name
for i, g in enumerate(self.Groups):
self.Groups[i] = g.strip()
cert_details.Groups.extend(self.Groups)
cert_details.NotBefore = self.NotBefore
cert_details.NotAfter = self.NotAfter
cert_details.PublicKey = self.PublicKey
cert_details.IsCA = self.IsCA
for i in self.Ips:
try:
iface = ipaddress.ip_interface(i)
cert_details.Ips.extend([int(iface.ip), int(iface.netmask)])
except ValueError:
pass
for s in self.Subnets:
try:
subnet = ipaddress.ip_interface(s)
cert_details.Subnets.extend([int(subnet.ip), int(subnet.netmask)])
except ValueError:
pass
cert_details.Issuer = fingerprint.digest()
signature = signing_key.sign(cert_details.SerializeToString())
cert = cert_proto.RawNebulaCertificate()
cert.Details.CopyFrom(cert_details)
cert.Signature = signature
cert_str = base64.b64encode(cert.SerializeToString()).decode('utf-8')
return f"-----BEGIN NEBULA CERTIFICATE-----\n{cert_str}\n-----END NEBULA CERTIFICATE-----\n"
def load_cert(self, cert_pem):
b = self._decode_pem(cert_pem)
cert = cert_proto.RawNebulaCertificate()
cert.ParseFromString(b)
self.Name = cert.Details.Name
self.Fingerprint = hashlib.sha256(b).hexdigest()
self.NotAfter = cert.Details.NotAfter
self.NotBefore = cert.Details.NotBefore
def generate_ca(self):
ca_private_key = Ed25519PrivateKey.generate()
ca_public_key = ca_private_key.public_key()
cert_details = cert_proto.RawNebulaCertificateDetails()
cert_details.Name = self.Name
cert_details.Groups.extend(self.Groups)
cert_details.NotBefore = self.NotBefore
cert_details.NotAfter = self.NotAfter
cert_details.PublicKey = ca_public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
cert_details.IsCA = True
for i in self.Ips:
try:
iface = ipaddress.ip_interface(i)
cert_details.Ips.extend([int(iface.ip), int(iface.netmask)])
except ValueError:
pass
for s in self.Subnets:
try:
subnet = ipaddress.ip_interface(s)
cert_details.Subnets.extend([int(subnet.ip), int(subnet.netmask)])
except ValueError:
pass
signature = ca_private_key.sign(cert_details.SerializeToString())
cert = cert_proto.RawNebulaCertificate()
cert.Details.CopyFrom(cert_details)
cert.Signature = signature
cert_str = base64.b64encode(cert.SerializeToString()).decode('utf-8')
public_key_bytes = ca_public_key.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw
)
public_key_str = base64.b64encode(public_key_bytes).decode('utf-8')
private_key_bytes = ca_private_key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption()
)
private_key_str = base64.b64encode(private_key_bytes + public_key_bytes).decode('utf-8')
cert_pem = f"-----BEGIN NEBULA CERTIFICATE-----\n{cert_str}\n-----END NEBULA CERTIFICATE-----\n"
public_key_pem = f"-----BEGIN NEBULA ED25519 PUBLIC KEY-----\n{public_key_str}\n-----END NEBULA ED25519 PUBLIC KEY-----\n"
private_key_pem = f"-----BEGIN NEBULA ED25519 PRIVATE KEY-----\n{private_key_str}\n-----END NEBULA ED25519 PRIVATE KEY-----\n"
return cert_pem, public_key_pem, private_key_pem
if __name__ == '__main__':
print("Generating CA")
nc = NebulaCertificate()
nc.Name = "Nebula CA"
nc.NotAfter = int(time.time() + 60*60*24*365)
nc.NotBefore = int(time.time())
cert_pem, public_key_pem, private_key_pem = nc.generate_ca()
print(cert_pem)
print(public_key_pem)
print(private_key_pem)

View file

@ -0,0 +1,41 @@
# Generated by Django 3.2.7 on 2021-09-09 12:50
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='BlocklistHost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('fingerprint', models.CharField(max_length=128)),
('name', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='Host',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.CharField(max_length=100)),
('fingerprint', models.CharField(max_length=64)),
('name', models.CharField(max_length=128)),
('expires', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Lighthouse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.CharField(max_length=100)),
('external_ip', models.CharField(max_length=100)),
('name', models.CharField(max_length=255)),
],
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.7 on 2021-09-10 15:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mesh', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='blocklisthost',
name='name',
field=models.CharField(default='', max_length=255),
),
migrations.AlterField(
model_name='lighthouse',
name='name',
field=models.CharField(default='', max_length=255),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.7 on 2021-09-10 15:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mesh', '0002_auto_20210910_2305'),
]
operations = [
migrations.AlterField(
model_name='host',
name='expires',
field=models.DateTimeField(),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.7 on 2021-09-11 03:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mesh', '0003_alter_host_expires'),
]
operations = [
migrations.CreateModel(
name='OTPEnroll',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('otp', models.CharField(max_length=64)),
('otp_expires', models.DateTimeField()),
('ip', models.CharField(max_length=32)),
('groups', models.CharField(blank=True, default='', max_length=250)),
('subnets', models.CharField(blank=True, default='', max_length=250)),
('expires', models.IntegerField()),
('is_lighthouse', models.BooleanField(default=False)),
('name', models.CharField(max_length=100)),
],
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 3.2.7 on 2021-09-11 04:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mesh', '0004_otpenroll'),
]
operations = [
migrations.RenameModel(
old_name='OTPEnroll',
new_name='OTTEnroll',
),
migrations.RenameField(
model_name='ottenroll',
old_name='otp',
new_name='ott',
),
migrations.RenameField(
model_name='ottenroll',
old_name='otp_expires',
new_name='ott_expires',
),
]

View file

39
mesh/models.py Normal file
View file

@ -0,0 +1,39 @@
from django.utils import timezone
from django.db import models
import pytz
class Host(models.Model):
ip = models.CharField(max_length=100)
fingerprint = models.CharField(max_length=64)
name = models.CharField(max_length=128)
expires = models.DateTimeField()
@property
def expired(self):
return self.expires.astimezone(pytz.UTC) <= timezone.localtime().astimezone(pytz.UTC)
class OTTEnroll(models.Model):
ott = models.CharField(max_length=64)
ott_expires = models.DateTimeField()
ip = models.CharField(max_length=32)
groups = models.CharField(max_length=250, default="", blank=True)
subnets = models.CharField(max_length=250, default="", blank=True)
expires = models.IntegerField()
is_lighthouse = models.BooleanField(default=False)
name = models.CharField(max_length=100)
class Lighthouse(models.Model):
ip = models.CharField(max_length=100)
external_ip = models.CharField(max_length=100)
name = models.CharField(max_length=255, default="")
class BlocklistHost(models.Model):
fingerprint = models.CharField(max_length=128)
name = models.CharField(max_length=255, default="")

View file

@ -0,0 +1,80 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% block title %}Nebula Mesh Admin{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-F3w7mX95PdgyTmZZMECAngseQB83DfGTowi0iMjiWaeVhAn4FJkqJByhZMI3AhiU" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js" integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ" crossorigin="anonymous"></script>
{% block extrahead %}{% endblock %}
</head>
{% block body_content %}
<body>
<main style="display: flex;">
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-dark" style="width: 280px; height: 100vh;">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<i class="bi bi-gear-wide-connected" style="font-size: 2rem"></i>
<span class="fs-5 p-2">Nebula Mesh Admin</span>
</a>
<hr>
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<a href="{% url "dashboard" %}" class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% else %}text-white{% endif %}">
<i class="bi bi-house"></i>
Dashboard
</a>
</li>
<li>
<a href="{% url "hosts" %}" class="nav-link {% if request.resolver_match.url_name == 'hosts' %}active{% else %}text-white{% endif %}">
<i class="bi bi-diagram-3"></i>
Hosts
</a>
</li>
<li>
<a href="{% url "lighthouses" %}" class="nav-link {% if request.resolver_match.url_name == 'lighthouses' %}active{% else %}text-white{% endif %}">
<i class="bi bi-globe2"></i>
Lighthouses
</a>
</li>
<li>
<a href="{% url "blocklist" %}" class="nav-link {% if request.resolver_match.url_name == 'blocklist' %}active{% else %}text-white{% endif %}">
<i class="bi bi-bricks"></i>
Blocklist
</a>
</li>
<li>
<a href="{% url "enroll" %}" class="nav-link {% if request.resolver_match.url_name == 'enroll' %}active{% else %}text-white{% endif %}">
<i class="bi bi-plus-square"></i>
Enrol Host
</a>
</li>
<li class="pt-4">
<a href="{% url "logout" %}" class="nav-link {% if request.resolver_match.url_name == 'logout' %}active{% else %}text-white{% endif %}">
<i class="bi bi-box-arrow-left"></i>
Logout
</a>
</li>
</ul>
</div>
<div class="d-flex flex-column p-3 bg-light" style="width:100%">
{% block page_content %}
<h2>Blam</h2>
{% endblock %}
</div>
</main>
</body>
{% endblock %}
{% block extrascripts %}{% endblock %}
</html>

View file

@ -0,0 +1,45 @@
{% extends 'mesh/base.html' %}
{% block page_content %}
<h2>Blocklist</h2>
<hr>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<form class="row g-3 mb-3" method="post">
<div class="col-8">
<input class="form-control form-control-sm" type="text" placeholder="Fingerprint" aria-label=".form-control-sm example">
</div>
<div class="col-auto">
<input type="hidden" name="action" value="create">
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</div>
</form>
<ul class="list-group">
{% for blockedhost in blocklist %}
<li class="list-group-item">
<div class="row align-items-end">
<div class="col-10">
Fingerprint: {{ blockedhost.fingerprint }}
</div>
<div class="col-2">
<form style="text-align: right" method="post">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{ blockedhost.id }}">
<button type="submit" class="btn btn-danger btn-sm center">Delete</button>
</form>
</div>
</div>
</li>
{% empty %}
<div class="alert alert-info" role="alert">No hosts on the blocklist.</div>
{% endfor %}
</ul>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends 'mesh/base.html' %}
{% load tz %}
{% block page_content %}
<h2>Dashboard</h2>
<hr>
<div class="card" style="width: 48rem;">
<div class="card-body">
<h5 class="card-title">Mesh Certificate Information</h5>
<hr>
<ul class="list-group">
<li class="list-group-item"><strong>Mesh Name:</strong> {{ cert.Name }}</li>
<li class="list-group-item"><strong>CA Fingerprint:</strong> {{ cert.Fingerprint }}</li>
<li class="list-group-item"><strong>Subnet:</strong> {{ subnet }}</li>
<li class="list-group-item"><strong>Not Before:</strong> {{ notbefore | localtime}}</li>
<li class="list-group-item"><strong>Not After:</strong> {{ notafter | localtime }}</li>
</ul>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,66 @@
{% extends 'mesh/base.html' %}
{% load tz %}
{% block page_content %}
<h2>Enroll Host</h2>
<hr>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{message | safe}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<form method="post" class="mb-4">
<h4>Create enrollment token</h4>
{% csrf_token %}
<input type="hidden" name="action" value="create">
<div class="mb-3">
<label for="host_ip" class="form-label">IP</label>
<input type="text" class="form-control" id="host_ip" name="host_ip">
</div>
<div class="mb-3">
<label for="host_expires" class="form-label">Expires</label>
<input type="number" class="form-control" id="host_expires" name="host_expires">
<div id="host_expires_help" class="form-text">Expiry seconds ie "3600" means the host will expire in an hr.</div>
</div>
<div class="mb-3">
<label for="host_name" class="form-label">Hostname</label>
<input type="text" class="form-control" id="host_name" name="host_name">
</div>
<div class="mb-3">
<label for="host_groups" class="form-label">Groups</label>
<input type="text" class="form-control" id="host_groups" name="host_groups">
<div id="host_groups_help" class="form-text">Comma-delimited list of groups.</div>
</div>
<button type="submit" class="btn btn-primary">Create Token</button>
</form>
<hr>
{% for enrol_otp in enrol_list %}
<ul class="list-group mb-3">
<li class="list-group-item">
<div class="row align-items-end">
<div class="col-10">
<strong>Host Name:</strong> {{ enrol_otp.name }}
</div>
<div class="col-2">
<form style="text-align: right" method="post" id="delete-{{ h.id }}">
{% csrf_token %}
<input type="hidden" name="id" value="{{ enrol_otp.id }}">
<button type="submit" class="btn btn-danger btn-sm center">Delete</button>
</form>
</div>
</div>
</li>
<li class="list-group-item"><strong>IP:</strong> {{ enrol_otp.ip }}</li>
<li class="list-group-item"><strong>OTP Expires:</strong> {{ enrol_otp.otp_expires | localtime }}</li>
</ul>
{% empty %}
<div class="alert alert-danger" role="alert">No OTP tokens found.</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,53 @@
{% extends 'mesh/base.html' %}
{% load tz %}
{% block page_content %}
<h2>Hosts</h2>
<hr>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<small>Note that deleting hosts here wont disable their ability to connect to the mesh.</small>
{% for h in hosts %}
<ul class="list-group mb-3 mt-3">
<li class="list-group-item">
<div class="row align-items-end">
<div class="col-10">
<strong>Host Name:</strong> {{ h.name }}
</div>
<div class="col-1">
<form style="text-align: right" method="post" action="{% url "blocklist" %}">
{% csrf_token %}
<input type="hidden" name="fingerprint" value="{{ h.fingerprint }}">
<input type="hidden" name="action" value="create">
<button type="submit" class="btn btn-primary btn-sm center">Block</button>
</form>
</div>
<div class="col-1">
<form style="text-align: right" method="post" id="delete-{{ h.id }}">
{% csrf_token %}
<input type="hidden" name="id" value="{{ h.id }}">
<input type="hidden" name="action" value="delete">
<button type="submit" class="btn btn-danger btn-sm center">Delete</button>
</form>
</div>
</div>
</li>
<li class="list-group-item"><strong>Host IP:</strong> {{ h.ip }}</li>
<li class="list-group-item"><strong>Host Fingerprint:</strong> {{ h.fingerprint }}</li>
<li class="list-group-item"><strong>Allocation expires:</strong> {{ h.expires | localtime }} {% if h.expired %}<span class="badge bg-danger">Expired</span>{% endif %}</li>
</ul>
{% empty %}
<div class="alert alert-info" role="alert">No hosts found.</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends 'mesh/base.html' %}
{% load tz %}
{% block page_content %}
<h2>Lighthouses</h2>
<hr>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="create">
<div class="mb-3">
<label for="lighthouse_ip" class="form-label">IP</label>
<input type="text" class="form-control" id="lighthouse_ip" name="lighthouse_ip">
<div id="lighthouse_ip_help" class="form-text">Internal IP</div>
</div>
<div class="mb-3">
<label for="lighthouse_extip" class="form-label">External IP</label>
<input type="text" class="form-control" id="lighthouse_extip" name="lighthouse_extip">
<div id="lighthouse_extip_help" class="form-text">External static IP. Specify multiple addresses separated by commas.</div>
</div>
<div class="mb-3">
<label for="lighthouse_name" class="form-label">Name</label>
<input type="text" class="form-control" id="lighthouse_name" name="lighthouse_name">
<div id="emailHelp" class="form-text">Descriptive name.</div>
</div>
<button type="submit" class="btn btn-primary">Add Lighthouse</button>
</form>
<hr>
{% for lighthouse in lighthouses %}
<ul class="list-group mb-3">
<li class="list-group-item">
<div class="row align-items-end">
<div class="col-10">
<strong>Lighthouse Name:</strong> {{ lighthouse.name }}
</div>
<div class="col-2">
<form style="text-align: right" method="post" id="delete-{{ h.id }}">
{% csrf_token %}
<input type="hidden" name="id" value="{{ lighthouse.id }}">
<button type="submit" class="btn btn-danger btn-sm center">Delete</button>
</form>
</div>
</div>
</li>
<li class="list-group-item"><strong>IP:</strong> {{ lighthouse.ip }}</li>
<li class="list-group-item"><strong>External IP:</strong> {{ lighthouse.external_ip }}</li>
</ul>
{% empty %}
<div class="alert alert-danger" role="alert">No lighthouses found.</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,62 @@
{% extends "mesh/base.html" %}
{% block extrahead %}
<style>
html,
body {
height: 100%;
text-align: center;
}
body {
display: flex;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}
.form-signin {
width: 100%;
max-width: 330px;
padding: 15px;
margin: auto;
}
.form-signin .checkbox {
font-weight: 400;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
</style>
{% endblock %}
{% block body_content %}
<main class="form-signin">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<a class="w-100 btn btn-lg btn-primary" href="{% url "oidc_login" %}">Sign in</a>
</main>
{% endblock %}

3
mesh/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

320
mesh/views.py Normal file
View file

@ -0,0 +1,320 @@
import base64
import hashlib
import secrets
import time
from datetime import datetime, timedelta
from pprint import pprint
from urllib.parse import urlencode
from functools import wraps
import pytz
import requests
from django.conf import settings
from django.contrib import messages
from django.http import HttpResponseRedirect, HttpResponse, JsonResponse
from django.shortcuts import render
from django.urls import reverse
from jose import jwt, JWTError, JWSError, jwk
from mesh import api
from mesh.lib.nebulacert import NebulaCertificate
from mesh.models import Host, Lighthouse, BlocklistHost, OTTEnroll
def session_is_authenticated(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
session_expires = request.session.get("expires", 0)
session_user = request.session.get("user")
if not session_user:
messages.add_message(request, messages.INFO, f"Please sign in.", extra_tags='info')
return HttpResponseRedirect("login")
if session_user and session_expires > time.time():
return view_func(request, *args, **kwargs)
messages.add_message(request, messages.INFO, f"Session expired, please login again.", extra_tags='info')
return HttpResponseRedirect("login")
return _wrapped_view
def logout(request):
request.session.clear()
messages.add_message(request, messages.INFO, f"Logged out.", extra_tags='info')
return HttpResponseRedirect(reverse('login'))
def login(request):
return render(request, "mesh/login.html")
@session_is_authenticated
def dashboard(request):
f = open(settings.CA_CERT)
cert_crt_pem = f.readlines()
cert_crt_pem = "".join(cert_crt_pem)
f.close()
c = NebulaCertificate()
c.load_cert(cert_crt_pem)
return render(
request,
"mesh/dashboard.html",
{
"cert": c,
"subnet": settings.MESH_SUBNET,
"notbefore": datetime.fromtimestamp(c.NotBefore),
"notafter": datetime.fromtimestamp(c.NotAfter),
}
)
@session_is_authenticated
def hosts(request):
if request.method == "POST":
id_to_delete = request.POST.get("id")
if id_to_delete:
try:
h = Host.objects.get(pk=id_to_delete)
messages.add_message(request, messages.SUCCESS, f'Deleted host {h.fingerprint}', extra_tags='success')
h.delete()
except Host.DoesNotExist:
messages.add_message(request, messages.ERROR, 'No such host', extra_tags='danger')
else:
messages.add_message(request, messages.ERROR, 'No host supplied', extra_tags='danger')
h = Host.objects.all()
return render(
request,
"mesh/hosts.html",
{"hosts": h}
)
@session_is_authenticated
def lighthouses(request):
if request.method == "POST":
if request.POST.get("action") == "create":
ip_addr = request.POST.get("lighthouse_ip")
ip_ext = request.POST.get("lighthouse_extip")
name = request.POST.get("lighthouse_name")
try:
Lighthouse.objects.get(ip=ip_addr)
messages.add_message(request, messages.ERROR, 'A lighthouse with this IP already exists', extra_tags='danger')
except Lighthouse.DoesNotExist:
pass
lighthouse = Lighthouse.objects.create(ip=ip_addr, external_ip=ip_ext, name=name)
lighthouse.save()
messages.add_message(request, messages.SUCCESS, f'Created lighthouse {lighthouse.name}', extra_tags='success')
else:
id_to_delete = request.POST.get("id")
if id_to_delete:
try:
h = Lighthouse.objects.get(pk=id_to_delete)
messages.add_message(request, messages.SUCCESS, f'Deleted lighthouse {h.name}', extra_tags='success')
h.delete()
except Lighthouse.DoesNotExist:
messages.add_message(request, messages.ERROR, 'No such lighthouse', extra_tags='danger')
else:
messages.add_message(request, messages.ERROR, 'No lighthouse supplied', extra_tags='danger')
lighthouse_list = Lighthouse.objects.all()
return render(request, "mesh/lighthouses.html", {"lighthouses": lighthouse_list})
@session_is_authenticated
def enroll(request):
if request.method == "POST":
if request.POST.get("action") == "create":
host_name = request.POST.get("host_name")
host_ip = request.POST.get("host_ip")
host_groups = request.POST.get("host_groups", "")
host_subnets = request.POST.get("host_subnets", "")
host_expires = int(request.POST.get("host_expires") or settings.MAX_DURATION)
ott = secrets.token_hex(32)
ott_expires = (datetime.utcnow() + timedelta(seconds=600)).replace(tzinfo=pytz.utc)
OTTEnroll.objects.create(
name=host_name,
ip=host_ip,
groups=host_groups,
subnets=host_subnets,
expires=int(time.time() + host_expires),
ott=ott,
ott_expires=ott_expires
)
messages.add_message(request, messages.SUCCESS, f'Created enroll OTP <strong>{ott}</strong>', extra_tags='success')
else:
id_to_delete = request.POST.get("id")
if id_to_delete:
try:
h = OTTEnroll.objects.get(pk=id_to_delete)
messages.add_message(request, messages.SUCCESS, f'Deleted OTP {h.name}', extra_tags='success')
h.delete()
except OTTEnroll.DoesNotExist:
messages.add_message(request, messages.ERROR, 'No such OTP', extra_tags='danger')
else:
messages.add_message(request, messages.ERROR, 'No OTP supplied', extra_tags='danger')
enrol_list = OTTEnroll.objects.all()
return render(request, "mesh/enroll.html", {"enrol_list": enrol_list})
@session_is_authenticated
def blocklist(request):
if request.method == "POST":
if request.POST.get("action") == "create":
fingerprint = request.POST.get("fingerprint")
name = request.POST.get("name", fingerprint)
try:
BlocklistHost.objects.get(fingerprint=fingerprint)
messages.add_message(request, messages.ERROR, 'A blocked host with this fingerprint already exists', extra_tags='danger')
except BlocklistHost.DoesNotExist:
pass
blocked_host = BlocklistHost.objects.create(fingerprint=fingerprint, name=name)
blocked_host.save()
messages.add_message(request, messages.SUCCESS, f'Blocked {fingerprint}', extra_tags='success')
else:
id_to_delete = request.POST.get("id")
if id_to_delete:
try:
h = BlocklistHost.objects.get(pk=id_to_delete)
messages.add_message(request, messages.SUCCESS, f'Deleted block {h.fingerprint}', extra_tags='success')
h.delete()
except BlocklistHost.DoesNotExist:
messages.add_message(request, messages.ERROR, 'No such block', extra_tags='danger')
else:
messages.add_message(request, messages.ERROR, 'No block id supplied', extra_tags='danger')
blocklist = BlocklistHost.objects.all()
return render(request, "mesh/blocklist.html", {"blocklist": blocklist})
def oidc_login(request):
oidc_config = api.get_oidc_config()
if oidc_config:
scheme = "https" if request.is_secure() else "http"
callback_path = reverse("oidc_callback")
redirect_uri = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}"
v = secrets.token_hex(24)
v_sha = base64.urlsafe_b64encode(hashlib.sha256(v.encode('ascii')).digest()).decode('ascii')
v_sha = v_sha.replace("=", "")
request.session['v'] = v
params = {
'response_type': 'code',
'client_id': settings.OIDC_CLIENT_ID,
'redirect_uri': redirect_uri,
'scope': 'openid',
'code_challenge': v_sha,
'code_challenge_method': 'S256'
}
url_encode_params = urlencode(params)
url = f"{oidc_config['authorization_endpoint']}?{url_encode_params}"
return HttpResponseRedirect(url)
else:
resp = HttpResponse("Could not retrieve oidc endpoint info")
resp.status_code = 500
return resp
def oidc_callback(request):
oidc_config = api.get_oidc_config()
if not oidc_config:
resp = HttpResponse("Could not retrieve oidc endpoint info")
resp.status_code = 500
return resp
oidc_jwks_request = requests.get(oidc_config['jwks_uri'])
if oidc_jwks_request.status_code == 200:
jwks_config = oidc_jwks_request.json()
else:
resp = HttpResponse("Could not retrieve oidc endpoint info")
resp.status_code = 500
return resp
if 'code' in request.GET:
scheme = "https" if request.is_secure() else "http"
callback_path = reverse("oidc_callback")
redirect_uri = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}"
params = {
'grant_type': 'authorization_code',
'code': request.GET['code'],
'client_id': settings.OIDC_CLIENT_ID,
'code_verifier': request.session.get('v'),
'redirect_uri': redirect_uri,
}
r = requests.post(oidc_config['token_endpoint'], data=params)
if r.status_code == 200:
tokens = r.json()
userinfo_resp = requests.get(
oidc_config['userinfo_endpoint'],
headers={
"Authorization": f"Bearer {tokens['access_token']}"
}
)
userinfo = userinfo_resp.json()
pprint(userinfo)
unverified_header = jwt.get_unverified_header(tokens['access_token'])
for k in jwks_config['keys']:
if k['kid'] == unverified_header['kid']:
constructed_key = jwk.construct(k)
try:
verified_token = jwt.decode(
tokens['access_token'],
constructed_key,
k['alg'],
audience=settings.OIDC_JWT_AUDIENCE
)
for g in userinfo.get('groups', []):
if g == settings.OIDC_ADMIN_GROUP:
request.session['user'] = verified_token['email']
request.session['expires'] = int(time.time() + settings.OIDC_SESSION_DURATION)
return HttpResponseRedirect(reverse('dashboard'))
messages.add_message(request, messages.ERROR, 'User not in administrator group', extra_tags='danger')
return HttpResponseRedirect("login")
except JWTError:
messages.add_message(request, messages.ERROR, 'Token verification error',
extra_tags='danger')
return HttpResponseRedirect("login")
else:
messages.add_message(request, messages.ERROR, 'Error retrieving token',
extra_tags='danger')
return HttpResponseRedirect("login")
else:
messages.add_message(request, messages.ERROR, 'Missing code',
extra_tags='danger')
return HttpResponseRedirect("login")

View file

16
nebula_mesh_admin/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for nebula_mesh_admin project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings')
application = get_asgi_application()

View file

@ -0,0 +1,169 @@
"""
Django settings for nebula_mesh_admin project.
Generated by 'django-admin startproject' using Django 3.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
import secrets
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"mesh"
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'nebula_mesh_admin.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates'
,
'DIRS': [BASE_DIR / 'templates']
,
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'nebula_mesh_admin.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.environ.get("DB_FILE", "/persist/db.sqlite3"),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = '/static/'
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
OIDC_CONFIG_URL = os.environ.get("OIDC_CONFIG_URL")
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID")
OIDC_ADMIN_GROUP = os.environ.get("OIDC_ADMIN_GROUP", "admin")
OIDC_JWT_AUDIENCE = os.environ.get("OIDC_JWT_AUDIENCE", "account")
OIDC_SESSION_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", "3600"))
DEFAULT_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", 3600*8))
MAX_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", 3600*10))
MESH_SUBNET = os.environ.get("MESH_SUBNET", "192.168.11.0/24")
USER_SUBNET = os.environ.get("USER_SUBNET", "192.168.11.192/26")
CA_KEY = os.environ.get("CA_KEY", "/persist/ca.key")
CA_CERT = os.environ.get("CA_CERT", "/persist/ca.crt")
if not os.path.exists(CA_CERT):
CA_NAME = os.environ.get("CA_NAME", "Nebula CA")
CA_EXPIRY = int(os.environ.get("CA_EXPIRY", 60 * 60 * 24 * 365 * 2))
print("Generating CA Key and Certificate:")
print(f" Name: {CA_NAME}")
print(f" Expiry: {CA_EXPIRY} seconds")
from mesh.lib.nebulacert import NebulaCertificate
import time
nc = NebulaCertificate()
nc.Name = "Nebula CA"
nc.NotAfter = int(time.time() + CA_EXPIRY) # 2 year expiry
nc.NotBefore = int(time.time())
cert_pem, public_key_pem, private_key_pem = nc.generate_ca()
f = open(CA_KEY, "w")
f.write(private_key_pem)
f.close()
f = open(CA_CERT, "w")
f.write(cert_pem)
f.close()
SECRET_KEY_FILE = os.environ.get("SECRET_KEY_FILE", "/persist/secret_key")
if not os.path.exists(SECRET_KEY_FILE):
f = open(SECRET_KEY_FILE, "w")
f.write(secrets.token_hex(32))
f.flush()
f.close()
f = open(SECRET_KEY_FILE)
SECRET_KEY = f.readline().strip()
f.close()
TIME_ZONE = os.environ.get("TIME_ZONE", "UTC")

39
nebula_mesh_admin/urls.py Normal file
View file

@ -0,0 +1,39 @@
"""nebula_mesh_admin URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
# from django.contrib import admin
from django.urls import path
from mesh import api, views
urlpatterns = [
# path('admin/', admin.site.urls),
path("sign", api.sign, name="sign"),
path("config", api.config, name="config"),
path("certs", api.certs, name="certs"),
path("enroll", api.ott_enroll, name="ott_enroll"),
path("login", views.login, name="login"),
path("logout", views.logout, name="logout"),
path("oidc_login", views.oidc_login, name="oidc_login"),
path("oidc_callback", views.oidc_callback, name="oidc_callback"),
path("hosts", views.hosts, name="hosts"),
path("lighthouses", views.lighthouses, name="lighthouses"),
path("blocklist", views.blocklist, name="blocklist"),
path("enrollhost", views.enroll, name="enroll"),
path("", views.dashboard, name="dashboard"),
]

16
nebula_mesh_admin/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for nebula_mesh_admin project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings')
application = get_wsgi_application()

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
Django
protobuf
cryptography
requests
python-jose