diff --git a/flake.nix b/flake.nix index 9e2a657..0e9ec0c 100644 --- a/flake.nix +++ b/flake.nix @@ -51,6 +51,7 @@ "keys" "secrets" "services" + "panel" ]; files = "^((" + concatStringsSep "|" optin + ")/.*\\.nix|[^/]*\\.nix)$"; in diff --git a/panel/.gitignore b/panel/.gitignore new file mode 100644 index 0000000..f4abba7 --- /dev/null +++ b/panel/.gitignore @@ -0,0 +1,13 @@ +# Nix +.direnv +result* + +# Python +*.pyc +__pycache__ + +# Django, application-specific +db.sqlite3 +src/db.sqlite3 +src/static +.credentials diff --git a/panel/README.md b/panel/README.md new file mode 100644 index 0000000..a1770fa --- /dev/null +++ b/panel/README.md @@ -0,0 +1,46 @@ +# Fediversity Panel + +The Fediversity Panel is a web service for managing Fediversity deployments with a graphical user interface, written in Django. + +## Development + +- To obtain all tools related to this project, enter the development environment with `nix-shell`. + + If you want to do that automatically on entering this directory: + + - [Set up `direnv`](https://github.com/nix-community/nix-direnv#installation) + - Run `direnv allow` in the directory where repository is stored on your machine + + > **Note** + > + > This is a security boundary, and allows automatically running code from this repository on your machine. + +- Run NixOS integration tests and Django unit tests: + + ```bash + nix-build -A tests + ``` + +- List all available Django management commands with: + + ```shell-session + manage + ``` + +- Run the server locally + + ```shell-session + manage runserver + ``` + +- Whenever you add a field in the database schema, run: + + ```console + manage makemigrations + ``` + + Then before starting the server again, run: + + ``` + manage migrate + ``` diff --git a/panel/default.nix b/panel/default.nix new file mode 100644 index 0000000..9931a95 --- /dev/null +++ b/panel/default.nix @@ -0,0 +1,53 @@ +{ + system ? builtins.currentSystem, + sources ? import ../npins, + pkgs ? import sources.nixpkgs { + inherit system; + config = { }; + overlays = [ ]; + }, +}: +let + package = + let + callPackage = pkgs.lib.callPackageWith (pkgs // pkgs.python3.pkgs); + in + callPackage ./nix/package.nix { }; + + pkgs' = pkgs.extend (_final: _prev: { panel = package; }); + + manage = pkgs.writeScriptBin "manage" '' + exec ${pkgs.lib.getExe pkgs.python3} ${toString ./src/manage.py} $@ + ''; +in +{ + shell = pkgs.mkShellNoCC { + inputsFrom = [ package ]; + packages = [ + pkgs.npins + manage + ]; + env = { + NPINS_DIRECTORY = toString ../npins; + }; + shellHook = '' + # in production, secrets are passed via CREDENTIALS_DIRECTORY by systemd. + # use this directory for testing with local secrets + mkdir -p .credentials + echo secret > ${builtins.toString ./.credentials}/SECRET_KEY + export CREDENTIALS_DIRECTORY=${builtins.toString ./.credentials} + export DATABASE_URL="sqlite:///${toString ./src}/db.sqlite3" + ''; + }; + + tests = pkgs'.callPackage ./nix/tests.nix { }; + inherit package; + + # re-export inputs so they can be overridden granularly + # (they can't be accessed from the outside any other way) + inherit + sources + system + pkgs + ; +} diff --git a/panel/nix/configuration.nix b/panel/nix/configuration.nix new file mode 100644 index 0000000..dae242b --- /dev/null +++ b/panel/nix/configuration.nix @@ -0,0 +1,199 @@ +{ + config, + pkgs, + lib, + ... +}: +let + inherit (lib) + concatStringsSep + mapAttrsToList + mkDefault + mkEnableOption + mkIf + mkOption + mkPackageOption + optionalString + types + ; + inherit (pkgs) writeShellApplication; + + # TODO: configure the name globally for everywhere it's used + name = "panel"; + + cfg = config.services.${name}; + + database-url = "sqlite:////var/lib/${name}/db.sqlite3"; + + python-environment = pkgs.python3.withPackages ( + ps: with ps; [ + cfg.package + uvicorn + ] + ); + + configFile = pkgs.concatText "configuration.py" [ + ((pkgs.formats.pythonVars { }).generate "settings.py" cfg.settings) + (builtins.toFile "extra-settings.py" cfg.extra-settings) + ]; + + manage-service = writeShellApplication { + name = "manage"; + text = ''exec ${cfg.package}/bin/manage.py "$@"''; + }; + + manage-admin = writeShellApplication { + # This allows running the `manage` command in the system environment, e.g. to initialise an admin user + # Executing + name = "manage"; + text = + '' + systemd-run --pty \ + --same-dir \ + --wait \ + --collect \ + --service-type=exec \ + --unit "manage-${name}.service" \ + --property "User=${name}" \ + --property "Group=${name}" \ + --property "Environment=DATABASE_URL=${database-url} USER_SETTINGS_FILE=${configFile}" \ + '' + + optionalString (credentials != [ ]) ( + (concatStringsSep " \\\n" (map (cred: "--property 'LoadCredential=${cred}'") credentials)) + " \\\n" + ) + + '' + ${lib.getExe manage-service} "$@" + ''; + }; + + credentials = mapAttrsToList (name: secretPath: "${name}:${secretPath}") cfg.secrets; +in +# TODO: for a more clever and generic way of running Django services: +# https://git.dgnum.eu/mdebray/djangonix/ +# unlicensed at the time of writing, but surely worth taking some inspiration from... +{ + options.services.${name} = { + enable = mkEnableOption "Service configuration for `${name}`"; + # NOTE: this requires that the package is present in `pkgs` + package = mkPackageOption pkgs name { }; + production = mkOption { + type = types.bool; + default = true; + }; + restart = mkOption { + description = "systemd restart behavior"; + type = types.enum [ + "no" + "on-success" + "on-failure" + "on-abnormal" + "on-abort" + "always" + ]; + default = "always"; + }; + domain = mkOption { type = types.str; }; + host = mkOption { + type = types.str; + default = "127.0.0.1"; + }; + port = mkOption { + type = types.port; + default = 8000; + }; + settings = mkOption { + type = types.attrsOf types.anything; + default = { + STATIC_ROOT = mkDefault "/var/lib/${name}/static"; + DEBUG = mkDefault false; + ALLOWED_HOSTS = mkDefault [ + cfg.domain + cfg.host + "localhost" + "[::1]" + ]; + CSRF_TRUSTED_ORIGINS = mkDefault [ "https://${cfg.domain}" ]; + COMPRESS_OFFLINE = true; + LIBSASS_OUTPUT_STYLE = "compressed"; + }; + description = '' + Django configuration as an attribute set. + Name-value pairs will be converted to Python variable assignments. + ''; + }; + extra-settings = mkOption { + type = types.lines; + default = ""; + description = '' + Django configuration written in Python verbatim. + Contents will be appended to the definitions in `settings`. + ''; + }; + secrets = mkOption { + type = types.attrsOf types.path; + default = { }; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ manage-admin ]; + + services = { + nginx.enable = true; + nginx.virtualHosts = { + ${cfg.domain} = + { + locations = { + "/".proxyPass = "http://localhost:${toString cfg.port}"; + "/static/".alias = "/var/lib/${name}/static/"; + }; + } + // lib.optionalAttrs cfg.production { + enableACME = true; + forceSSL = true; + }; + }; + }; + + users.users.${name} = { + isSystemUser = true; + group = name; + }; + + users.groups.${name} = { }; + systemd.services.${name} = { + description = "${name} ASGI server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + python-environment + manage-service + ]; + preStart = '' + # Auto-migrate on first run or if the package has changed + versionFile="/var/lib/${name}/package-version" + if [[ $(cat "$versionFile" 2>/dev/null) != ${cfg.package} ]]; then + manage migrate --no-input + manage collectstatic --no-input --clear + manage compress --force + echo ${cfg.package} > "$versionFile" + fi + ''; + script = '' + uvicorn ${name}.asgi:application --host ${cfg.host} --port ${toString cfg.port} + ''; + serviceConfig = { + Restart = "always"; + User = name; + WorkingDirectory = "/var/lib/${name}"; + StateDirectory = name; + RuntimeDirectory = name; + LogsDirectory = name; + } // lib.optionalAttrs (credentials != [ ]) { LoadCredential = credentials; }; + environment = { + USER_SETTINGS_FILE = "${configFile}"; + DATABASE_URL = database-url; + }; + }; + }; +} diff --git a/panel/nix/package.nix b/panel/nix/package.nix new file mode 100644 index 0000000..c3dcce8 --- /dev/null +++ b/panel/nix/package.nix @@ -0,0 +1,57 @@ +{ + lib, + buildPythonPackage, + setuptools, + django_4, + django-compressor, + django-libsass, + dj-database-url, +}: +let + src = + with lib.fileset; + toSource { + root = ../src; + fileset = intersection (gitTracked ../../.) ../src; + }; + pyproject = with lib; fromTOML pyproject-toml; + # TODO: define this globally + name = "panel"; + # TODO: we may want this in a file so it's easier to read statically + version = "0.0.0"; + pyproject-toml = '' + [project] + name = "Fediversity-Panel" + version = "${version}" + + [tool.setuptools] + packages = [ "${name}" ] + include-package-data = true + ''; +in +buildPythonPackage { + pname = name; + inherit (pyproject.project) version; + pyproject = true; + inherit src; + + preBuild = '' + echo "recursive-include ${name} *" > MANIFEST.in + cp ${builtins.toFile "source" pyproject-toml} pyproject.toml + ''; + + propagatedBuildInputs = [ + setuptools + django_4 + django-compressor + django-libsass + dj-database-url + ]; + + postInstall = '' + mkdir -p $out/bin + cp -v ${src}/manage.py $out/bin/manage.py + chmod +x $out/bin/manage.py + wrapProgram $out/bin/manage.py --prefix PYTHONPATH : "$PYTHONPATH" + ''; +} diff --git a/panel/nix/tests.nix b/panel/nix/tests.nix new file mode 100644 index 0000000..eaa9068 --- /dev/null +++ b/panel/nix/tests.nix @@ -0,0 +1,62 @@ +{ lib, pkgs }: +let + # TODO: specify project/service name globally + name = "panel"; + defaults = { + services.${name} = { + enable = true; + production = false; + restart = "no"; + domain = "example.com"; + secrets = { + SECRET_KEY = pkgs.writeText "SECRET_KEY" "secret"; + }; + }; + + virtualisation = { + memorySize = 2048; + cores = 2; + }; + }; +in +lib.mapAttrs (name: test: pkgs.testers.runNixOSTest (test // { inherit name; })) { + application-tests = { + inherit defaults; + nodes.server = _: { imports = [ ./configuration.nix ]; }; + # run all application-level tests managed by Django + # https://docs.djangoproject.com/en/5.0/topics/testing/overview/ + testScript = '' + server.succeed("manage test") + ''; + }; + admin = { + inherit defaults; + nodes.server = _: { imports = [ ./configuration.nix ]; }; + # check that the admin interface is served + testScript = '' + server.wait_for_unit("multi-user.target") + server.wait_for_unit("${name}.service") + server.wait_for_open_port(8000) + server.succeed("curl --fail -L -H 'Host: example.org' http://localhost/admin") + ''; + }; + + sass-processing = { + inherit defaults; + nodes.server = _: { imports = [ ./configuration.nix ]; }; + extraPythonPackages = ps: with ps; [ beautifulsoup4 ]; + skipTypeCheck = true; + # check that stylesheets are pre-processed and served + testScript = '' + from bs4 import BeautifulSoup + server.wait_for_unit("multi-user.target") + server.wait_for_unit("${name}.service") + server.wait_for_open_port(8000) + stdout = server.succeed("curl --fail -H 'Host: example.org' http://localhost") + # the CSS is auto-generated with a hash in the file name + html = BeautifulSoup(stdout, 'html.parser') + css = html.find('link', type="text/css")['href'] + server.succeed(f"curl --fail -H 'Host: example.org' http://localhost/{css}") + ''; + }; +} diff --git a/panel/shell.nix b/panel/shell.nix new file mode 100644 index 0000000..a6bdf20 --- /dev/null +++ b/panel/shell.nix @@ -0,0 +1 @@ +(import ./. { }).shell diff --git a/panel/src/manage.py b/panel/src/manage.py new file mode 100755 index 0000000..8f5606f --- /dev/null +++ b/panel/src/manage.py @@ -0,0 +1,22 @@ +#!/nix/store/px2nj16i5gc3d4mnw5l1nclfdxhry61p-python3-3.12.7/bin/python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'panel.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/panel/src/panel/__init__.py b/panel/src/panel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/panel/src/panel/asgi.py b/panel/src/panel/asgi.py new file mode 100644 index 0000000..4ec4cd7 --- /dev/null +++ b/panel/src/panel/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for panel project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'panel.settings') + +application = get_asgi_application() diff --git a/panel/src/panel/settings.py b/panel/src/panel/settings.py new file mode 100644 index 0000000..d5074c1 --- /dev/null +++ b/panel/src/panel/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for panel project. + +Generated by 'django-admin startproject' using Django 4.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-9*i_k2o@x-c7w%o!*@b88t%n)eh=c2nj2f2m*-=$gwfn#zoso7' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'panel.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'panel.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/panel/src/panel/urls.py b/panel/src/panel/urls.py new file mode 100644 index 0000000..70ca35c --- /dev/null +++ b/panel/src/panel/urls.py @@ -0,0 +1,22 @@ +""" +URL configuration for panel project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/panel/src/panel/wsgi.py b/panel/src/panel/wsgi.py new file mode 100644 index 0000000..7efa959 --- /dev/null +++ b/panel/src/panel/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for panel project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'panel.settings') + +application = get_wsgi_application()