Enter Agenix (#57)

This commit is contained in:
Nicolas Jeannerod 2024-12-13 14:28:19 +01:00
commit 8b9e9e96ba
Signed by untrusted user: Niols
GPG key ID: 35DB9EC8886E1CB8
25 changed files with 378 additions and 35 deletions

View file

@ -15,9 +15,15 @@ details as to what they are for. As an overview:
- [`infra/`](./infra) contains the configurations for the various VMs that are
in production for the project, for instance the Git instances or the Wiki.
- [`keys/`](./keys) contains the public keys of the contributors to this project
as well as the systems that we administrate.
- [`matrix/`](./matrix) contains everything having to do with setting up a
fully-featured Matrix server.
- [`secrets/`](./secrets) contains the secrets that need to get injected into
machine configurations.
- [`server/`](./server) contains the configuration of the VM hosting the
website. This should be integrated into `infra/` shortly in the future, as
tracked in https://git.fediversity.eu/Fediversity/Fediversity/issues/31.

View file

@ -1,5 +1,26 @@
{
"nodes": {
"agenix": {
"inputs": {
"darwin": "darwin",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs",
"systems": "systems"
},
"locked": {
"lastModified": 1723293904,
"narHash": "sha256-b+uqzj+Wa6xgMS9aNbX4I+sXeb5biPDi39VgvSFqFvU=",
"owner": "ryantm",
"repo": "agenix",
"rev": "f6291c5935fdc4e0bef208cfc0dcab7e3f7a1c41",
"type": "github"
},
"original": {
"owner": "ryantm",
"repo": "agenix",
"type": "github"
}
},
"crane": {
"flake": false,
"locked": {
@ -34,9 +55,31 @@
"type": "github"
}
},
"darwin": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1700795494,
"narHash": "sha256-gzGLZSiOhf155FW7262kdHo2YDeugp3VuIFb4/GGng0=",
"owner": "lnl7",
"repo": "nix-darwin",
"rev": "4b9b83d5a92e8c1fbfd8eb27eda375908c11ec4d",
"type": "github"
},
"original": {
"owner": "lnl7",
"ref": "master",
"repo": "nix-darwin",
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1731274291,
@ -266,7 +309,7 @@
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs_2",
"nixpkgs": "nixpkgs_3",
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
@ -339,6 +382,27 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703113217,
"narHash": "sha256-7ulcXOk63TIT2lVDSExj7XzFx09LpdSAPtvgtM7yQPE=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "3bfaacf46133c037bb356193bd2f1765d9dc82c1",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"libgit2": {
"flake": false,
"locked": {
@ -519,7 +583,7 @@
"flake-parts": "flake-parts_2",
"nix": "nix",
"nix-cargo-integration": "nix-cargo-integration",
"nixpkgs": "nixpkgs_3",
"nixpkgs": "nixpkgs_4",
"nixpkgs-old": "nixpkgs-old"
},
"locked": {
@ -541,7 +605,7 @@
"flake-parts": "flake-parts_4",
"nix": "nix_2",
"nix-cargo-integration": "nix-cargo-integration_2",
"nixpkgs": "nixpkgs_4"
"nixpkgs": "nixpkgs_5"
},
"locked": {
"lastModified": 1727424043,
@ -560,16 +624,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1730958623,
"narHash": "sha256-JwQZIGSYnRNOgDDoIgqKITrPVil+RMWHsZH1eE1VGN0=",
"lastModified": 1703013332,
"narHash": "sha256-+tFNwMvlXLbJZXiMHqYq77z/RfmpfpiI3yjL6o/Zo9M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "85f7e662eda4fa3a995556527c87b2524b691933",
"rev": "54aac082a4d9bb5bbc5c4e899603abfb76a3f6d6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
@ -723,6 +787,22 @@
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1730958623,
"narHash": "sha256-JwQZIGSYnRNOgDDoIgqKITrPVil+RMWHsZH1eE1VGN0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "85f7e662eda4fa3a995556527c87b2524b691933",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1732837521,
"narHash": "sha256-jNRNr49UiuIwaarqijgdTR2qLPifxsVhlJrKzQ8XUIE=",
@ -738,7 +818,7 @@
"type": "github"
}
},
"nixpkgs_4": {
"nixpkgs_5": {
"locked": {
"lastModified": 1724819573,
"narHash": "sha256-GnR7/ibgIH1vhoy8cYdmXE6iyZqKqFxQSVkFgosBh6w=",
@ -754,7 +834,7 @@
"type": "github"
}
},
"nixpkgs_5": {
"nixpkgs_6": {
"locked": {
"lastModified": 1732350895,
"narHash": "sha256-GcOQbOgmwlsRhpLGSwZJwLbo3pu9ochMETuRSS1xpz4=",
@ -934,12 +1014,13 @@
},
"root": {
"inputs": {
"agenix": "agenix",
"disko": "disko",
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nixops4": "nixops4",
"nixops4-nixos": "nixops4-nixos",
"nixpkgs": "nixpkgs_5"
"nixpkgs": "nixpkgs_6"
}
},
"rust-overlay": {
@ -1028,6 +1109,21 @@
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt": {
"inputs": {
"nixpkgs": [

View file

@ -3,6 +3,7 @@
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
flake-parts.url = "github:hercules-ci/flake-parts";
git-hooks.url = "github:cachix/git-hooks.nix";
agenix.url = "github:ryantm/agenix";
disko.url = "github:nix-community/disko";
@ -26,7 +27,9 @@
./deployment/flake-part.nix
./infra/flake-part.nix
./keys/flake-part.nix
./services/flake-part.nix
./secrets/flake-part.nix
];
perSystem =
@ -47,6 +50,8 @@
optin = [
"deployment"
"infra"
"keys"
"secrets"
"services"
];
files = "^((" + concatStringsSep "|" optin + ")/.*\\.nix|[^/]*\\.nix)$";
@ -69,6 +74,7 @@
devShells.default = pkgs.mkShell {
packages = [
pkgs.nil
inputs'.agenix.packages.default
inputs'.nixops4.packages.default
];
shellHook = config.pre-commit.installationScript;

View file

@ -1,4 +1,4 @@
{ inputs, ... }:
{ self, inputs, ... }:
{
nixops4Deployments.git =
@ -13,11 +13,15 @@
ssh = {
host = "185.206.232.34";
opts = "";
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILriawl1za2jbxzelkL5v8KPmcvuj7xVBgwFxuM/zhYr";
hostPublicKey = self.keys.systems.vm02116;
};
nixpkgs = inputs.nixpkgs;
nixos.module = {
imports = [ ./vm02116 ];
imports = [
./vm02116
self.nixosModules.ageSecrets
{ fediversity.hostPublicKey = self.keys.systems.vm02116; }
];
};
};
@ -27,11 +31,15 @@
ssh = {
host = "185.206.232.179";
opts = "";
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPAsOCOsJ0vNL9fGj0XC25ir8B+k2NlVJzsiVUx+0eWM";
hostPublicKey = self.keys.systems.vm02179;
};
nixpkgs = inputs.nixpkgs;
nixos.module = {
imports = [ ./vm02179 ];
imports = [
./vm02179
self.nixosModules.ageSecrets
{ fediversity.hostPublicKey = self.keys.systems.vm02179; }
];
};
};
@ -41,11 +49,15 @@
ssh = {
host = "185.206.232.186";
opts = "";
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII6mnBgEeyYE4tzHeFNHVNBV6KR+hAqh3PYSqlh0QViW";
hostPublicKey = self.keys.systems.vm02186;
};
nixpkgs = inputs.nixpkgs;
nixos.module = {
imports = [ ./vm02186 ];
imports = [
./vm02186
self.nixosModules.ageSecrets
{ fediversity.hostPublicKey = self.keys.systems.vm02186; }
];
};
};
};
@ -63,11 +75,15 @@
ssh = {
host = "185.206.232.187";
opts = "";
hostPublicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN24ZfdQNklKkIqfMg/+0vqENuDcy6fhT6SfAq01ae83";
hostPublicKey = self.keys.systems.vm02187;
};
nixpkgs = inputs.nixpkgs;
nixos.module = {
imports = [ ./vm02187 ];
imports = [
./vm02187
self.nixosModules.ageSecrets
{ fediversity.hostPublicKey = self.keys.systems.vm02187; }
];
};
};
};

View file

@ -1,4 +1,4 @@
{ pkgs, ... }:
{ config, pkgs, ... }:
let
domain = "git.fediversity.eu";
in
@ -27,15 +27,21 @@ in
FROM = "git@fediversity.eu";
USER = "git@fediversity.eu";
};
secrets.mailer.PASSWD = "/var/lib/forgejo/data/keys/forgejo-mailpw";
secrets.mailer.PASSWD = config.age.secrets.forgejo-email-password.path;
database = {
type = "mysql";
socket = "/run/mysqld/mysqld.sock";
passwordFile = "/var/lib/forgejo/data/keys/forgejo-dbpassword";
passwordFile = config.age.secrets.forgejo-database-password.path;
};
};
age.secrets.forgejo-database-password = {
owner = "forgejo";
group = "forgejo";
mode = "440";
};
users.groups.keys.members = [ "forgejo" ];
services.mysql = {

View file

@ -1,6 +1,6 @@
{ pkgs, ... }:
{
{ config, pkgs, ... }:
{
virtualisation.docker.enable = true;
services.gitea-actions-runner = {
@ -9,8 +9,7 @@
enable = true;
name = "vm02179.procolix.com";
url = "https://git.fediversity.eu";
# Obtaining the path to the runner token file may differ
token = "MKmFPY4nxfR4zPYHIRLoiJdrrfkGmcRymj0GWOAk";
tokenFile = config.age.secrets.forgejo-runner-token.path;
labels = [
"docker:docker://node:16-bullseye"
"native:host"

View file

@ -1 +0,0 @@
MKmFPY4nxfR4zPYHIRLoiJdrrfkGmcRymj0GWOAk

View file

@ -9,7 +9,7 @@
name = config.networking.fqdn;
url = "https://git.fediversity.eu";
token = "MKmFPY4nxfR4zPYHIRLoiJdrrfkGmcRymj0GWOAk";
tokenFile = config.age.secrets.forgejo-runner-token.path;
settings = {
log.level = "info";

View file

@ -1,4 +1,4 @@
{ pkgs, ... }:
{ config, ... }:
{
services.phpfpm.pools.mediawiki.phpOptions = ''
@ -11,7 +11,7 @@
name = "Fediversity Wiki";
webserver = "nginx";
nginx.hostName = "wiki.fediversity.eu";
passwordFile = pkgs.writeText "password" "eiM9etha8ohmo9Ohphahpesiux0ahda6";
passwordFile = config.age.secrets.wiki-password.path;
extraConfig = ''
# Disable anonymous editing
$wgGroupPermissions['*']['edit'] = false;
@ -24,7 +24,7 @@
## Permissions
$wgGroupPermissions['*']['edit'] = false;
$wgGroupPermissions['*']['createaccount'] = false;
$wgGroupPermissions['*']['createaccount'] = true;
$wgGroupPermissions['*']['autocreateaccount'] = true;
$wgGroupPermissions['user']['edit'] = true;
$wgGroupPermissions['user']['createaccount'] = true;
@ -35,6 +35,19 @@
$wgUploadSizeWarning = 1024*1024*512;
$wgMaxUploadSize = 1024*1024*1024;
$wgEnableEmail = true;
$wgPasswordSender = "wiki@fediversity.eu";
$wgEmergencyContact = "wiki@fediversity.eu";
$wgSMTP = [
'host' => 'mail.protagio.nl',
'IDHost' => 'fediversity.eu',
'localhost' => 'fediversity.eu',
'port' => 587,
'auth' => true,
'username' => 'wiki@fediversity.eu',
];
require_once("${config.age.secrets.wiki-smtp-password.path}");
$wgHeadScriptCode = <<<'END'
<link rel=me href="https://mastodon.fediversity.eu/@fediversity">
END;
@ -45,17 +58,19 @@
};
};
age.secrets.wiki-smtp-password.owner = "mediawiki";
services.nginx = {
enable = true;
virtualHosts."wiki.fediversity.eu" = {
basicAuth = {
fediv = "SecretSauce123!";
};
basicAuthFile = config.age.secrets.wiki-basicauth-htpasswd.path;
forceSSL = true;
enableACME = true;
};
};
age.secrets.wiki-basicauth-htpasswd.owner = "nginx";
security.acme = {
acceptTerms = true;
defaults.email = "systeemmail@procolix.com";

1
keys/contributors/niols Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEElREJN0AC7lbp+5X204pQ5r030IbgCllsIxyU3iiKY niols@wallace

32
keys/default.nix Normal file
View file

@ -0,0 +1,32 @@
let
inherit (builtins)
attrValues
elemAt
foldl'
mapAttrs
match
readDir
readFile
;
## `mergeAttrs` and `concatMapAttrs` are in `lib.trivial` and `lib.attrsets`,
## but we would rather avoid a dependency in nixpkgs for this file.
mergeAttrs = x: y: x // y;
concatMapAttrs = f: v: foldl' mergeAttrs { } (attrValues (mapAttrs f v));
removePubSuffix =
s:
let
maybeMatch = match "(.*)\.pub" s;
in
if maybeMatch == null then s else elemAt maybeMatch 0;
removeTrailingWhitespace = s: elemAt (match "(.*[^[:space:]])[[:space:]]*" s) 0;
collectKeys =
dir:
concatMapAttrs (name: _: {
"${removePubSuffix name}" = removeTrailingWhitespace (readFile (dir + "/${name}"));
}) (readDir dir);
in
{
contributors = collectKeys ./contributors;
systems = collectKeys ./systems;
}

3
keys/flake-part.nix Normal file
View file

@ -0,0 +1,3 @@
{
flake.keys = import ./.;
}

1
keys/systems/vm02116 Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILriawl1za2jbxzelkL5v8KPmcvuj7xVBgwFxuM/zhYr

1
keys/systems/vm02179 Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPAsOCOsJ0vNL9fGj0XC25ir8B+k2NlVJzsiVUx+0eWM

1
keys/systems/vm02186 Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAII6mnBgEeyYE4tzHeFNHVNBV6KR+hAqh3PYSqlh0QViW

1
keys/systems/vm02187 Normal file
View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN24ZfdQNklKkIqfMg/+0vqENuDcy6fhT6SfAq01ae83

51
secrets/README.md Normal file
View file

@ -0,0 +1,51 @@
# Secrets
Secrets are handled using [Agenix](https://github.com/ryantm/agenix).
## Cheat sheet
### Adding a secret
As an example, let us add a secret in a file “cheeses” whose content should be
“best ones come unpasteurised”.
1. Edit [`secrets.nix`](./secrets.nix), adding a field to the final record with
the file name mapped to the systems that should be able to decrypt the
secret, for instance:
```nix
cheeses = [ vm02116 forgejo-ci ];
```
2. Run Agenix to add the content of the file. Agenix is provided by the
development Shell but can also be run directly with `nix run
github:ryantm/agenix --`. Run `agenix -e cheeses.age` (with the `.age`
extension); this will open your `$EDITOR` ; enter “best ones come
unpasteurised”, save and close.
3. If you are doing something flake-related such as NixOps4, remember to commit
or at least stage the secret.
4. In the machine's configuration, load our `ageSecrets` NixOS module, declare the machine's host key and start using your secrets, eg.:
```nix
{ self, config, ... }:
{
imports = [ self.nixosModules.ageSecrets ];
fediversity.hostPublicKey = self.keys.systems.vmFromage;
services.imaginaryCheeseFactory.frenchSecretFile = config.age.secrets.cheeses.path;
}
```
If the secrets requires specific owner/group/mode, those can be set with:
```nix
age.secrets.cheeses.owner = "jeanpierre";
age.secrets.cheeses.group = "france";
age.secrets.cheeses.mode = "440";
```
5. Never read the content of the file in Nix, that is never do anything like:
```nix
services.imaginaryCheeseFactory.frenchSecret = readFile config.age.secrets.cheeses.path;
```
This will put the secret as a world-readable file in the Nix store. The
service that you are using must be able to read from a file at runtime, and
if the NixOS default module options do not provide that, you must find a way
around it.

39
secrets/flake-part.nix Normal file
View file

@ -0,0 +1,39 @@
{
inputs,
lib,
...
}:
let
inherit (builtins) elem;
inherit (lib.attrsets) concatMapAttrs optionalAttrs;
inherit (lib.strings) removeSuffix;
secrets = import ./secrets.nix;
in
{
flake = {
inherit secrets;
nixosModules.ageSecrets = (
{ config, ... }:
{
imports = [ inputs.agenix.nixosModules.default ];
options.fediversity.hostPublicKey = lib.mkOption {
description = ''
The host public key of the machine. It is used in particular
to filter Age secrets and only keep the relevant ones.
'';
};
config.age.secrets = concatMapAttrs (
name: secret:
optionalAttrs (elem config.fediversity.hostPublicKey secret.publicKeys) ({
${removeSuffix ".age" name}.file = ./. + "/${name}";
})
) secrets;
}
);
};
}

Binary file not shown.

View file

@ -0,0 +1,8 @@
age-encryption.org/v1
-> ssh-ed25519 1MUEqQ Y+wylE1yiRBPh5aX3LNeX7/5YQ/EfPOplCBmIoR69yA
Vfvi1DZo927okyWLcfoVhVOada5bVdgcLXWzroIycGU
-> ssh-ed25519 Fa25Dw PFDPqt30lbvvf1Mu/AVMKfv/XyC2fIfnpvKrmyjDiRw
S9Qn+jNMpS4T5OlTIq0SFMTyKlq4Sz7ADdtKDuQoGB4
--- 8/wxDtoP6ZfHqvQS8ld264jPEunSzbFP7Yqy664fyQ0
<>õCÉs±<73>%}+Õ xÎ¥NX¤^‚Ø»ÞË
s<EFBFBD>$bÝbæÙ<C3A6>ò€õ©N

View file

@ -0,0 +1,11 @@
age-encryption.org/v1
-> ssh-ed25519 1MUEqQ 5Bvi8UvLbifM2vlDOr4NRaZLRfIg6kAPY0oiwiSy50o
TnbS5BHO4hmjs7Ux9rRMzK9ahsIkU9GpmAx59MzIpI0
-> ssh-ed25519 h0QWFg 4Cu85VZM6zyysIYwMFccXUWUGejkylHiytJA4+2nN1Q
e8XuOUfrOZ6xoWNK4gvVgs0H5pgtqUfrv/DBeh1WIsU
-> ssh-ed25519 pJV4iw JQgQMTxfDZ/26In72UHPU+k0ZGBK1DRQWoOwfxS0xwI
8De1c3d95ySwjqjQn9rHlYDfMDTHct1kbyjVx+8EZyA
--- neht26C0cEHeTGVa+epEwoO+oqXvyO94xwp25zAX6wY
¡DèN¯+ÛVâU8©Ø¼Qv©Ò<C2A9>¾þAð~Ž+ûáÄ<C3A1>³L©wª`<60>ó<EFBFBD>üE©XfV®¿©¥0@ùqHj
βRGOY
.?Då9ƒ<39>O[%\

37
secrets/secrets.nix Normal file
View file

@ -0,0 +1,37 @@
let
inherit (builtins) attrValues foldl' mapAttrs;
## `mergeAttrs` and `concatMapAttrs` are in `lib.trivial` and `lib.attrsets`,
## but we would rather avoid a dependency in nixpkgs for this file.
mergeAttrs = x: y: x // y;
concatMapAttrs = f: v: foldl' mergeAttrs { } (attrValues (mapAttrs f v));
keys = import ../keys;
contributors = attrValues keys.contributors;
in
concatMapAttrs
(name: systems: {
"${name}.age".publicKeys = contributors ++ systems;
})
(
with keys.systems;
##############################################################################
## File name <-> system host keys mapping
##
## This attribute set defines precisely which secrets exist and which systems
## are able to decrypt them.
{
forgejo-database-password = [ vm02116 ];
forgejo-email-password = [ vm02116 ];
forgejo-runner-token = [
vm02179
vm02186
];
wiki-basicauth-htpasswd = [ vm02187 ];
wiki-password = [ vm02187 ];
wiki-smtp-password = [ vm02187 ];
}
)

Binary file not shown.

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 1MUEqQ yJ53uyB0OqgbyZS+0Qu/glWZGqx8ALEr2Z0hKUrQgUg
Ewvye5oREhNCASqyql56m2mNbAGnK69fVkjZ0N2ILMk
-> ssh-ed25519 dgBsjw glI8t7C/N4BqpnuZlCnv6TFb+YUQn+0oAjbJI7GrzWw
qFxxFVt2R6FkupbP7qErZ+VFHYwEHVmY4iC6hyEf+Vg
--- fQbt68Fdj7wk8mWFx0W0Z1iRbkWxxK7+zIKw/v+BCE0
¢OÕ+Q±×‰F¾^0縿9ãÕ?\TeËB(ügs½³°¹'—™7…ì§ÁˆŒ(ÁO=>³<)h`qè&<26>^

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 1MUEqQ 4BpvvqFr+tmHeapy7bk3uS6fCS/CbeYkAJuxb5r1g00
YVGpim5rYSzHMTA85lcTy22Fr5464Axdy/nKR3/z8RA
-> ssh-ed25519 dgBsjw mF++5ewvC+oordjFMR82SvGukQTYhqnH80nIgzUkunA
siCm1cQfuzs0I1xl1ACv6gomHmfONqGcxmj2fa4oABY
--- 2dszG1nnnEflzPy+dRj/0CW39mq49QPdgw+to8T1fRg
ûãÆ&£ñ;D÷3í¸s[ÿ±†-«0=x«yËÓ+&MD õËÅie¾ðà/|qßÁ3r´|iIŒÕ~ ˜ÃÄ¢­RfCÕ`Jšòþå