lldap: automatic provision + system users + stalwart + whatever

This commit is contained in:
eyjhb 2025-02-03 18:15:53 +01:00
parent 4a0129585a
commit 82caf96d36
Signed by: eyjhb
GPG key ID: 609F508E3239F920
19 changed files with 405 additions and 285 deletions

View file

@ -81,6 +81,11 @@ in {
};
};
# setup lldap user for authelia that can send emails
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
authelia = llib.mkProvisionUserSystem "authelia" config.age.secrets.authelia-smtp-password.path;
});
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;

View file

@ -1,148 +0,0 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) types;
cfg = config.mine.lldap_provision;
# helpers
_configFile = {
user_attributes = lib.mapAttrsToList (n: v: v) cfg.user_attributes;
group_attributes = lib.mapAttrsToList (n: v: v) cfg.group_attributes;
users = lib.mapAttrsToList (n: v: v // {
user_id = if v ? user_id then v.user_id else n;
}) cfg.users;
groups = lib.mapAttrsToList (n: v: v // {
display_name = if v ? display_name then v.display_name else n;
}) cfg.groups;
};
configFile = (pkgs.formats.json {}).generate "lldap-declarative.json" _configFile;
# opts
optsAttributes = { name, config, ... }: {
options = {
name = lib.mkOption {
type = types.str;
default = name;
description = "The name of the attribute";
};
attributeType = lib.mkOption {
type = types.enum [ "STRING" "INTEGER" "JPEG_PHOTO" "DATE_TIME" ];
description = "Type of the attribute";
};
isList = lib.mkOption {
type = types.bool;
default = false;
description = "Is this attribute a list (multiple values for this attribute)";
};
isEditable = lib.mkOption {
type = types.bool;
default = false;
description = "Should the user be able to edit this value?";
};
isVisible = lib.mkOption {
type = types.bool;
default = false;
description = "Should the user be able to see this value?";
};
};
};
in {
options = {
mine.lldap_provision = {
enable = lib.mkEnableOption "LLDAP declarative setup";
url = lib.mkOption {
type = types.str;
default = config.services.lldap.settings.http_url;
description = "URL for the LLDAP instance";
};
username = lib.mkOption {
type = types.str;
description = "Username to use when signing into lldap";
};
passwordFile = lib.mkOption {
type = types.path;
description = "Path for the password file to authenticate the user";
};
group_attributes = lib.mkOption {
type = types.attrsOf (types.submodule optsAttributes);
default = {};
};
user_attributes = lib.mkOption {
type = types.attrsOf (types.submodule optsAttributes);
default = {};
};
users = lib.mkOption {
type = types.attrsOf types.anything;
default = {};
example = {
user1 = {
password = "env:LLDAP_USER1_PASSWORD";
mail = "something@something.dk";
foo = "value for user attribute foo";
bar = "value for user attribute bar";
groups = [ "group1" "group2" ];
};
user2 = { user_id = "superuserawesome"; };
};
};
groups = lib.mkOption {
type = types.attrsOf types.anything;
default = {};
example = {
base_member = {
foo = "value for group attribute foo";
bar = "value for group attribute bar";
};
system = {
display_name = "system_service - override display_name";
};
testgroup = {};
};
};
};
};
config = lib.mkIf cfg.enable {
systemd.services.lldapsetup = {
description = "setup lldap declaratively";
wantedBy = [ config.systemd.services.lldap.name "multi-user.target" ];
after = [ config.systemd.services.lldap.name ];
environment = {
LLDAP_URL = cfg.url;
LLDAP_USERNAME = cfg.username;
LLDAP_PASSWORD = "file:${cfg.passwordFile}";
};
path = with pkgs; [
lldap
];
script = let
pythonEnv = pkgs.python3.withPackages(ps: with ps; [ gql aiohttp requests ]);
pythonDir = pkgs.runCommand "lldap-bootstrap" {} ''
mkdir -p $out/bootstrap
cp -a ${./.}/. $out/bootstrap
'';
in ''
cd ${pythonDir}
${pythonEnv}/bin/python -m bootstrap.main ${configFile}
'';
};
};
}

View file

@ -19,10 +19,88 @@ index 6f42473..b3746a1 100644
&config,
'';
whoamiPatch = pkgs.writeText "lldap-whoami.patch" ''
diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs
index 7257c31..feda03c 100644
--- a/server/src/infra/ldap_handler.rs
+++ b/server/src/infra/ldap_handler.rs
@@ -26,7 +26,7 @@ use ldap3_proto::proto::{
LdapDerefAliases, LdapExtendedRequest, LdapExtendedResponse, LdapFilter, LdapModify,
LdapModifyRequest, LdapModifyType, LdapOp, LdapPartialAttribute, LdapPasswordModifyRequest,
LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, LdapSearchResultEntry,
- LdapSearchScope,
+ LdapSearchScope, OID_PASSWORD_MODIFY, OID_WHOAMI,
};
use std::collections::HashMap;
use tracing::{debug, instrument, warn};
@@ -181,7 +181,7 @@ fn root_dse_response(base_dn: &str) -> LdapOp {
LdapPartialAttribute {
atype: "supportedExtension".to_string(),
// Password modification extension.
- vals: vec![b"1.3.6.1.4.1.4203.1.11.1".to_vec()],
+ vals: vec![OID_PASSWORD_MODIFY.as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "supportedControl".to_string(),
@@ -204,6 +204,11 @@ fn root_dse_response(base_dn: &str) -> LdapOp {
atype: "isGlobalCatalogReady".to_string(),
vals: vec![b"false".to_vec()],
},
+ LdapPartialAttribute {
+ atype: "supportedExtension".to_string(),
+ // whoami extension.
+ vals: vec![OID_WHOAMI.as_bytes().to_vec()],
+ },
],
})
}
@@ -413,16 +418,33 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
#[instrument(skip_all, level = "debug")]
async fn do_extended_request(&mut self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
- match LdapPasswordModifyRequest::try_from(request) {
- Ok(password_request) => self
+ if let Ok(password_request) = LdapPasswordModifyRequest::try_from(request) {
+ return self
.do_password_modification(&password_request)
.await
- .unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)]),
- Err(_) => vec![make_extended_response(
- LdapResultCode::UnwillingToPerform,
- format!("Unsupported extended operation: {}", &request.name),
- )],
+ .unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)]);
}
+
+ if request.name == OID_WHOAMI {
+ let dn = self
+ .user_info
+ .as_ref()
+ .map(|user_info| {
+ format!(
+ "dn:uid={},ou=people,{}",
+ user_info.user.clone().into_string(),
+ self.ldap_info.base_dn_str
+ )
+ })
+ .unwrap_or_default();
+
+ return vec![make_extended_response(LdapResultCode::Success, dn)];
+ }
+
+ return vec![make_extended_response(
+ LdapResultCode::UnwillingToPerform,
+ format!("Unsupported extended operation: {}", &request.name),
+ )];
}
async fn handle_modify_change(
'';
pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {};
in {
imports = [
# ./test.nix
./provision.nix
];
environment.systemPackages = [
@ -33,7 +111,7 @@ in {
enable = true;
package = pkgs.lldap.overrideAttrs (old: {
patches = old.patches ++ [ resetPasswordStartPatch ];
patches = old.patches ++ [ resetPasswordStartPatch whoamiPatch ];
});
settings = {
@ -100,22 +178,29 @@ in {
users = {
admin = "admin";
bind = "bind_user";
};
# bind = "bind_user";
} // (lib.mapAttrs (n: v: v.user_id) config.services.lldap.provision.users);
groups = {
admin = "lldap_admin";
member = "base_member";
system = "system_service";
system_email = "system_email";
};
password_manager = "lldap_password_manager";
strict_readonly = "lldap_strict_readonly";
# system = "system_service";
# system_email = "system_email";
} // (lib.mapAttrs (n: v: v.display_name) config.services.lldap.provision.groups);
ou = {
groups = "groups";
users = "people";
};
attr = {
attr = let
toCamelCase = w: let
parts = lib.splitString "_" w;
cap = lib.imap0 (i: v: if i == 0 then v else (lib.toUpper (lib.substring 0 1 v)) + (lib.substring 1 (lib.stringLength v) v));
in lib.concatStrings (cap parts);
in {
uid = "uid";
firstname = "givenName";
lastname = "sn";
@ -124,9 +209,9 @@ in {
groupname = "cn";
# custom
member_email = "member_email";
mail_disk_quota = "mail_disk_quota";
};
# member_email = "member_email";
# mail_disk_quota = "mail_disk_quota";
} // (lib.mapAttrs (n: v: toCamelCase v.name) config.services.lldap.provision.user_attributes);
age_secret = config.age.secrets.lldap-bind-user-pass.path;
};
@ -156,6 +241,31 @@ in {
mkAnd = v: { type = "and"; values = v; };
mkOr = v: { type = "or"; values = v; };
# mkProvision helpers for creating users
mkProvisionEmail = name: "${name}@${config.mine.shared.settings.domain}";
mkProvisionUserNormal = name: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
membermail = mkProvisionEmail name;
mail = "env:EMAIL_${lib.toUpper name}";
groups = [ lconfig.groups.member ];
membermaildiskquota = 100*1024*1024; # mb
});
mkProvisionUserSystem = name: password_file: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
membermail = mkProvisionEmail name;
password = "file:${password_file}";
groups = [ lconfig.groups.system_mail lconfig.groups.system_service ];
membermaildiskquota = 10*1024*1024; # mb
});
mkProvisionUserAdmin = name: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
membermail = mkProvisionEmail name;
groups = [ lconfig.groups.admin lconfig.groups.member ];
membermaildiskquota = 100*1024*1024; # mb
});
};
mine.shared.meta.lldap = {

View file

@ -104,7 +104,7 @@ class LLDAPGroups:
if isinstance(v, str):
v = [v]
insertAttributes.append({"name": to_snakecase(k), "value": v})
insertAttributes.append({"name": k, "value": v})
ds = DSLSchema(self._client.schema)
query = dsl_gql(

View file

@ -22,7 +22,7 @@ from .attributes import LLDAPAttributes
import logging
logging.basicConfig()
logging.getLogger("lldapbootstrap").setLevel(logging.DEBUG)
logging.getLogger("bootstrap").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

View file

@ -95,7 +95,7 @@ class LLDAPUsers:
if isinstance(v, str):
v = [v]
insertAttributes.append({"name": to_snakecase(k), "value": v})
insertAttributes.append({"name": k, "value": v})
ds = DSLSchema(self._client.schema)
query = dsl_gql(

View file

@ -0,0 +1,166 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) types;
cfg = config.services.lldap;
format = pkgs.formats.json { };
# helpers
_configFile = {
user_attributes = lib.mapAttrsToList (n: v: v) cfg.provision.user_attributes;
group_attributes = lib.mapAttrsToList (n: v: v) cfg.provision.group_attributes;
users = lib.mapAttrsToList (n: v: v) cfg.provision.users;
groups = lib.mapAttrsToList (n: v: v) cfg.provision.groups;
# users = lib.mapAttrsToList (n: v: v // {
# user_id = if v ? user_id then v.user_id else n;
# }) cfg.users;
# groups = lib.mapAttrsToList (n: v: v // {
# display_name = if v ? display_name then v.display_name else n;
# }) cfg.groups;
};
configFile = format.generate "lldap-declarative.json" _configFile;
# opts
optsAttributes = { name, config, ... }: {
options = {
name = lib.mkOption {
type = types.str;
default = name;
description = "The name of the attribute";
};
attributeType = lib.mkOption {
type = types.enum [ "STRING" "INTEGER" "JPEG_PHOTO" "DATE_TIME" ];
description = "Type of the attribute";
};
isList = lib.mkOption {
type = types.bool;
default = false;
description = "Is this attribute a list (multiple values for this attribute)";
};
isEditable = lib.mkOption {
type = types.bool;
default = false;
description = "Should the user be able to edit this value?";
};
isVisible = lib.mkOption {
type = types.bool;
default = false;
description = "Should the user be able to see this value?";
};
};
};
optsUser = { name, config, ... }: {
freeformType = format.type;
options = {
user_id = lib.mkOption {
type = types.str;
default = name;
description = "The name of the user";
};
};
};
optsGroup = { name, config, ... }: {
freeformType = format.type;
options = {
display_name = lib.mkOption {
type = types.str;
default = name;
description = "The display name of the group";
};
};
};
in {
options = {
services.lldap = {
provisionUsername = lib.mkOption {
type = types.str;
description = "Username to use when signing into lldap";
};
provisionPasswordFile = lib.mkOption {
type = types.path;
description = "Path for the password file to authenticate the user";
};
provision = {
group_attributes = lib.mkOption {
type = types.attrsOf (types.submodule optsAttributes);
default = {};
};
user_attributes = lib.mkOption {
type = types.attrsOf (types.submodule optsAttributes);
default = {};
};
users = lib.mkOption {
type = types.attrsOf (types.submodule optsUser);
default = {};
example = {
user1 = {
password = "env:LLDAP_USER1_PASSWORD";
mail = "something@something.dk";
foo = "value for user attribute foo";
bar = "value for user attribute bar";
groups = [ "group1" "group2" ];
};
user2 = { user_id = "superuserawesome"; };
};
};
groups = lib.mkOption {
type = types.attrsOf (types.submodule optsGroup);
default = {};
example = {
base_member = {
foo = "value for group attribute foo";
bar = "value for group attribute bar";
};
system = {
display_name = "system_service - override display_name";
};
testgroup = {};
};
};
};
};
};
config = lib.mkIf (cfg.enable && cfg.provision != {}) {
systemd.services.lldapsetup = {
description = "setup lldap declaratively";
wantedBy = [ config.systemd.services.lldap.name "multi-user.target" ];
after = [ config.systemd.services.lldap.name ];
environment = {
LLDAP_URL = "${cfg.settings.http_url}:${builtins.toString cfg.settings.http_port}";
LLDAP_USERNAME = cfg.provisionUsername;
LLDAP_PASSWORD = "file:${cfg.provisionPasswordFile}";
};
path = with pkgs; [
lldap
];
script = let
pythonEnv = pkgs.python3.withPackages(ps: with ps; [ gql aiohttp requests ]);
pythonDir = pkgs.runCommand "lldap-bootstrap" {} ''
mkdir -p $out/bootstrap
cp -a ${./bootstrap}/. $out/bootstrap
'';
in ''
cd ${pythonDir}
${pythonEnv}/bin/python -m bootstrap.main ${configFile}
'';
};
};
}

View file

@ -0,0 +1,68 @@
{ config, lib, ... }:
{
imports = [
./module
];
services.lldap = {
provisionUsername = "admin";
provisionPasswordFile = config.age.secrets.lldap-admin-user-pass.path;
provision = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
# users
users = {
# normal users
testusername = {
membermail = "env:EMAIL_EMAIL0";
groups = [ config.services.lldap.provision.groups.system_mail.display_name ];
};
user1 = llib.mkProvisionUserNormal "thief420";
# admin users
admin = llib.mkProvisionUserAdmin "admin";
eyjhb = llib.mkProvisionUserAdmin "eyjhb";
rasmus = llib.mkProvisionUserAdmin "rasmus";
# system users - defined in each service
# should not be done here
# bind user
bind = {
user_id = "bind_user";
groups = [ lconfig.groups.password_manager lconfig.groups.strict_readonly ];
};
};
# groups
groups = {
"base_member" = {};
"system_service" = {};
"system_mail" = {};
};
# attributes
group_attributes = {
group_foo = {
attributeType = "STRING";
isEditable = true;
isVisible = true;
};
};
user_attributes = {
membermail = {
attributeType = "STRING";
isEditable = false;
isVisible = true;
};
membermaildiskquota = {
attributeType = "INTEGER";
};
};
});
};
systemd.services.lldapsetup.serviceConfig.EnvironmentFile = config.age.secrets.lldap-user-emails-env.path;
}

View file

@ -1,98 +0,0 @@
{ config, lib, ... }:
let
mkEmail = name: "${name}@${config.mine.shared.settings.domain}";
mkUserNormal = name: {
user_id = name;
member_email = mkEmail name;
mail = "env:EMAIL_${lib.toUpper name}";
groups = [ "base_member" ];
mail_disk_quota = 100*1024*1024; # mb
};
mkUserSystem = name: password_file: {
user_id = name;
member_email = mkEmail name;
password = "file:${password_file}";
# TODO: remove base_member in the future, or have
# more granular controls for emails and shit
groups = [ "base_member" "system_service" ];
mail_disk_quota = 10*1024*1024; # mb
};
mkUserAdmin = name: {
user_id = name;
member_email = mkEmail name;
groups = [ "base_member" "lldap_admin" ];
mail_disk_quota = 100*1024*1024; # mb
};
in {
imports = [
./bootstrap/lldap-state-module.nix
];
mine.lldap_provision = {
enable = true;
url = config.mine.shared.meta.lldap.url;
username = "admin";
passwordFile = config.age.secrets.lldap-admin-user-pass.path;
# username = "testusername";
# passwordFile = ./test.txt;
group_attributes = {
group_foo = {
attributeType = "STRING";
isEditable = true;
isVisible = true;
};
};
user_attributes = {
member_email = {
attributeType = "STRING";
isEditable = false;
isVisible = true;
};
mail_disk_quota = {
attributeType = "INTEGER";
};
};
groups = let
gs = [
"base_member"
"system_service"
"system_email"
];
in lib.listToAttrs (lib.forEach gs (v: lib.nameValuePair v { display_name = v; }));
users = {
# normal users
testusername = {
member_email = "env:USER1_EMAIL";
};
user1 = mkUserNormal "thief420";
# admin users
admin = mkUserAdmin "admin";
eyjhb = mkUserAdmin "eyjhb";
rasmus = mkUserAdmin "rasmus";
# system users
authelia = mkUserSystem "authelia" config.age.secrets.authelia-smtp-password.path;
wger = mkUserSystem "wger" config.age.secrets.wger-ldap-pass.path;
# bind user
bind_user = {
groups = [ "lldap_password_manager" "lldap_strict_readonly" ];
};
};
};
systemd.services.lldapsetup.environment = {
USER1_EMAIL = "eyjhbbbbbbb@fricloud.dk";
EMAIL_THIEF420 = "someemail@gmail.com";
};
}

View file

@ -50,7 +50,10 @@ in {
filter = let
_mkFilter = attrs: ph: config.mine.shared.lib.ldap.mkFilter (lconfig: llib:
llib.mkAnd [
(llib.mkGroup lconfig.groups.member)
(llib.mkOr [
(llib.mkGroup lconfig.groups.member)
(llib.mkGroup lconfig.groups.system_mail)
])
(llib.mkOr (lib.forEach attrs (v: llib.mkSearch v ph)))
]
);
@ -58,22 +61,21 @@ in {
attrs = config.mine.shared.settings.ldap.attr // { emailAlias = "mailAlias"; emailList = "mailList"; };
in {
name = _mkFilter [ attrs.uid ] "?";
email = _mkFilter [ attrs.email attrs.emailAlias attrs.emailList ] "?";
verify = _mkFilter [ attrs.email attrs.emailAlias ] "*?*";
expand = _mkFilter [ attrs.emailList ] "?";
domains = _mkFilter [ attrs.email attrs.emailAlias ] "*@?";
email = _mkFilter [ attrs.membermail ] "?";
};
attributes = {
name = "uid";
attributes = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
name = lconfig.attr.uid;
# name = lconfig.attr.member_mail;
description = lconfig.attr.firstname;
email = lconfig.attr.membermail;
quota = lconfig.attr.membermaildiskquota;
class = "objectClass";
description = "givenName";
secret = "uid";
groups = "memberOf";
email = "mail";
# email-alias = "mailAlias";
# quota = "diskQuota";
};
# we dont have access to this in lldap
# secret = lconfig.attr.stalwart_secret;
});
};

View file

@ -18,7 +18,7 @@ in {
# wger specific settings
wgerSettings = {
EMAIL_FROM = "wger Workout Manager <wger@${svc_domain}>";
EMAIL_FROM = "wger Workout Manager <wger@${config.mine.shared.settings.domain}>";
# use authelia for authentication (disable guest users + regisration)
AUTH_PROXY_HEADER = config.mine.shared.lib.authelia.protectedHeaders.username;
@ -39,7 +39,7 @@ in {
EMAIL_PORT = config.mine.shared.settings.mail.ports.submissions;
EMAIL_USE_SSL = true;
EMAIL_HOST_USER = "wger";
EMAIL_HOST_PASSWORD = "$EMAIL_HOST_PASSWORD";
EMAIL_HOST_PASSWORD = "file:${config.age.secrets.wger-ldap-pass.path}";
EMAIL_FROM_ADDRESS = config.services.wger.wgerSettings.EMAIL_FROM;
EMAIL_PAGE_DOMAIN = SITE_URL;
};
@ -62,6 +62,14 @@ in {
locations."/api".proxyPass = "http://localhost:${builtins.toString port}";
};
# setup lldap user for authelia that can send emails
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
wger = llib.mkProvisionUserSystem "wger" config.age.secrets.wger-ldap-pass.path;
});
# setup permissions
age.secrets.wger-ldap-pass.owner = config.services.wger.user;
# metadata
mine.shared.meta.wger = {
name = "Wger";

View file

@ -23,6 +23,8 @@ let
for k, v in json.load(f).items():
if isinstance(v, str) and v.startswith("$"):
v = os.environ[v[1:]]
if isinstance(v, str) and v.startswith("file:"):
v = open(v[5:], "r").read().strip()
globals()[k] = v
@ -30,6 +32,8 @@ let
for k, v in json.load(f).items():
if isinstance(v, str) and v.startswith("$"):
v = os.environ[v[1:]]
if isinstance(v, str) and v.startswith("file:"):
v = open(v[5:], "r").read().strip()
WGER_SETTINGS[k] = v
'';

View file

@ -17,6 +17,7 @@
group = "secrets-lldap-bind-user-pass";
mode = "0440";
};
lldap-user-emails-env.file = ./lldap/user-emails-env.age;
lldap-bind-user-pass-hedgedoc-env.file = ./lldap/bind-user-pass-hedgedoc-env.age;
# mumble

Binary file not shown.

View file

@ -28,6 +28,7 @@ in
"lldap/admin-user-pass.age".publicKeys = defaultAccess;
"lldap/bind-user-pass.age".publicKeys = defaultAccess;
"lldap/bind-user-pass-hedgedoc-env.age".publicKeys = defaultAccess;
"lldap/user-emails-env.age".publicKeys = defaultAccess;
# mumble
"murmur/env.age".publicKeys = defaultAccess;

View file

@ -1,11 +1,12 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg KGoB/V0cCAZsfVmoLDmA5Xs2HOHqjg54TYqixYQduEw
sqDb6QnEbwEncAbxKLRLkjCQIwMLBTNMVcejFOwhZWM
-> X25519 o64XZRaiK7ZEquTMmXTyhpdArawiuXC+5W5seFrJclY
qTLXrNGMTPAXs5EzMuCiQ07Ho2LT1KTku2f1AlCHPlk
-> ssh-ed25519 n8n9DQ a8ESfbksuY++k52UJwTKJtb4/aiYzQqUgyYqfug5oyA
bZygFOW6YSg83CmZRpsNDux+UgOxCfja1eQ/R4NyLXM
-> ssh-ed25519 BTp6UA yFBZAlGtHV98t6UA8QbELjOW/Pu6KYVPjbXFvijl9m0
+eobFp5YNBsr2+10Huimwypn3S4/lc7zoX5Ldko9mhA
--- g7w825LgydJlmyZiqnIL0ofUsTn+e47rFmSG8ft6Qqg
!lï•:^çÄÙƒ}R&Xº^_ã213·-éŒË£0Ån<C3BE>DK€­æ&Ù©Dþ:¾^½ÒUwÃÌóŸ 8(£‡ä X‡¾QZsÖªŒ<C2AA>â^(CÂ!ÍìÊ$ ™Üöý×(wÎ8t“ô¾<C3B4>Ñ!Úç²±Ð̈ït;¥ÃNgÚÛ§ˆ<C2A7>Ž[²f+Ù
-> ssh-ed25519 QSDXqg 1g79p7fDXhx/jHR4Z6PY4MsJyITD84/bimvr0jRcgCQ
41z4XNjkwik7rdj9UdJs/ZR+gUGa+rTOXFEQz50UWlo
-> X25519 XzyBVMh7elt7LdkzGAG1qz5kiKAZIeFHJeYVhYCh+gY
cbEWc7hdQ7ddoBnFRUFYzvunIGn/tNMckaEao7Lcxw4
-> ssh-ed25519 n8n9DQ bl0lknR3pVULG/2mRe7rtb6oFjBgr5zVayHM8Oc0dCM
gYkyO5PNrzwDMqcS5s5RnXH7kLIw5IdYB9qLzXTraX0
-> ssh-ed25519 BTp6UA D36+NXsMYDzeqgFbMTsdTuKWgHbQUcTw0jheGX3ndmw
pTtYYl2jLimVJdGAKhRqgcBg6tpg0LOvNnY+QoRgomk
--- lsPTx+ZG4T1MFVjue9ceTeieyEI99LF0D9RNBTnZG08
ÿƒ¨
-¾w¶ðf²Brî6Îö´ßH^»»ÇõÄéPÉE­ÃŒó¨žœ¬ßà3×B-áû-ót?³nd^ë7¬có/[z‡f˜xŠëû÷{ ji1A°BŸÛ¢7Âø*ú