From 67114c133697ca2b1d00a672d488b83dc10a77b5 Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan Date: Sat, 26 Mar 2022 14:20:30 +0000 Subject: [PATCH] Implement initial containers module --- flake.lock | 6 +- flake.nix | 5 +- lib.nix | 2 +- nixos/boxes/colony.nix | 12 ++- nixos/containers/vaultwarden.nix | 61 ++++++++++++ nixos/default.nix | 2 +- nixos/modules/_list.nix | 1 + nixos/modules/build.nix | 20 +++- nixos/modules/common.nix | 14 +-- nixos/modules/containers.nix | 161 +++++++++++++++++++++++++++++++ nixos/modules/firewall.nix | 7 +- nixos/modules/secrets.nix | 3 +- nixos/modules/server.nix | 10 +- nixos/modules/tmproot.nix | 102 +++++++++++--------- nixos/modules/user.nix | 31 +++++- secrets/vaultwarden.env.age | 8 ++ 16 files changed, 372 insertions(+), 73 deletions(-) create mode 100644 nixos/containers/vaultwarden.nix create mode 100644 nixos/modules/containers.nix create mode 100644 secrets/vaultwarden.env.age diff --git a/flake.lock b/flake.lock index aeba6b0..e36addb 100644 --- a/flake.lock +++ b/flake.lock @@ -150,11 +150,11 @@ }, "impermanence": { "locked": { - "lastModified": 1644623728, - "narHash": "sha256-aG+JnIaFXTM9YqcE5uyBgPlPrkmX4bs+yY5YCfA/vBQ=", + "lastModified": 1647189769, + "narHash": "sha256-6PJ4wqDuFMIw34gM/LxQ9qZPw8vPls4xC7UCeweSvKs=", "owner": "devplayer0", "repo": "impermanence", - "rev": "74be13a87a3bbcbbaf94aea66f9576a1163db4f0", + "rev": "723c1a7535b7cd194c3a2a693a2566ba1e047a89", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 6e7fcd0..5645b52 100644 --- a/flake.nix +++ b/flake.nix @@ -92,9 +92,11 @@ configs = [ # Systems - nixos/boxes/colony.nix nixos/installer.nix + nixos/boxes/colony.nix + nixos/containers/vaultwarden.nix + # Homes home-manager/configs/castle.nix home-manager/configs/macsimum.nix @@ -109,6 +111,7 @@ }; nixos.secretsPath = ./secrets; + deploy-rs.deploy.sshOpts = [ "-i" ".keys/deploy.key" ]; } # Not an internal part of the module system apparently, but it doesn't have any dependencies other than lib diff --git a/lib.nix b/lib.nix index bd58c19..458eee5 100644 --- a/lib.nix +++ b/lib.nix @@ -134,6 +134,6 @@ rec { sshKeyFiles = { me = .keys/me.pub; - deploy = .keys/deploy.key; + deploy = .keys/deploy.pub; }; } diff --git a/nixos/boxes/colony.nix b/nixos/boxes/colony.nix index ae52d60..cebd730 100644 --- a/nixos/boxes/colony.nix +++ b/nixos/boxes/colony.nix @@ -1,9 +1,8 @@ { nixos.systems.colony = { system = "x86_64-linux"; - nixpkgs = "stable"; + nixpkgs = "unstable"; home-manager = "unstable"; - docCustom = false; configuration = { lib, pkgs, modulesPath, config, ... }: let @@ -32,9 +31,10 @@ }; }; server.enable = true; - tmproot.unsaved.ignore = [ - "/var/db/dhcpcd/enp1s0.lease" - ]; + + containers = { + instances.vaultwarden = {}; + }; }; fileSystems = { @@ -58,6 +58,8 @@ enp1s0.useDHCP = true; }; }; + + #systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug"; }; }; } diff --git a/nixos/containers/vaultwarden.nix b/nixos/containers/vaultwarden.nix new file mode 100644 index 0000000..8c3c8e4 --- /dev/null +++ b/nixos/containers/vaultwarden.nix @@ -0,0 +1,61 @@ +{ + nixos.systems.vaultwarden = { + system = "x86_64-linux"; + nixpkgs = "unstable"; + + configuration = { lib, config, ... }: + let + inherit (lib) mkMerge mkIf mkForce; + + vwData = "/var/lib/vaultwarden"; + vwSecrets = "vaultwarden.env"; + in + { + config = mkMerge [ + { + my = { + server.enable = true; + + secrets = { + files."${vwSecrets}" = {}; + }; + + firewall = { + tcp.allowed = [ 80 3012 ]; + }; + }; + + systemd.services.vaultwarden.serviceConfig.StateDirectory = mkForce "vaultwarden"; + services = { + vaultwarden = { + enable = true; + config = { + dataFolder = vwData; + webVaultEnabled = true; + + rocketPort = 80; + websocketEnabled = true; + websocketPort = 3012; + }; + environmentFile = config.age.secrets."${vwSecrets}".path; + }; + }; + } + (mkIf config.my.build.isDevVM { + my.tmproot.persistence.config.directories = [ + { + directory = vwData; + user = config.users.users.vaultwarden.name; + group = config.users.groups.vaultwarden.name; + } + ]; + virtualisation = { + forwardPorts = [ + { from = "host"; host.port = 8080; guest.port = 80; } + ]; + }; + }) + ]; + }; + }; +} diff --git a/nixos/default.nix b/nixos/default.nix index 4574507..d6c82b4 100644 --- a/nixos/default.nix +++ b/nixos/default.nix @@ -37,7 +37,7 @@ let lib = pkgs.lib; # Put the inputs in specialArgs to avoid infinite recursion when modules try to do imports - specialArgs = { inherit inputs; }; + specialArgs = { inherit inputs; inherit (cfg) systems; }; # `baseModules` informs the manual which modules to document baseModules = diff --git a/nixos/modules/_list.nix b/nixos/modules/_list.nix index d4f4f18..418aceb 100644 --- a/nixos/modules/_list.nix +++ b/nixos/modules/_list.nix @@ -9,5 +9,6 @@ server = ./server.nix; deploy-rs = ./deploy-rs.nix; secrets = ./secrets.nix; + containers = ./containers.nix; }; } diff --git a/nixos/modules/build.nix b/nixos/modules/build.nix index 04c29d5..a618ca0 100644 --- a/nixos/modules/build.nix +++ b/nixos/modules/build.nix @@ -31,6 +31,15 @@ let } ]; }; + asContainer = extendModules { + # TODO: see previous + specialArgs = { inherit baseModules; }; + modules = [ + { + boot.isContainer = true; + } + ]; + }; in { options = with lib.types; { @@ -46,13 +55,19 @@ in inherit (asDevVM) type; default = { }; visible = "shallow"; - description = "Configuration as a development VM"; + description = "Configuration as a development VM."; }; asISO = mkOption { inherit (asISO) type; default = { }; visible = "shallow"; - description = "Configuration as a bootable .iso image"; + description = "Configuration as a bootable .iso image."; + }; + asContainer = mkOption { + inherit (asContainer) type; + default = { }; + visible = "shallow"; + description = "Configuration as a container."; }; buildAs = options.system.build; @@ -76,6 +91,7 @@ in # The meta.mainProgram should probably be set upstream but oh well... devVM = recursiveUpdate config.my.asDevVM.system.build.vm { meta.mainProgram = "run-${config.system.name}-vm"; }; iso = config.my.asISO.system.build.isoImage; + container = config.my.asContainer.system.build.toplevel; }; }; }; diff --git a/nixos/modules/common.nix b/nixos/modules/common.nix index b512b26..c7b4538 100644 --- a/nixos/modules/common.nix +++ b/nixos/modules/common.nix @@ -97,18 +97,15 @@ in networking = { domain = mkDefault "int.nul.ie"; - useDHCP = mkDefault false; + useDHCP = false; enableIPv6 = mkDefault true; - }; - virtualisation = { - forwardPorts = flatten [ - (optional config.services.openssh.openFirewall { from = "host"; host.port = 2222; guest.port = 22; }) - ]; + useNetworkd = mkDefault true; }; environment.systemPackages = with pkgs; [ bash-completion vim + ldns ]; programs = { @@ -146,6 +143,11 @@ in }) (mkIf config.my.build.isDevVM { networking.interfaces.eth0.useDHCP = mkDefault true; + virtualisation = { + forwardPorts = flatten [ + (optional config.services.openssh.openFirewall { from = "host"; host.port = 2222; guest.port = 22; }) + ]; + }; }) ]; diff --git a/nixos/modules/containers.nix b/nixos/modules/containers.nix new file mode 100644 index 0000000..613b784 --- /dev/null +++ b/nixos/modules/containers.nix @@ -0,0 +1,161 @@ +{ lib, options, config, systems, ... }: +let + inherit (builtins) attrNames attrValues mapAttrs; + inherit (lib) concatMapStringsSep filterAttrs mkDefault mkIf mkMerge mkAliasDefinitions mkVMOverride mkAfter; + inherit (lib.my) mkOpt'; + + cfg = config.my.containers; + + devVMKeyPath = "/run/dev.key"; + + containerOpts = with lib.types; { name, ... }: { + options = { + system = mkOpt' unspecified systems."${name}".configuration.config.my.buildAs.container + "Top-level system configuration."; + opts = mkOpt' lib.my.naiveModule { } "Options to pass to `containers.*name*`."; + }; + }; +in +{ + options.my.containers = with lib.types; { + networking = { + bridgeName = mkOpt' str "containers" "Name of host bridge."; + hostAddresses = mkOpt' (either str (listOf str)) "172.16.137.1/24" "Addresses for the host bridge."; + }; + persistDir = mkOpt' str "/persist/containers" "Where to store container persistence data."; + instances = mkOpt' (attrsOf (submodule containerOpts)) { } "Individual containers."; + }; + + config = mkMerge [ + (mkIf (cfg.instances != { }) { + assertions = [ + { + assertion = config.systemd.network.enable; + message = "Containers currently require systemd-networkd!"; + } + ]; + + my.firewall.trustedInterfaces = [ cfg.networking.bridgeName ]; + + systemd = { + network = { + netdevs."25-container-bridge".netdevConfig = { + Name = cfg.networking.bridgeName; + Kind = "bridge"; + }; + # Based on the pre-installed 80-container-vz + networks."80-container-vb" = { + matchConfig = { + Name = "vb-*"; + Driver = "veth"; + }; + networkConfig = { + # systemd LLDP doesn't work on bridge interfaces + LLDP = true; + EmitLLDP = "customer-bridge"; + # Although nspawn will set the veth's master, systemd will clear it (systemd 250 adds a `KeepMaster` + # to avoid this) + Bridge = cfg.networking.bridgeName; + }; + }; + networks."80-containers-bridge" = { + matchConfig = { + Name = cfg.networking.bridgeName; + Driver = "bridge"; + }; + networkConfig = { + Address = cfg.networking.hostAddresses; + DHCPServer = true; + # TODO: Configuration for routed IPv6 (and maybe IPv4) + IPMasquerade = "both"; + IPv6SendRA = true; + }; + }; + }; + + tmpfiles.rules = map (n: "d ${cfg.persistDir}/${n} 0755 root root") (attrNames cfg.instances); + }; + + containers = mapAttrs (n: c: mkMerge [ + { + path = "/nix/var/nix/profiles/per-container/${n}"; + ephemeral = true; + autoStart = mkDefault true; + bindMounts = { + "/persist" = { + hostPath = "${cfg.persistDir}/${n}"; + isReadOnly = false; + }; + }; + + privateNetwork = true; + hostBridge = cfg.networking.bridgeName; + additionalCapabilities = [ "CAP_NET_ADMIN" ]; + } + c.opts + + (mkIf config.my.build.isDevVM { + path = mkVMOverride c.system; + bindMounts."${devVMKeyPath}" = { + hostPath = config.my.secrets.vmKeyPath; + isReadOnly = true; + }; + }) + ]) cfg.instances; + }) + + # Inside container + (mkIf config.boot.isContainer { + assertions = [ + { + assertion = config.systemd.network.enable; + message = "Containers currently require systemd-networkd!"; + } + ]; + + my = { + tmproot.enable = true; + }; + + system.activationScripts = { + # impermanence will throw a fit and bail the whole activation script if this already exists (the container + # start script pre-creates it for some reason) + clearMachineId.text = "rm -f /etc/machine-id"; + createPersistentStorageDirs.deps = [ "clearMachineId" ]; + + # Ordinarily I think the Nix daemon does this but ofc it doesn't in the container + createNixPerUserDirs = { + text = + let + users = attrValues (filterAttrs (_: u: u.isNormalUser) config.users.users); + in + concatMapStringsSep "\n" + (u: ''install -d -o ${u.name} -g ${u.group} /nix/var/nix/{profiles,gcroots}/per-user/"${u.name}"'') users; + deps = [ "users" "groups" ]; + }; + }; + + networking = { + useHostResolvConf = false; + }; + # Based on the pre-installed 80-container-host0 + systemd.network.networks."80-container-eth0" = { + matchConfig = { + Name = "eth0"; + Virtualization = "container"; + }; + networkConfig = { + DHCP = "yes"; + LLDP = true; + EmitLLDP = "customer-bridge"; + }; + dhcpConfig = { + UseTimezone = true; + }; + }; + + # If the host is a dev VM + age.identityPaths = [ devVMKeyPath ]; + }) + ]; +} diff --git a/nixos/modules/firewall.nix b/nixos/modules/firewall.nix index d6bdca0..6d2b2b9 100644 --- a/nixos/modules/firewall.nix +++ b/nixos/modules/firewall.nix @@ -4,6 +4,7 @@ let inherit (lib.my) parseIPPort mkOpt' mkBoolOpt'; cfg = config.my.firewall; + iptCfg = config.networking.firewall; in { options.my.firewall = with lib.types; { @@ -31,9 +32,9 @@ in enable = true; ruleset = let - trusted' = "{ ${concatStringsSep ", " cfg.trustedInterfaces} }"; - openTCP = cfg.tcp.allowed ++ config.networking.firewall.allowedTCPPorts; - openUDP = cfg.udp.allowed ++ config.networking.firewall.allowedUDPPorts; + trusted' = "{ ${concatStringsSep ", " (cfg.trustedInterfaces ++ iptCfg.trustedInterfaces)} }"; + openTCP = cfg.tcp.allowed ++ iptCfg.allowedTCPPorts; + openUDP = cfg.udp.allowed ++ iptCfg.allowedUDPPorts; in '' table inet filter { diff --git a/nixos/modules/secrets.nix b/nixos/modules/secrets.nix index 9033361..d6b4d2c 100644 --- a/nixos/modules/secrets.nix +++ b/nixos/modules/secrets.nix @@ -8,6 +8,7 @@ let in { options.my.secrets = with lib.types; { + vmKeyPath = mkOpt' str "/tmp/xchg/dev.key" "Path to dev key when in a dev VM."; key = mkOpt' (nullOr str) null "Public key that secrets for this system should be encrypted for."; files = mkOpt' (attrsOf unspecified) { } "Secrets to decrypt with agenix."; }; @@ -19,7 +20,7 @@ in } // opts) cfg.files; } (mkIf config.my.build.isDevVM { - age.identityPaths = [ "/tmp/xchg/dev.key" ]; + age.identityPaths = [ cfg.vmKeyPath ]; }) ]; } diff --git a/nixos/modules/server.nix b/nixos/modules/server.nix index 4c32973..8ddf16f 100644 --- a/nixos/modules/server.nix +++ b/nixos/modules/server.nix @@ -1,7 +1,7 @@ -{ config, lib, ... }: +{ lib, pkgs, config, ... }: let inherit (lib) mkIf mkDefault; - inherit (lib.my) mkBoolOpt'; + inherit (lib.my) mkBoolOpt' mkDefault'; cfg = config.my.server; uname = if config.my.user.enable then config.my.user.config.name else "root"; @@ -17,6 +17,12 @@ in my.user.homeConfig = { my.gui.enable = false; }; + + documentation.nixos.enable = mkDefault' false; + + environment.systemPackages = with pkgs; [ + tcpdump + ]; }; meta.buildDocsInSandbox = false; diff --git a/nixos/modules/tmproot.nix b/nixos/modules/tmproot.nix index de643a7..323f1ba 100644 --- a/nixos/modules/tmproot.nix +++ b/nixos/modules/tmproot.nix @@ -1,10 +1,12 @@ -{ lib, pkgs, config, ... }: +{ lib, pkgs, options, config, ... }: let - inherit (lib) optionalString concatStringsSep concatMap concatMapStringsSep mkIf mkDefault mkMerge mkVMOverride; + inherit (lib) + optionalString concatStringsSep concatMap concatMapStringsSep mkIf mkDefault mkMerge mkForce mkVMOverride + mkAliasDefinitions; inherit (lib.my) mkOpt' mkBoolOpt' mkVMOverride'; cfg = config.my.tmproot; - enablePersistence = cfg.persistDir != null; + enablePersistence = cfg.persistence.dir != null; showUnsaved = '' @@ -58,8 +60,11 @@ in options = with lib.types; { my.tmproot = { enable = mkBoolOpt' true "Whether to enable tmproot."; - persistDir = mkOpt' (nullOr str) "/persist" "Path where persisted files are stored."; size = mkOpt' str "2G" "Size of tmpfs root"; + persistence = { + dir = mkOpt' (nullOr str) "/persist" "Path where persisted files are stored."; + config = mkOpt' options.environment.persistence.type.nestedTypes.elemType { } "Persistence configuration"; + }; unsaved = { showMotd = mkBoolOpt' true "Whether to show unsaved files with `dynamic-motd`."; ignore = mkOpt' (listOf str) [ ] "Path prefixes to ignore if unsaved."; @@ -77,33 +82,53 @@ in } ]; - my.tmproot.unsaved.ignore = [ - "/tmp" + my.tmproot = { + unsaved.ignore = [ + "/tmp" - # setup-etc.pl will create this for us - "/etc/NIXOS" + # setup-etc.pl will create this for us + "/etc/NIXOS" - # Once mutableUsers is disabled, we should be all clear here - "/etc/passwd" - "/etc/group" - "/etc/shadow" - "/etc/subuid" - "/etc/subgid" + # Once mutableUsers is disabled, we should be all clear here + "/etc/passwd" + "/etc/group" + "/etc/shadow" + "/etc/subuid" + "/etc/subgid" - # Lock file for /etc/{passwd,shadow} - "/etc/.pwd.lock" + # Lock file for /etc/{passwd,shadow} + "/etc/.pwd.lock" - # systemd last updated? I presume they'll get updated on boot... - "/etc/.updated" - "/var/.updated" + # systemd last updated? I presume they'll get updated on boot... + "/etc/.updated" + "/var/.updated" - # Specifies obsolete files that should be deleted on activation - we'll never have those! - "/etc/.clean" + # Specifies obsolete files that should be deleted on activation - we'll never have those! + "/etc/.clean" - # These are set in environment.etc by the sshd module, but because their mode needs to be changed, - # setup-etc will copy them instead of symlinking - "/etc/ssh/authorized_keys.d" - ]; + # These are set in environment.etc by the sshd module, but because their mode needs to be changed, + # setup-etc will copy them instead of symlinking + "/etc/ssh/authorized_keys.d" + ]; + persistence.config = { + # In impermanence the key in `environment.persistence.*` (aka name passed the attrsOf submodule) sets the + # default value, so we need to override it when we mkAliasDefinitions + _module.args.name = mkForce cfg.persistence.dir; + + hideMounts = mkDefault true; + directories = [ + "/var/log" + # In theory we'd include only the files needed individually (i.e. the {U,G}ID map files that track deleted + # users and groups), but `update-users-groups.pl` actually deletes the original files for "atomic update". + # Also the script runs before impermanence does. + "/var/lib/nixos" + "/var/lib/systemd" + ]; + files = [ + "/etc/machine-id" + ]; + }; + }; environment.systemPackages = [ (pkgs.writeScriptBin "tmproot-unsaved" showUnsaved) @@ -119,7 +144,7 @@ in echo -e "\t\e[31;1;4mWarning:\e[0m $count file(s) on / will be lost on shutdown!" echo -e '\tTo see them, run `tmproot-unsaved` as root.' ${optionalString enablePersistence '' - echo -e '\tAdd these files to `environment.persistence."${cfg.persistDir}"` to keep them!' + echo -e '\tAdd these files to `my.tmproot.persistence.config` to keep them!' ''} echo -e "\tIf they don't need to be kept, add them to \`my.tmproot.unsaved.ignore\`." echo @@ -149,8 +174,8 @@ in { assertions = [ { - assertion = config.fileSystems ? "${cfg.persistDir}"; - message = "The 'fileSystems' option does not specify your persistence file system (${cfg.persistDir})."; + assertion = (config.fileSystems ? "${cfg.persistence.dir}") || config.boot.isContainer; + message = "The 'fileSystems' option does not specify your persistence file system (${cfg.persistence.dir})."; } ]; @@ -172,7 +197,7 @@ in sourceDir = "${d.persistentStoragePath}${d.directory}"; in ''([ "$device" = "/mnt-root${sourceDir}" ] && ensurePersistSource "${sourceDir}" "${d.mode}")'') - config.environment.persistence."${cfg.persistDir}".directories} + cfg.persistence.config.directories} waitDevice "$@" } @@ -181,33 +206,20 @@ in alias waitDevice=_waitDevice ''; - environment.persistence."${cfg.persistDir}" = { - hideMounts = mkDefault true; - directories = [ - "/var/log" - # In theory we'd include only the files needed individually (i.e. the {U,G}ID map files that track deleted - # users and groups), but `update-users-groups.pl` actually deletes the original files for "atomic update". - # Also the script runs before impermanence does. - "/var/lib/nixos" - "/var/lib/systemd" - ]; - files = [ - "/etc/machine-id" - ]; - }; + environment.persistence."${cfg.persistence.dir}" = mkAliasDefinitions options.my.tmproot.persistence.config; virtualisation = { diskImage = "./.vms/${config.system.name}-persist.qcow2"; }; } (mkIf config.services.openssh.enable { - environment.persistence."${cfg.persistDir}".files = + my.tmproot.persistence.config.files = concatMap (k: [ k.path "${k.path}.pub" ]) config.services.openssh.hostKeys; }) (mkIf config.my.build.isDevVM { fileSystems = mkVMOverride { # Hijack the "root" device for persistence in the VM - "${cfg.persistDir}" = { + "${cfg.persistence.dir}" = { device = config.virtualisation.bootDevice; neededForBoot = true; }; diff --git a/nixos/modules/user.nix b/nixos/modules/user.nix index 38217a3..400cfa9 100644 --- a/nixos/modules/user.nix +++ b/nixos/modules/user.nix @@ -5,6 +5,7 @@ let cfg = config.my.user; user' = cfg.config; + user = config.users.users.${user'.name}; in { options.my.user = with lib.types; { @@ -37,9 +38,33 @@ in in mkIf (shell != null) (mkDefault' shell); openssh.authorizedKeys.keyFiles = [ lib.my.sshKeyFiles.me ]; }; - # In order for this option to evaluate on its own, home-manager expects the `name` (which is derived from the - # parent attr name) to be the users name, aka `home-manager.users.` - homeConfig = { _module.args.name = lib.mkForce user'.name; }; + homeConfig = { + # In order for this option to evaluate on its own, home-manager expects the `name` (which is derived from the + # parent attr name) to be the users name, aka `home-manager.users.` + _module.args.name = lib.mkForce user'.name; + }; + }; + tmproot.persistence.config = + let + perms = { + mode = "0700"; + user = user.name; + group = user.group; + }; + in { + files = map (file: { + inherit file; + parentDirectory = perms; + }) [ + "/home/${user'.name}/.bash_history" + ]; + directories = map (directory: { + inherit directory; + } // perms) [ + # Persist all of fish; it's not easy to persist just the history fish won't let you move it to a different + # directory. Also it does some funny stuff and can't really be a symlink it seems. + "/home/${user'.name}/.local/share/fish" + ]; }; }; diff --git a/secrets/vaultwarden.env.age b/secrets/vaultwarden.env.age new file mode 100644 index 0000000..7205bbd --- /dev/null +++ b/secrets/vaultwarden.env.age @@ -0,0 +1,8 @@ +age-encryption.org/v1 +-> X25519 Lm6m9mqSeFYvQ3bo73i9KrAzADgWLRcxmUg31JwqgWw +FXbd6LUIA9OlCiMb1Us3T3/RkbQbxWD3pZ77/y3UuDM +-> C3L/E-grease -7Y+*Gh +UEBPiPpYXfbZltNeUQrX4ahsDakgciN6sSLLHkPsX69oGtLuGRQeoDC6tvEtG2Ws +wJEX57JORoAWfZsUtF0Oj+hN++ANcCm1andG45Yf +--- 1Pr1sAqpDFUZBGe97NYMyN3AEgv/EJgBl9DK4Ga93oc +•Ö”’0˜ü`LVoÖ\åÃoÐ¥ëÜyÍýmè ÃÃn^Ä×ûär|Q@†µáq{ÄÖ…f¬ƒéîÏ‹<){ucÈu–Þ–ÅÆH¾ð»!U+7FYhh°W¶ÅkˆÐ;RO¶ \ No newline at end of file