├── mesh
├── __init__.py
├── lib
│ ├── __init__.py
│ ├── nebulacert.py
│ └── cert_pb2.py
├── migrations
│ ├── __init__.py
│ ├── 0003_alter_host_expires.py
│ ├── 0002_auto_20210910_2305.py
│ ├── 0005_auto_20210911_1213.py
│ ├── 0004_otpenroll.py
│ └── 0001_initial.py
├── admin.py
├── tests.py
├── apps.py
├── templates
│ └── mesh
│ │ ├── dashboard.html
│ │ ├── login.html
│ │ ├── blocklist.html
│ │ ├── hosts.html
│ │ ├── lighthouses.html
│ │ ├── enroll.html
│ │ └── base.html
├── models.py
├── api.py
└── views.py
├── nebula_mesh_admin
├── __init__.py
├── asgi.py
├── wsgi.py
├── urls.py
└── settings.py
├── requirements.txt
├── docker_entrypoint.sh
├── Dockerfile
├── manage.py
├── LICENSE
└── README.md
/mesh/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mesh/lib/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mesh/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nebula_mesh_admin/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/mesh/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/mesh/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django
2 | protobuf
3 | cryptography
4 | requests
5 | python-jose
6 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
11 | Mesh Name: {{ cert.Name }}
12 | CA Fingerprint: {{ cert.Fingerprint }}
13 | Subnet: {{ subnet }}
14 | Not Before: {{ notbefore | localtime}}
15 | Not After: {{ notafter | localtime }}
16 |
17 |
18 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
54 | {{message}}
55 |
56 |
57 | {% endfor %}
58 | {% endif %}
59 | Sign in
60 |
61 |
62 | {% endblock %}
63 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
8 | {{message}}
9 |
10 |
11 | {% endfor %}
12 | {% endif %}
13 |
14 |
23 |
45 | {% endblock %}
46 |
--------------------------------------------------------------------------------
/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 |
10 | {{message}}
11 |
12 |
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 |
21 |
22 |
23 |
24 | Host Name: {{ h.name }}
25 |
26 |
27 |
33 |
34 |
35 |
41 |
42 |
43 |
44 | Host IP: {{ h.ip }}
45 | Host Fingerprint: {{ h.fingerprint }}
46 | Allocation expires: {{ h.expires | localtime }} {% if h.expired %}Expired {% endif %}
47 |
48 | {% empty %}
49 | No hosts found.
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 |
10 | {{message}}
11 |
12 |
13 | {% endfor %}
14 | {% endif %}
15 |
16 |
36 |
37 |
38 | {% for lighthouse in lighthouses %}
39 |
40 |
41 |
42 |
43 | Lighthouse Name: {{ lighthouse.name }}
44 |
45 |
46 |
51 |
52 |
53 |
54 | IP: {{ lighthouse.ip }}
55 | External IP: {{ lighthouse.external_ip }}
56 |
57 | {% empty %}
58 | No lighthouses found.
59 | {% endfor %}
60 |
61 | {% endblock %}
62 |
63 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
10 | {{message | safe}}
11 |
12 |
13 | {% endfor %}
14 | {% endif %}
15 |
16 |
40 |
41 |
42 | {% for enrol_otp in enrol_list %}
43 |
44 |
45 |
46 |
47 | Host Name: {{ enrol_otp.name }}
48 |
49 |
50 |
55 |
56 |
57 |
58 | IP: {{ enrol_otp.ip }}
59 | OTP Expires: {{ enrol_otp.otp_expires | localtime }}
60 |
61 | {% empty %}
62 | No OTP tokens found.
63 | {% endfor %}
64 |
65 | {% endblock %}
66 |
67 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------