mirror of
https://github.com/unreality/nebula-mesh-admin.git
synced 2024-09-20 06:46:03 +08:00
initial commit
This commit is contained in:
commit
7d185d1b44
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
22
LICENSE
Normal 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
35
README.md
Normal 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
4
docker_entrypoint.sh
Normal 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
22
manage.py
Normal 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
0
mesh/__init__.py
Normal file
3
mesh/admin.py
Normal file
3
mesh/admin.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
272
mesh/api.py
Normal file
272
mesh/api.py
Normal 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
6
mesh/apps.py
Normal 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
0
mesh/lib/__init__.py
Normal file
175
mesh/lib/cert_pb2.py
Normal file
175
mesh/lib/cert_pb2.py
Normal 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
182
mesh/lib/nebulacert.py
Normal 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)
|
41
mesh/migrations/0001_initial.py
Normal file
41
mesh/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
23
mesh/migrations/0002_auto_20210910_2305.py
Normal file
23
mesh/migrations/0002_auto_20210910_2305.py
Normal 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),
|
||||
),
|
||||
]
|
18
mesh/migrations/0003_alter_host_expires.py
Normal file
18
mesh/migrations/0003_alter_host_expires.py
Normal 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(),
|
||||
),
|
||||
]
|
27
mesh/migrations/0004_otpenroll.py
Normal file
27
mesh/migrations/0004_otpenroll.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
27
mesh/migrations/0005_auto_20210911_1213.py
Normal file
27
mesh/migrations/0005_auto_20210911_1213.py
Normal 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',
|
||||
),
|
||||
]
|
0
mesh/migrations/__init__.py
Normal file
0
mesh/migrations/__init__.py
Normal file
39
mesh/models.py
Normal file
39
mesh/models.py
Normal 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="")
|
80
mesh/templates/mesh/base.html
Normal file
80
mesh/templates/mesh/base.html
Normal 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>
|
45
mesh/templates/mesh/blocklist.html
Normal file
45
mesh/templates/mesh/blocklist.html
Normal 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 %}
|
19
mesh/templates/mesh/dashboard.html
Normal file
19
mesh/templates/mesh/dashboard.html
Normal 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 %}
|
66
mesh/templates/mesh/enroll.html
Normal file
66
mesh/templates/mesh/enroll.html
Normal 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 %}
|
||||
|
53
mesh/templates/mesh/hosts.html
Normal file
53
mesh/templates/mesh/hosts.html
Normal 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 %}
|
||||
|
62
mesh/templates/mesh/lighthouses.html
Normal file
62
mesh/templates/mesh/lighthouses.html
Normal 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 %}
|
||||
|
62
mesh/templates/mesh/login.html
Normal file
62
mesh/templates/mesh/login.html
Normal 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
3
mesh/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
320
mesh/views.py
Normal file
320
mesh/views.py
Normal 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")
|
0
nebula_mesh_admin/__init__.py
Normal file
0
nebula_mesh_admin/__init__.py
Normal file
16
nebula_mesh_admin/asgi.py
Normal file
16
nebula_mesh_admin/asgi.py
Normal 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()
|
169
nebula_mesh_admin/settings.py
Normal file
169
nebula_mesh_admin/settings.py
Normal 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
39
nebula_mesh_admin/urls.py
Normal 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
16
nebula_mesh_admin/wsgi.py
Normal 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
5
requirements.txt
Normal file
|
@ -0,0 +1,5 @@
|
|||
Django
|
||||
protobuf
|
||||
cryptography
|
||||
requests
|
||||
python-jose
|
Loading…
Reference in a new issue