chore(koi): kanidm -> zitadel
This commit is contained in:
parent
7832342642
commit
6f3fb407cb
17 changed files with 208 additions and 325 deletions
|
@ -33,13 +33,15 @@
|
|||
./containers/navidrome
|
||||
./containers/conduwuit
|
||||
./containers/zond
|
||||
./containers/kanidm
|
||||
./containers/zitadel
|
||||
./containers/siyuan
|
||||
./containers/memos
|
||||
./containers/wakapi
|
||||
./containers/outline
|
||||
./containers/teisu.nix
|
||||
./containers/bots/pcre-sub-bot.nix
|
||||
./containers/bots/channel-logger-bot.nix
|
||||
./containers/bots/bsky-crossposter
|
||||
./vms/hass.nix
|
||||
./vms/bnuuy.nix
|
||||
# ./vms/windows.nix
|
||||
|
@ -80,6 +82,8 @@
|
|||
"8.8.8.8"
|
||||
"8.8.4.4"
|
||||
];
|
||||
|
||||
firewall.logRefusedConnections = false;
|
||||
};
|
||||
|
||||
virtualisation.libvirtd = {
|
||||
|
@ -102,7 +106,7 @@
|
|||
}];
|
||||
|
||||
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
|
||||
boot.kernelParams = [ "panic=5" "mitigations=off" ];
|
||||
boot.kernelParams = [ "panic=5" "panic_on_oops=1" "mitigations=off" ];
|
||||
boot.kernelPackages = pkgs.linuxPackages_latest;
|
||||
|
||||
services.desu-deploy = {
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
{ config, ... }:
|
||||
|
||||
let
|
||||
UID = 1111;
|
||||
in {
|
||||
imports = [
|
||||
./proxy.nix
|
||||
];
|
||||
|
||||
desu.secrets.kanidm-tls-key.owner = "kanidm";
|
||||
desu.secrets.kanidm-tls-cert.owner = "kanidm";
|
||||
|
||||
users.users.kanidm = {
|
||||
isNormalUser = true;
|
||||
uid = UID;
|
||||
};
|
||||
|
||||
virtualisation.oci-containers.containers.kanidm = {
|
||||
image = "kanidm/server:1.4.2";
|
||||
volumes = [
|
||||
# "/srv/kanidm/data:/data/db"
|
||||
"${./server.toml}:/data/server.toml"
|
||||
"${./style.css}:/hpkg/style.css"
|
||||
"${./fish.png}:/hpkg/img/fish.png"
|
||||
];
|
||||
|
||||
user = "${builtins.toString UID}";
|
||||
|
||||
extraOptions = [
|
||||
"--mount=type=bind,source=/srv/kanidm/data,target=/data/db"
|
||||
"--mount=type=bind,source=${config.desu.secrets.kanidm-tls-key.path},target=/data/key.pem,readonly"
|
||||
"--mount=type=bind,source=${config.desu.secrets.kanidm-tls-cert.path},target=/data/chain.pem,readonly"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /srv/kanidm/data 0700 ${builtins.toString UID} ${builtins.toString UID} -"
|
||||
];
|
||||
|
||||
services.nginx.virtualHosts."id.stupid.fish" = {
|
||||
forceSSL = true;
|
||||
useACMEHost = "stupid.fish";
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "https://kanidm.docker:8443$request_uri";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 86 KiB |
|
@ -1,22 +0,0 @@
|
|||
notes for self:
|
||||
|
||||
## creating an oauth2 app:
|
||||
|
||||
```bash
|
||||
kanidm system oauth2 create myapp myapp_display_name https://url.to.app
|
||||
kanidm system oauth2 warning-insecure-client-disable-pkce myapp # optional, for oauth2-proxy
|
||||
kanidm system oauth2 prefer-short-username myapp # optional
|
||||
kanidm system oauth2 show-basic-secret myapp
|
||||
kanidm system oauth2 add-redirect-url myapp https://url.to.app/oauth2/callback # the default path for oauth2-proxy
|
||||
|
||||
# adding users to the app
|
||||
kanidm group create myapp_users
|
||||
kanidm group add-members myapp_users teidesu
|
||||
kanidm system oauth2 update-scope-map myapp myapp_users email openid profile
|
||||
```
|
||||
|
||||
## oauth2 proxy env:
|
||||
```bash
|
||||
OAUTH2_PROXY_COOKIE_SECRET=...
|
||||
OAUTH2_PROXY_CLIENT_SECRET=...
|
||||
```
|
|
@ -1,9 +0,0 @@
|
|||
bindaddress = "0.0.0.0:8443"
|
||||
adminbindpath = "/tmp/kanidm.sock"
|
||||
trust_x_forward_for = true
|
||||
db_path = "/data/db/kanidm.db"
|
||||
tls_chain = "/data/chain.pem"
|
||||
tls_key = "/data/key.pem"
|
||||
|
||||
domain = "id.stupid.fish"
|
||||
origin = "https://id.stupid.fish"
|
|
@ -1,239 +0,0 @@
|
|||
:root {
|
||||
--totp-width-and-height: 30px;
|
||||
--totp-stroke-width: 60px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.form-cred-reset-body {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#settings-window .form-cred-reset-body {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
.form-signin {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Sidebar
|
||||
*/
|
||||
|
||||
.side-menu {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.side-menu-item {
|
||||
--icon-size: 24px;
|
||||
padding: .4rem .7rem;
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: var(--bs-gray-300);
|
||||
}
|
||||
|
||||
.icon-container img {
|
||||
filter: invert(40%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Personal Settings sidemenu
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
* Navbar
|
||||
*/
|
||||
|
||||
.kanidm_logo {
|
||||
width: 12em;
|
||||
height: 12em;
|
||||
}
|
||||
|
||||
.identity-verification-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: fit-content;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.totp-display-container {
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: fit-content;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
border-radius: 15px;
|
||||
background: #21252915;
|
||||
box-shadow: -5px -5px 11px #ededed, 5px 5px 11px #ffffff;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.totp-display {
|
||||
font-size: 35px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.totp-timer {
|
||||
margin: 10px;
|
||||
position: relative;
|
||||
height: var(--totp-width-and-height);
|
||||
width: var(--totp-width-and-height);
|
||||
}
|
||||
|
||||
/* Removes SVG styling that would hide the time label */
|
||||
.totp-timer__circle {
|
||||
fill: none;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.totp-timer__path-remaining {
|
||||
stroke-width: var(--totp-stroke-width);
|
||||
|
||||
/* Makes sure the animation starts at the top of the circle */
|
||||
transform: rotate(90deg);
|
||||
transform-origin: center;
|
||||
|
||||
/* One second aligns with the speed of the countdown timer */
|
||||
transition: 1s linear all;
|
||||
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.totp-timer__svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.totp-timer__path-remaining.green {
|
||||
color: rgb(65, 184, 131);
|
||||
}
|
||||
|
||||
.totp-timer__path-remaining.orange {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.totp-timer__path-remaining.red {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.totp-timer__path-remaining.no-transition {
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.card>a {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.oauth2-img {
|
||||
max-width: 100%;
|
||||
max-height: 90%;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn-tiny {
|
||||
--bs-btn-padding-y: .05rem;
|
||||
--bs-btn-padding-x: .4rem;
|
||||
--bs-btn-font-size: .75rem;
|
||||
}
|
||||
|
||||
#cred-update-commit-bar {
|
||||
display: block;
|
||||
/*
|
||||
position: fixed;
|
||||
bottom: .5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
*/
|
||||
background: white;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
padding: 2px;
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
}
|
||||
|
||||
/* stupid.fish customizations */
|
||||
.kanidm_logo {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: url(/pkg/img/fish.png) no-repeat;
|
||||
background-size: contain;
|
||||
padding: 4em 8em;
|
||||
}
|
||||
|
||||
.footer .text-muted::after {
|
||||
content: ' and some stupidity';
|
||||
}
|
||||
|
||||
.form-signin h3 {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* ayu mirage */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #242936;
|
||||
color: #cccac2;
|
||||
--bs-body-bg: #242936;
|
||||
--bs-body-color: #cccac2;
|
||||
--bs-border-color: #707a8c45;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: #1f2430;
|
||||
}
|
||||
|
||||
.bg-light,
|
||||
.bg-dark {
|
||||
background-color: #1f2430 !important;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #b8cfe680 !important;
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
--bs-btn-color: #cccac2;
|
||||
--bs-btn-bg: #1f2430;
|
||||
--bs-btn-border-color: #1f2430;
|
||||
--bs-btn-hover-color: #cccac2;
|
||||
--bs-btn-hover-bg: #2e3037;
|
||||
--bs-btn-hover-border-color: #2e3037;
|
||||
--bs-btn-focus-shadow-rgb: 66, 70, 73;
|
||||
--bs-btn-active-color: #cccac2;
|
||||
--bs-btn-active-bg: #2e3037;
|
||||
--bs-btn-active-border-color: #2e3037;
|
||||
--bs-btn-active-shadow: none;
|
||||
--bs-btn-disabled-color: #b8cfe680;
|
||||
--bs-btn-disabled-bg: #1f2430;
|
||||
--bs-btn-disabled-border-color: #1f2430;
|
||||
}
|
||||
|
||||
.alert-light {
|
||||
--bs-alert-color: #cccac2;
|
||||
--bs-alert-bg: #1f2430;
|
||||
--bs-alert-border-color: transparent;
|
||||
--bs-alert-link-color: #cccac2;
|
||||
}
|
||||
}
|
|
@ -33,7 +33,7 @@ in {
|
|||
|
||||
desu.secrets.siyuan-teidesu-proxy-env.owner = "siyuan-teidesu";
|
||||
desu.openid-proxy.services.siyuan-teidesu = {
|
||||
clientId = "teidesu-siyuan";
|
||||
clientId = "299749237216837638";
|
||||
domain = "siyuan.tei.su";
|
||||
upstream = "http://siyuan-teidesu.docker:6806";
|
||||
envSecret = "siyuan-teidesu-proxy-env";
|
||||
|
|
|
@ -76,7 +76,7 @@ in
|
|||
];
|
||||
|
||||
desu.openid-proxy.services.torrent = {
|
||||
clientId = "torrent";
|
||||
clientId = "299749111337385990";
|
||||
domain = "torrent.stupid.fish";
|
||||
upstream = "http://torrent.containers";
|
||||
envSecret = "torrent-proxy-env";
|
||||
|
|
55
hosts/koi/containers/zitadel/default.nix
Normal file
55
hosts/koi/containers/zitadel/default.nix
Normal file
|
@ -0,0 +1,55 @@
|
|||
{ pkgs, config, ... }:
|
||||
|
||||
let
|
||||
UID = 1122;
|
||||
in {
|
||||
imports = [
|
||||
./proxy.nix
|
||||
];
|
||||
|
||||
users.users.zitadel = {
|
||||
isNormalUser = true;
|
||||
uid = UID;
|
||||
};
|
||||
|
||||
services.postgresql.ensureUsers = [
|
||||
{ name = "zitadel"; ensureDBOwnership = true; }
|
||||
];
|
||||
services.postgresql.ensureDatabases = [ "zitadel" ];
|
||||
desu.postgresql.ensurePasswords.zitadel = "zitadel";
|
||||
|
||||
desu.secrets.zitadel-env.owner = "zitadel";
|
||||
|
||||
virtualisation.oci-containers.containers.zitadel = {
|
||||
image = "ghcr.io/zitadel/zitadel:v2.66.1";
|
||||
cmd = [ "start-from-setup" "--masterkeyFromEnv" "--tlsMode" "external" ];
|
||||
environment = {
|
||||
"ZITADEL_DATABASE_POSTGRES_HOST" = "172.17.0.1";
|
||||
"ZITADEL_DATABASE_POSTGRES_PORT" = "5432";
|
||||
"ZITADEL_DATABASE_POSTGRES_DATABASE" = "zitadel";
|
||||
"ZITADEL_DATABASE_POSTGRES_USER_USERNAME" = "zitadel";
|
||||
"ZITADEL_DATABASE_POSTGRES_USER_PASSWORD" = "zitadel";
|
||||
"ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE" = "disable";
|
||||
"ZITADEL_EXTERNALSECURE" = "true";
|
||||
"ZITADEL_EXTERNALDOMAIN" = "id.stupid.fish";
|
||||
"ZITADEL_EXTERNALPORT" = "443";
|
||||
"ZITADEL_TLS_ENABLED" = "false";
|
||||
"ZITADEL_WEBAUTHNNAME" = "stupid.fish";
|
||||
};
|
||||
environmentFiles = [
|
||||
config.desu.secrets.zitadel-env.path
|
||||
];
|
||||
user = builtins.toString UID;
|
||||
};
|
||||
systemd.services.docker-zitadel.requires = [ "postgresql.service" ];
|
||||
|
||||
services.nginx.virtualHosts."id.stupid.fish" = {
|
||||
forceSSL = true;
|
||||
useACMEHost = "stupid.fish";
|
||||
|
||||
locations."/" = {
|
||||
proxyPass = "http://zitadel.docker:8080$request_uri";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
}
|
137
hosts/koi/containers/zitadel/init.sql
Normal file
137
hosts/koi/containers/zitadel/init.sql
Normal file
|
@ -0,0 +1,137 @@
|
|||
CREATE SCHEMA IF NOT EXISTS eventstore;
|
||||
CREATE SCHEMA IF NOT EXISTS projections;
|
||||
CREATE SCHEMA IF NOT EXISTS system;
|
||||
CREATE TABLE IF NOT EXISTS system.encryption_keys (
|
||||
id TEXT NOT NULL
|
||||
, key TEXT NOT NULL
|
||||
|
||||
, PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS eventstore.events2 (
|
||||
instance_id TEXT NOT NULL
|
||||
, aggregate_type TEXT NOT NULL
|
||||
, aggregate_id TEXT NOT NULL
|
||||
|
||||
, event_type TEXT NOT NULL
|
||||
, "sequence" BIGINT NOT NULL
|
||||
, revision SMALLINT NOT NULL
|
||||
, created_at TIMESTAMPTZ NOT NULL
|
||||
, payload JSONB
|
||||
, creator TEXT NOT NULL
|
||||
, "owner" TEXT NOT NULL
|
||||
|
||||
, "position" DECIMAL NOT NULL
|
||||
, in_tx_order INTEGER NOT NULL
|
||||
|
||||
, PRIMARY KEY (instance_id, aggregate_type, aggregate_id, "sequence")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS es_active_instances ON eventstore.events2 (created_at DESC, instance_id);
|
||||
CREATE INDEX IF NOT EXISTS es_wm ON eventstore.events2 (aggregate_id, instance_id, aggregate_type, event_type);
|
||||
CREATE INDEX IF NOT EXISTS es_projection ON eventstore.events2 (instance_id, aggregate_type, event_type, "position");
|
||||
|
||||
-- represents an event to be created.
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE eventstore.command AS (
|
||||
instance_id TEXT
|
||||
, aggregate_type TEXT
|
||||
, aggregate_id TEXT
|
||||
, command_type TEXT
|
||||
, revision INT2
|
||||
, payload JSONB
|
||||
, creator TEXT
|
||||
, owner TEXT
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION eventstore.commands_to_events(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$
|
||||
SELECT
|
||||
c.instance_id
|
||||
, c.aggregate_type
|
||||
, c.aggregate_id
|
||||
, c.command_type AS event_type
|
||||
, cs.sequence + ROW_NUMBER() OVER (PARTITION BY c.instance_id, c.aggregate_type, c.aggregate_id ORDER BY c.in_tx_order) AS sequence
|
||||
, c.revision
|
||||
, NOW() AS created_at
|
||||
, c.payload
|
||||
, c.creator
|
||||
, cs.owner
|
||||
, EXTRACT(EPOCH FROM NOW()) AS position
|
||||
, c.in_tx_order
|
||||
FROM (
|
||||
SELECT
|
||||
c.instance_id
|
||||
, c.aggregate_type
|
||||
, c.aggregate_id
|
||||
, c.command_type
|
||||
, c.revision
|
||||
, c.payload
|
||||
, c.creator
|
||||
, c.owner
|
||||
, ROW_NUMBER() OVER () AS in_tx_order
|
||||
FROM
|
||||
UNNEST(commands) AS c
|
||||
) AS c
|
||||
JOIN (
|
||||
SELECT
|
||||
cmds.instance_id
|
||||
, cmds.aggregate_type
|
||||
, cmds.aggregate_id
|
||||
, CASE WHEN (e.owner IS NOT NULL OR e.owner <> '') THEN e.owner ELSE command_owners.owner END AS owner
|
||||
, COALESCE(MAX(e.sequence), 0) AS sequence
|
||||
FROM (
|
||||
SELECT DISTINCT
|
||||
instance_id
|
||||
, aggregate_type
|
||||
, aggregate_id
|
||||
, owner
|
||||
FROM UNNEST(commands)
|
||||
) AS cmds
|
||||
LEFT JOIN eventstore.events2 AS e
|
||||
ON cmds.instance_id = e.instance_id
|
||||
AND cmds.aggregate_type = e.aggregate_type
|
||||
AND cmds.aggregate_id = e.aggregate_id
|
||||
JOIN (
|
||||
SELECT
|
||||
DISTINCT ON (
|
||||
instance_id
|
||||
, aggregate_type
|
||||
, aggregate_id
|
||||
)
|
||||
instance_id
|
||||
, aggregate_type
|
||||
, aggregate_id
|
||||
, owner
|
||||
FROM
|
||||
UNNEST(commands)
|
||||
) AS command_owners ON
|
||||
cmds.instance_id = command_owners.instance_id
|
||||
AND cmds.aggregate_type = command_owners.aggregate_type
|
||||
AND cmds.aggregate_id = command_owners.aggregate_id
|
||||
GROUP BY
|
||||
cmds.instance_id
|
||||
, cmds.aggregate_type
|
||||
, cmds.aggregate_id
|
||||
, 4 -- owner
|
||||
) AS cs
|
||||
ON c.instance_id = cs.instance_id
|
||||
AND c.aggregate_type = cs.aggregate_type
|
||||
AND c.aggregate_id = cs.aggregate_id
|
||||
ORDER BY
|
||||
in_tx_order;
|
||||
$$ LANGUAGE SQL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION eventstore.push(commands eventstore.command[]) RETURNS SETOF eventstore.events2 VOLATILE AS $$
|
||||
INSERT INTO eventstore.events2
|
||||
SELECT * FROM eventstore.commands_to_events(commands)
|
||||
RETURNING *
|
||||
$$ LANGUAGE SQL;
|
||||
CREATE SEQUENCE IF NOT EXISTS eventstore.system_seq;
|
||||
CREATE TABLE IF NOT EXISTS eventstore.unique_constraints (
|
||||
instance_id TEXT,
|
||||
unique_type TEXT,
|
||||
unique_field TEXT,
|
||||
PRIMARY KEY (instance_id, unique_type, unique_field)
|
||||
);
|
|
@ -61,7 +61,7 @@ in {
|
|||
"--client-id=${service.clientId}"
|
||||
"--upstream=${service.upstream}"
|
||||
"--redirect-url=https://${service.domain}/oauth2/callback"
|
||||
"--oidc-issuer-url=https://id.stupid.fish/oauth2/openid/${service.clientId}"
|
||||
"--oidc-issuer-url=https://id.stupid.fish"
|
||||
] ++ service.extra;
|
||||
};
|
||||
}) (builtins.attrNames cfg.services)
|
|
@ -24,7 +24,7 @@ in
|
|||
|
||||
desu.secrets.hass-proxy-env = {};
|
||||
desu.openid-proxy.services.hass = {
|
||||
clientId = "hass";
|
||||
clientId = "299748893099360262";
|
||||
domain = "hass.stupid.fish";
|
||||
upstream = "http://10.42.0.3:8123";
|
||||
envSecret = "hass-proxy-env";
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6
secrets/zitadel-env.age
Normal file
6
secrets/zitadel-env.age
Normal file
|
@ -0,0 +1,6 @@
|
|||
age-encryption.org/v1
|
||||
-> ssh-ed25519 sj88Xw KHqbr2MMzik5ygZw4RC65UAX6QB8/y8m761UtWLaLFo
|
||||
WWW15wcaj1HDWy/T9TQyp+MAR+pzfX41sCWq4Rj6THs
|
||||
--- MJrS0sFAtch6GH83x5r1yJ9ogvYti0drZR8Pdx1Y60U
|
||||
s4ÊAÉ°w›ìÐ «wLæEžî'¿ç >\<5C>Ð.×öoªpT6~<7E>©Ûº¦<1D>¼qMzô:ÞÐÃö‡ìÕ)iS‹š%œÿ"<œñÿæ_¥ÍcýäÆ<C3A4>ßcr¯;µœÌ@Mu Öä%U^0À€¡µ¡ƒò4µ£ºÂQNe”¨Q¡ßÉ+ù)N=â+AÐÕþrfbWœážÚr»
|
||||
+‹}e'©9¤Çâ¿!9„JÚÚ<C39A> …0tl½•½^î¦S†=ÊUܳ¥r"âÑ_7
|
Loading…
Reference in a new issue