Compare commits

...

3 commits

Author SHA1 Message Date
Patrick 03e0b54183
WIP: netbird 2024-03-21 20:39:59 +01:00
Patrick 86593e3b7d
WIP: netbird 2024-03-19 23:13:24 +01:00
Patrick a2fc99bd6c
feat: init netbird dashboard 2024-03-19 16:30:32 +01:00
15 changed files with 574 additions and 2 deletions

View file

@ -57,4 +57,6 @@
nixpkgs.config.permittedInsecurePackages = lib.trace "remove when possible" [
"nix-2.16.2"
];
services.netbird.enable = true;
}

View file

@ -21,6 +21,7 @@
apispotify = "apisptfy";
kanidm = "auth";
oauth2-proxy = "oauth2";
netbird = "netbird";
};
in "${domains.${hostName}}.${config.secrets.secrets.global.domains.web}";
# TODO hard coded elisabeth nicht so schön
@ -61,6 +62,49 @@ in {
{
enable = true;
recommendedSetup = true;
upstreams.netbird = {
servers."${ipOf "netbird"}:80" = {};
extraConfig = ''
zone netbird 64k ;
keepalive 5 ;
'';
};
upstreams.netbird-mgmt = {
servers."${ipOf "netbird"}:3000" = {};
extraConfig = ''
zone netbird 64k ;
keepalive 5 ;
'';
};
virtualHosts.${domainOf "netbird"} = {
forceSSL = true;
useACMEHost = "web";
locations = {
"/" = {
proxyPass = "http://netbird";
proxyWebsockets = true;
X-Frame-Options = "SAMEORIGIN";
};
"/signalexchange.SignalExchange/".extraConfig = ''
grpc_pass grpc://${ipOf "netbird"}:3001;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
grpc_socket_keepalive on;
'';
"/api".proxyPass = "http://netbird-mgmt";
"/management.ManagementService/".extraConfig = ''
grpc_pass grpc://${ipOf "netbird"}:3000;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
grpc_socket_keepalive on;
'';
};
extraConfig = ''
client_max_body_size 500M ;
'';
};
}
(blockOf "vaultwarden" {maxBodySize = "1G";})
(blockOf "forgejo" {maxBodySize = "1G";})
@ -154,7 +198,7 @@ in {
}
])
(blockOf "paperless" {maxBodySize = "5G";})
(blockOf "ttrss" {port = 80;})
#(blockOf "ttrss" {port = 80;})
(blockOf "yourspotify" {port = 80;})
(blockOf "apispotify" {
port = 80;
@ -262,8 +306,9 @@ in {
// mkContainer "vaultwarden" {}
// mkContainer "ddclient" {}
// mkContainer "ollama" {}
// mkContainer "ttrss" {}
#// mkContainer "ttrss" {}
// mkContainer "yourspotify" {}
// mkContainer "netbird" {}
// mkContainer "kanidm" {}
// mkContainer "nextcloud" {
enablePanzer = true;

View file

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF0+o9G3hA/5PbaxVy+EoOOJw5cHKu3p5yjAaHDHFQ8Q

View file

@ -0,0 +1,107 @@
{
pkgs,
config,
lib,
...
}: let
inherit
(lib)
mkPackageOption
mkIf
mkEnableOption
mkOption
types
isBool
boolToString
;
toStringEnv = value:
if isBool value
then boolToString value
else toString value;
cfg = config.services.netbird-dashboard;
in {
options.services.netbird-dashboard = {
enable = mkEnableOption "the static netbird dashboard frontend";
package = mkPackageOption pkgs "netbird-dashboard" {};
enableNginx = mkEnableOption "Nginx as a webserver serving the backend";
domain = mkOption {
type = types.str;
description = "The domain under which the dashboard runs.";
default = "localhost";
};
settings = mkOption {
type = types.submodule {
freeformType = types.attrsOf (types.oneOf [types.str types.bool]);
config = {
#AUTH_AUTHORITY = ""; #${AUTH_AUTHORITY:-https://$AUTH0_DOMAIN}
#AUTH_CLIENT_ID = ""; #${AUTH_CLIENT_ID:-$AUTH0_CLIENT_ID}
# Due to how the backend and frontend work this secret will be templated into the backend
# and then served statically from your website
# This enables you to login without the normally needed indirection through the backend
# but this also means anyone that can reach your website can
# fetch this secret, which is why there is no real need to put it into
# special options as its public anyway
# As far as I know leaking this secret is just
# an information leak as one can fetch some basic app
# informations from the IDP
# To actually do something one still needs to have login
# data and this secret so this being public will not
# suffice for anything just decreasing security
AUTH_CLIENT_SECRET = ""; #${AUTH_CLIENT_SECRET}
AUTH_AUDIENCE = "netbird"; #${AUTH_AUDIENCE:-$AUTH0_AUDIENCE}
#AUTH_REDIRECT_URI=${AUTH_REDIRECT_URI}
#AUTH_SILENT_REDIRECT_URI=${AUTH_SILENT_REDIRECT_URI}
USE_AUTH0 = false; #${USE_AUTH0:-true}
AUTH_SUPPORTED_SCOPES = "openid profile email"; #${AUTH_SUPPORTED_SCOPES:-openid profile email api offline_access email_verified}
NETBIRD_MGMT_API_ENDPOINT = config.services.netbird-server.domain; #$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(:80|:443)$//')
NETBIRD_MGMT_GRPC_API_ENDPOINT = config.services.netbird-server.domain; #${NETBIRD_MGMT_GRPC_API_ENDPOINT}
#NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
#NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
NETBIRD_TOKEN_SOURCE = "idToken";
#NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
};
};
};
};
config = let
deriv = pkgs.runCommand "template-netbird-dashboard" {} ''
cp -r ${cfg.package} ./temp
${
lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: ''export "${name}"="${toStringEnv value}"'') cfg.settings)
}
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
find temp -type d -exec chmod 755 {} \;
OIDC_TRUSTED_DOMAINS="./temp/OidcTrustedDomains.js"
${pkgs.gettext}/bin/envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
for f in $(grep -R -l AUTH_SUPPORTED_SCOPES ./); do
${pkgs.gettext}/bin/envsubst "$ENV_STR" < "$f" > "$f".copy
mv -f "$f".copy "$f"
done
mkdir -p $out
cp -r ./temp/. $out/
'';
in
mkIf cfg.enable
{
services.nginx = mkIf cfg.enableNginx {
enable = true;
virtualHosts = {
${cfg.domain} = {
locations = {
"/" = {
root = "${deriv}/";
tryFiles = "$uri /index.html";
};
};
};
};
};
};
}

251
modules/netbird-server.nix Normal file
View file

@ -0,0 +1,251 @@
{
config,
pkgs,
lib,
...
}: let
inherit
(lib)
mkEnableOption
mkOption
types
mkPackageOption
mkIf
;
cfg = config.services.netbird-server;
configFile = formatType.generate "config.json" cfg.settings;
formatType = pkgs.formats.json {};
in {
options.services.netbird-server = {
enable = mkEnableOption "netbird, a self hosted wireguard VPN";
package = mkPackageOption pkgs "netbird" {};
domain = mkOption {
description = "The domain of your netbird instance";
};
port = mkOption {
description = "The port the management interface will listen on";
type = types.port;
default = 3000;
};
oidcConfigEndpoint = mkOption {
type = types.str;
example = "https://example.eu.auth0.com/.well-known/openid-configuration";
description = "The oidc discovery endpoint";
};
signalPort = mkOption {
description = "The listening port for the signal protocol";
default = 3001;
type = types.port;
};
singleAccountModeDomain = mkOption {
description = "Optional domain for single account mode, set to null to disable singleAccountMode";
type = types.nullOr types.str;
default = "netbird.selfhosted";
example = null;
};
turn = {
domain = mkOption {
description = "The domain under which the TURN server is reachable";
type = types.str;
example = "localhost";
default = cfg.domain;
};
port = mkOption {
description = "The port under which the TURN server is reachable";
type = types.port;
default = 3478;
};
userName = mkOption {
description = "The Username for logging into your turn server";
type = types.str;
default = "netbird";
};
password = mkOption {
description = "The password for logging into your turn server";
type = types.str;
default = lib.trace "should not be part of the final config" "netbird";
};
};
settings = mkOption {
default = {};
type = types.submodule {
freeformType = formatType.type;
config = {
Stuns = [
{
Proto = "udp";
Uri = "turn:${cfg.turn.domain}:${toString cfg.turn.port}";
Username = "";
Password = null;
}
];
TURNConfig = {
Turns = [
{
Proto = "udp";
Uri = "stun:${cfg.turn.domain}:${toString cfg.turn.port}";
Username = cfg.turn.userName;
Password = cfg.turn.password;
}
];
CredentialsTTL = "12h";
Secret = lib.trace "this should probably be an option as well" "secret";
TimeBasedCredentials = false;
};
Signal = {
Proto = "https";
URI = "${cfg.domain}:443";
Username = "";
Password = null;
};
ReverseProxy = {
TrustedHTTPProxies = [];
TrustedHTTPProxiesCount = 0;
TrustedPeers = [
"0.0.0.0/0"
];
};
Datadir = "/var/lib/netbird-mgmt";
DataStoreEncryptionKey = lib.trace "uppsi wuppsi ich hab mein netbird unsiccccccher gemacht" "X4/obyAolDVhjGsz8NDb4TJqgCfwmCA7lOtJFHt9L3w=";
StoreConfig = {
Engine = "sqlite";
};
HttpConfig = {
Address = "0.0.0.0:${toString cfg.port}";
#"AuthIssuer" = "$NETBIRD_AUTH_AUTHORITY";
#"AuthAudience" = "$NETBIRD_AUTH_AUDIENCE";
#"AuthKeysLocation" = "$NETBIRD_AUTH_JWT_CERTS";
AuthUserIDClaim = "sub";
#"CertFile" = "$NETBIRD_MGMT_API_CERT_FILE";
#"CertKey" = "$NETBIRD_MGMT_API_CERT_KEY_FILE";
#"IdpSignKeyRefreshEnabled" = "$NETBIRD_MGMT_IDP_SIGNKEY_REFRESH";
OIDCConfigEndpoint = cfg.oidcConfigEndpoint;
};
IdpManagerConfig = {
ManagerType = "none";
ClientConfig = {
#"Issuer" = "$NETBIRD_AUTH_AUTHORITY";
#TokenEndpoint = "$NETBIRD_AUTH_TOKEN_ENDPOINT";
ClientID = "netbird-manager";
ClientSecret = lib.trace "oho wer stiehlt meine zugäneg zuerts" "$NETBIRD_IDP_MGMT_CLIENT_SECRET";
GrantType = "client_credentials";
};
#"ExtraConfig" = "$NETBIRD_IDP_MGMT_EXTRA_CONFIG";
#"Auth0ClientCredentials" = null;
#"AzureClientCredentials" = null;
#"KeycloakClientCredentials" = null;
#"ZitadelClientCredentials" = null;
};
DeviceAuthorizationFlow = {
#Provider = "$NETBIRD_AUTH_DEVICE_AUTH_PROVIDER";
ProviderConfig = {
Audience = "netbird";
#"AuthorizationEndpoint" = "";
#"Domain" = "$NETBIRD_AUTH0_DOMAIN";
#"ClientID" = "$NETBIRD_AUTH_DEVICE_AUTH_CLIENT_ID";
#"ClientSecret" = "";
#"TokenEndpoint" = "$NETBIRD_AUTH_TOKEN_ENDPOINT";
#"DeviceAuthEndpoint" = "$NETBIRD_AUTH_DEVICE_AUTH_ENDPOINT";
Scope = "openid profile email";
#"UseIDToken" = "$NETBIRD_AUTH_DEVICE_AUTH_USE_ID_TOKEN";
#"RedirectURLs" = null;
};
};
PKCEAuthorizationFlow = {
ProviderConfig = {
Audience = "netbird";
ClientID = "netbird";
ClientSecret = lib.trace "oho bei zo vielen sicherheitzlücken" "";
Domain = "";
#AuthorizationEndpoint = "$NETBIRD_AUTH_PKCE_AUTHORIZATION_ENDPOINT";
#TokenEndpoint = "$NETBIRD_AUTH_TOKEN_ENDPOINT";
Scope = "openid profile email";
RedirectURLs = ["http://localhost:53000"];
UseIDToken = true;
};
};
};
};
};
};
config = mkIf cfg.enable {
systemd.services = {
netbird-signal = {
after = ["network.target"];
wantedBy = ["netbird-management.service"];
restartTriggers = [
configFile
];
serviceConfig = {
ExecStart = ''
${cfg.package}/bin/netbird-signal run \
--log-file console \
--port ${builtins.toString cfg.signalPort}
'';
Restart = "always";
RuntimeDirectory = "netbird-mgmt";
StateDirectory = "netbird-mgmt";
WorkingDirectory = "/var/lib/netbird-mgmt";
};
unitConfig = {
StartLimitInterval = 5;
StartLimitBurst = 10;
};
stopIfChanged = false;
};
netbird-management = {
description = "The management server for Netbird, a wireguard VPN";
documentation = ["https://netbird.io/docs/"];
after = [
"network.target"
"netbird-setup.service"
];
wantedBy = ["multi-user.target"];
wants = [
"netbird-signal.service"
"netbird-setup.service"
];
restartTriggers = [
configFile
];
serviceConfig = {
ExecStart = ''
${cfg.package}/bin/netbird-mgmt management \
--config ${configFile} \
--datadir /var/lib/netbird-mgmt/data \
--disable-anonymous-metrics \
${
if cfg.singleAccountModeDomain == null
then "--disable-single-account-mode"
else "--single-account-mode-domain ${cfg.singleAccountModeDomain}"
} \
--idp-sign-key-refresh-enabled \
--port ${builtins.toString cfg.port} \
--log-file consolef
'';
# TODO add extraCOmmandLine option
Restart = "always";
RuntimeDirectory = "netbird-mgmt";
StateDirectory = [
"netbird-mgmt"
"netbird-mgmt/data"
];
WorkingDirectory = "/var/lib/netbird-mgmt";
};
unitConfig = {
StartLimitInterval = 5;
StartLimitBurst = 10;
};
stopIfChanged = false;
};
};
};
}

View file

@ -119,6 +119,8 @@ in {
scopeMaps."immich.access" = ["openid" "email" "profile"];
preferShortUsername = true;
};
groups."netbird.access" = {
};
groups."forgejo.access" = {
members = ["forgejo.admins"];

View file

@ -0,0 +1,68 @@
{config, ...}: {
imports = [
../netbird-server.nix
../netbird-dashboard.nix
];
wireguard.elisabeth = {
client.via = "elisabeth";
firewallRuleForNode.elisabeth.allowedTCPPorts = [80 3000 3001];
};
networking.firewall.allowedTCPPorts = [80 3000 3001];
networking.firewall.allowedUDPPorts = [3478];
services.netbird-dashboard = {
enable = true;
enableNginx = true;
domain = "netbird.${config.secrets.secrets.global.domains.web}";
settings = {
AUTH_AUTHORITY = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/openid/netbird";
AUTH_CLIENT_ID = "netbird";
};
};
services.netbird-server = {
enable = true;
domain = "netbird.${config.secrets.secrets.global.domains.web}";
# TODO remove
oidcConfigEndpoint = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/openid/netbird/.well-known/openid-configuration";
singleAccountModeDomain = "netbird.patrick";
settings = {
HttpConfig = {
AuthIssuer = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/openid/netbird";
AuthKeysLocation = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/openid/netbird/public_key.jwk";
};
# Seems to be only useful for idp that netbird supports
IdpManagerConfig.ClientConfig = {
Issuer = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/openid/netbird";
TokenEndpoint = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/token";
};
DeviceAuthorizationFlow = {
Provider = "none";
ProviderConfig = {
AuthorizationEndpoint = "https://auth.${config.secrets.secrets.global.domains.web}/ui/oauth2/";
ClientID = "netbird";
#ClientSecret = "";
TokenEndpoint = "https://auth.${config.secrets.secrets.global.domains.web}/oauth2/token";
#RedirectURLs = ["http://localhost:53000"];
};
};
PKCEAuthorizationFlow.ProviderConfig = {
AuthorizationEndpoint = "https://auth.${config.secrets.secrets.global.domains.web}/ui/oauth2/";
};
};
};
services.coturn = {
enable = true;
realm = "netbird.${config.secrets.secrets.global.domains.web}";
lt-cred-mech = true;
no-cli = true;
extraConfig = ''
fingerprint
user=turn:netbird
no-software-attribute
external-ip=87.170.9.213
'';
};
}

View file

@ -5,6 +5,7 @@
zsh-histdb = super.callPackage ./zsh-histdb.nix {};
your_spotify = super.callPackage ./your_spotify.nix {};
deploy = super.callPackage ./deploy.nix {};
netbird-dashboard = super.callPackage ./netbird-dashboard {};
minify = super.callPackage ./minify {};
mongodb-bin = super.callPackage ./mongodb-bin.nix {};
awakened-poe-trade = super.callPackage ./awakened-poe-trade.nix {};

View file

@ -0,0 +1,22 @@
diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx
index 86137fe..0339fb7 100644
--- a/src/layouts/AppLayout.tsx
+++ b/src/layouts/AppLayout.tsx
@@ -6,7 +6,7 @@ import { cn } from "@utils/helpers";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { Viewport } from "next/dist/lib/metadata/types/extra-types";
-import { Inter } from "next/font/google";
+import localFont from "next/font/local";
import React from "react";
import { Toaster } from "react-hot-toast";
import OIDCProvider from "@/auth/OIDCProvider";
@@ -17,7 +17,7 @@ import ErrorBoundaryProvider from "@/contexts/ErrorBoundary";
import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider";
import { NavigationEvents } from "@/contexts/NavigationEvents";
-const inter = Inter({ subsets: ["latin"] });
+const inter = localFont({ src: "./inter.ttf" });
// Extend dayjs with relativeTime plugin
dayjs.extend(relativeTime);

View file

@ -0,0 +1,40 @@
{
lib,
buildNpmPackage,
fetchFromGitHub,
inter,
}:
buildNpmPackage rec {
pname = "netbird-dashboard";
version = "2.1.3";
src = fetchFromGitHub {
owner = "netbirdio";
repo = "dashboard";
rev = "v${version}";
hash = "sha256-RxqGNIo7UdcVKz7UmupjsCzDpaSoz9UawiUc+h2tyTU=";
};
patches = [
./0001-remove-buildtime-google-fonts.patch
];
CYPRESS_INSTALL_BINARY = 0;
npmDepsHash = "sha256-ts3UuThIMf+wwSr3DpZ+k1i9RnHi/ltvhD/7lomVxQk=";
npmFlags = ["--legacy-peer-deps"];
preBuild = ''
cp ${inter}/share/fonts/truetype/InterVariable.ttf src/layouts/inter.ttf
'';
installPhase = ''
mkdir -p $out
cp -R out/* $out
'';
meta = with lib; {
description = "NetBird Management Service Web UI Panel";
homepage = "https://github.com/netbirdio/dashboard";
license = licenses.bsd3;
maintainers = with maintainers; [thubrecht];
};
}

Binary file not shown.

View file

@ -0,0 +1,16 @@
age-encryption.org/v1
-> X25519 zghXrLqQhlVqAAbMi1k8gvG5IjG9boIJCyEx63DwwGo
/ae8dzj7mPxZdpciA+lLiR6H/WCrIvTkUfaXGP+RZiY
-> piv-p256 XTQkUA A4PgmdpN1WmH++JUTIdADZBqDrCQ2N8HP9FzQ7DtyJuU
CIzSKNP8YYYfMycueE564094XeKJ9mNEceAuUEnvFFI
-> piv-p256 ZFgiIw AgxtRiqyF4Fo6Us/l8vXhWl2tQakCQGwd1Dogf/Wqnyv
Gi9O1lFR2hhfkXoC7cmlpT+iHx0DxeDFmuU9i+Gc4Ms
-> piv-p256 5vmPtQ AsbmH20Pc58VF7tBnoE5iqzlrsahCDTHkvuyAQ4W5SPy
BcJr9QsIDanypSNZ0UWrt0VnJK99LM0FOmCQWc+2rPY
-> piv-p256 ZFgiIw A5CT86jvz263c1GoDrtGVBXZx9EZeQwCL2d/tCXGqay3
8kHhsuD77fPPLPe8JYTuHNcCtp0VJcdrTg220BVdyGc
-> v-"@h-grease sJN %C \ ?mh0`=L
FarOmtacPX3pzMNzucQdNxI8MpVZdumJghhEPiukRJxp5+3InvEp7lvBhtZv49i3
QPoKNFjUweN6aXA9Vs1cpSQ
--- k49nSQRFr22Pc4QtH0WlYQ2/yMpBXSJasmQ97ZcxLkU
^sÛø¬hÈÊ<C388>LÜvÕçøí©++'•å8¶{ZŠïÀÑCÆðŽ}ö¯ZúPQ0U$¸±²¨þ•:YÀ—õ”ïËÊ1­NO l'

View file

@ -0,0 +1 @@
yv8nqlqgBxDIf6oYrn01FRKoKnqZPfdenWIFHxfSLiA=

View file

@ -0,0 +1,16 @@
age-encryption.org/v1
-> X25519 bq+eQrKzKWG2cvp+7cKzpkN7KEbxf4H8aSOBxOBNeVE
uiZloroeAw+q0T9CTGbAg6cdHShGaa5YOVk0iE5FLMM
-> piv-p256 XTQkUA A5CqoI0rxRrOyHv6LksBqtzWPapfCLi6IdK3KAUATzJF
d3VMdZpw0TjU8kZ6WNLcbvenDD4WWxJp2rEogNnW43o
-> piv-p256 ZFgiIw Ah/2IZobkAFu0r0rSHvB9RyQhXh+wk1R9Vlky8J44xib
5GXXZuXybVXcrpU8G8bWYwMOjnzdw7X+YjQaQlA1F4E
-> piv-p256 5vmPtQ AjmJ3ZgFxcbSbGefvufWZNzo0nOc8vl+4jA7kb5kwSbI
2ks2FzxZ/YloeAVCRT/0NEo4hRWzUbknj+pnwtGuEZM
-> piv-p256 ZFgiIw ApvFPxETdpXGYLa9srv+pKFHNOGfa7ie8oyOInKDbOqC
8rIukUZzrkWdH11pnTYfPd259ql/UGg5/Z6SuNvslUA
-> X=N9-grease CPXXj9j! Mf6?oC AuDyAWo z5x1TGOh
CYoYan7n
--- 9xwTgosTBqh7i3YCpHUhvkYV6bormJ3hYP4WHTwwQk4
íIˆy»0Ö[Ûž$ÞŒ‹Ž- ¦¨:k@™ Ê}é9 Ÿåµ
2òîl'*Ô­[Sêr$ÿ*ÅWjüBì,-w£RÒ1ãBý&ø!€.ó@