1
0
Fork 0

Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
607b17a10a
rekey for public key lois 2025-03-12 14:34:57 +01:00
7afae84b6c Add pub key Lois 2025-03-12 12:01:50 +01:00
9dd92b4cc1 basic versioned forms
this is still rudimentary since the actual forms are not auto-generated
from the underlying model. the comments show a path towards that.
2025-03-06 11:47:26 +01:00
981ba011ab store versioned configurations
Test manually:

```shell-session
$ manage shell
>>> from panel.models import Configuration
>>> Configuration().value
'{"enable":false,"domain":"fediversity.eu"}'
>>> Configuration().save()
>>> Configuration.objects.first().parsed_value
Configuration(enable=False, domain=<Domain.EU: 'fediversity.eu'>)
```
2025-03-05 09:32:03 +01:00
438f7d280a add django-pydantic-field 2025-03-05 09:00:18 +01:00
cba66d1b8b allow adding extra Python packages 2025-03-05 08:57:55 +01:00
24 changed files with 400 additions and 83 deletions

View file

@ -33,6 +33,14 @@
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDHTIqF4CAylSxKPiSo5JOPuocn0y2z38wOSsQ1MUaZ2"
];
};
Lois = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVQ7yYXr4ZguGWYHZ7v2L3kPmYjaFo46PTgAEviW5D8 lois@mouse"
];
};
};
security.sudo.wheelNeedsPassword = false;

1
keys/contributors/lois Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKVQ7yYXr4ZguGWYHZ7v2L3kPmYjaFo46PTgAEviW5D8 lois@mouse

View file

@ -4,15 +4,11 @@
pkgs ? import sources.nixpkgs {
inherit system;
config = { };
overlays = [ ];
overlays = [ (import ./nix/overlay.nix) ];
},
}:
let
package =
let
callPackage = pkgs.lib.callPackageWith (pkgs // pkgs.python3.pkgs);
in
callPackage ./nix/package.nix { };
package = pkgs.callPackage ./nix/package.nix { };
pkgs' = pkgs.extend (_final: _prev: { panel = package; });

11
panel/nix/overlay.nix Normal file
View file

@ -0,0 +1,11 @@
/**
Nixpkgs overlay adding extra packages needed for the application
*/
_: prev:
let
extraPython3Packages = prev.callPackage ./python-packages { };
in
{
python3 = prev.lib.attrsets.recursiveUpdate prev.python3 { pkgs = extraPython3Packages; };
}

View file

@ -1,13 +1,7 @@
{
lib,
buildPythonPackage,
dj-database-url,
django-compressor,
django-debug-toolbar,
django-libsass,
django_4,
setuptools,
sqlite,
python3,
}:
let
src =
@ -31,7 +25,7 @@ let
include-package-data = true
'';
in
buildPythonPackage {
python3.pkgs.buildPythonPackage {
pname = name;
inherit (pyproject.project) version;
pyproject = true;
@ -42,15 +36,22 @@ buildPythonPackage {
cp ${builtins.toFile "source" pyproject-toml} pyproject.toml
'';
propagatedBuildInputs = [
dj-database-url
django-compressor
django-debug-toolbar
django-libsass
django_4
setuptools
sqlite
];
propagatedBuildInputs =
let
pythonPackages = with python3.pkgs; [
dj-database-url
django-compressor
django-debug-toolbar
django-libsass
django-pydantic-field
django_4
setuptools
];
in
[
sqlite
]
++ pythonPackages;
postInstall = ''
mkdir -p $out/bin

View file

@ -0,0 +1,32 @@
/**
Collection of locally provided Python packages.
Add packages by creating a directory containing a `default.nix` file.
The directory name will be used for the attribute name in package set.
A package crecipe can use Python packages in its argument directly, e.g.
```nix
{ fetchFromGitHub, buildPythonPackage, django_4 }:
{
# ...
}
```
*/
{ pkgs }:
let
callPackage = pkgs.lib.callPackageWith (pkgs // pkgs.python3.pkgs // extraPython3Packages);
extraPython3Packages =
let
dir = toString ./.;
in
with builtins;
listToAttrs (
map (name: {
inherit name;
value = callPackage (dir + "/${name}") { };
}) (attrNames (readDir dir))
);
in
extraPython3Packages

View file

@ -0,0 +1,34 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
django,
pydantic,
setuptools,
}:
buildPythonPackage rec {
pname = "django-pydantic-field";
version = "v0.3.12";
pyproject = true;
src = fetchFromGitHub {
owner = "surenkov";
repo = pname;
rev = version;
hash = "sha256-rlnS67OGljWD8Sbyutb43txAH0jA2+8ju1ntSEP3whM=";
};
nativeBuildInputs = [ setuptools ];
propagatedBuildInputs = [
django
pydantic
];
meta = with lib; {
description = "";
homepage = "https://github.com/${src.owner}/${pname}";
license = licenses.mit;
};
}

View file

@ -0,0 +1,36 @@
import os
import re
import sys
from importlib import import_module
from importlib.util import find_spec
from django.utils.functional import classproperty
from django_pydantic_field import SchemaField
from django.apps import apps
from django.db import models
from django import forms
class Version():
model: models.Model
form: forms.ModelForm
@classproperty
def latest(cls):
current_dir = os.path.dirname(os.path.abspath(__file__))
version_pattern = re.compile(r'v(\d+)\.py$')
versions = []
for filename in os.listdir(current_dir):
match = version_pattern.match(filename)
if match:
versions.append(int(match.group(1)))
return max(versions)
def __init__(self, version: int):
module = import_module(f"{__name__}.v{version}")
self.model = getattr(module, "Configuration")
self.form = getattr(module, "Form")

View file

@ -0,0 +1,44 @@
from django import forms
from django.db import models
from pydantic import BaseModel, Field
from enum import Enum
from django_pydantic_field import SchemaField
class Configuration(BaseModel):
enable: bool = Field(
default=False,
description="Enable the configuration",
)
# XXX: hard-code available apex domains for now,
# they will be prefixed by the user name
class Domain(Enum):
EU = "fediversity.eu"
NET = "fediversity.net"
domain: Domain = Field(
default=Domain.EU,
description="DNS domain where to expose services"
)
# TODO@(fricklerhandwerk):
# generate this automatically from the Pydantic model so this code can go away:
# - add custom types, splicing `forms.Form` into `pydantic.BaseModel` and `forms.Field` into `pydantic.Field` so we can attach Django validators to Pydantic models
# - map primitive or well-known field types as captured by `pydantic.Field` to `forms.Field` constructors, and `BaseModel` to `Form` using `BaseModel.model_fields`
# - use a custom widget that renders nested forms in e.g. a `<div>`
# - likely this also needs overriding `as_p()`, `as_li()`
# more inspiration: https://levelup.gitconnected.com/how-to-export-pydantic-models-as-django-forms-c1b59ddca580
# that work goes through the JSON Schema generated by Pydantic, which seems unnecessary to me, but it follows the same princple
# TODO(@fricklerhandwerk):
# eventually we probably want to validate each field separately,
# so we should also auto-generate views per field that will be called by htmx directives defined in the templates.
# those htmx parts can be generated by each form field using `attrs`
class Form(forms.Form):
enable = forms.BooleanField(required=False)
domain = forms.ChoiceField(choices=[(d.name, d.value) for d in Configuration.Domain])
def to_python(self):
return Configuration(
enable=self.cleaned_data['enable'],
domain=Configuration.Domain[self.cleaned_data['domain']]
)

View file

@ -0,0 +1,26 @@
# Generated by Django 4.2.16 on 2025-03-05 08:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Configuration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField(default=1, help_text='Configuration schema version')),
('value', models.JSONField(help_text='Stored configuration value', null=True)),
('operator', models.ForeignKey(help_text='Operator who owns the configuration', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='configurations', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

29
panel/src/panel/models.py Normal file
View file

@ -0,0 +1,29 @@
from django.db import models
from django.contrib.auth.models import User
from panel.configuration import Version
from pydantic import BaseModel
class Configuration(models.Model):
operator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name="configurations",
help_text="Operator who owns the configuration",
)
version = models.PositiveIntegerField(
help_text="Configuration schema version",
default=Version.latest,
)
value = models.JSONField(
help_text="Stored configuration value",
default=Version(Version.latest).model().model_dump_json,
)
@property
def parsed_value(self) -> BaseModel:
return Version(self.version).model.model_validate_json(self.value)

View file

@ -58,6 +58,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_pydantic_field',
'debug_toolbar',
'compressor',
]

View file

@ -26,6 +26,9 @@
<li>
<a href="{% url 'service_list' %}">Services</a>
</li>
<li>
<a href="{% url 'configuration_form' %}">Configuration</a>
</li>
{% load custom_tags %}
<li>

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<form method="post" enctype="multipart/form-data" action="{% url 'configuration_form' %}">
{% csrf_token %}
{{ form.as_p }}
<button class="button" disabled>Deploy</button>
<button class="button" type="submit" >Save</button>
</form>
<p><sub>Configuration schema version {{ version }}</sub></p>
{% endblock %}

View file

@ -25,4 +25,5 @@ urlpatterns = [
path("", include("django.contrib.auth.urls")),
path("account/", views.AccountDetail.as_view(), name='account_detail'),
path("services/", views.ServiceList.as_view(), name='service_list'),
path("configuration/", views.ConfigurationForm.as_view(), name='configuration_form'),
]

View file

@ -1,7 +1,13 @@
from enum import Enum
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.views.generic import TemplateView
from django.views.generic import DetailView
from django.views.generic import TemplateView, DetailView
from django.views.generic.edit import FormView
from panel import models
from panel.configuration import Version
class Index(TemplateView):
template_name = 'index.html'
@ -14,3 +20,69 @@ class AccountDetail(LoginRequiredMixin, DetailView):
class ServiceList(TemplateView):
template_name = 'service_list.html'
class ConfigurationForm(LoginRequiredMixin, FormView):
template_name = 'configuration_form.html'
success_url = reverse_lazy('configuration_form')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["version"] = self.get_object().version
return context
def get_form_class(self):
config = self.get_object()
return Version(config.version).form
def get_object(self):
"""Get or create the configuration object for the current user"""
obj, created = models.Configuration.objects.get_or_create(
operator=self.request.user)
return obj
# TODO(@fricklerhandwerk):
# this should probably live somewhere else
def convert_enums_to_names(self, data_dict):
"""
Recursively convert all enum values in a dictionary to their string names.
This handles nested dictionaries and lists as well.
Needed for converting a Pydantic `BaseModel` instance to a `Form` input.
"""
if isinstance(data_dict, dict):
result = {}
for key, value in data_dict.items():
if isinstance(value, Enum):
# Convert Enum to its name
result[key] = value.name
elif isinstance(value, (dict, list)):
# Recursively process nested structures
result[key] = self.convert_enums_to_names(value)
else:
# Keep other values as is
result[key] = value
return result
elif isinstance(data_dict, list):
# Process each item in the list
return [self.convert_enums_to_names(item) for item in data_dict]
elif isinstance(data_dict, Enum):
# Convert single Enum value
return data_dict.name
else:
# Return non-dict, non-list, non-Enum values as is
return data_dict
def get_initial(self):
initial = super().get_initial()
config = self.get_object()
config_dict = config.parsed_value.model_dump()
initial.update(self.convert_enums_to_names(config_dict))
return initial
def form_valid(self, form):
obj = self.get_object()
obj.value = form.to_python().model_dump_json()
obj.save()
return super().form_valid(form)

View file

@ -1,16 +1,18 @@
age-encryption.org/v1
-> ssh-ed25519 Jpc21A 5HeEmeKYFHH3Wko4afd4SG2LcQfxvvCv81dnfUMkCzw
B4CuJx6eGFU3bdz4y/V1M+XW5lfao99Lig1+rrq//9s
-> ssh-ed25519 BAs8QA AJtZJIwSZTFXccbPVrXwGsqD3CT7n7PRTCpkE3OQiQs
gVlxB8ABw83lqOFr2rBSh2Y212/yr1SheZSjW3d2bqw
-> ssh-ed25519 ofQnlg jZtGq/Xqr9pXMvGJqIBAeIlB1BxXyxR9HKljETvkqTg
6RikmxoLwUOy7IOp4V9zTCC8UMWyjS5KyLxWXvuuxC0
-> ssh-ed25519 COspvA 0tlSQU5/iVenCwuu9CPK5EQC39FYfCho8bmBOCyZGXw
1dOShM2CM1fJqqV1e1LgEfmxLq+e9tt71eWhvSUixsE
-> ssh-ed25519 1MUEqQ O/1idQox9vPu19mdLyLoqLQWImBg1pC+PPMfItQDhz0
eBkqIelOFQ+CUPSwPdI5SsQhBZ3JI9gD0b3SVUIK9ag
-> ssh-ed25519 Fa25Dw ZT+LO4ml0P6DeKi9u7QBP9e/UWkQRN2gDYJ6g1bbg2s
CNULJZIGfU2bnjzQ28Ozkhs6HlM0964vDLBi5hRirPQ
--- aHFDJRy4XUQCLbDF9Yeb8iXFvEutu3PS0oM5l3oW3r8
Ž×hí¨¢;ÎB—
<EFBFBD>e)¿÷qÝo©ùIºÇ¹Á­FÁïY½-çX?kà
-> ssh-ed25519 Jpc21A aY4iHQUrjmuTgBkIwG3vg8XBK458PWXpiZ5E/m/UHnU
VCuYTllN1tW5RzIigPiN+p/W5uI3Urh0J3IpLXDL1H4
-> ssh-ed25519 BAs8QA pP6PTq+vp+fP1oOv3ep4dWspwANxj9DSS94t0a+1Q1o
tpqUnXqp/wmfXFMe2iXRRda+JmW5ZgypduKOS8meCJw
-> ssh-ed25519 ofQnlg om0geQk3YR3+WXsPdIC46wL02M57Qror6MD/PynrTAs
Yj5xcXf203kW70SndVBBagh62yAn0T41lzg3ReD1kEs
-> ssh-ed25519 COspvA bvBwdWb0kO89Myw3u2heNwd/4vN1+4tiWjNyoF3t+hM
eCX26mAJy8stuYrRijqicgODAlyKt3zjeZchCkBpfOI
-> ssh-ed25519 2XrTgw wQMvYCYmw4Iql/EmUSW5HG0fz4POn/VIZrMsL5vuUBc
RaDLMF7OadInlWbQ70/5gpQ4tpwae8i74hu5Wftf6Yg
-> ssh-ed25519 1MUEqQ ygipOVN6+Z09bfMZFdHRT8Wx+H4Ml0YM0w0vrUANugA
XvtQMpD+iEpEKGwPVcq9mAftfaRlOJXTXUdcqyvVn9w
-> ssh-ed25519 Fa25Dw qc7z4aL3dHjoOTdPBVm4q6V458BuTGLMekP5Hlk0bk0
kZuabCaiH7DBhO8mDta8AXUxH65Cpm8u9P9ntw8A5pI
--- zpGb6Td6MdLKxE3mkK1a7JqBH77th6045mcdGIsNth0
ÿr*"DQÏÛ-©·í½`ÀU¯…µŠ`ËÒ¹Æu{<7B>ªC<C2AA>]GZ(p
Ü×Uf‰

View file

@ -1,16 +1,18 @@
age-encryption.org/v1
-> ssh-ed25519 Jpc21A KDoPj7ywZDlw2tVIWBtuypVarOUdBOrLmuBdoWV9FGY
gnEtIo5tfBAQeDztEK4i6+ZHx02WG/cqWjTJ/BNS3OM
-> ssh-ed25519 BAs8QA 8nmAEHH9zLjrWdjSxlW6c3xWP/kBZQ+P7SmJo5umC1o
9WLBny4Hk1llZB/mwQ9nzG80atCHkOtqMElJuCL7yt8
-> ssh-ed25519 ofQnlg qEiN2Xn5P9UhzosnZYwsk/pv0Gwg+qDQIjEi7Y6R+ls
4Ka0LaC8jWwzkMdwHVT4KW8ALGU7I2XoWChSkX/4ibA
-> ssh-ed25519 COspvA fcT8kt8mukX73QHcH+a2sbsmUc8U3U6fKM2n5U3+Szc
TC9JFZo6YWohCskzy0vCSSKVctnpLQUlyMfC+08MDWM
-> ssh-ed25519 1MUEqQ x+lo3jlXQc5hJUVmIk594j8RZkgHAfxoivWqte/Nd0o
rmC0Y2yL/d3iZH3LKtyY5ggY3bza4+F0xJBLXPsH2yU
-> ssh-ed25519 Fa25Dw McE5h1x23+JjZRI2IJ9FvElghshij/+cZxT82RorjkM
vMZSy1a4BE0XEaTTVBAkbBViCuYcPOsV/FOnEn1Sh8I
--- cP4vWMTBTfLLh8Xm0D/4ArTE4xmq2w4/kj8VijWSGt4
þ½HE4jDËr&üo<C3BC>Ñ«·럱tER+áÂ8[¶ý
ò ÐS„òż%jilÚ2
-> ssh-ed25519 Jpc21A ExqTXUYWuoVsdKwuWzCD72NctIpGvAF4QknTU04he2M
rN48eYUwPJtTc/UBpB79FayC0W2UnrKdjFTdWKShtc0
-> ssh-ed25519 BAs8QA xODgENkmP/KjT6IGiMW3cBkdrY+o5rbAGywY7Fx99EY
DNAlVBdObTlgeVhKYtzPv46RCtn7zNm1aURWBOpBXEs
-> ssh-ed25519 ofQnlg cEM500igumTfcCWWCH55z22Pp8QqLcqmjTD5e1lp1T0
oKBWnaFpaFiEGf51fPqObAkRfRE4gywjQrYGB9kygUs
-> ssh-ed25519 COspvA gQbazYgzv8oBeND0VtZ3P241kZM9klO2qysjkc20CFQ
nW558CrEvtuUEpLo6EUeUTVK6EVUXbNZwP4+GLVVH3A
-> ssh-ed25519 2XrTgw QlyQRFaRkniJ4BrJEVEP5muS+POPdKSmpS5u4ORiRTc
/UeO72Y/U9aml3S2s9wE9HUIXPoR+6GDSXF+PT141Qg
-> ssh-ed25519 1MUEqQ oMz1Cq68FuE1jm63H2Rfr/WqhkCeJ2SQrVtk88FBYGo
ou2ZRPuGTlLxsV/DhXoRUhqaQq9Ub+1ZdOcqqazrBZM
-> ssh-ed25519 Fa25Dw USp87LMAo6HfD6gHdA+lrRlwHzKtMwXGjELImsQ7onk
g8GvPArugT7KIdpgpfWjHFUNyXgL9rRuymQg/RIiQJw
--- 6oCFkdV4DmaxMe7lDoDSKgtCKySGqVqrbDv8aRa/h/o
»W)Eí¨{ä žÑrÛ
à3OVx)ö¶QéȬ6‰b¶¶O·O¸Ü—ÇÈ‘%Ë

Binary file not shown.

View file

@ -1,14 +1,17 @@
age-encryption.org/v1
-> ssh-ed25519 BAs8QA 0TS+HcjtKeUAsLyzrsnCbj53GAq7pvXF12yQSxaxuFs
IjmmZV2Zh4cwj1+7r/fAKnuftpl46P5fO6SxtRMevIM
-> ssh-ed25519 ofQnlg b4maqJdxyyi7b3arE9sxySwqeFjFlC6oT+PgQjIGj0Y
Gi5d4sJa0te/MsbkKYIOByIQ+TXBgu7hh2InES1pvXw
-> ssh-ed25519 COspvA RiXEgUbPi3vep/8fM/RuRUYhCfBHO1XZt6Ov3WPnkV0
tTMLMb92ct5Zkqt42y8R3UI/zblAbsuEammavVcwGOU
-> ssh-ed25519 1MUEqQ XxxSvZrI9S6FI7CwYOSKDlfVBdLTur7/07Sm2HHLJwg
iW5PduiY/7N2kSJpBzmfnt8aNWKPfLZ43Kq6fyLeydw
-> ssh-ed25519 ChtTUw zixDXeL07d4+pzFBSt/1f8yB+QxXOMv6sE6h469YzVs
rSC9S8v9gmtBw9FMKLg0h0muCmfMRuFD24JpTVw3ALc
--- vf2SwG1rpxjri3TGARwdMBc/mccj6RSTgf54YeQeR/8
În9K±¼‰îÁä ÞÈ9÷y¼¿«dMÈdWn@õYç0ì.ü½ž1uÜoÚ«¨Á¾jý<6A>iý`
<EFBFBD>;1
-> ssh-ed25519 Jpc21A jzJ5wTSLBsJ0DxelUDsT7BxM9qc73hPsCvB/1R3qGC4
5giHjKIjnBVmn4NAtGLSIgKQGts9kOc+EPS6AKugn1s
-> ssh-ed25519 BAs8QA J/y7P+A4z1iETfzta1UBf2AnKOD5lFTuGRo7EjWF4Qw
zUBV+byTPL2kbKS6ZCbu8mk9Lp/fq1iF2Sii0XHxB5I
-> ssh-ed25519 ofQnlg w1RFmJnfOSpKu9tiVvPy3WdLVGO1vUdPW1exb+M7xEM
jk6/yzZMJCvzW1/5T+DKze+PxduLcWDrBGcVN6k5Vfo
-> ssh-ed25519 COspvA DEBQL0y8GQpdib3WUkj1a/FVLVF8aMAZ77MdxLqJkVE
V96fUeVJD3v2/V1H5GLo5YIKlIU7fuyYBr7F48gzJ60
-> ssh-ed25519 2XrTgw ixdrdSfgH9Ch1Y4aflWP1QG6khhKN8mD1jFyOXhDTyA
Kd8QfZ1IqWqiaAY4C8+J/AE6vqIRNAZtU6jbIjRYvCA
-> ssh-ed25519 1MUEqQ Mwda57DHcYYsBJr0L6q9IcO4xyr6NvfTlXyRK/sfjWk
WX2CsIJBJL1Q9ZMsLzLS2s2L1b+7Mm0WXF+PqRVh1p4
-> ssh-ed25519 ChtTUw trZu9wftz3Hjd2xTKf8TYM9oLpNBcwQX47Pfi/cetjE
5F1McxV1iLHyIVYdPDeR2twB5aq1fz9g/nrjAF5ys2w
--- 7p/n8TyrrtmVay+dPSX+bdlEqzFByuWk/6FyKFKh758
I€BãUÕ3M:ƶ,²<>‡¿?-{ü7Ðhý3²#7Œ§ãö'Ôg”~L&ƒ)˜=<3D>bB:9hËA¿TO

View file

@ -1,16 +1,18 @@
age-encryption.org/v1
-> ssh-ed25519 Jpc21A oYtKMwPB/M034KiJAmYsjg46+IC525Uul+VoFzHWwDE
v6YVVia1UVBwd4RoJsvozbCdy/3jYHMmFKyiDfaGR+g
-> ssh-ed25519 BAs8QA 42kHHxx7k6jkazovkEIJ+uhOKr485RCQgvTXKY/+eWQ
4YAGNgdsWk7dC32SMRyicifvrkz/wCor/98lCQ2Kl/I
-> ssh-ed25519 ofQnlg nfPgeBQ9674MP0dKJLLkvcJSTSAOLs0cnRc/Pp2Z1E8
Lm+GLkCAQ2B9wxVIaDjw18Ru3KQat79mJVF8nLFb7+A
-> ssh-ed25519 COspvA u07qlzPhj4e6qoilGLa9L7x2QMgjD6D40ROeUKiFGyk
gpPwOMfZM/DMxnzg67mxZCyetSxJEmqpiKs1WqdLO7E
-> ssh-ed25519 1MUEqQ x8aGrPIHJymzUAXrQHeYrG3SeeEkc6bQcRcKCdHmJH0
5C3tswXVRpEzj+Plkt+F4DLhhX/m1jJoKv9iwIWfxlk
-> ssh-ed25519 dgBsjw 4pSsRKJIJWaDAOPrdnNM5O/u0mh99ZAniUHjjiT59jM
EhEjGusUWQ//3r62GklyjuPSS3VXfzivDkYhrAh4tRE
--- fr5CmQ26pYk4qLbSXyVjEP20BYeALQ0sD5mPW0JsJpM
æ&E9êùñIÙð¾T ÄŽ4êƶ§es4Fø·ÃÓ;v7ÇÐIu
|åQݸ°ˆVÅò®¿Æ\­,K£Íý¼ŠÐ·n­ùí`WÉ l2RÓªä¤TGN$
-> ssh-ed25519 Jpc21A vsNSibhHXdlRVArHkFqPy2vapvpo+lfs6QNESfZHk0c
ArMZEQCONAIGQwyEh/QkJ3m5Bnru2/A1fQdJNtPraII
-> ssh-ed25519 BAs8QA 7vfPIUymPXpfX7vhUyNVqBmTllXgJ99gCSHOgWH66HA
gaueu0eHyqY+VAkNIzPb/aLQ1VG13kSpth2tJfhK7sU
-> ssh-ed25519 ofQnlg iaJY3mcaKyLjTAqNVnzyivIVRwXxxFzP0ru35s/TU3Q
mfKliFvPT+hEOpOPtkdR/UEmEadXZGpQ8+iWg+S/Q8c
-> ssh-ed25519 COspvA +LC5rnZIS2R5DA3mIyeo2hR45mcBwNUjRS051qN+q2w
yLYl5g8o29ApSCn+H4Df8P8y+eFv2Hbj6b/nHrzFMdA
-> ssh-ed25519 2XrTgw dG9hmRFpaCBgaoHIkWmJM1Ls/mBqnV5gueGjCTEmRE0
YkIQDWAwpr3pjjFozGEa3+4+WqJan0KQzUeYNxRjUPc
-> ssh-ed25519 1MUEqQ 0Mtf2NGpVP3TYuFGrTPyQM+h6PjpgJNwW9amz1w7h0w
J8RM+vl/e8JifUP3dqwH5L9AUqu24pALv6wqxaNhy3g
-> ssh-ed25519 dgBsjw 9Y1n4J8E5T022V8QCApLykKoX56Zto8eLiy5KZvPuR4
3piVQigR7rFry43YTTHmXkBSDIAFa/ife1Vuq6/3ubk
--- 5Mr3Xe9RF1mneoWBno4SVkNqHx76EilFG0UvsHbqRQo
çì&­mu°.ÄåNêäã:%œ…<C593>žPî  ]`L<<3C>Î ­ ìÜñÅ]÷ÏÂe/T(s"a×Å<C397>½-ýû$MA¿%LÄÉœv
óm”‡@+9£¼¿÷%oa/¹K*³å&

Binary file not shown.

Binary file not shown.