more work on ldap bootstrapping
This commit is contained in:
parent
19cd1b3255
commit
ae3c110e18
10 changed files with 362 additions and 11 deletions
|
@ -5,7 +5,7 @@ import gql
|
||||||
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
|
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
|
||||||
from gql.transport.aiohttp import AIOHTTPTransport
|
from gql.transport.aiohttp import AIOHTTPTransport
|
||||||
|
|
||||||
from .utils import to_camelcase, to_snakecase
|
from .utils import to_camelcase, to_snakecase, get_value
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -100,6 +100,7 @@ class LLDAPGroups:
|
||||||
def update(self, groupId: int, attrs: dict[str, str | list[str]]):
|
def update(self, groupId: int, attrs: dict[str, str | list[str]]):
|
||||||
insertAttributes: list[dict[str, str | list[str]]] = []
|
insertAttributes: list[dict[str, str | list[str]]] = []
|
||||||
for k, v in attrs.items():
|
for k, v in attrs.items():
|
||||||
|
v = get_value(v)
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
v = [v]
|
v = [v]
|
||||||
|
|
||||||
|
|
148
machines/gerd/services/lldap/bootstrap/lldap-state-module.nix
Normal file
148
machines/gerd/services/lldap/bootstrap/lldap-state-module.nix
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
{ 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}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
|
@ -3,8 +3,10 @@
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import requests
|
||||||
import secrets
|
import secrets
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import gql
|
import gql
|
||||||
|
@ -26,9 +28,19 @@ logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
class LLDAP:
|
class LLDAP:
|
||||||
def __init__(self, server_url: str, auth_token: str):
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_url: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
):
|
||||||
|
if password.startswith("file:"):
|
||||||
|
password = open(password[5:], "r").read().strip()
|
||||||
|
|
||||||
self._server_url: str = server_url
|
self._server_url: str = server_url
|
||||||
self._server_auth_token: str = auth_token
|
|
||||||
|
self._server_refresh_token: str | None = None
|
||||||
|
self._simple_login(username, password)
|
||||||
|
|
||||||
self._client: gql.Client = self._init_gql_client()
|
self._client: gql.Client = self._init_gql_client()
|
||||||
|
|
||||||
|
@ -37,6 +49,35 @@ class LLDAP:
|
||||||
self._attrsUser = LLDAPAttributes(self._client, using_user_attributes=True)
|
self._attrsUser = LLDAPAttributes(self._client, using_user_attributes=True)
|
||||||
self._attrsGroup = LLDAPAttributes(self._client, using_group_attributes=True)
|
self._attrsGroup = LLDAPAttributes(self._client, using_group_attributes=True)
|
||||||
|
|
||||||
|
def _simple_login(self, username: str, password: str):
|
||||||
|
r = requests.post(
|
||||||
|
f"{self._server_url}/auth/simple/login",
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise Exception(f"failed to signin got response: {r.text}")
|
||||||
|
|
||||||
|
rjson = r.json()
|
||||||
|
self._server_auth_token = rjson["token"]
|
||||||
|
self._server_refresh_token = rjson["refreshToken"]
|
||||||
|
|
||||||
|
def _logout(self):
|
||||||
|
if not self._server_refresh_token:
|
||||||
|
return
|
||||||
|
|
||||||
|
r = requests.get(
|
||||||
|
f"{self._server_url}/auth/logout",
|
||||||
|
headers={"refresh-token": self._server_refresh_token},
|
||||||
|
)
|
||||||
|
print(r.text, r.status_code)
|
||||||
|
|
||||||
def _init_gql_client(self) -> gql.Client:
|
def _init_gql_client(self) -> gql.Client:
|
||||||
# Select your transport with a defined url endpoint
|
# Select your transport with a defined url endpoint
|
||||||
transport = AIOHTTPTransport(
|
transport = AIOHTTPTransport(
|
||||||
|
@ -269,8 +310,8 @@ class LLDAP:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(self):
|
def run(self, provision_file_path: str):
|
||||||
data = json.load(open("test2.json", "r"))
|
data = json.load(open(provision_file_path, "r"))
|
||||||
|
|
||||||
self._run_ensure_attrs_user(self._attrsUser, data["user_attributes"])
|
self._run_ensure_attrs_user(self._attrsUser, data["user_attributes"])
|
||||||
self._run_ensure_attrs_user(self._attrsGroup, data["group_attributes"])
|
self._run_ensure_attrs_user(self._attrsGroup, data["group_attributes"])
|
||||||
|
@ -279,9 +320,26 @@ class LLDAP:
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
auth_token = os.getenv("LLDAP_TOKEN")
|
url = os.getenv("LLDAP_URL")
|
||||||
if not auth_token:
|
if not url:
|
||||||
raise Exception("No LLDAP_TOKEN provided. please set")
|
raise Exception("No LLDAP_URL provided. please set")
|
||||||
|
|
||||||
x = LLDAP("https://ldap.fricloud.dk", auth_token)
|
auth_username = os.getenv("LLDAP_USERNAME")
|
||||||
x.run()
|
if not auth_username:
|
||||||
|
raise Exception("No LLDAP_USERNAME provided. please set")
|
||||||
|
|
||||||
|
auth_password = os.getenv("LLDAP_PASSWORD")
|
||||||
|
if not auth_password:
|
||||||
|
raise Exception("No LLDAP_PASSWORD provided. please set")
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
raise Exception(
|
||||||
|
"Please provide a JSON file containing the provisioning details"
|
||||||
|
)
|
||||||
|
|
||||||
|
x = LLDAP(url, auth_username, auth_password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
x.run(sys.argv[1])
|
||||||
|
finally:
|
||||||
|
x._logout()
|
||||||
|
|
|
@ -5,7 +5,7 @@ import gql
|
||||||
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
|
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
|
||||||
from gql.transport.aiohttp import AIOHTTPTransport
|
from gql.transport.aiohttp import AIOHTTPTransport
|
||||||
|
|
||||||
from .utils import to_camelcase, to_snakecase
|
from .utils import to_camelcase, to_snakecase, get_value
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
@ -91,6 +91,7 @@ class LLDAPUsers:
|
||||||
def update(self, userId: str, attrs: dict[str, str | list[str]]):
|
def update(self, userId: str, attrs: dict[str, str | list[str]]):
|
||||||
insertAttributes: list[dict[str, str | list[str]]] = []
|
insertAttributes: list[dict[str, str | list[str]]] = []
|
||||||
for k, v in attrs.items():
|
for k, v in attrs.items():
|
||||||
|
v = get_value(v)
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
v = [v]
|
v = [v]
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def to_camelcase(s):
|
def to_camelcase(s):
|
||||||
|
@ -8,3 +12,21 @@ def to_camelcase(s):
|
||||||
|
|
||||||
def to_snakecase(s):
|
def to_snakecase(s):
|
||||||
return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
|
return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def get_value(s):
|
||||||
|
if not isinstance(s, str):
|
||||||
|
return s
|
||||||
|
|
||||||
|
if s.startswith("file:"):
|
||||||
|
filepath = s[len("file:") :]
|
||||||
|
logger.debug(f"reading from file '{filepath}'")
|
||||||
|
return open(filepath, "r").read().strip()
|
||||||
|
|
||||||
|
if s.startswith("env:"):
|
||||||
|
envkey = s.strip()[len("env:") :]
|
||||||
|
logger.debug(f"reading from envvar '{envkey}'")
|
||||||
|
return os.getenv(envkey)
|
||||||
|
|
||||||
|
logger.debug(f"using original value")
|
||||||
|
return s
|
||||||
|
|
|
@ -21,6 +21,10 @@ index 6f42473..b3746a1 100644
|
||||||
|
|
||||||
pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {};
|
pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {};
|
||||||
in {
|
in {
|
||||||
|
imports = [
|
||||||
|
# ./test.nix
|
||||||
|
];
|
||||||
|
|
||||||
environment.systemPackages = [
|
environment.systemPackages = [
|
||||||
pkgLLDAPCli
|
pkgLLDAPCli
|
||||||
];
|
];
|
||||||
|
@ -102,6 +106,8 @@ in {
|
||||||
groups = {
|
groups = {
|
||||||
admin = "lldap_admin";
|
admin = "lldap_admin";
|
||||||
member = "base_member";
|
member = "base_member";
|
||||||
|
system = "system_service";
|
||||||
|
system_email = "system_email";
|
||||||
};
|
};
|
||||||
|
|
||||||
ou = {
|
ou = {
|
||||||
|
@ -116,6 +122,10 @@ in {
|
||||||
email = "mail";
|
email = "mail";
|
||||||
avatar = "jpegPhoto";
|
avatar = "jpegPhoto";
|
||||||
groupname = "cn";
|
groupname = "cn";
|
||||||
|
|
||||||
|
# custom
|
||||||
|
member_email = "member_email";
|
||||||
|
mail_disk_quota = "mail_disk_quota";
|
||||||
};
|
};
|
||||||
|
|
||||||
age_secret = config.age.secrets.lldap-bind-user-pass.path;
|
age_secret = config.age.secrets.lldap-bind-user-pass.path;
|
||||||
|
|
98
machines/gerd/services/lldap/test.nix
Normal file
98
machines/gerd/services/lldap/test.nix
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
{ 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";
|
||||||
|
};
|
||||||
|
}
|
|
@ -41,6 +41,7 @@
|
||||||
|
|
||||||
# wger
|
# wger
|
||||||
wger-env.file = ./wger/env.age;
|
wger-env.file = ./wger/env.age;
|
||||||
|
wger-ldap-pass.file = ./wger/ldap-pass.age;
|
||||||
|
|
||||||
# restic
|
# restic
|
||||||
restic-env.file = ./restic/env.age;
|
restic-env.file = ./restic/env.age;
|
||||||
|
|
|
@ -51,6 +51,7 @@ in
|
||||||
|
|
||||||
# wger
|
# wger
|
||||||
"wger/env.age".publicKeys = defaultAccess;
|
"wger/env.age".publicKeys = defaultAccess;
|
||||||
|
"wger/ldap-pass.age".publicKeys = defaultAccess;
|
||||||
|
|
||||||
# restic
|
# restic
|
||||||
"restic/env.age".publicKeys = defaultAccess;
|
"restic/env.age".publicKeys = defaultAccess;
|
||||||
|
|
11
secrets/wger/ldap-pass.age
Normal file
11
secrets/wger/ldap-pass.age
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
age-encryption.org/v1
|
||||||
|
-> ssh-ed25519 QSDXqg uYPdHX2B6acTsvvU49DtkE6seTek1FT/+WIeexfKIDI
|
||||||
|
koEouYTBn6/VencZc4HmooytCR7zcdrSL/77ScL47yQ
|
||||||
|
-> X25519 sTTQGj2XPszrqQrUSiXVYlbQsxGYLn9Ee46isVezMQI
|
||||||
|
1obJbY9FMAmCPlPWdaJdCjYm9Z01WdiGv72Dy9NTZNM
|
||||||
|
-> ssh-ed25519 n8n9DQ 5+yZMCzJNRvpLYWDOof40LSVX31DdawJfKzjPhwiUy8
|
||||||
|
bUUVYjDaJ3kvB/gc6wAjLX090YGG4VigulNwc3kioMo
|
||||||
|
-> ssh-ed25519 BTp6UA 2WxuEBEe12Bx0hJaLmfrJhN5HKLZIQtpzekTprvTdTc
|
||||||
|
LdxlDEXOGsYgBB8p+qj/Twv7F1RK6W3DXiM+cwBvU+o
|
||||||
|
--- RdzYVsyAfuRcvj9nz+f57KbfNV+MG5EkIzDmBbzlT1A
|
||||||
|
—ù6T®ñ…ŠMåò*ÞÂtqƒr‹³eIù–/µØ`q¦aÜ<>Áýû-p0ÕJb0ðQ³$~qÁ‹zɃôŽ(
|
Loading…
Add table
Add a link
Reference in a new issue