25 | 64 | 65 |
├── 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 |