├── Dockerfile ├── LICENSE ├── README.md ├── docker_entrypoint.sh ├── manage.py ├── mesh ├── __init__.py ├── admin.py ├── api.py ├── apps.py ├── lib │ ├── __init__.py │ ├── cert_pb2.py │ └── nebulacert.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20210910_2305.py │ ├── 0003_alter_host_expires.py │ ├── 0004_otpenroll.py │ ├── 0005_auto_20210911_1213.py │ └── __init__.py ├── models.py ├── templates │ └── mesh │ │ ├── base.html │ │ ├── blocklist.html │ │ ├── dashboard.html │ │ ├── enroll.html │ │ ├── hosts.html │ │ ├── lighthouses.html │ │ └── login.html ├── tests.py └── views.py ├── nebula_mesh_admin ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py └── requirements.txt /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | RUN pip install gunicorn 8 | 9 | EXPOSE 8000 10 | 11 | VOLUME /persist 12 | 13 | RUN chmod a+x docker_entrypoint.sh 14 | CMD ["/app/docker_entrypoint.sh"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Raal Goff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Nebula Mesh Admin 2 | ----------------- 3 | 4 | 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. 5 | 6 | ### Quick Start 7 | 8 | ```commandline 9 | git clone https://github.com/unreality/nebula-mesh-admin.git 10 | docker build -t nebula-mesh-admin:latest nebula-mesh-admin/ 11 | docker volume create nebula-vol 12 | 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 13 | ``` 14 | 15 | ### Documentation 16 | 17 | Until I get around to expanding the documentation, there is a more detailed setup guide at [https://blog.unreality.xyz/post/nebula-sso/](https://blog.unreality.xyz/post/nebula-sso/) 18 | 19 | ### Environment settings 20 | 21 | Required variables: 22 | * ``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 23 | * ``OIDC_CLIENT_ID`` - The OIDC client ID you have created for the Mesh Admin 24 | * ``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 25 | * ``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) 26 | 27 | 28 | Optional variables: 29 | * ``OIDC_SESSION_DURATION`` (default 1 hr) - How long a user session stays active in the admin console 30 | * ``DEFAULT_DURATION`` (default 8 hrs) - default time for a short-lived certificate 31 | * ``MAX_DURATION`` (default 10 hrs) - maximum time for a short-lived certificate 32 | * ``MESH_SUBNET`` (default 192.168.11.0/24) - mesh subnet 33 | * ``USER_SUBNET`` (default 192.168.11.192/26) - ip pool for short-lived (user) certificates 34 | * ``CA_KEY`` - path to CA key. If not specified one is generated 35 | * ``CA_CERT`` - path to CA cert. If not specified one is generated 36 | * ``CA_NAME`` (default 'Nebula CA') - If a CA cert/keypair is generated, this is the name specified when generating 37 | * ``CA_EXPIRY`` (default 2 years) - If a CA cert/keypair is generated, this is expiry time used when generating 38 | * ``TIME_ZONE`` (default UTC) - timezone for rendering expiry times 39 | * ``SECRET_KEY_FILE`` - secret key file for holding a Django SECRET_KEY. If not specified one is generated 40 | -------------------------------------------------------------------------------- /docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python manage.py migrate 3 | python manage.py collectstatic --noinput 4 | exec gunicorn -b 0.0.0.0:8000 -t 90 -w 4 nebula_mesh_admin.wsgi 5 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /mesh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unreality/nebula-mesh-admin/ad314225445a1489e611b8d0153344695e236392/mesh/__init__.py -------------------------------------------------------------------------------- /mesh/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /mesh/api.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import json 3 | import time 4 | from datetime import datetime, timedelta 5 | 6 | import pytz 7 | import requests 8 | from django.conf import settings 9 | from django.http import HttpResponse, JsonResponse 10 | from django.urls import reverse 11 | from django.views.decorators.csrf import csrf_exempt 12 | from jose import jwt, JWTError, JWSError, jwk 13 | 14 | from mesh.lib.nebulacert import NebulaCertificate 15 | from mesh.models import Host, Lighthouse, BlocklistHost, OTTEnroll 16 | 17 | 18 | def get_oidc_config(): 19 | oidc_config_request = requests.get(settings.OIDC_CONFIG_URL) 20 | 21 | if oidc_config_request.status_code == 200: 22 | oidc_config = oidc_config_request.json() 23 | 24 | return oidc_config 25 | else: 26 | return None 27 | 28 | 29 | @csrf_exempt 30 | def ott_enroll(request): 31 | 32 | if request.method == 'POST': 33 | try: 34 | sign_request = json.loads(request.body) 35 | except ValueError: 36 | resp = JsonResponse({'status': 'error', 'message': 'Invalid JSON payload'}) 37 | resp.status_code = 400 38 | return resp 39 | 40 | ott_str = sign_request.get('ott') 41 | if not ott_str: 42 | resp = JsonResponse({'status': 'error', 'message': 'No OTT'}) 43 | resp.status_code = 400 44 | return resp 45 | 46 | public_key = sign_request.get('public_key') 47 | if not public_key: 48 | resp = JsonResponse({'status': 'error', 'message': 'No public_key'}) 49 | resp.status_code = 400 50 | return resp 51 | 52 | try: 53 | ott = OTTEnroll.objects.get(ott=ott_str, ott_expires__gt=datetime.utcnow().replace(tzinfo=pytz.utc)) 54 | 55 | nc = NebulaCertificate() 56 | nc.Name = ott.name 57 | nc.Groups = ott.groups.split(",") 58 | nc.NotBefore = int(time.time()) 59 | nc.NotAfter = ott.expires 60 | nc.set_public_key_pem(public_key) 61 | nc.IsCA = False 62 | 63 | nc.Ips = [ott.ip] 64 | nc.Subnets = [] 65 | 66 | f = open(settings.CA_KEY) 67 | signing_key_pem = "".join(f.readlines()) 68 | f.close() 69 | 70 | f = open(settings.CA_CERT) 71 | signing_cert_pem = "".join(f.readlines()) 72 | f.close() 73 | 74 | s = nc.sign_to_pem(signing_key_pem=signing_key_pem, 75 | signing_cert_pem=signing_cert_pem) 76 | 77 | host = Host( 78 | ip=ott.ip, 79 | fingerprint=nc.fingerprint().hexdigest(), 80 | name=ott.name, 81 | expires=datetime.fromtimestamp(ott.expires, tz=pytz.utc) 82 | ) 83 | host.save() 84 | 85 | static_host_map = {} 86 | lighthouses = [] 87 | blocklist = [] 88 | 89 | for lighthouse in Lighthouse.objects.all(): 90 | static_host_map[lighthouse.ip] = lighthouse.external_ip.split(",") 91 | lighthouses.append(lighthouse.ip) 92 | 93 | for b in BlocklistHost.objects.all(): 94 | blocklist.append(b.fingerprint) 95 | 96 | ott.delete() 97 | 98 | return JsonResponse({ 99 | 'certificate': s, 100 | 'static_host_map': static_host_map, 101 | 'lighthouses': lighthouses, 102 | 'blocklist': blocklist 103 | }) 104 | 105 | except OTTEnroll.DoesNotExist: 106 | resp = JsonResponse({'status': 'error', 'message': 'Invalid enrollment token'}) 107 | resp.status_code = 401 108 | return resp 109 | 110 | return HttpResponse("") 111 | 112 | 113 | @csrf_exempt 114 | def sign(request): 115 | 116 | if request.method == 'POST': 117 | try: 118 | sign_request = json.loads(request.body) 119 | except ValueError: 120 | resp = JsonResponse({'status': 'error', 'message': 'Invalid JSON payload'}) 121 | resp.status_code = 400 122 | return resp 123 | 124 | auth_header = request.headers.get("Authorization") 125 | auth_tokens = auth_header.split(" ", 2) 126 | 127 | if len(auth_tokens) == 2: 128 | auth_jwt = auth_tokens[1] 129 | 130 | oidc_config = get_oidc_config() 131 | 132 | oidc_jwks_request = requests.get(oidc_config['jwks_uri']) 133 | 134 | if oidc_jwks_request.status_code == 200: 135 | jwks_config = oidc_jwks_request.json() 136 | unverified_header = jwt.get_unverified_header(auth_jwt) 137 | 138 | for k in jwks_config['keys']: 139 | if k['kid'] == unverified_header['kid']: 140 | constructed_key = jwk.construct(k) 141 | try: 142 | verified_token = jwt.decode(auth_jwt, 143 | constructed_key, 144 | k['alg'], 145 | audience=settings.OIDC_JWT_AUDIENCE) 146 | 147 | if not sign_request.get("public_key"): 148 | resp = JsonResponse({'status': 'error', 'message': "invalid signing request: no public_key"}) 149 | resp.status_code = 401 150 | return resp 151 | 152 | duration = int(sign_request.get("duration", settings.DEFAULT_DURATION)) 153 | duration = min(duration, settings.MAX_DURATION) 154 | 155 | nc = NebulaCertificate() 156 | nc.Name = verified_token.get("email") 157 | nc.Groups = verified_token.get("groups", []) 158 | nc.NotBefore = int(time.time()) 159 | nc.NotAfter = int(time.time() + duration) 160 | nc.set_public_key_pem(sign_request.get("public_key")) 161 | nc.IsCA = False 162 | 163 | subnet_iface = ipaddress.ip_interface(settings.MESH_SUBNET) 164 | iface = ipaddress.ip_interface(settings.USER_SUBNET) 165 | host = None 166 | for ip in iface.network: 167 | if ip == iface.network.network_address: 168 | continue 169 | 170 | if ip == iface.network.broadcast_address: 171 | continue 172 | 173 | ip_str = f"{str(ip)}/{subnet_iface.network.prefixlen}" 174 | try: 175 | host = Host.objects.get(ip=ip_str) 176 | if host.expired: # if the host is expired, re-use it 177 | host.name = verified_token.get("email") 178 | host.expires = (datetime.utcnow() + timedelta(seconds=duration)).replace(tzinfo=pytz.utc) 179 | host.save() 180 | 181 | break 182 | 183 | except Host.DoesNotExist: 184 | host = Host( 185 | ip=ip_str, 186 | fingerprint=nc.fingerprint().hexdigest(), 187 | name=verified_token.get("email"), 188 | expires=(datetime.utcnow() + timedelta(seconds=duration)).replace(tzinfo=pytz.utc) 189 | ) 190 | 191 | host.save() 192 | break 193 | 194 | if not host: 195 | resp = JsonResponse({'status': 'error', 'message': "no free ip in subnet"}) 196 | resp.status_code = 500 197 | return resp 198 | 199 | nc.Ips = [host.ip] 200 | nc.Subnets = [] 201 | 202 | f = open(settings.CA_KEY) 203 | signing_key_pem = "".join(f.readlines()) 204 | f.close() 205 | 206 | f = open(settings.CA_CERT) 207 | signing_cert_pem = "".join(f.readlines()) 208 | f.close() 209 | 210 | s = nc.sign_to_pem(signing_key_pem=signing_key_pem, 211 | signing_cert_pem=signing_cert_pem) 212 | 213 | host.fingerprint = nc.fingerprint().hexdigest() 214 | host.save() 215 | 216 | static_host_map = {} 217 | lighthouses = [] 218 | blocklist = [] 219 | 220 | for lighthouse in Lighthouse.objects.all(): 221 | static_host_map[lighthouse.ip] = lighthouse.external_ip.split(",") 222 | lighthouses.append(lighthouse.ip) 223 | 224 | for b in BlocklistHost.objects.all(): 225 | blocklist.append(b.fingerprint) 226 | 227 | return JsonResponse({ 228 | 'certificate': s, 229 | 'static_host_map': static_host_map, 230 | 'lighthouses': lighthouses, 231 | 'blocklist': blocklist 232 | }) 233 | 234 | except JWTError: 235 | resp = JsonResponse({'status': 'error', 'message': "Token verification error"}) 236 | resp.status_code = 401 237 | return resp 238 | 239 | else: 240 | resp = JsonResponse({'status': 'error', 'message': "Could not retrieve jwks info"}) 241 | resp.status_code = 500 242 | return resp 243 | 244 | 245 | def certs(request): 246 | f = open(settings.CA_CERT) 247 | signing_cert_pem = f.readlines() 248 | f.close() 249 | 250 | return HttpResponse(signing_cert_pem) 251 | 252 | 253 | def config(request): 254 | scheme = "https" if request.is_secure() else "http" 255 | 256 | callback_path = reverse("sign") 257 | sign_endpoint = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}" 258 | 259 | callback_path = reverse("certs") 260 | certs_endpoint = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}" 261 | 262 | f = open(settings.CA_CERT) 263 | signing_cert_pem = f.readlines() 264 | f.close() 265 | 266 | return JsonResponse({ 267 | "oidcConfigURL": settings.OIDC_CONFIG_URL, 268 | "oidcClientID": settings.OIDC_CLIENT_ID, 269 | "signEndpoint": sign_endpoint, 270 | "certEndpoint": certs_endpoint, 271 | "ca": "".join(signing_cert_pem), 272 | }) 273 | -------------------------------------------------------------------------------- /mesh/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MeshConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'mesh' 7 | -------------------------------------------------------------------------------- /mesh/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unreality/nebula-mesh-admin/ad314225445a1489e611b8d0153344695e236392/mesh/lib/__init__.py -------------------------------------------------------------------------------- /mesh/lib/cert_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: cert.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import message as _message 7 | from google.protobuf import reflection as _reflection 8 | from google.protobuf import symbol_database as _symbol_database 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor.FileDescriptor( 17 | name='cert.proto', 18 | package='cert', 19 | syntax='proto3', 20 | serialized_options=b'Z\036github.com/slackhq/nebula/cert', 21 | create_key=_descriptor._internal_create_key, 22 | 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' 23 | ) 24 | 25 | 26 | 27 | 28 | _RAWNEBULACERTIFICATE = _descriptor.Descriptor( 29 | name='RawNebulaCertificate', 30 | full_name='cert.RawNebulaCertificate', 31 | filename=None, 32 | file=DESCRIPTOR, 33 | containing_type=None, 34 | create_key=_descriptor._internal_create_key, 35 | fields=[ 36 | _descriptor.FieldDescriptor( 37 | name='Details', full_name='cert.RawNebulaCertificate.Details', index=0, 38 | number=1, type=11, cpp_type=10, label=1, 39 | has_default_value=False, default_value=None, 40 | message_type=None, enum_type=None, containing_type=None, 41 | is_extension=False, extension_scope=None, 42 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 43 | _descriptor.FieldDescriptor( 44 | name='Signature', full_name='cert.RawNebulaCertificate.Signature', index=1, 45 | number=2, type=12, cpp_type=9, label=1, 46 | has_default_value=False, default_value=b"", 47 | message_type=None, enum_type=None, containing_type=None, 48 | is_extension=False, extension_scope=None, 49 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 50 | ], 51 | extensions=[ 52 | ], 53 | nested_types=[], 54 | enum_types=[ 55 | ], 56 | serialized_options=None, 57 | is_extendable=False, 58 | syntax='proto3', 59 | extension_ranges=[], 60 | oneofs=[ 61 | ], 62 | serialized_start=20, 63 | serialized_end=113, 64 | ) 65 | 66 | 67 | _RAWNEBULACERTIFICATEDETAILS = _descriptor.Descriptor( 68 | name='RawNebulaCertificateDetails', 69 | full_name='cert.RawNebulaCertificateDetails', 70 | filename=None, 71 | file=DESCRIPTOR, 72 | containing_type=None, 73 | create_key=_descriptor._internal_create_key, 74 | fields=[ 75 | _descriptor.FieldDescriptor( 76 | name='Name', full_name='cert.RawNebulaCertificateDetails.Name', index=0, 77 | number=1, type=9, cpp_type=9, label=1, 78 | has_default_value=False, default_value=b"".decode('utf-8'), 79 | message_type=None, enum_type=None, containing_type=None, 80 | is_extension=False, extension_scope=None, 81 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 82 | _descriptor.FieldDescriptor( 83 | name='Ips', full_name='cert.RawNebulaCertificateDetails.Ips', index=1, 84 | number=2, type=13, cpp_type=3, label=3, 85 | has_default_value=False, default_value=[], 86 | message_type=None, enum_type=None, containing_type=None, 87 | is_extension=False, extension_scope=None, 88 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 89 | _descriptor.FieldDescriptor( 90 | name='Subnets', full_name='cert.RawNebulaCertificateDetails.Subnets', index=2, 91 | number=3, type=13, cpp_type=3, label=3, 92 | has_default_value=False, default_value=[], 93 | message_type=None, enum_type=None, containing_type=None, 94 | is_extension=False, extension_scope=None, 95 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 96 | _descriptor.FieldDescriptor( 97 | name='Groups', full_name='cert.RawNebulaCertificateDetails.Groups', index=3, 98 | number=4, type=9, cpp_type=9, label=3, 99 | has_default_value=False, default_value=[], 100 | message_type=None, enum_type=None, containing_type=None, 101 | is_extension=False, extension_scope=None, 102 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 103 | _descriptor.FieldDescriptor( 104 | name='NotBefore', full_name='cert.RawNebulaCertificateDetails.NotBefore', index=4, 105 | number=5, type=3, cpp_type=2, label=1, 106 | has_default_value=False, default_value=0, 107 | message_type=None, enum_type=None, containing_type=None, 108 | is_extension=False, extension_scope=None, 109 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 110 | _descriptor.FieldDescriptor( 111 | name='NotAfter', full_name='cert.RawNebulaCertificateDetails.NotAfter', index=5, 112 | number=6, type=3, cpp_type=2, label=1, 113 | has_default_value=False, default_value=0, 114 | message_type=None, enum_type=None, containing_type=None, 115 | is_extension=False, extension_scope=None, 116 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 117 | _descriptor.FieldDescriptor( 118 | name='PublicKey', full_name='cert.RawNebulaCertificateDetails.PublicKey', index=6, 119 | number=7, type=12, cpp_type=9, label=1, 120 | has_default_value=False, default_value=b"", 121 | message_type=None, enum_type=None, containing_type=None, 122 | is_extension=False, extension_scope=None, 123 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 124 | _descriptor.FieldDescriptor( 125 | name='IsCA', full_name='cert.RawNebulaCertificateDetails.IsCA', index=7, 126 | number=8, type=8, cpp_type=7, label=1, 127 | has_default_value=False, default_value=False, 128 | message_type=None, enum_type=None, containing_type=None, 129 | is_extension=False, extension_scope=None, 130 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 131 | _descriptor.FieldDescriptor( 132 | name='Issuer', full_name='cert.RawNebulaCertificateDetails.Issuer', index=8, 133 | number=9, type=12, cpp_type=9, label=1, 134 | has_default_value=False, default_value=b"", 135 | message_type=None, enum_type=None, containing_type=None, 136 | is_extension=False, extension_scope=None, 137 | serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), 138 | ], 139 | extensions=[ 140 | ], 141 | nested_types=[], 142 | enum_types=[ 143 | ], 144 | serialized_options=None, 145 | is_extendable=False, 146 | syntax='proto3', 147 | extension_ranges=[], 148 | oneofs=[ 149 | ], 150 | serialized_start=116, 151 | serialized_end=291, 152 | ) 153 | 154 | _RAWNEBULACERTIFICATE.fields_by_name['Details'].message_type = _RAWNEBULACERTIFICATEDETAILS 155 | DESCRIPTOR.message_types_by_name['RawNebulaCertificate'] = _RAWNEBULACERTIFICATE 156 | DESCRIPTOR.message_types_by_name['RawNebulaCertificateDetails'] = _RAWNEBULACERTIFICATEDETAILS 157 | _sym_db.RegisterFileDescriptor(DESCRIPTOR) 158 | 159 | RawNebulaCertificate = _reflection.GeneratedProtocolMessageType('RawNebulaCertificate', (_message.Message,), { 160 | 'DESCRIPTOR' : _RAWNEBULACERTIFICATE, 161 | '__module__' : 'cert_pb2' 162 | # @@protoc_insertion_point(class_scope:cert.RawNebulaCertificate) 163 | }) 164 | _sym_db.RegisterMessage(RawNebulaCertificate) 165 | 166 | RawNebulaCertificateDetails = _reflection.GeneratedProtocolMessageType('RawNebulaCertificateDetails', (_message.Message,), { 167 | 'DESCRIPTOR' : _RAWNEBULACERTIFICATEDETAILS, 168 | '__module__' : 'cert_pb2' 169 | # @@protoc_insertion_point(class_scope:cert.RawNebulaCertificateDetails) 170 | }) 171 | _sym_db.RegisterMessage(RawNebulaCertificateDetails) 172 | 173 | 174 | DESCRIPTOR._options = None 175 | # @@protoc_insertion_point(module_scope) 176 | -------------------------------------------------------------------------------- /mesh/lib/nebulacert.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import hashlib 3 | import ipaddress 4 | import time 5 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 6 | from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey 7 | from cryptography.hazmat.primitives import serialization 8 | import base64 9 | import mesh.lib.cert_pb2 as cert_proto 10 | 11 | 12 | class NebulaCertificate(object): 13 | 14 | def __init__(self): 15 | self.Name = "" 16 | self.Groups = [] 17 | 18 | self.Ips = [] 19 | self.Subnets = [] 20 | 21 | self.IsCA = False 22 | self.NotBefore = int(time.time()) 23 | self.NotAfter = int(time.time() + 3600) 24 | self.PublicKey = "" 25 | 26 | self.Fingerprint = "" 27 | 28 | def _decode_pem(self, pem): 29 | s = pem.split("-----") 30 | 31 | try: 32 | return base64.b64decode(s[2].strip()) 33 | except KeyError: 34 | print("Bad key") 35 | return False 36 | except binascii.Error as err: 37 | print(f"Bad b64 {err}") 38 | return False 39 | 40 | def fingerprint(self): 41 | return hashlib.sha256(self.PublicKey) 42 | 43 | def set_public_key_pem(self, pk): 44 | public_key = self._decode_pem(pk) 45 | 46 | if public_key: 47 | self.PublicKey = public_key 48 | 49 | return public_key is not False 50 | 51 | def sign_to_pem(self, signing_key_pem, signing_cert_pem): 52 | 53 | signing_key_bytes = self._decode_pem(signing_key_pem) 54 | 55 | if signing_key_bytes is False: 56 | return False 57 | 58 | if len(signing_key_bytes) == 64: 59 | signing_key_bytes = signing_key_bytes[0:32] 60 | signing_key = Ed25519PrivateKey.from_private_bytes(signing_key_bytes) 61 | 62 | signing_cert_bytes = self._decode_pem(signing_cert_pem) 63 | fingerprint = hashlib.sha256(signing_cert_bytes) 64 | 65 | cert_details = cert_proto.RawNebulaCertificateDetails() 66 | cert_details.Name = self.Name 67 | for i, g in enumerate(self.Groups): 68 | self.Groups[i] = g.strip() 69 | cert_details.Groups.extend(self.Groups) 70 | cert_details.NotBefore = self.NotBefore 71 | cert_details.NotAfter = self.NotAfter 72 | 73 | cert_details.PublicKey = self.PublicKey 74 | cert_details.IsCA = self.IsCA 75 | 76 | for i in self.Ips: 77 | try: 78 | iface = ipaddress.ip_interface(i) 79 | cert_details.Ips.extend([int(iface.ip), int(iface.netmask)]) 80 | except ValueError: 81 | pass 82 | 83 | for s in self.Subnets: 84 | try: 85 | subnet = ipaddress.ip_interface(s) 86 | cert_details.Subnets.extend([int(subnet.ip), int(subnet.netmask)]) 87 | except ValueError: 88 | pass 89 | 90 | cert_details.Issuer = fingerprint.digest() 91 | 92 | signature = signing_key.sign(cert_details.SerializeToString()) 93 | 94 | cert = cert_proto.RawNebulaCertificate() 95 | cert.Details.CopyFrom(cert_details) 96 | cert.Signature = signature 97 | 98 | cert_str = base64.b64encode(cert.SerializeToString()).decode('utf-8') 99 | 100 | return f"-----BEGIN NEBULA CERTIFICATE-----\n{cert_str}\n-----END NEBULA CERTIFICATE-----\n" 101 | 102 | def load_cert(self, cert_pem): 103 | b = self._decode_pem(cert_pem) 104 | cert = cert_proto.RawNebulaCertificate() 105 | cert.ParseFromString(b) 106 | 107 | self.Name = cert.Details.Name 108 | self.Fingerprint = hashlib.sha256(b).hexdigest() 109 | self.NotAfter = cert.Details.NotAfter 110 | self.NotBefore = cert.Details.NotBefore 111 | 112 | def generate_ca(self): 113 | ca_private_key = Ed25519PrivateKey.generate() 114 | ca_public_key = ca_private_key.public_key() 115 | 116 | cert_details = cert_proto.RawNebulaCertificateDetails() 117 | cert_details.Name = self.Name 118 | cert_details.Groups.extend(self.Groups) 119 | cert_details.NotBefore = self.NotBefore 120 | cert_details.NotAfter = self.NotAfter 121 | 122 | cert_details.PublicKey = ca_public_key.public_bytes( 123 | encoding=serialization.Encoding.Raw, 124 | format=serialization.PublicFormat.Raw 125 | ) 126 | cert_details.IsCA = True 127 | 128 | for i in self.Ips: 129 | try: 130 | iface = ipaddress.ip_interface(i) 131 | cert_details.Ips.extend([int(iface.ip), int(iface.netmask)]) 132 | except ValueError: 133 | pass 134 | 135 | for s in self.Subnets: 136 | try: 137 | subnet = ipaddress.ip_interface(s) 138 | cert_details.Subnets.extend([int(subnet.ip), int(subnet.netmask)]) 139 | except ValueError: 140 | pass 141 | 142 | signature = ca_private_key.sign(cert_details.SerializeToString()) 143 | 144 | cert = cert_proto.RawNebulaCertificate() 145 | cert.Details.CopyFrom(cert_details) 146 | cert.Signature = signature 147 | 148 | cert_str = base64.b64encode(cert.SerializeToString()).decode('utf-8') 149 | 150 | public_key_bytes = ca_public_key.public_bytes( 151 | encoding=serialization.Encoding.Raw, 152 | format=serialization.PublicFormat.Raw 153 | ) 154 | public_key_str = base64.b64encode(public_key_bytes).decode('utf-8') 155 | 156 | private_key_bytes = ca_private_key.private_bytes( 157 | encoding=serialization.Encoding.Raw, 158 | format=serialization.PrivateFormat.Raw, 159 | encryption_algorithm=serialization.NoEncryption() 160 | ) 161 | 162 | private_key_str = base64.b64encode(private_key_bytes + public_key_bytes).decode('utf-8') 163 | 164 | cert_pem = f"-----BEGIN NEBULA CERTIFICATE-----\n{cert_str}\n-----END NEBULA CERTIFICATE-----\n" 165 | public_key_pem = f"-----BEGIN NEBULA ED25519 PUBLIC KEY-----\n{public_key_str}\n-----END NEBULA ED25519 PUBLIC KEY-----\n" 166 | private_key_pem = f"-----BEGIN NEBULA ED25519 PRIVATE KEY-----\n{private_key_str}\n-----END NEBULA ED25519 PRIVATE KEY-----\n" 167 | 168 | return cert_pem, public_key_pem, private_key_pem 169 | 170 | 171 | if __name__ == '__main__': 172 | print("Generating CA") 173 | 174 | nc = NebulaCertificate() 175 | nc.Name = "Nebula CA" 176 | nc.NotAfter = int(time.time() + 60*60*24*365) 177 | nc.NotBefore = int(time.time()) 178 | cert_pem, public_key_pem, private_key_pem = nc.generate_ca() 179 | 180 | print(cert_pem) 181 | print(public_key_pem) 182 | print(private_key_pem) 183 | -------------------------------------------------------------------------------- /mesh/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-09 12:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='BlocklistHost', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('fingerprint', models.CharField(max_length=128)), 19 | ('name', models.CharField(max_length=255)), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='Host', 24 | fields=[ 25 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('ip', models.CharField(max_length=100)), 27 | ('fingerprint', models.CharField(max_length=64)), 28 | ('name', models.CharField(max_length=128)), 29 | ('expires', models.DateTimeField(auto_now_add=True)), 30 | ], 31 | ), 32 | migrations.CreateModel( 33 | name='Lighthouse', 34 | fields=[ 35 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 36 | ('ip', models.CharField(max_length=100)), 37 | ('external_ip', models.CharField(max_length=100)), 38 | ('name', models.CharField(max_length=255)), 39 | ], 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /mesh/migrations/0002_auto_20210910_2305.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-10 15:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('mesh', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='blocklisthost', 15 | name='name', 16 | field=models.CharField(default='', max_length=255), 17 | ), 18 | migrations.AlterField( 19 | model_name='lighthouse', 20 | name='name', 21 | field=models.CharField(default='', max_length=255), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /mesh/migrations/0003_alter_host_expires.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-10 15:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('mesh', '0002_auto_20210910_2305'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='host', 15 | name='expires', 16 | field=models.DateTimeField(), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /mesh/migrations/0004_otpenroll.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-11 03:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('mesh', '0003_alter_host_expires'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='OTPEnroll', 15 | fields=[ 16 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('otp', models.CharField(max_length=64)), 18 | ('otp_expires', models.DateTimeField()), 19 | ('ip', models.CharField(max_length=32)), 20 | ('groups', models.CharField(blank=True, default='', max_length=250)), 21 | ('subnets', models.CharField(blank=True, default='', max_length=250)), 22 | ('expires', models.IntegerField()), 23 | ('is_lighthouse', models.BooleanField(default=False)), 24 | ('name', models.CharField(max_length=100)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /mesh/migrations/0005_auto_20210911_1213.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-09-11 04:13 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('mesh', '0004_otpenroll'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameModel( 14 | old_name='OTPEnroll', 15 | new_name='OTTEnroll', 16 | ), 17 | migrations.RenameField( 18 | model_name='ottenroll', 19 | old_name='otp', 20 | new_name='ott', 21 | ), 22 | migrations.RenameField( 23 | model_name='ottenroll', 24 | old_name='otp_expires', 25 | new_name='ott_expires', 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /mesh/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unreality/nebula-mesh-admin/ad314225445a1489e611b8d0153344695e236392/mesh/migrations/__init__.py -------------------------------------------------------------------------------- /mesh/models.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from django.db import models 3 | import pytz 4 | 5 | 6 | class Host(models.Model): 7 | ip = models.CharField(max_length=100) 8 | fingerprint = models.CharField(max_length=64) 9 | name = models.CharField(max_length=128) 10 | expires = models.DateTimeField() 11 | 12 | @property 13 | def expired(self): 14 | return self.expires.astimezone(pytz.UTC) <= timezone.localtime().astimezone(pytz.UTC) 15 | 16 | 17 | class OTTEnroll(models.Model): 18 | ott = models.CharField(max_length=64) 19 | ott_expires = models.DateTimeField() 20 | 21 | ip = models.CharField(max_length=32) 22 | groups = models.CharField(max_length=250, default="", blank=True) 23 | subnets = models.CharField(max_length=250, default="", blank=True) 24 | 25 | expires = models.IntegerField() 26 | is_lighthouse = models.BooleanField(default=False) 27 | 28 | name = models.CharField(max_length=100) 29 | 30 | 31 | class Lighthouse(models.Model): 32 | ip = models.CharField(max_length=100) 33 | external_ip = models.CharField(max_length=100) 34 | name = models.CharField(max_length=255, default="") 35 | 36 | 37 | class BlocklistHost(models.Model): 38 | fingerprint = models.CharField(max_length=128) 39 | name = models.CharField(max_length=255, default="") 40 | -------------------------------------------------------------------------------- /mesh/templates/mesh/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | {% block title %}Nebula Mesh Admin{% endblock %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% block extrahead %}{% endblock %} 13 | 14 | 15 | {% block body_content %} 16 | 17 |
18 | 19 |
20 | 21 | 22 | Nebula Mesh Admin 23 | 24 |
25 | 64 | 65 |
66 | 67 |
68 | {% block page_content %} 69 |

Blam

70 | {% endblock %} 71 |
72 | 73 | 74 |
75 | 76 | {% endblock %} 77 | 78 | {% block extrascripts %}{% endblock %} 79 | 80 | 81 | -------------------------------------------------------------------------------- /mesh/templates/mesh/blocklist.html: -------------------------------------------------------------------------------- 1 | {% extends 'mesh/base.html' %} 2 | {% block page_content %} 3 |

Blocklist

4 |
5 | {% if messages %} 6 | {% for message in messages %} 7 | 11 | {% endfor %} 12 | {% endif %} 13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /mesh/templates/mesh/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'mesh/base.html' %} 2 | {% load tz %} 3 | {% block page_content %} 4 |

Dashboard

5 |
6 |
7 |
8 |
Mesh Certificate Information
9 |
10 | 17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /mesh/templates/mesh/enroll.html: -------------------------------------------------------------------------------- 1 | {% extends 'mesh/base.html' %} 2 | {% load tz %} 3 | 4 | {% block page_content %} 5 |

Enroll Host

6 |
7 | {% if messages %} 8 | {% for message in messages %} 9 | 13 | {% endfor %} 14 | {% endif %} 15 | 16 |
17 |

Create enrollment token

18 | {% csrf_token %} 19 | 20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
Expiry seconds ie "3600" means the host will expire in an hr.
28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
Comma-delimited list of groups.
37 |
38 | 39 |
40 | 41 |
42 | {% for enrol_otp in enrol_list %} 43 | 61 | {% empty %} 62 | 63 | {% endfor %} 64 | 65 | {% endblock %} 66 | 67 | -------------------------------------------------------------------------------- /mesh/templates/mesh/hosts.html: -------------------------------------------------------------------------------- 1 | {% extends 'mesh/base.html' %} 2 | {% load tz %} 3 | 4 | {% block page_content %} 5 |

Hosts

6 |
7 | {% if messages %} 8 | {% for message in messages %} 9 | 13 | {% endfor %} 14 | {% endif %} 15 | 16 | 17 | 18 | Note that deleting hosts here wont disable their ability to connect to the mesh. 19 | {% for h in hosts %} 20 | 48 | {% empty %} 49 | 50 | {% endfor %} 51 | 52 | {% endblock %} 53 | 54 | -------------------------------------------------------------------------------- /mesh/templates/mesh/lighthouses.html: -------------------------------------------------------------------------------- 1 | {% extends 'mesh/base.html' %} 2 | {% load tz %} 3 | 4 | {% block page_content %} 5 |

Lighthouses

6 |
7 | {% if messages %} 8 | {% for message in messages %} 9 | 13 | {% endfor %} 14 | {% endif %} 15 | 16 |
17 | {% csrf_token %} 18 | 19 |
20 | 21 | 22 |
Internal IP
23 |
24 |
25 | 26 | 27 |
External static IP. Specify multiple addresses separated by commas.
28 |
29 |
30 | 31 | 32 |
Descriptive name.
33 |
34 | 35 |
36 | 37 |
38 | {% for lighthouse in lighthouses %} 39 | 57 | {% empty %} 58 | 59 | {% endfor %} 60 | 61 | {% endblock %} 62 | 63 | -------------------------------------------------------------------------------- /mesh/templates/mesh/login.html: -------------------------------------------------------------------------------- 1 | {% extends "mesh/base.html" %} 2 | 3 | 4 | {% block extrahead %} 5 | 47 | {% endblock %} 48 | 49 | {% block body_content %} 50 |
51 | {% if messages %} 52 | {% for message in messages %} 53 | 57 | {% endfor %} 58 | {% endif %} 59 | Sign in 60 |
61 | 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /mesh/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /mesh/views.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import secrets 4 | import time 5 | from datetime import datetime, timedelta 6 | from pprint import pprint 7 | from urllib.parse import urlencode 8 | from functools import wraps 9 | import pytz 10 | import requests 11 | from django.conf import settings 12 | from django.contrib import messages 13 | from django.http import HttpResponseRedirect, HttpResponse, JsonResponse 14 | from django.shortcuts import render 15 | from django.urls import reverse 16 | from jose import jwt, JWTError, JWSError, jwk 17 | from mesh import api 18 | from mesh.lib.nebulacert import NebulaCertificate 19 | from mesh.models import Host, Lighthouse, BlocklistHost, OTTEnroll 20 | 21 | 22 | def session_is_authenticated(view_func): 23 | @wraps(view_func) 24 | def _wrapped_view(request, *args, **kwargs): 25 | 26 | session_expires = request.session.get("expires", 0) 27 | session_user = request.session.get("user") 28 | 29 | if not session_user: 30 | messages.add_message(request, messages.INFO, f"Please sign in.", extra_tags='info') 31 | return HttpResponseRedirect("login") 32 | 33 | if session_user and session_expires > time.time(): 34 | return view_func(request, *args, **kwargs) 35 | 36 | messages.add_message(request, messages.INFO, f"Session expired, please login again.", extra_tags='info') 37 | 38 | return HttpResponseRedirect("login") 39 | 40 | return _wrapped_view 41 | 42 | 43 | def logout(request): 44 | request.session.clear() 45 | messages.add_message(request, messages.INFO, f"Logged out.", extra_tags='info') 46 | return HttpResponseRedirect(reverse('login')) 47 | 48 | 49 | def login(request): 50 | return render(request, "mesh/login.html") 51 | 52 | 53 | @session_is_authenticated 54 | def dashboard(request): 55 | f = open(settings.CA_CERT) 56 | cert_crt_pem = f.readlines() 57 | cert_crt_pem = "".join(cert_crt_pem) 58 | f.close() 59 | 60 | c = NebulaCertificate() 61 | c.load_cert(cert_crt_pem) 62 | 63 | return render( 64 | request, 65 | "mesh/dashboard.html", 66 | { 67 | "cert": c, 68 | "subnet": settings.MESH_SUBNET, 69 | "notbefore": datetime.fromtimestamp(c.NotBefore), 70 | "notafter": datetime.fromtimestamp(c.NotAfter), 71 | } 72 | ) 73 | 74 | 75 | @session_is_authenticated 76 | def hosts(request): 77 | 78 | if request.method == "POST": 79 | id_to_delete = request.POST.get("id") 80 | if id_to_delete: 81 | try: 82 | h = Host.objects.get(pk=id_to_delete) 83 | messages.add_message(request, messages.SUCCESS, f'Deleted host {h.fingerprint}', extra_tags='success') 84 | h.delete() 85 | except Host.DoesNotExist: 86 | messages.add_message(request, messages.ERROR, 'No such host', extra_tags='danger') 87 | else: 88 | messages.add_message(request, messages.ERROR, 'No host supplied', extra_tags='danger') 89 | 90 | h = Host.objects.all() 91 | 92 | return render( 93 | request, 94 | "mesh/hosts.html", 95 | {"hosts": h} 96 | ) 97 | 98 | 99 | @session_is_authenticated 100 | def lighthouses(request): 101 | 102 | if request.method == "POST": 103 | if request.POST.get("action") == "create": 104 | ip_addr = request.POST.get("lighthouse_ip") 105 | ip_ext = request.POST.get("lighthouse_extip") 106 | name = request.POST.get("lighthouse_name") 107 | 108 | try: 109 | Lighthouse.objects.get(ip=ip_addr) 110 | messages.add_message(request, messages.ERROR, 'A lighthouse with this IP already exists', extra_tags='danger') 111 | except Lighthouse.DoesNotExist: 112 | pass 113 | 114 | lighthouse = Lighthouse.objects.create(ip=ip_addr, external_ip=ip_ext, name=name) 115 | lighthouse.save() 116 | 117 | messages.add_message(request, messages.SUCCESS, f'Created lighthouse {lighthouse.name}', extra_tags='success') 118 | else: 119 | id_to_delete = request.POST.get("id") 120 | if id_to_delete: 121 | try: 122 | h = Lighthouse.objects.get(pk=id_to_delete) 123 | messages.add_message(request, messages.SUCCESS, f'Deleted lighthouse {h.name}', extra_tags='success') 124 | h.delete() 125 | except Lighthouse.DoesNotExist: 126 | messages.add_message(request, messages.ERROR, 'No such lighthouse', extra_tags='danger') 127 | else: 128 | messages.add_message(request, messages.ERROR, 'No lighthouse supplied', extra_tags='danger') 129 | 130 | lighthouse_list = Lighthouse.objects.all() 131 | 132 | return render(request, "mesh/lighthouses.html", {"lighthouses": lighthouse_list}) 133 | 134 | 135 | @session_is_authenticated 136 | def enroll(request): 137 | 138 | if request.method == "POST": 139 | if request.POST.get("action") == "create": 140 | host_name = request.POST.get("host_name") 141 | host_ip = request.POST.get("host_ip") 142 | host_groups = request.POST.get("host_groups", "") 143 | host_subnets = request.POST.get("host_subnets", "") 144 | host_expires = int(request.POST.get("host_expires") or settings.MAX_DURATION) 145 | ott = secrets.token_hex(32) 146 | ott_expires = (datetime.utcnow() + timedelta(seconds=600)).replace(tzinfo=pytz.utc) 147 | 148 | OTTEnroll.objects.create( 149 | name=host_name, 150 | ip=host_ip, 151 | groups=host_groups, 152 | subnets=host_subnets, 153 | expires=int(time.time() + host_expires), 154 | ott=ott, 155 | ott_expires=ott_expires 156 | ) 157 | 158 | messages.add_message(request, messages.SUCCESS, f'Created enroll OTP {ott}', extra_tags='success') 159 | else: 160 | id_to_delete = request.POST.get("id") 161 | if id_to_delete: 162 | try: 163 | h = OTTEnroll.objects.get(pk=id_to_delete) 164 | messages.add_message(request, messages.SUCCESS, f'Deleted OTP {h.name}', extra_tags='success') 165 | h.delete() 166 | except OTTEnroll.DoesNotExist: 167 | messages.add_message(request, messages.ERROR, 'No such OTP', extra_tags='danger') 168 | else: 169 | messages.add_message(request, messages.ERROR, 'No OTP supplied', extra_tags='danger') 170 | 171 | enrol_list = OTTEnroll.objects.all() 172 | 173 | return render(request, "mesh/enroll.html", {"enrol_list": enrol_list}) 174 | 175 | 176 | @session_is_authenticated 177 | def blocklist(request): 178 | 179 | if request.method == "POST": 180 | if request.POST.get("action") == "create": 181 | fingerprint = request.POST.get("fingerprint") 182 | name = request.POST.get("name", fingerprint) 183 | 184 | try: 185 | BlocklistHost.objects.get(fingerprint=fingerprint) 186 | messages.add_message(request, messages.ERROR, 'A blocked host with this fingerprint already exists', extra_tags='danger') 187 | except BlocklistHost.DoesNotExist: 188 | pass 189 | 190 | blocked_host = BlocklistHost.objects.create(fingerprint=fingerprint, name=name) 191 | blocked_host.save() 192 | 193 | messages.add_message(request, messages.SUCCESS, f'Blocked {fingerprint}', extra_tags='success') 194 | else: 195 | id_to_delete = request.POST.get("id") 196 | if id_to_delete: 197 | try: 198 | h = BlocklistHost.objects.get(pk=id_to_delete) 199 | messages.add_message(request, messages.SUCCESS, f'Deleted block {h.fingerprint}', extra_tags='success') 200 | h.delete() 201 | except BlocklistHost.DoesNotExist: 202 | messages.add_message(request, messages.ERROR, 'No such block', extra_tags='danger') 203 | else: 204 | messages.add_message(request, messages.ERROR, 'No block id supplied', extra_tags='danger') 205 | 206 | blocklist = BlocklistHost.objects.all() 207 | 208 | return render(request, "mesh/blocklist.html", {"blocklist": blocklist}) 209 | 210 | 211 | def oidc_login(request): 212 | oidc_config = api.get_oidc_config() 213 | 214 | if oidc_config: 215 | scheme = "https" if request.is_secure() else "http" 216 | callback_path = reverse("oidc_callback") 217 | redirect_uri = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}" 218 | 219 | v = secrets.token_hex(24) 220 | v_sha = base64.urlsafe_b64encode(hashlib.sha256(v.encode('ascii')).digest()).decode('ascii') 221 | v_sha = v_sha.replace("=", "") 222 | 223 | request.session['v'] = v 224 | 225 | params = { 226 | 'response_type': 'code', 227 | 'client_id': settings.OIDC_CLIENT_ID, 228 | 'redirect_uri': redirect_uri, 229 | 'scope': 'openid', 230 | 'code_challenge': v_sha, 231 | 'code_challenge_method': 'S256' 232 | } 233 | url_encode_params = urlencode(params) 234 | 235 | url = f"{oidc_config['authorization_endpoint']}?{url_encode_params}" 236 | return HttpResponseRedirect(url) 237 | else: 238 | resp = HttpResponse("Could not retrieve oidc endpoint info") 239 | resp.status_code = 500 240 | return resp 241 | 242 | 243 | def oidc_callback(request): 244 | oidc_config = api.get_oidc_config() 245 | 246 | if not oidc_config: 247 | resp = HttpResponse("Could not retrieve oidc endpoint info") 248 | resp.status_code = 500 249 | return resp 250 | 251 | oidc_jwks_request = requests.get(oidc_config['jwks_uri']) 252 | 253 | if oidc_jwks_request.status_code == 200: 254 | jwks_config = oidc_jwks_request.json() 255 | else: 256 | resp = HttpResponse("Could not retrieve oidc endpoint info") 257 | resp.status_code = 500 258 | return resp 259 | 260 | if 'code' in request.GET: 261 | scheme = "https" if request.is_secure() else "http" 262 | callback_path = reverse("oidc_callback") 263 | redirect_uri = f"{scheme}://{request.META.get('HTTP_HOST')}{callback_path}" 264 | 265 | params = { 266 | 'grant_type': 'authorization_code', 267 | 'code': request.GET['code'], 268 | 'client_id': settings.OIDC_CLIENT_ID, 269 | 'code_verifier': request.session.get('v'), 270 | 'redirect_uri': redirect_uri, 271 | } 272 | r = requests.post(oidc_config['token_endpoint'], data=params) 273 | 274 | if r.status_code == 200: 275 | tokens = r.json() 276 | 277 | userinfo_resp = requests.get( 278 | oidc_config['userinfo_endpoint'], 279 | headers={ 280 | "Authorization": f"Bearer {tokens['access_token']}" 281 | } 282 | ) 283 | 284 | userinfo = userinfo_resp.json() 285 | pprint(userinfo) 286 | 287 | unverified_header = jwt.get_unverified_header(tokens['access_token']) 288 | 289 | for k in jwks_config['keys']: 290 | if k['kid'] == unverified_header['kid']: 291 | constructed_key = jwk.construct(k) 292 | try: 293 | verified_token = jwt.decode( 294 | tokens['access_token'], 295 | constructed_key, 296 | k['alg'], 297 | audience=settings.OIDC_JWT_AUDIENCE 298 | ) 299 | 300 | for g in userinfo.get('groups', []): 301 | if g == settings.OIDC_ADMIN_GROUP: 302 | request.session['user'] = verified_token['email'] 303 | request.session['expires'] = int(time.time() + settings.OIDC_SESSION_DURATION) 304 | 305 | return HttpResponseRedirect(reverse('dashboard')) 306 | 307 | messages.add_message(request, messages.ERROR, 'User not in administrator group', extra_tags='danger') 308 | return HttpResponseRedirect("login") 309 | except JWTError: 310 | messages.add_message(request, messages.ERROR, 'Token verification error', 311 | extra_tags='danger') 312 | return HttpResponseRedirect("login") 313 | else: 314 | messages.add_message(request, messages.ERROR, 'Error retrieving token', 315 | extra_tags='danger') 316 | return HttpResponseRedirect("login") 317 | else: 318 | messages.add_message(request, messages.ERROR, 'Missing code', 319 | extra_tags='danger') 320 | return HttpResponseRedirect("login") 321 | -------------------------------------------------------------------------------- /nebula_mesh_admin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unreality/nebula-mesh-admin/ad314225445a1489e611b8d0153344695e236392/nebula_mesh_admin/__init__.py -------------------------------------------------------------------------------- /nebula_mesh_admin/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for nebula_mesh_admin project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /nebula_mesh_admin/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for nebula_mesh_admin project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | import os 13 | import secrets 14 | from pathlib import Path 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | DEBUG = True 20 | 21 | ALLOWED_HOSTS = ["*"] 22 | 23 | 24 | # Application definition 25 | 26 | INSTALLED_APPS = [ 27 | 'django.contrib.auth', 28 | 'django.contrib.contenttypes', 29 | 'django.contrib.sessions', 30 | 'django.contrib.messages', 31 | 'django.contrib.staticfiles', 32 | "mesh" 33 | ] 34 | 35 | MIDDLEWARE = [ 36 | 'django.middleware.security.SecurityMiddleware', 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | 'django.middleware.common.CommonMiddleware', 39 | 'django.middleware.csrf.CsrfViewMiddleware', 40 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 41 | 'django.contrib.messages.middleware.MessageMiddleware', 42 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 43 | ] 44 | 45 | ROOT_URLCONF = 'nebula_mesh_admin.urls' 46 | 47 | TEMPLATES = [ 48 | { 49 | 'BACKEND': 'django.template.backends.django.DjangoTemplates' 50 | , 51 | 'DIRS': [BASE_DIR / 'templates'] 52 | , 53 | 'APP_DIRS': True, 54 | 'OPTIONS': { 55 | 'context_processors': [ 56 | 'django.template.context_processors.debug', 57 | 'django.template.context_processors.request', 58 | 'django.contrib.auth.context_processors.auth', 59 | 'django.contrib.messages.context_processors.messages', 60 | ], 61 | }, 62 | }, 63 | ] 64 | 65 | WSGI_APPLICATION = 'nebula_mesh_admin.wsgi.application' 66 | 67 | 68 | # Database 69 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 70 | 71 | DATABASES = { 72 | 'default': { 73 | 'ENGINE': 'django.db.backends.sqlite3', 74 | 'NAME': os.environ.get("DB_FILE", "/persist/db.sqlite3"), 75 | } 76 | } 77 | 78 | 79 | # Password validation 80 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 81 | 82 | AUTH_PASSWORD_VALIDATORS = [ 83 | { 84 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 85 | }, 86 | { 87 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 88 | }, 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 94 | }, 95 | ] 96 | 97 | 98 | # Internationalization 99 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 100 | 101 | LANGUAGE_CODE = 'en-us' 102 | 103 | USE_I18N = True 104 | 105 | USE_L10N = True 106 | 107 | USE_TZ = True 108 | 109 | 110 | # Static files (CSS, JavaScript, Images) 111 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 112 | 113 | STATIC_URL = '/static/' 114 | 115 | # Default primary key field type 116 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 117 | 118 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 119 | 120 | OIDC_CONFIG_URL = os.environ.get("OIDC_CONFIG_URL") 121 | OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID") 122 | OIDC_ADMIN_GROUP = os.environ.get("OIDC_ADMIN_GROUP", "admin") 123 | OIDC_JWT_AUDIENCE = os.environ.get("OIDC_JWT_AUDIENCE", "account") 124 | OIDC_SESSION_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", "3600")) 125 | 126 | DEFAULT_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", 3600*8)) 127 | MAX_DURATION = int(os.environ.get("OIDC_SESSION_DURATION", 3600*10)) 128 | 129 | MESH_SUBNET = os.environ.get("MESH_SUBNET", "192.168.11.0/24") 130 | USER_SUBNET = os.environ.get("USER_SUBNET", "192.168.11.192/26") 131 | CA_KEY = os.environ.get("CA_KEY", "/persist/ca.key") 132 | CA_CERT = os.environ.get("CA_CERT", "/persist/ca.crt") 133 | 134 | if not os.path.exists(CA_CERT): 135 | CA_NAME = os.environ.get("CA_NAME", "Nebula CA") 136 | CA_EXPIRY = int(os.environ.get("CA_EXPIRY", 60 * 60 * 24 * 365 * 2)) 137 | print("Generating CA Key and Certificate:") 138 | print(f" Name: {CA_NAME}") 139 | print(f" Expiry: {CA_EXPIRY} seconds") 140 | 141 | from mesh.lib.nebulacert import NebulaCertificate 142 | import time 143 | 144 | nc = NebulaCertificate() 145 | nc.Name = "Nebula CA" 146 | nc.NotAfter = int(time.time() + CA_EXPIRY) # 2 year expiry 147 | nc.NotBefore = int(time.time()) 148 | cert_pem, public_key_pem, private_key_pem = nc.generate_ca() 149 | 150 | f = open(CA_KEY, "w") 151 | f.write(private_key_pem) 152 | f.close() 153 | 154 | f = open(CA_CERT, "w") 155 | f.write(cert_pem) 156 | f.close() 157 | 158 | SECRET_KEY_FILE = os.environ.get("SECRET_KEY_FILE", "/persist/secret_key") 159 | if not os.path.exists(SECRET_KEY_FILE): 160 | f = open(SECRET_KEY_FILE, "w") 161 | f.write(secrets.token_hex(32)) 162 | f.flush() 163 | f.close() 164 | 165 | f = open(SECRET_KEY_FILE) 166 | SECRET_KEY = f.readline().strip() 167 | f.close() 168 | 169 | TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") 170 | -------------------------------------------------------------------------------- /nebula_mesh_admin/urls.py: -------------------------------------------------------------------------------- 1 | """nebula_mesh_admin URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | # from django.contrib import admin 17 | from django.urls import path 18 | from mesh import api, views 19 | 20 | urlpatterns = [ 21 | # path('admin/', admin.site.urls), 22 | path("sign", api.sign, name="sign"), 23 | path("config", api.config, name="config"), 24 | path("certs", api.certs, name="certs"), 25 | path("enroll", api.ott_enroll, name="ott_enroll"), 26 | 27 | path("login", views.login, name="login"), 28 | path("logout", views.logout, name="logout"), 29 | path("oidc_login", views.oidc_login, name="oidc_login"), 30 | path("oidc_callback", views.oidc_callback, name="oidc_callback"), 31 | 32 | path("hosts", views.hosts, name="hosts"), 33 | path("lighthouses", views.lighthouses, name="lighthouses"), 34 | path("blocklist", views.blocklist, name="blocklist"), 35 | path("enrollhost", views.enroll, name="enroll"), 36 | 37 | path("", views.dashboard, name="dashboard"), 38 | 39 | ] 40 | -------------------------------------------------------------------------------- /nebula_mesh_admin/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for nebula_mesh_admin project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nebula_mesh_admin.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | protobuf 3 | cryptography 4 | requests 5 | python-jose 6 | --------------------------------------------------------------------------------