Compare commits

..

No commits in common. "main" and "element" have entirely different histories.

129 changed files with 405 additions and 32489 deletions

21
LICENSE
View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 eyJhb and the Fricloud.dk contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -5,7 +5,6 @@ set -ex
USERNAME="root"
IP="gerd.fricloud.dk"
NIXPKGS=$(nix build --impure --json --expr '(import ./shared/sources).nixpkgs' | jq -r '.[].outputs.out')
NIXPKGS=$(nix eval --impure --json --expr '(import ./shared/sources/default.nix).nixpkgs.outPath' | jq -r)
export NIX_PATH="nixpkgs=$NIXPKGS"

View file

@ -4,37 +4,24 @@
./../shared/applications/server/acme.nix
./../shared/applications/server/nginx.nix
./../shared/applications/server/postgresql.nix # INCLUDES DATABASE BACKUPS
./../shared/applications/server/restic.nix # EXTERNAL BACKUP
# ./../shared/applications/server/podman.nix
./../shared/applications/server/postgresql.nix
./../shared/applications/state/postgresql.nix
./../shared/applications/state/ssh.nix
./gerd/services/fricloud-website.nix
./gerd/services/member-website
./gerd/services/lldap
./gerd/services/lldap.nix
./gerd/services/authelia
./gerd/services/forgejo
./gerd/services/teeworlds.nix
./gerd/services/murmur.nix
./gerd/services/hedgedoc.nix
./gerd/services/cyberchef.nix
./gerd/services/nextcloud.nix
./gerd/services/nextcloud.nix
./gerd/services/stalwart
./gerd/services/wger
./gerd/services/searx.nix
./gerd/services/miniflux.nix
./gerd/services/matrix
./gerd/services/uptime-kuma.nix
./gerd/services/rallly
./gerd/services/notify
./gerd/services/drasl.nix
./gerd/services/drtvrss.nix
./gerd/services/vikunja.nix
./gerd/services/headscale
./gerd/services/monitoring
./gerd/services/element.nix
./gerd/services/matrix-synapse.nix
];
networking.hostName = "gerd";
@ -45,15 +32,11 @@
disks = {
disk = "/dev/sda";
pools.rpool.datasets = {
# zfs create -o quota=1G rpool/safe/svcs/service-name
"safe/svcs/forgejo" = { mountpoint = "/srv/forgejo"; extra.options.quota = "5G"; };
"safe/svcs/hedgedoc" = { mountpoint = "/srv/hedgedoc"; extra.options.quota = "5G"; };
"safe/svcs/nextcloud" = { mountpoint = "/srv/nextcloud"; extra.options.quota = "5G"; };
"safe/svcs/stalwart" = { mountpoint = "/srv/stalwart"; extra.options.quota = "5G"; };
"safe/svcs/synapse" = { mountpoint = "/srv/synapse"; extra.options.quota = "5G"; };
"safe/svcs/wger" = { mountpoint = "/srv/wger"; extra.options.quota = "5G"; };
"safe/svcs/prometheus" = { mountpoint = "/srv/prometheus"; extra.options.quota = "5G"; };
"safe/svcs/postgresql" = { mountpoint = "/srv/postgresql"; extra.options.quota = "5G"; };
"backup/postgresql" = { mountpoint = "/media/backup/postgresqlbackup"; extra.options.quota = "5G"; };
};
@ -66,24 +49,11 @@
platforms.hetzner = {
enable = true;
network.address = [
"65.108.221.240"
"65.108.221.240/32"
"2a01:4f9:c012:743e::1/64"
];
};
};
# setup zramswap (we are very ram limited)
zramSwap = {
enable = true;
memoryPercent = 75;
algorithm = "lz4";
};
# TMP FIX FOR https://github.com/nix-community/impermanence/issues/229
boot.initrd.systemd.suppressedUnits = [ "systemd-machine-id-commit.service" ];
systemd.suppressedSystemUnits = [ "systemd-machine-id-commit.service" ];
system.stateVersion = "24.11";
}

View file

@ -51,10 +51,10 @@ let
auth_request_set $email $upstream_http_remote_email;
## Inject the metadata response headers from the variables into the request made to the backend.
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.username} $user;
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.groups} $groups;
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.email} $email;
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.name} $name;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Email $email;
proxy_set_header Remote-Name $name;
## Configure the redirection when the authz failure occurs. Lines starting with 'Modern Method' and 'Legacy Method'
## should be commented / uncommented as pairs. The modern method uses the session cookies configuration's authelia_url
@ -73,29 +73,15 @@ let
## Legacy Method: When there is a 401 response code from the authz endpoint redirect to the portal with the 'rd'
## URL parameter set to $target_url. This requires users update 'auth.example.com/' with their external authelia URL.
error_page 401 =302 https://${config.mine.shared.settings.authelia.domain}/?rd=$target_url;
'';
nginxUnsetAuthHeaders = ''
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.username} "";
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.groups} "";
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.email} "";
proxy_set_header ${config.mine.shared.lib.authelia.protectedHeaders.name} "";
error_page 401 =302 https://auth.fricloud.dk/?rd=$target_url;
'';
in {
mine.shared.lib.authelia.mkProtectedWebsite = websiteConfig: lib.recursiveUpdate websiteConfig {
extraConfig = (websiteConfig.extraConfig or "") + "\n" + "include ${autheliaLocation};";
locations = lib.mapAttrs (n: v: v // { extraConfig = nginxUnsetAuthHeaders + (v.extraConfig or ""); }) (websiteConfig.locations or {});
mine.shared.lib.authelia.mkProtectedWebsite = { vhostConfig, endpoint ? "/" }: lib.recursiveUpdate vhostConfig {
extraConfig = (lib.attrByPath [ "extraConfig" ] "" vhostConfig) + "\n" + "include ${autheliaLocation};";
locations."${endpoint}" = config.mine.shared.lib.authelia.mkProtectedLocation (lib.attrByPath [ "locations" endpoint ] {} vhostConfig);
};
mine.shared.lib.authelia.mkProtectedLocation = vhostLocationConfig: lib.recursiveUpdate vhostLocationConfig {
extraConfig = (lib.attrByPath [ "extraConfig" ] "" vhostLocationConfig) + "\n" + "include ${autheliaRequest};";
};
mine.shared.lib.authelia.protectedHeaders = {
username = "Remote-User";
groups = "Remote-Groups"; # comma separated string of groups
email = "Remote-Email";
name = "Remote-Name";
};
}

View file

@ -1,4 +1,4 @@
{ pkgs, config, ... }:
{ config, ... }:
let
svc_domain = "auth.${config.mine.shared.settings.domain}";
@ -10,14 +10,6 @@ let
in {
services.authelia.instances.main = {
enable = true;
package = pkgs.authelia.override {
authelia-web = pkgs.authelia.passthru.web.overrideAttrs (old: {
postPatch = old.postPatch + ''
substituteInPlace src/views/LoginPortal/FirstFactor/FirstFactorForm.tsx \
--replace-fail "const [rememberMe, setRememberMe] = useState(false)" "const [rememberMe, setRememberMe] = useState(true)"
'';
});
};
environmentVariables.AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = config.age.secrets.lldap-bind-user-pass.path;
environmentVariables.AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = config.age.secrets.authelia-smtp-password.path;
@ -34,10 +26,6 @@ in {
authelia_url = "https://${svc_domain}";
} ];
# setup redis for sessions, otherwise it's in-memory, and everyone
# has to login again each time authelia is restarted
session.redis.host = "${config.services.redis.servers.authelia.unixSocket}";
server.address = "tcp://127.0.0.1:${builtins.toString port}";
# totp - disable for now, as it requires email server
@ -90,35 +78,9 @@ in {
user = config.mine.shared.settings.ldap.bind_dn;
};
};
# authelia have changed how the by-default handles auth, so in theory everything
# should contact the `userinfo` endpoint. but not everything does, which leads to us
# having to create a default policy for this
# https://github.com/pulsejet/nextcloud-oidc-login/issues/311#issuecomment-2763239352
identity_providers.oidc.claims_policies.default.id_token = [
"rat"
"groups"
"email"
"email_verified"
"alt_emails"
"preferred_username"
"name"
];
};
};
# setup redis for persisting session
# across reboots
services.redis.servers.authelia = {
enable = true;
user = authelia_user;
};
# setup lldap user for authelia that can send emails
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
authelia = llib.mkProvisionUserSystem "authelia" config.age.secrets.authelia-smtp-password.path;
});
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;

View file

@ -3,7 +3,4 @@
./authelia.nix
./authelia-nginx.nix
];
# generate new authelia client secret like so
# authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986
}

View file

@ -1,153 +0,0 @@
{ config, pkgs, lib, ... }:
let
sources = import ./../../../shared/sources;
flake-compat = sources.flake-compat;
drasl = import flake-compat { src = sources.drasl; };
svc_domain = "drasl.${config.mine.shared.settings.domain}";
port = 25585;
draslOIDCName = "Authelia";
in {
imports = [
drasl.defaultNix.nixosModules.drasl
];
services.drasl = {
enable = true;
settings = {
ApplicationOwner = config.mine.shared.settings.brand;
Domain = svc_domain;
BaseURL = "https://${svc_domain}";
ListenAddress = "localhost:${builtins.toString port}";
# all ldap admins in group `drasl-admin` are default admins here
DefaultAdmins = config.mine.shared.lib.ldap.mkScope (lconfig: llib: let
admins = lib.forEach (
lib.filter
(v: lib.elem lconfig.groups.drasl_admin (v.groups or []))
(lib.attrValues lconfig.provision.users)
) (v: v.mail);
in admins);
# allow importing players
ImportExistingPlayer = {
Allow = true;
Nickname = "Mojang";
AccountURL = "https://api.mojang.com";
SessionURL = "https://sessionserver.mojang.com";
SetSkinURL = "https://www.minecraft.net/msaprofile/mygames/editskin";
RequireSkinVerification = false; # TODO: should maybe be changed to true in the future
};
RegistrationExistingPlayer.Allow = true;
# only allow loging using OIDC
CreateNewPlayer.Allow = true;
RegistrationNewPlayer.Allow = true;
AllowPasswordLogin = false;
# configure OIDC
RegistrationOIDC = [{
Name = draslOIDCName;
Issuer = "https://${config.mine.shared.settings.authelia.domain}";
ClientID = "drasl";
# ClientSecret = "<gotten-from-env>";
PKCE = true;
RequireInvite = false;
AllowChoosingPlayerName = true;
}];
};
};
# secrets
systemd.services.drasl.serviceConfig.EnvironmentFile = config.age.secrets.drasl-env.path;
systemd.services.drasl.restartTriggers = [ config.age.secrets.drasl-env.path ]; # unsure if this works
# setup for oidc
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "drasl";
client_name = "Drasl";
client_secret = "$pbkdf2-sha512$310000$x8USzEVE/HW7/tiYtgTFaA$POg.0gZuWfHTuO0Z2Dd1GZ.T2813IAG.nWnwOarHGBz7aCGI1rdRoaS7gZ9V6bnTWWiFL/lqk5NFoqdZn94neg";
consent_mode = "implicit";
redirect_uris = [ "${config.services.drasl.settings.BaseURL}/web/oidc-callback/${draslOIDCName}" ];
scopes = [
"openid"
"profile"
"email"
];
}];
# nginx
services.nginx.virtualHosts."${svc_domain}" = let
httpListenOn = "http://localhost:${builtins.toString port}";
in config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = httpListenOn;
};
# needed for clients to auth
locations."/authlib-injector".proxyPass = httpListenOn;
# needed for server to auth
locations."/auth".proxyPass = httpListenOn;
locations."/account".proxyPass = httpListenOn;
locations."/session".proxyPass = httpListenOn;
locations."/services".proxyPass = httpListenOn;
# skins
locations."/web/texture".proxyPass = httpListenOn;
};
# persistence
environment.persistence.root.directories = [
{ directory = "/var/lib/private/drasl"; mode = "0700"; }
];
# meta
mine.shared.meta.drasl = rec {
name = "Drasl";
description = ''
Yggdrasil-compatible API server for Minecraft, which can be used instead of the official Minecraft authentication server.
This means that we do not require Mojangs servers, to authenticate with any server managed by ${config.mine.shared.settings.brand}.
It is possible to login with OIDC on Drasl, and then import your Mojang player into Drasl.
'';
url = "https://${svc_domain}";
package = let
pkg = config.services.drasl.package;
in {
name = pkg.pname;
version = pkg.version;
meta = with lib; {
description = "Yggdrasil-compatible API server for Minecraft";
license = lib.licenses.gpl3Only;
homepage = "https://github.com/unmojang/drasl";
platforms = platforms.all;
};
};
};
# TODO(eyJhb): this should not be placed here
mine.shared.meta.minecraft = rec {
name = "Minecraft";
description = ''We're running a vanilla Minecraft hosted externally by a member'';
url = "mcvanilla.${config.mine.shared.settings.domain}";
package = let
pkg = pkgs.minecraft-server;
in {
name = pkg.pname;
version = "1.21.5";
meta = pkg.meta;
};
};
}

View file

@ -1,55 +0,0 @@
{ config, lib, ... }:
let
sources = import ./../../../shared/sources;
flake-compat = sources.flake-compat;
drtvrss = import flake-compat { src = sources.drtvrss; };
svc_domain = "drtv.${config.mine.shared.settings.domain}";
port = 8125;
in {
imports = [
# (builtins.trace drtvrss.defaultNix.nixosModules.x86_64-linux.default drtvrss.defaultNix.nixosModules.default)
drtvrss.defaultNix.nixosModules.x86_64-linux.default
];
services.drtvrss = {
enable = true;
host = "127.0.0.1:${builtins.toString port}";
base_url = "https://${svc_domain}";
klagemail = "rasmus@rend.al";
recommended_shows = [ "matador_130149" ];
};
systemd.services.drtvrss.confinement.enable = lib.mkForce false;
# nginx
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString port}";
};
# meta
mine.shared.meta.drtvrss = rec {
name = "DRTV RSS";
description = ''
Alternative frontend for DRTV, whithout the requirement to login + the ability to generate RSS feeds of your favorite shows.
'';
url = "https://${svc_domain}";
package = {
name = "drtvrss";
version = "v0.0.1";
meta = with lib; {
description = "DRTVRSS";
license = licenses.agpl3Only;
homepage = "https://github.com/RasmusRendal/drtvrss";
platforms = platforms.all;
};
};
};
}

View file

@ -6,17 +6,11 @@ let
# configure element web client
pkg_element = pkgs.element-web.override {
conf = {
default_server_name = config.mine.shared.settings.domain;
embedded_pages.login_for_welcome = true;
default_theme = "dark";
features.feature_latex_maths = true;
disable_guests = true;
brand = config.mine.shared.settings.brand;
default_theme = "dark";
features = {
feature_latex_maths = true;
feature_video_rooms = false;
};
default_server_name = config.mine.shared.settings.domain;
};
};
in {

View file

@ -99,7 +99,6 @@ in {
client_id = "forgejo";
client_name = "Forgejo";
client_secret = "$pbkdf2-sha512$310000$cOGtLwMHyfugAJCIiUUjfQ$ao7zC8QB1m8aTGNf1dxYbRAPivZ0G1eaJ4bNFVfJiTFZX06U5baBjT0emvoaeFHXMFbYHzorb2/8vxnY/D0b5Q";
consent_mode = "implicit";
redirect_uris = [ "https://${config.mine.shared.settings.forgejo.domain}/user/oauth2/${AUTHELIA_AUTH_NAME}/callback" ];
scopes = [
"openid"

View file

@ -37,6 +37,10 @@ in {
};
};
# TODO(eyJhb): remove after our ban expires (and nginx config)
# already issued for this exact set of domains in the last 168 hours: git.fricloud.dk, retry after 2024-08-10T01:34:44Z
security.acme.certs."git.fricloud.dk".extraDomainNames = [ "git2.fricloud.dk" ];
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;

View file

@ -1,26 +1,11 @@
diff --git a/templates/user/auth/link_account.tmpl b/templates/user/auth/link_account.tmpl
index e8bb3d409c..aa6d18b97a 100644
index 8dd49ccd60..8cdce5e1ad 100644
--- a/templates/user/auth/link_account.tmpl
+++ b/templates/user/auth/link_account.tmpl
@@ -4,12 +4,12 @@
<div class="overflow-menu-items tw-justify-center">
<!-- TODO handle .ShowRegistrationButton once other login bugs are fixed -->
{{if not .AllowOnlyInternalRegistration}}
- <a class="item {{if not .user_exists}}active{{end}}"
+ <a class="item"
data-tab="auth-link-signup-tab">
{{ctx.Locale.Tr "auth.oauth_signup_tab"}}
</a>
{{end}}
- <a class="item {{if .user_exists}}active{{end}}"
+ <a class="item active"
data-tab="auth-link-signin-tab">
{{ctx.Locale.Tr "auth.oauth_signin_tab"}}
</a>
@@ -17,11 +17,11 @@
</overflow-menu>
<div class="ui middle very relaxed page grid">
<div class="column tw-flex tw-flex-col tw-gap-4 tw-max-w-2xl tw-m-auto">
<div class="column">
- <div class="ui tab {{if not .user_exists}}active{{end}}"
+ <div class="ui tab"
data-tab="auth-link-signup-tab">

View file

@ -1,20 +1,20 @@
diff --git a/templates/user/auth/signin_inner.tmpl b/templates/user/auth/signin_inner.tmpl
index d4ba664e37..2c94eafc22 100644
index 9872096fbc..1076f90326 100644
--- a/templates/user/auth/signin_inner.tmpl
+++ b/templates/user/auth/signin_inner.tmpl
@@ -11,6 +11,7 @@
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.SignInLink}}" method="post">
+ <div {{if not .LinkAccountMode}}style="display:none;"{{end}}>
{{.CsrfTokenHtml}}
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
<label for="user_name">{{ctx.Locale.Tr "home.uname_holder"}}</label>
@@ -43,6 +44,7 @@
{{end}}
</button>
</div>
+ </div>
</form>
{{template "user/auth/oauth_container" .}}
@@ -10,6 +10,7 @@
</h4>
<div class="ui attached segment">
<form class="ui form tw-max-w-2xl tw-m-auto" action="{{.SignInLink}}" method="post">
+ <div {{if not .LinkAccountMode}}style="display:none;"{{end}}>
{{.CsrfTokenHtml}}
<div class="required field {{if and (.Err_UserName) (or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn))}}error{{end}}">
<label for="user_name">{{ctx.Locale.Tr "home.uname_holder"}}</label>
@@ -53,6 +54,7 @@
<div class="divider divider-text">
{{ctx.Locale.Tr "sign_in_or"}}
</div>
+ </div>
<div id="oauth2-login-navigator" class="tw-py-1">
<div class="tw-flex tw-flex-col tw-justify-center">
<div id="oauth2-login-navigator-inner" class="tw-flex tw-flex-col tw-flex-wrap tw-items-center tw-gap-2">

View file

@ -1,26 +0,0 @@
{ config, pkgs, ... }:
let
svc_domain = "headscale.${config.mine.shared.settings.domain}";
in {
imports = [
./headscale.nix
./headplane.nix
];
mine.shared.meta.headscale = {
name = "";
description = "";
package = let
pkg = pkgs.headscale;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
mine.shared.settings.headscale.domain = svc_domain;
}

View file

@ -1,126 +0,0 @@
{ config, pkgs, ... }:
let
svc_domain = config.mine.shared.settings.headscale.domain;
sources = import ./../../../../shared/sources;
flake-compat = sources.flake-compat;
newpkgs = (import (builtins.fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/c2a03962b8e24e669fb37b7df10e7c79531ff1a4.tar.gz";
}) {});
headplanesrc = let
tmppkgs = (import (builtins.fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/ab7b6889ae9d484eed2876868209e33eb262511d.tar.gz";
}) {});
src = builtins.fetchTarball {
url = "https://github.com/tale/headplane/archive/2f316176c8c37ad63946d7075c727478f81303b2.tar.gz";
};
in tmppkgs.applyPatches {
src = src;
name = "headplane-patched";
patches = [
(tmppkgs.writeText "headplane-package-pnpm-hash.patch" ''
diff --git a/nix/package.nix b/nix/package.nix
index bb430d7..11349c4 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -23,7 +23,7 @@ stdenv.mkDerivation (finalAttrs: {
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
- hash = "sha256-OOWgYaGwa5PtWhFEEkRCojCDmkPIR6tJ5cfFMOLND3I=";
+ hash = "sha256-xjjkqbgjYaAGYAmlTFE+Lq3Hp6myZKaW3br0YTDNhQA=";
};
'')
];
};
headplane = import flake-compat { src = headplanesrc; };
in {
imports = [
headplane.defaultNix.nixosModules.headplane
];
services.headplane = {
enable = true;
agent.enable = false;
settings = {
server = {
host = "127.0.0.1";
port = 53874;
cookie_secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; # replaced in env
cookie_secure = true;
};
headscale = {
url = "https://${svc_domain}";
config_strict = false;
};
oidc = {
issuer = "https://${config.mine.shared.settings.authelia.domain}";
client_id = "headplane";
client_secret = "<from_env>";
redirect_uri = "https://${svc_domain}/admin/oidc/callback";
# headscale API key for authenticating users
headscale_api_key = "<from_env>";
# default to state directory
user_storage_file = "/var/lib/headplane/users.json";
# set to the default authelia auth method
token_endpoint_auth_method = "client_secret_basic";
# disable authenticating with headscale api key
disable_api_key_login = true;
};
};
};
# headplane module does not allow setting package,
# so we have to add headplane to pkgs
nixpkgs.overlays = [
(self: super: {
headplane = headplane.defaultNix.packages.x86_64-linux.headplane;
})
];
systemd.services.headplane.serviceConfig = {
# setup state directory
StateDirectory = "headplane";
# load configs from env file
EnvironmentFile = [ config.age.secrets.headplane-env.path ];
};
# setup for oidc
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "headplane";
client_name = "Headplane";
client_secret = "$pbkdf2-sha512$310000$h7Te42JTu4Xsqz/8CGan7Q$qDd183LHmEsgNvVAI8Xf.1DpRMeS8DqNmDpkkjkxgRR/lZYQgAkXYzL2MyvLqNFFSVKAdMTsD/Jxk72g9fxnew";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/admin/oidc/callback" ];
scopes = [
"openid"
"profile"
"email"
];
}];
# nginx
services.nginx.virtualHosts."${svc_domain}".locations."/admin" = {
proxyPass = "http://127.0.0.1:${builtins.toString config.services.headplane.settings.server.port}";
priority = 5;
};
# persistence
environment.persistence.root.directories = [
"/var/lib/headplane"
];
}

View file

@ -1,80 +0,0 @@
{ config, pkgs, ... }:
let
svc_domain = config.mine.shared.settings.headscale.domain;
svc_domain_dns_magic_domain = "ts.${config.mine.shared.settings.domain}";
grpc_port = 50443;
in {
services.headscale = {
enable = true;
settings = {
server_url = "https://${svc_domain}";
# grpc
grpc_listen_addr = "127.0.0.1:${builtins.toString grpc_port}";
grpc_allow_insecure = true;
# dns
dns.base_domain = svc_domain_dns_magic_domain;
dns.nameservers.global = [ "1.1.1.1" ];
# acl -> save in database
policy.mode = "database";
# auth
oidc = {
only_start_if_oidc_is_available = true;
issuer = "https://${config.mine.shared.settings.authelia.domain}";
client_id = "headscale";
client_secret_path = "$CREDENTIALS_DIRECTORY/authelia-secret";
};
# debug
log.level = "debug";
};
};
systemd.services.headscale.serviceConfig.LoadCredential = [
"authelia-secret:${config.age.secrets.headscale-authelia-secret.path}"
];
# setup for oidc
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "headscale";
client_name = "Headscale";
client_secret = "$pbkdf2-sha512$310000$/xiUR1oDvUEn4OKEu31COw$Z.IMgW3Qb2.mgCyEq1UUDC0i7cX2GOiywUjY4MsNv4ixQCP1jFO7njctCW2mVCqvkgfylDpsWRM3z.uXTs89IA";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/oidc/callback" ];
scopes = [
"openid"
"profile"
"email"
];
}];
# nginx
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
# headscale gRPC API
locations."/headscale" = {
extraConfig = ''
grpc_pass 127.0.0.1:${builtins.toString grpc_port};
'';
priority = 0;
};
# fallback to headscale
locations."/" = {
proxyPass = "http://127.0.0.1:${builtins.toString config.services.headscale.port}";
proxyWebsockets = true;
};
};
# persistence
environment.persistence.root.directories = [
"/var/lib/headscale"
];
}

View file

@ -0,0 +1,158 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "ldap.${config.mine.shared.settings.domain}";
resetPasswordStartPatch = pkgs.writeText "lldap-reset-password-start.patch" ''
diff --git a/server/src/main.rs b/server/src/main.rs
index 71e4928..63be13c 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -158,7 +158,7 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
))?;
}
if config.force_update_private_key || config.force_ldap_user_pass_reset {
- bail!("Restart the server without --force-update-private-key or --force-ldap-user-pass-reset to continue.");
+ // bail!("Restart the server without --force-update-private-key or --force-ldap-user-pass-reset to continue.");
}
let server_builder = infra::ldap_server::build_ldap_server(
&config,
'';
in {
services.lldap = {
enable = true;
package = pkgs.lldap.overrideAttrs (old: {
patches = old.patches ++ [ resetPasswordStartPatch ];
});
settings = {
verbose = true;
ldap_user_email = "fricloudlldap.grief462@simplelogin.com";
ldap_base_dn = config.mine.shared.settings.ldap.dc;
};
environment = {
# always set admin password on startup
LLDAP_LDAP_USER_PASS_FILE = config.age.secrets.lldap-admin-user-pass.path;
LLDAP_FORCE_LDAP_USER_PASS_RESET = "true";
};
};
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString config.services.lldap.settings.http_port}";
};
# persistent files
environment.persistence.root.directories = [
{ directory = "/var/lib/private/lldap"; mode = "0700"; }
];
# lldap user + setup secrets owner (need to add user for secrets to work)
users.users.lldap = { group = "lldap"; isSystemUser = true; };
users.groups.lldap = {};
age.secrets = {
lldap-admin-user-pass.owner = "lldap";
};
# set settings other services can use
# CN = Common Name
# OU = Organizational Unit
# DC = Domain Component
#
# The users are all located in ou=people, + the base DN, so by default user bob is at cn=bob,ou=people,dc=example,dc=com.
# Similarly, the groups are located in ou=groups, so the group family will be at cn=family,ou=groups,dc=example,dc=com.
# Testing group membership through memberOf is supported, so you can have a filter like: (memberOf=cn=admins,ou=groups,dc=example,dc=com).
mine.shared.settings.ldap = rec {
host = "localhost";
port = 3890;
url = "ldap://${host}:${builtins.toString port}";
dc = "dc=${config.mine.shared.settings.domain_sld},dc=${config.mine.shared.settings.domain_tld}";
bind_dn = "uid=${users.bind},ou=${ou.users},${dc}";
search_base = "ou=${ou.users},${dc}";
user_filter = ph: let
attrs = [ attr.uid attr.email ];
in config.mine.shared.lib.ldap.mkFilter (lconfig: llib:
llib.mkAnd [
(llib.mkGroup lconfig.groups.member)
(llib.mkOr (lib.forEach attrs (v: llib.mkSearch v ph)))
]
);
oc = {
person = "person";
mailAccount = "mailAccount";
groupOfUniqueNames = "groupOfUniqueNames";
};
users = {
admin = "admin";
bind = "bind_user";
};
groups = {
admin = "lldap_admin";
member = "base_member";
};
ou = {
groups = "groups";
users = "people";
};
attr = {
uid = "uid";
firstname = "givenName";
lastname = "sn";
email = "mail";
avatar = "jpegPhoto";
groupname = "cn";
};
age_secret = config.age.secrets.lldap-bind-user-pass.path;
};
mine.shared.lib.ldap = rec {
mkGroup = group_name: "memberof=cn=${group_name},ou=${config.mine.shared.settings.ldap.ou.groups},${config.mine.shared.settings.ldap.dc}";
mkOC = object_class_name: "objectclass=${object_class_name}";
mkSearch = attribute: ph: "${attribute}=${ph}";
mkFilterAdvanced = expr: let
isExpr = value: if value ? type then true else false;
__mkExpr = value: if isExpr value then mkFilterAdvanced value else "(${value})";
_mkExpr = op: value: "(${op}" + (builtins.concatStringsSep "" (lib.forEach value (v: __mkExpr v))) + ")";
mkExpr = expr: assert isExpr expr; if expr.type == "and" then _mkExpr "&" expr.values else _mkExpr "|" expr.values;
in mkExpr expr;
mkAndOr = andExprs: orExprs: mkFilterAdvanced {
type = "and";
values = andExprs ++ [
{ type = "or"; values = orExprs; }
];
};
mkFilter = t: mkFilterAdvanced (t config.mine.shared.settings.ldap config.mine.shared.lib.ldap);
mkScope = t: t config.mine.shared.settings.ldap config.mine.shared.lib.ldap;
mkAnd = v: { type = "and"; values = v; };
mkOr = v: { type = "or"; values = v; };
};
mine.shared.meta.lldap = {
name = "LDAP";
description = "We host our own LDAP server, you can use it to change your displayname, name, password, etc.";
url = "https://${svc_domain}";
package = let
pkg = config.services.lldap.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,301 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "ldap.${config.mine.shared.settings.domain}";
resetPasswordStartPatch = pkgs.writeText "lldap-reset-password-start.patch" ''
diff --git a/server/src/main.rs b/server/src/main.rs
index 6f42473..b3746a1 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -171,7 +171,7 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
))?;
}
if config.force_update_private_key || config.force_ldap_user_pass_reset.is_yes() {
- bail!("Restart the server without --force-update-private-key or --force-ldap-user-pass-reset to continue.");
+ // bail!("Restart the server without --force-update-private-key or --force-ldap-user-pass-reset to continue.");
}
let server_builder = infra::ldap_server::build_ldap_server(
&config,
'';
whoamiPatch = pkgs.writeText "lldap-whoami.patch" ''
diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs
index 7257c31..feda03c 100644
--- a/server/src/infra/ldap_handler.rs
+++ b/server/src/infra/ldap_handler.rs
@@ -26,7 +26,7 @@ use ldap3_proto::proto::{
LdapDerefAliases, LdapExtendedRequest, LdapExtendedResponse, LdapFilter, LdapModify,
LdapModifyRequest, LdapModifyType, LdapOp, LdapPartialAttribute, LdapPasswordModifyRequest,
LdapResult as LdapResultOp, LdapResultCode, LdapSearchRequest, LdapSearchResultEntry,
- LdapSearchScope,
+ LdapSearchScope, OID_PASSWORD_MODIFY, OID_WHOAMI,
};
use std::collections::HashMap;
use tracing::{debug, instrument, warn};
@@ -181,7 +181,7 @@ fn root_dse_response(base_dn: &str) -> LdapOp {
LdapPartialAttribute {
atype: "supportedExtension".to_string(),
// Password modification extension.
- vals: vec![b"1.3.6.1.4.1.4203.1.11.1".to_vec()],
+ vals: vec![OID_PASSWORD_MODIFY.as_bytes().to_vec()],
},
LdapPartialAttribute {
atype: "supportedControl".to_string(),
@@ -204,6 +204,11 @@ fn root_dse_response(base_dn: &str) -> LdapOp {
atype: "isGlobalCatalogReady".to_string(),
vals: vec![b"false".to_vec()],
},
+ LdapPartialAttribute {
+ atype: "supportedExtension".to_string(),
+ // whoami extension.
+ vals: vec![OID_WHOAMI.as_bytes().to_vec()],
+ },
],
})
}
@@ -413,16 +418,33 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
#[instrument(skip_all, level = "debug")]
async fn do_extended_request(&mut self, request: &LdapExtendedRequest) -> Vec<LdapOp> {
- match LdapPasswordModifyRequest::try_from(request) {
- Ok(password_request) => self
+ if let Ok(password_request) = LdapPasswordModifyRequest::try_from(request) {
+ return self
.do_password_modification(&password_request)
.await
- .unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)]),
- Err(_) => vec![make_extended_response(
- LdapResultCode::UnwillingToPerform,
- format!("Unsupported extended operation: {}", &request.name),
- )],
+ .unwrap_or_else(|e: LdapError| vec![make_extended_response(e.code, e.message)]);
}
+
+ if request.name == OID_WHOAMI {
+ let dn = self
+ .user_info
+ .as_ref()
+ .map(|user_info| {
+ format!(
+ "dn:uid={},ou=people,{}",
+ user_info.user.clone().into_string(),
+ self.ldap_info.base_dn_str
+ )
+ })
+ .unwrap_or_default();
+
+ return vec![make_extended_response(LdapResultCode::Success, dn)];
+ }
+
+ return vec![make_extended_response(
+ LdapResultCode::UnwillingToPerform,
+ format!("Unsupported extended operation: {}", &request.name),
+ )];
}
async fn handle_modify_change(
'';
pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {};
in {
imports = [
./provision.nix
];
environment.systemPackages = [
pkgLLDAPCli
];
services.lldap = {
enable = true;
package = pkgs.lldap.overrideAttrs (old: {
patches = old.patches ++ [ resetPasswordStartPatch whoamiPatch ];
});
settings = {
verbose = true;
ldap_user_email = "fricloudlldap.grief462@simplelogin.com";
ldap_base_dn = config.mine.shared.settings.ldap.dc;
};
environment = {
# always set admin password on startup
LLDAP_LDAP_USER_PASS_FILE = config.age.secrets.lldap-admin-user-pass.path;
LLDAP_FORCE_LDAP_USER_PASS_RESET = "true";
};
};
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString config.services.lldap.settings.http_port}";
};
# persistent files
environment.persistence.root.directories = [
{ directory = "/var/lib/private/lldap"; mode = "0700"; }
];
# lldap user + setup secrets owner (need to add user for secrets to work)
users.users.lldap = { group = "lldap"; isSystemUser = true; };
users.groups.lldap = {};
age.secrets = {
lldap-admin-user-pass.owner = "lldap";
};
# set settings other services can use
# CN = Common Name
# OU = Organizational Unit
# DC = Domain Component
#
# The users are all located in ou=people, + the base DN, so by default user bob is at cn=bob,ou=people,dc=example,dc=com.
# Similarly, the groups are located in ou=groups, so the group family will be at cn=family,ou=groups,dc=example,dc=com.
# Testing group membership through memberOf is supported, so you can have a filter like: (memberOf=cn=admins,ou=groups,dc=example,dc=com).
mine.shared.settings.ldap = rec {
host = "localhost";
port = 3890;
url = "ldap://${host}:${builtins.toString port}";
dc = "dc=${config.mine.shared.settings.domain_sld},dc=${config.mine.shared.settings.domain_tld}";
bind_dn = "uid=${users.bind},ou=${ou.users},${dc}";
search_base = "ou=${ou.users},${dc}";
user_filter = ph: let
attrs = [ attr.uid attr.email ];
in config.mine.shared.lib.ldap.mkFilter (lconfig: llib:
llib.mkAnd [
(llib.mkGroup lconfig.groups.member)
(llib.mkOr (lib.forEach attrs (v: llib.mkSearch v ph)))
]
);
oc = {
person = "person";
mailAccount = "mailAccount";
groupOfUniqueNames = "groupOfUniqueNames";
};
provision = config.services.lldap.provision;
users = {
admin = "admin";
# bind = "bind_user";
} // (lib.mapAttrs (n: v: v.user_id) config.services.lldap.provision.users);
groups = {
admin = "lldap_admin";
member = "base_member";
password_manager = "lldap_password_manager";
strict_readonly = "lldap_strict_readonly";
# system = "system_service";
# system_email = "system_email";
} // (lib.mapAttrs (n: v: v.display_name) config.services.lldap.provision.groups);
ou = {
groups = "groups";
users = "people";
};
attr = let
toCamelCase = w: let
parts = lib.splitString "_" w;
cap = lib.imap0 (i: v: if i == 0 then v else (lib.toUpper (lib.substring 0 1 v)) + (lib.substring 1 (lib.stringLength v) v));
in lib.concatStrings (cap parts);
in {
uid = "uid";
creationdate = "creationdate";
firstname = "givenName";
lastname = "sn";
email = "mail";
avatar = "jpegPhoto";
groupname = "cn";
# custom
# member_email = "member_email";
# mail_disk_quota = "mail_disk_quota";
} // (lib.mapAttrs (n: v: toCamelCase v.name) config.services.lldap.provision.user_attributes);
age_secret = config.age.secrets.lldap-bind-user-pass.path;
};
mine.shared.lib.ldap = rec {
mkGroup = group_name: "memberof=cn=${group_name},ou=${config.mine.shared.settings.ldap.ou.groups},${config.mine.shared.settings.ldap.dc}";
mkOC = object_class_name: "objectclass=${object_class_name}";
mkSearch = attribute: ph: "${attribute}=${ph}";
mkFilterAdvanced = expr: let
isExpr = value: if value ? type then true else false;
__mkExpr = value: if isExpr value then mkFilterAdvanced value else "(${value})";
_mkExpr = op: value: "(${op}" + (builtins.concatStringsSep "" (lib.forEach value (v: __mkExpr v))) + ")";
mkExpr = expr: assert isExpr expr; if expr.type == "and" then _mkExpr "&" expr.values else _mkExpr "|" expr.values;
in mkExpr expr;
mkAndOr = andExprs: orExprs: mkFilterAdvanced {
type = "and";
values = andExprs ++ [
{ type = "or"; values = orExprs; }
];
};
mkFilter = t: mkFilterAdvanced (t config.mine.shared.settings.ldap config.mine.shared.lib.ldap);
mkScope = t: t config.mine.shared.settings.ldap config.mine.shared.lib.ldap;
mkAnd = v: { type = "and"; values = v; };
mkOr = v: { type = "or"; values = v; };
# mkProvision helpers for creating users
mkProvisionEmail = name: "${name}@${config.mine.shared.settings.domain}";
mkProvisionUserNormal = name: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
display_name = name; # required for nextcloud
membermail = mkProvisionEmail name;
mail = "env:EMAIL_${lib.toUpper name}";
groups = [ lconfig.groups.member ];
membermaildiskquota = 100*1024*1024; # mb
nextcloudquota = 5*1024*1024; # mb
});
mkProvisionUserSystem = name: password_file: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
membermail = mkProvisionEmail name;
mail = mkProvisionEmail name;
password = "file:${password_file}";
groups = [ lconfig.groups.system_mail lconfig.groups.system_service ];
membermaildiskquota = 10*1024*1024; # mb
});
mkProvisionUserSystemExt = name: password_file: custom_attrs: lib.recursiveUpdate (config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
membermail = mkProvisionEmail name;
password = "file:${password_file}";
groups = [ lconfig.groups.system_mail lconfig.groups.system_service ];
membermaildiskquota = 10*1024*1024; # mb
})) custom_attrs;
mkProvisionUserAdmin = name: config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
user_id = name;
display_name = name; # required for nextcloud
membermail = mkProvisionEmail name;
mail = mkProvisionEmail name;
groups = with lconfig.groups; [ admin nextcloud_admin grafana_admin drasl_admin member ];
membermaildiskquota = 100*1024*1024; # mb
nextcloudquota = 100*1024*1024; # mb
});
};
mine.shared.meta.lldap = {
name = "LDAP";
description = "We host our own LDAP server, you can use it to change your displayname, name, password, etc.";
url = "https://${svc_domain}";
package = let
pkg = config.services.lldap.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,161 +0,0 @@
from typing import Any
import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport
from .utils import to_camelcase, to_snakecase
import logging
logger = logging.getLogger(__name__)
class AttributeTypes:
STRING = "STRING"
INTEGER = "INTEGER"
JPEG_PHOTO = "JPEG_PHOTO"
DATE_TIME = "DATE_TIME"
class AttributeSchema:
def __init__(self, raw_vals: dict[str, Any]):
self.name: str = raw_vals["name"]
self.attributeType: str = raw_vals["attributeType"]
self.isList: bool = bool(raw_vals["isList"])
self.isVisible: bool = bool(raw_vals["isVisible"])
self.isEditable: bool = bool(raw_vals["isEditable"])
self.isReadonly: bool = bool(raw_vals["isReadonly"])
self.isHardcoded: bool = bool(raw_vals["isHardcoded"])
def __repr__(self):
return f"<AttributeSchema name={self.name} attributeType={self.attributeType} isList={self.isList} isVisible={self.isVisible} isEditable={self.isEditable} isReadonly={self.isReadonly} isHardcoded={self.isHardcoded} />"
class AttributeValue:
def __init__(self, raw_attribute: dict[str, Any]):
self.name: str = raw_attribute["name"]
tmpValue = raw_attribute.get("value", [])
if isinstance(tmpValue, str):
tmpValue = [tmpValue]
self.value: list[str] = tmpValue
def __repr__(self):
return f"<AttributeValue name={self.name} value={self.value} />"
class LLDAPAttributes:
def __init__(
self,
client: gql.Client,
using_user_attributes: bool = False,
using_group_attributes: bool = False,
):
self._client = client
self._using_user_attributes = using_user_attributes
self._using_group_attributes = using_group_attributes
if self._using_user_attributes and self._using_group_attributes:
raise Exception("can not both use user attributes and group attributes")
if not self._using_user_attributes and not self._using_group_attributes:
raise Exception("neither user attributes and group attributes specified")
def list_all(self) -> dict[str, AttributeSchema]:
ds = DSLSchema(self._client.schema)
if self._using_user_attributes:
querySchema = ds.Schema.userSchema
querySchemaStr = "userSchema"
else:
querySchema = ds.Schema.groupSchema
querySchemaStr = "groupSchema"
query = dsl_gql(
DSLQuery(
ds.Query.schema().select(
querySchema.select(
ds.AttributeList.attributes.select(
ds.AttributeSchema.name,
ds.AttributeSchema.attributeType,
ds.AttributeSchema.isList,
ds.AttributeSchema.isVisible,
ds.AttributeSchema.isEditable,
ds.AttributeSchema.isReadonly,
ds.AttributeSchema.isHardcoded,
)
)
),
),
)
result = self._client.execute(query)
attrs: dict[str, AttributeSchema] = {}
for raw_attribute in result["schema"][querySchemaStr]["attributes"]:
attr = AttributeSchema(raw_attribute)
attrs[attr.name] = attr
return attrs
def create(
self,
name: str,
attributeType: str,
isList: bool,
isVisible: bool,
isEditable: bool,
):
logger.debug(
f"adding attribute, name:'{name}' attributeType:'{attributeType}' isList:'{isList}' isVisible:'{isVisible}' isEditable:'{isEditable}'"
)
ds = DSLSchema(self._client.schema)
if self._using_user_attributes:
mutationSchema = ds.Mutation.addUserAttribute
else:
mutationSchema = ds.Mutation.addGroupAttribute
query = dsl_gql(
DSLMutation(
mutationSchema.args(
name=name,
attributeType=attributeType,
isList=isList,
isVisible=isVisible,
isEditable=isEditable,
).select(ds.Success.ok)
),
)
self._client.execute(query)
def get(self, name: str) -> AttributeSchema | None:
attrs = self.list_all()
return attrs.get(name)
def update(self, name: str):
raise Exception("unable to update attribute")
def delete(self, name: str):
logger.debug(f"deleting attribute '{name}'")
ds = DSLSchema(self._client.schema)
if self._using_user_attributes:
mutationSchema = ds.Mutation.deleteUserAttribute
else:
mutationSchema = ds.Mutation.deleteGroupAttribute
query = dsl_gql(
DSLMutation(
mutationSchema.args(
name=name,
).select(ds.Success.ok)
),
)
self._client.execute(query)

View file

@ -1,79 +0,0 @@
{
"group_attributes": [
{
"name": "hosted_email2",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": false
},
{
"name": "hosted_email10",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": false
}
],
"user_attributes": [
{
"name": "member_email",
"attributeType": "STRING",
"isEditable": false,
"isList": false,
"isVisible": true
}
],
"groups": [
{
"display_name": "base_member",
"hosted_email2": "tehnte_member",
"hosted_email10": "tehnte_member"
},
{ "display_name": "system_service" },
{ "display_name": "lldap_admin" },
{ "display_name": "lldap_password_manager" },
{ "display_name": "lldap_strict_readonly" },
{ "display_name": "disabled" }
],
"users": [
{
"user_id": "admin",
"groups": ["lldap_admin"],
"mail": "admin@fricloud.dk"
},
{
"user_id": "bind_user",
"groups": ["lldap_password_manager", "lldap_strict_readonly"],
"mail": "lldap_bind_user@fricloud.dk"
},
{
"user_id": "authelia",
"groups": ["system_service", "base_member"],
"mail": "authelia@fricloud.dk"
},
{
"user_id": "wger",
"groups": ["base_member"],
"mail": "wger@fricloud.dk"
},
{
"user_id": "testusername",
"groups": ["base_member"],
"password": "env:PASSWORD",
"mail": "testusername@fricloud.dk"
},
{
"user_id": "eyjhb",
"groups": ["lldap_admin", "base_member"],
"mail": "eyjhb@fricloud.dk",
"member_email": "eyjhb@fricloud.dk"
},
{
"user_id": "rasmus",
"groups": ["lldap_admin", "base_member"],
"mail": "rasmus@fricloud.dk",
"member_email": "rasmus@fricloud.dk"
}
]
}

View file

@ -1,137 +0,0 @@
from typing import Any
import re
import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport
from .utils import to_camelcase, to_snakecase, get_value
import logging
logger = logging.getLogger(__name__)
class Group:
def __init__(
self,
raw_attrs: list[dict[str, str]],
):
self._attributes: dict[str, Any] = {
item["name"]: item["value"] for item in raw_attrs
}
self.groupId: int = int(self.__getattr__("groupId")[0])
self.name: str = self.__getattr__("displayName")[0]
def _attributes_camelcase(self) -> dict[str, str]:
return {to_camelcase(k): v for k, v in self._attributes.items()}
def __getattr__(self, key: str):
return self._attributes_camelcase().get(key, "")
def __repr__(self):
return f"<Group groupId={self.groupId} name={self.name} />"
class LLDAPGroups:
def __init__(self, client: gql.Client):
self._client = client
def list_all(self) -> dict[str, Group]:
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLQuery(
ds.Query.groups().select(
ds.Group.attributes.select(
ds.AttributeValue.name,
ds.AttributeValue.value,
ds.AttributeValue.schema.select(
ds.AttributeSchema.isHardcoded,
),
),
),
),
)
result = self._client.execute(query)
groups: dict[str, Group] = {}
for group in result["groups"]:
g = Group(
raw_attrs=group.get("attributes", []),
)
groups[g.name] = g
return groups
def create(self, groupName: str):
logger.debug(f"creating group with name '{groupName}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.createGroup.args(
name=groupName,
).select(ds.Group.displayName)
),
)
self._client.execute(query)
def get_by_name(self, groupName: str) -> Group | None:
groups = self.list_all()
return groups.get(groupName)
def get_by_id(self, groupId: int) -> Group | None:
groups = self.list_all()
for group in groups.values():
if group.groupId == groupId:
return group
return None
def name_to_id(self, groupName: str) -> int:
group = self.get_by_name(groupName)
if not group:
raise Exception(f"no group with the name {groupName}")
return group.groupId
def update(self, groupId: int, attrs: dict[str, str | list[str]]):
insertAttributes: list[dict[str, str | list[str]]] = []
for k, v in attrs.items():
v = get_value(v)
if isinstance(v, str):
v = [v]
insertAttributes.append({"name": k, "value": v})
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.updateGroup.args(
group={
"id": groupId,
"insertAttributes": insertAttributes,
},
).select(ds.Success.ok),
),
)
self._client.execute(query)
def delete(self, groupId: int):
logger.debug(f"deleting group with id '{groupId}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.deleteGroup.args(
groupId=groupId,
).select(ds.Success.ok),
),
)
self._client.execute(query)
def test(self):
self.list_all()
# self.update("testusername", {"displayName": "Test User Name"})

View file

@ -1,345 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell --pure --keep LLDAP_TOKEN -i python3 -p "python3.withPackages (ps: with ps; [ requests gql aiohttp])"
from typing import Any
import subprocess
import requests
import secrets
import json
import sys
import os
import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport
from pprint import pprint
from .users import LLDAPUsers
from .groups import LLDAPGroups
from .attributes import LLDAPAttributes
import logging
logging.basicConfig()
logging.getLogger("bootstrap").setLevel(logging.DEBUG)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class LLDAP:
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_refresh_token: str | None = None
self._simple_login(username, password)
self._client: gql.Client = self._init_gql_client()
self._users = LLDAPUsers(self._client)
self._groups = LLDAPGroups(self._client)
self._attrsUser = LLDAPAttributes(self._client, using_user_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:
# Select your transport with a defined url endpoint
transport = AIOHTTPTransport(
url=f"{self._server_url}/api/graphql",
headers={"Authorization": f"Bearer {self._server_auth_token}"},
)
# Create a GraphQL client using the defined transport
client = gql.Client(transport=transport, fetch_schema_from_transport=True)
# force fetch schema
query = gql.gql(
"""
query {
users {
displayName
}
}
"""
)
result = client.execute(query)
return client
def _run_ensure_attrs_user(self, attrsClass, neededAttrsUser: list[dict[str, Any]]):
dictNeededAttrUser = {v["name"]: v for v in neededAttrsUser}
remoteAttrs = attrsClass.list_all()
# add needed attributes
for neededAttr in neededAttrsUser:
neededAttrName = neededAttr["name"]
if neededAttrName in remoteAttrs:
cattr = remoteAttrs[neededAttrName]
if (
neededAttr["attributeType"] != cattr.attributeType
or neededAttr["isEditable"] != cattr.isEditable
or neededAttr["isList"] != cattr.isList
or neededAttr["isVisible"] != cattr.isVisible
):
logger.debug(
f"attribute '{neededAttrName}' out of sync, deleting and adding again"
)
attrsClass.delete(neededAttrName)
else:
continue
attrsClass.create(
neededAttrName,
attributeType=neededAttr["attributeType"],
isEditable=neededAttr["isEditable"],
isList=neededAttr["isList"],
isVisible=neededAttr["isVisible"],
)
# remove unneeded attributes
for remoteAttrName, remoteAttr in remoteAttrs.items():
# skip hardcoded ones
if remoteAttr.isHardcoded:
continue
if remoteAttrName not in dictNeededAttrUser:
attrsClass.delete(remoteAttrName)
def _run_ensure_groups(self, neededGroups: list[dict[str, Any]]):
tmpNeededGroups = {v["display_name"]: v for v in neededGroups}
remoteGroups = self._groups.list_all()
for neededGroup in neededGroups:
neededGroupDisplay_Name = neededGroup["display_name"]
if neededGroupDisplay_Name not in remoteGroups:
self._groups.create(neededGroupDisplay_Name)
# refresh groups
remoteGroups = self._groups.list_all()
remoteGroup = remoteGroups[neededGroupDisplay_Name]
# we cannot update the display name, and we never would anyways
del neededGroup["display_name"]
self._groups.update(remoteGroup.groupId, neededGroup)
# delete unused groups
for remoteGroupName, remoteGroup in remoteGroups.items():
# skip all lldap_ groups
if remoteGroupName.startswith("lldap_"):
continue
if remoteGroupName not in tmpNeededGroups:
self._groups.delete(remoteGroup.groupId)
def _run_ensure_users(
self,
neededUsers: list[dict[str, Any]],
softDelete: bool = True,
):
tmpNeededUsers = {v["user_id"]: v for v in neededUsers}
remoteUsers = self._users.list_all()
for neededUser in neededUsers:
# get required info from dict, and DELETE from dict
# while we're at it. This means that we can safely use
# `neededUser` for updating later
neededUserId = neededUser.pop("user_id")
neededUserGroups = neededUser.pop("groups", [])
neededUserPassword: str | None = neededUser.pop("password", None)
# create user if needed
if neededUserId not in remoteUsers:
self._users.create(
neededUserId,
neededUser.get("mail", "no-email-specified"),
)
# refresh users
remoteUsers = self._users.list_all()
# update user
self._users.update(neededUserId, neededUser)
# set correct groups
remoteUser = remoteUsers[neededUserId]
# print warning about groups attribute
if neededUserGroups:
logger.info(
f"using attribute 'groups' for userId '{neededUserId}', for setting groups, NOT SETTING AS ATTRIBUTE!!"
)
# add to correct groups
for groupName in neededUserGroups:
if groupName not in remoteUser.groups:
self._users.add_group(
neededUserId,
self._groups.name_to_id(groupName),
)
# remove from unused groups
for groupName, group in remoteUser.groups.items():
if groupName not in neededUserGroups:
self._users.remove_group(
neededUserId,
self._groups.name_to_id(groupName),
)
if neededUserPassword:
logger.info(
f"using attribute 'password' for userId '{neededUserId}', for setting password, NOT SETTING AS ATTRIBUTE!!"
)
if neededUserPassword.startswith("file:"):
passwordFile = neededUserPassword[len("file:") :]
logger.debug(
f"reading password from file from file '{passwordFile}' for user '{neededUserId}'"
)
self._user_set_password(
neededUserId,
open(passwordFile, "r").read().strip(),
)
elif neededUserPassword.startswith("env:"):
cleanedPasswordEnv = neededUserPassword.strip()[len("env:") :]
logger.debug(
f"reading password from envvar '{cleanedPasswordEnv}' for user '{neededUserId}'"
)
password = os.getenv(cleanedPasswordEnv)
if not password:
raise Exception(
f"could not find env '{cleanedPasswordEnv}' for getting password"
)
self._user_set_password(
neededUserId,
password.strip(),
)
else:
logger.debug(
f"using the raw value of password, as the password for user '{neededUserId}'"
)
self._user_set_password(
neededUserId,
neededUserPassword.strip(),
)
# delete unused users
for remoteUserName, remoteUser in remoteUsers.items():
if remoteUserName not in tmpNeededUsers:
if softDelete:
self._user_disable(remoteUserName)
else:
self._users.delete(remoteUser.userId)
def _user_disable(self, userId: str, disabled_group_name: str = "disabled"):
user = self._users.get(userId)
if not user:
return
# remove all groups
for groupName, groupId in user.groups.items():
if groupName == disabled_group_name:
continue
self._users.remove_group(userId, groupId)
# if disabled group is in the users groups, then return
if disabled_group_name in user.groups:
return
# ensure group exists
groups = self._groups.list_all()
if disabled_group_name not in groups:
self._groups.create(disabled_group_name)
# add disabled group
self._users.add_group(userId, self._groups.name_to_id(disabled_group_name))
# set password to a long string
self._user_set_password(userId, secrets.token_urlsafe(128))
def _user_set_password(self, userId: str, password: str):
subprocess.check_output(
[
"lldap_set_password",
f"--base-url={self._server_url}",
f"--token={self._server_auth_token}",
f"--username={userId}",
f"--password={password}",
]
)
def run(self, provision_file_path: str):
data = json.load(open(provision_file_path, "r"))
self._run_ensure_attrs_user(self._attrsUser, data["user_attributes"])
self._run_ensure_attrs_user(self._attrsGroup, data["group_attributes"])
self._run_ensure_groups(data["groups"])
self._run_ensure_users(data["users"])
if __name__ == "__main__":
url = os.getenv("LLDAP_URL")
if not url:
raise Exception("No LLDAP_URL provided. please set")
auth_username = os.getenv("LLDAP_USERNAME")
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()

View file

@ -1,155 +0,0 @@
from typing import Any
import re
import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport
from .utils import to_camelcase, to_snakecase, get_value
import logging
logger = logging.getLogger(__name__)
class User:
def __init__(
self,
raw_groups: list[dict[str, Any]],
raw_attrs: list[dict[str, str]],
):
self._attributes: dict[str, Any] = {
item["name"]: item["value"] for item in raw_attrs
}
self.groups: dict[str, int] = {
info["displayName"]: info["id"] for info in raw_groups
}
self.userId: str = self.__getattr__("userId")[0]
self.email: str = self.__getattr__("mail")[0]
def _attributes_camelcase(self) -> dict[str, str]:
return {to_camelcase(k): v for k, v in self._attributes.items()}
def __getattr__(self, key: str):
return self._attributes_camelcase().get(key, "")
class LLDAPUsers:
def __init__(self, client: gql.Client):
self._client = client
def list_all(self) -> dict[str, User]:
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLQuery(
ds.Query.users().select(
ds.User.groups.select(
ds.Group.id,
ds.Group.displayName,
),
ds.User.attributes.select(
ds.AttributeValue.name,
ds.AttributeValue.value,
ds.AttributeValue.schema.select(
ds.AttributeSchema.isHardcoded,
),
),
),
),
)
result = self._client.execute(query)
users: dict[str, User] = {}
for user in result["users"]:
u = User(
raw_groups=user.get("groups", []),
raw_attrs=user.get("attributes", []),
)
users[u.userId] = u
return users
def create(self, userId: str, email: str):
logger.debug(f"creating user with name '{userId}' and email '{email}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.createUser.args(
user={"id": userId, "email": email},
).select(ds.User.displayName)
),
)
self._client.execute(query)
def get(self, userId: str):
users = self.list_all()
return users.get(userId)
def update(self, userId: str, attrs: dict[str, str | list[str]]):
insertAttributes: list[dict[str, str | list[str]]] = []
for k, v in attrs.items():
v = get_value(v)
if isinstance(v, str):
v = [v]
insertAttributes.append({"name": k, "value": v})
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.updateUser.args(
user={
"id": userId,
"insertAttributes": insertAttributes,
},
).select(ds.Success.ok),
),
)
self._client.execute(query)
def delete(self, userId: str):
logger.debug(f"deleting user with name '{userId}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.deleteUser.args(
userId=userId,
).select(ds.Success.ok),
),
)
self._client.execute(query)
# groups
def add_group(self, userId: str, groupId: int):
logger.debug(f"adding user '{userId}' to group '{groupId}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.addUserToGroup.args(
userId=userId,
groupId=groupId,
).select(ds.Success.ok)
),
)
self._client.execute(query)
def remove_group(self, userId: str, groupId: int):
logger.debug(f"removing user '{userId}' from group '{groupId}'")
ds = DSLSchema(self._client.schema)
query = dsl_gql(
DSLMutation(
ds.Mutation.removeUserFromGroup.args(
userId=userId,
groupId=groupId,
).select(ds.Success.ok)
),
)
self._client.execute(query)

View file

@ -1,32 +0,0 @@
import logging
import re
import os
logger = logging.getLogger(__name__)
def to_camelcase(s):
s = re.sub(r"(_|-)+", " ", s).title().replace(" ", "").replace("*", "")
return "".join([s[0].lower(), s[1:]])
def to_snakecase(s):
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

View file

@ -1,167 +0,0 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) types;
cfg = config.services.lldap;
format = pkgs.formats.json { };
# helpers
_configFile = {
user_attributes = lib.mapAttrsToList (n: v: v) cfg.provision.user_attributes;
group_attributes = lib.mapAttrsToList (n: v: v) cfg.provision.group_attributes;
users = lib.mapAttrsToList (n: v: v) cfg.provision.users;
groups = lib.mapAttrsToList (n: v: v) cfg.provision.groups;
# 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 = format.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?";
};
};
};
optsUser = { name, config, ... }: {
freeformType = format.type;
options = {
user_id = lib.mkOption {
type = types.str;
default = name;
description = "The name of the user";
};
};
};
optsGroup = { name, config, ... }: {
freeformType = format.type;
options = {
display_name = lib.mkOption {
type = types.str;
default = name;
description = "The display name of the group";
};
};
};
in {
options = {
services.lldap = {
provisionUsername = lib.mkOption {
type = types.str;
description = "Username to use when signing into lldap";
};
provisionPasswordFile = lib.mkOption {
type = types.path;
description = "Path for the password file to authenticate the user";
};
provision = {
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.submodule optsUser);
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.submodule optsGroup);
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 && cfg.provision != {}) {
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.settings.http_url}:${builtins.toString cfg.settings.http_port}";
LLDAP_USERNAME = cfg.provisionUsername;
LLDAP_PASSWORD = "file:${cfg.provisionPasswordFile}";
};
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 ${./bootstrap}/. $out/bootstrap
'';
in ''
cd ${pythonDir}
${pythonEnv}/bin/python -m bootstrap.main ${configFile}
'';
};
systemd.services.lldap.restartTriggers = [ configFile ];
};
}

View file

@ -1,70 +0,0 @@
{ config, lib, ... }:
{
imports = [
./module
];
services.lldap = {
provisionUsername = "admin";
provisionPasswordFile = config.age.secrets.lldap-admin-user-pass.path;
provision = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
# users
users = {
# bind user
bind = {
user_id = "bind_user";
groups = [ lconfig.groups.password_manager lconfig.groups.strict_readonly ];
};
# system users - defined in each service
# should not be done here
# admin users
admin = llib.mkProvisionUserAdmin "admin";
eyjhb = llib.mkProvisionUserAdmin "eyjhb";
rasmus = llib.mkProvisionUserAdmin "rasmus";
# normal users
user1 = llib.mkProvisionUserNormal "thief420";
testusername = (llib.mkProvisionUserNormal "testusername") // { mail = "testusername@fricloud.dk"; };
};
# groups
groups = {
"base_member" = {};
"system_service" = {};
"system_mail" = {};
"nextcloud_admin" = {};
"drasl_admin" = {};
"grafana_admin" = {};
};
# attributes
group_attributes = {
group_foo = {
attributeType = "STRING";
isEditable = true;
isVisible = true;
};
};
user_attributes = {
membermail = {
attributeType = "STRING";
isEditable = false;
isVisible = true;
};
membermaildiskquota = {
attributeType = "INTEGER";
};
nextcloudquota = {
attributeType = "INTEGER";
};
};
});
};
systemd.services.lldapsetup.serviceConfig.EnvironmentFile = config.age.secrets.lldap-user-emails-env.path;
}

View file

@ -56,6 +56,7 @@ in {
max_upload_size = max_upload_size;
# only authenticated media
# TODO: Should default to true at some point
enable_authenticated_media = true;
# retentien policies
@ -74,12 +75,6 @@ in {
allowed_lifetime_min = "1d";
allowed_lifetime_max = "1y";
};
# automatically forget room on leave/kick/ban, and
# purge from db after X time
forget_rooms_on_leave = true;
forgotten_room_retention_period = "28d";
};
};
@ -128,7 +123,6 @@ in {
client_id = "synapse";
client_name = "Synapse";
client_secret = "$pbkdf2-sha512$310000$SmE9y.LA9lnzxNWL6CeWQA$zcrum.Rst9xQy/MKBI5i.UiUdSjx/F0ak65Z3vYk0w7/GMWIqXaW3GnE7bJQw6nHi5eZ2uhKHtW/DKp2TDVhbQ";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/_synapse/client/oidc/callback" ];
scopes = [
"openid"

View file

@ -1,7 +0,0 @@
{
imports = [
./matrix-synapse.nix
./element.nix
./housecleaning.nix
];
}

View file

@ -1,36 +0,0 @@
{ config, pkgs, ... }:
{
# delete empty directories
# - https://github.com/element-hq/synapse/issues/7690
# - https://github.com/matrix-org/synapse/issues/7690
systemd.services.matrix-synapse.preStart =
''${pkgs.findutils}/bin/find ${config.services.matrix-synapse.dataDir}/media_store -empty -type d -delete'';
systemd.timers."matrix-synapse-housecleaning-empty-dirs" = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Mon *-*-* 04:00:00";
Unit = config.systemd.services.matrix-synapse.name;
};
};
# vacuum database
systemd.services."matrix-synapse-psql-vacuum" = let
psqlUser = config.systemd.services.postgresql.serviceConfig.User;
dbName = config.services.matrix-synapse.settings.database.args.database;
in {
serviceConfig.User = psqlUser;
serviceConfig.RemainAfterExit = "yes";
script = ''${config.services.postgresql.package}/bin/psql -d ${dbName} -c "vacuum full"'';
};
systemd.timers."matrix-synapse-housecleaning-vacuum-db" = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Mon *-*-* 04:00:00";
Unit = config.systemd.services.matrix-synapse-psql-vacuum.name;
};
};
}

View file

@ -8,7 +8,6 @@ import argparse
import logging
import json
import sys
import os
logging.basicConfig()
logger = logging.getLogger(__name__)
@ -95,10 +94,10 @@ def extract_secrets() -> dict[str, str]:
def index():
# extract user information
user_info = {
"username": request.headers.get(os.environ.get("AUTH_PROXY_USERNAME")),
"name": request.headers.get(os.environ.get("AUTH_PROXY_NAME")),
"groups": request.headers.get(os.environ.get("AUTH_PROXY_GROUPS")),
"email": request.headers.get(os.environ.get("AUTH_PROXY_EMAIL")),
"username": request.headers.get("Remote-User"),
"name": request.headers.get("Remote-Name"),
"groups": request.headers.get("Remote-Groups"),
"email": request.headers.get("Remote-Email"),
}
tmpl_firstpass = render_template_string(
tmpl_index,

View file

@ -1,4 +1,4 @@
{ config, lib, pkgs, ... }:
{ config, pkgs, ... }:
let
urlpath = "/members";
@ -9,14 +9,6 @@ in {
description = "members area website";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
environment = {
AUTH_PROXY_USERNAME = config.mine.shared.lib.authelia.protectedHeaders.username;
AUTH_PROXY_GROUPS = config.mine.shared.lib.authelia.protectedHeaders.groups;
AUTH_PROXY_EMAIL = config.mine.shared.lib.authelia.protectedHeaders.email;
AUTH_PROXY_NAME = config.mine.shared.lib.authelia.protectedHeaders.name;
};
serviceConfig = {
ExecStart = let
pythonEnv = pkgs.python3.withPackages(ps: with ps; [ flask ]);
@ -26,25 +18,10 @@ in {
};
services.nginx.virtualHosts."${config.mine.shared.settings.domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
locations."${urlpath}" = config.mine.shared.lib.authelia.mkProtectedLocation {
endpoint = urlpath;
vhostConfig.locations."${urlpath}" = {
# extraConfig = "rewrite ^${urlpath}(.*)$ /$1 break;";
proxyPass = "http://localhost:${builtins.toString port}";
};
};
mine.shared.meta.website-members = {
name = "Members Website";
description = "This website you are looking at right now, which is our members website.";
url = "https://${config.mine.shared.settings.domain}${urlpath}";
package = {
name = "members-website";
version = "v0.0.1";
meta = with lib; {
description = "Members website for ${config.mine.shared.settings.domain}";
license = licenses.free;
homepage = "https://git.fricloud.dk/fricloud/server-configs/src/branch/main/machines/gerd/services/member-website/app.py";
platforms = platforms.all;
};
};
};
}

View file

@ -1,68 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "miniflux.${config.mine.shared.settings.domain}";
port = 6466;
listenOn = "localhost:${builtins.toString port}";
httpListenOn = "http://${listenOn}";
in {
services.miniflux = {
enable = true;
config = {
# listen only on localhost
LISTEN_ADDR = listenOn;
# setup the correct baseurl
BASE_URL = "https://${svc_domain}";
# disable admin account, disable local auth
CREATE_ADMIN = 0;
DISABLE_LOCAL_AUTH = "true";
# use auth proxy
AUTH_PROXY_HEADER = config.mine.shared.lib.authelia.protectedHeaders.username;
AUTH_PROXY_USER_CREATION = "true";
# For privacy, proxy images instead of hotlinking
MEDIA_PROXY_RESOURCE_TYPES = "image,audio,video";
MEDIA_PROXY_MODE = "all";
};
};
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = httpListenOn;
};
# allow API for mobile apps etc.
locations."/v1".proxyPass = httpListenOn;
# allow sharing
locations."/share".proxyPass = httpListenOn;
# used for sharing
# TODO: would be nice if nginx could serve this instead
locations."~* \.(js|jpg|png|css)$".proxyPass = httpListenOn;
};
# meta
mine.shared.meta.miniflux = {
name = "Miniflux";
description = "We host our own miniflux, use it to read all your feeds!";
url = "https://${svc_domain}";
package = let
pkg = config.services.miniflux.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,19 +0,0 @@
{
imports = [
./grafana.nix
./prometheus.nix
./mon-postgres.nix
./mon-stalwart.nix
./mon-authelia.nix
./mon-matrix-synapse.nix
./mon-zfs.nix
./mon-miniflux.nix
./mon-hedgedoc.nix
./mon-forgejo.nix
./mon-uptime-kuma.nix
./mon-searx.nix
./mon-nextcloud.nix
./mon-node-exporter.nix
];
}

View file

@ -1,92 +0,0 @@
{ config, ... }:
let
svc_domain = "grafana.${config.mine.shared.settings.domain}";
auth_domain = config.mine.shared.settings.authelia.domain;
grafana_user = config.systemd.services.grafana.serviceConfig.User;
in {
services.grafana = {
enable = true;
settings = {
server = {
http_addr = "127.0.0.1";
http_port = 3010;
root_url = "https://${svc_domain}";
};
# only allow signun with oauth
auth.disable_login_form = true;
"auth.generic_oauth" = {
enabled = true;
name = "Authelia";
icon = "signin";
client_id = "grafana";
client_secret = "$__file{${config.age.secrets.grafana-authelia-secret.path}}";
scopes = "openid profile email groups";
empty_scopes = false;
auth_url = "https://${auth_domain}/api/oidc/authorization";
token_url = "https://${auth_domain}/api/oidc/token";
api_url = "https://${auth_domain}/api/oidc/userinfo";
login_attribute_path = "preferred_username";
groups_attribute_path = "groups";
name_attribute_path = "name";
use_pkce = true;
role_attribute_path = config.mine.shared.lib.ldap.mkScope (lconfig: llib:
"contains(groups, '${lconfig.groups.grafana_admin}') && 'Admin' || contains(groups, 'editor') && 'Editor' || 'Viewer'"
);
};
};
provision = {
enable = true;
# dashboards.settings.providers = [{
# name = "my dashboards";
# options.path = "/etc/grafana-dashboards";
# }];
datasources.settings.datasources = [
{
name = "Prometheus";
type = "prometheus";
url = "http://${config.services.prometheus.listenAddress}:${toString config.services.prometheus.port}";
}
];
};
};
# authelia
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "grafana";
client_name = "Grafana";
client_secret = "$pbkdf2-sha512$310000$81MV1.67njuS/5H2UvVsnA$vaNO3/tzVA76Jho4ngS.xFjDuYn1sDn/9qo7cD0ueMnVvzaoJj00ND5wCGzVSUnvLuxNE/enC1K5r7xKAe/Hrg";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/login/generic_oauth" ];
scopes = [
"openid"
"email"
"profile"
"groups"
];
}];
environment.persistence.root.directories = [
config.services.grafana.dataDir
];
systemd.tmpfiles.rules = [
"Z ${config.services.grafana.dataDir} 0770 ${grafana_user} ${grafana_user} -"
];
age.secrets.grafana-authelia-secret.owner = grafana_user;
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString config.services.grafana.settings.server.http_port}";
};
}

View file

@ -1,18 +0,0 @@
{ config, lib, ... }:
{
services.authelia.instances.main.settings = {
telemetry.metrics = {
enabled = true;
};
};
services.prometheus.scrapeConfigs = [
{
job_name = "authelia";
static_configs = [{
targets = [ (lib.removePrefix "tcp://" config.services.authelia.instances.main.settings.telemetry.metrics.address) ];
}];
}
];
}

View file

@ -1,14 +0,0 @@
{ config, ... }:
{
services.forgejo.settings.metrics.ENABLED = true;
services.prometheus.scrapeConfigs = [
{
job_name = "forgejo";
static_configs = [{
targets = [ "localhost:${builtins.toString config.services.forgejo.settings.server.HTTPPORT}" ];
}];
}
];
}

View file

@ -1,18 +0,0 @@
{ config, ... }:
{
services.hedgedoc.settings = {
# enabled by default anyways
# TODO(eyJhb): disable exposing this to the WORLD
enableStatsApi = true;
};
services.prometheus.scrapeConfigs = [
{
job_name = "hedgedoc";
static_configs = [{
targets = [ "localhost:${builtins.toString config.services.hedgedoc.settings.port}"];
}];
}
];
}

View file

@ -1,27 +0,0 @@
let
metrics_port = 9734;
in {
services.matrix-synapse = {
settings = {
enable_metrics = true;
listeners = [
{
port = metrics_port;
type = "metrics";
bind_addresses = [ "localhost" ];
tls = false;
resources = [];
}
];
};
};
services.prometheus.scrapeConfigs = [
{
job_name = "matrix-synapse";
static_configs = [{
targets = [ "localhost:${builtins.toString metrics_port}"];
}];
}
];
}

View file

@ -1,16 +0,0 @@
{ config, ... }:
{
services.miniflux.config = {
METRICS_COLLECTOR = 1;
};
services.prometheus.scrapeConfigs = [
{
job_name = "miniflux";
static_configs = [{
targets = [ config.services.miniflux.config.LISTEN_ADDR ];
}];
}
];
}

View file

@ -1,45 +0,0 @@
{ config, lib, pkgs, ... }:
let
# occ bin
occ = config.services.nextcloud.occ + "/bin/nextcloud-occ";
nextcloudSetupServerinfoToken = pkgs.writeShellScript "nextcloud-setup-serverinfo-token.sh" ''
# set serverinfo_token
SERVERINFO_TOKEN="$(cat $CREDENTIALS_DIRECTORY/nextcloud-serverinfo-token)"
${occ} config:app:set serverinfo token --value "$SERVERINFO_TOKEN" > /dev/null 2>&1
'';
in {
systemd.services.nextcloud-setup = {
# runs this after all the main nextcloud-setup stuff
script = lib.mkAfter ''
${nextcloudSetupServerinfoToken}
'';
# setup credentials for service
serviceConfig.LoadCredential = [
"nextcloud-serverinfo-token:${config.age.secrets.nextcloud-serverinfo-token.path}"
];
};
services.prometheus.exporters.nextcloud = {
enable = true;
listenAddress = "localhost";
tokenFile = config.age.secrets.nextcloud-serverinfo-token.path;
url = let
scheme = if config.services.nextcloud.https then "https" else "http";
in "${scheme}://${config.services.nextcloud.hostName}";
};
# setup permissions
age.secrets.nextcloud-serverinfo-token.owner = config.services.prometheus.exporters.nextcloud.user;
services.prometheus.scrapeConfigs = [
{
job_name = "nextcloud";
static_configs = [{
targets = [ "localhost:${builtins.toString config.services.prometheus.exporters.nextcloud.port}" ];
}];
}
];
}

View file

@ -1,23 +0,0 @@
{ config, lib, ... }:
{
services.prometheus.exporters.node = {
enable = true;
listenAddress = "localhost";
enabledCollectors = [
"cpu"
"filesystem"
"systemd"
];
};
services.prometheus.scrapeConfigs = [
{
job_name = "node-exporter";
static_configs = [{
targets = [ "localhost:${builtins.toString config.services.prometheus.exporters.node.port}"];
}];
}
];
}

View file

@ -1,34 +0,0 @@
{ config, pkgs, ... }:
{
services.prometheus.exporters.postgres = {
enable = true;
listenAddress = "localhost";
runAsLocalSuperUser = true;
extraFlags = let
extraQuery = pkgs.writeText "prometehus-postgres-query.yaml" ''
pg_database:
query: "SELECT pg_database.datname, pg_database_size(pg_database.datname) as size FROM pg_database"
metrics:
- datname:
usage: "LABEL"
description: "Name of the database"
- size:
usage: "GAUGE"
description: "Disk space used by the database"
'';
in [
"--extend.query-path=${extraQuery}"
];
};
services.prometheus.scrapeConfigs = [
{
job_name = "postgres";
static_configs = [{
targets = [ "localhost:${toString config.services.prometheus.exporters.postgres.port}" ];
}];
}
];
}

View file

@ -1,16 +0,0 @@
{ config, ... }:
{
services.searx.settings.general.open_metrics = "thisreallydoesnotmatterasitisnotaccessiblefromoutsideofthisserver";
services.prometheus.scrapeConfigs = [
{
job_name = "searx";
basic_auth.username = "canbeanything";
basic_auth.password = config.services.searx.settings.general.open_metrics;
static_configs = [{
targets = [ config.services.searx.uwsgiConfig.http ];
}];
}
];
}

View file

@ -1,22 +0,0 @@
{ config, ... }:
{
services.stalwart-mail.settings = {
metrics.prometheus.enable = true;
};
services.prometheus.scrapeConfigs = [
{
job_name = "stalwart";
metrics_path = "/metrics/prometheus";
static_configs = [{
targets = [ "localhost:${toString config.mine.shared.settings.mail.ports.http_management}" ];
}];
metric_relabel_configs = [{
source_labels = [ "__name__" ];
target_label = "__name__";
replacement = "stalwart_$1";
}];
}
];
}

View file

@ -1,12 +0,0 @@
{ config, ... }:
{
services.prometheus.scrapeConfigs = [
{
job_name = "uptime-kuma";
static_configs = [{
targets = [ "localhost:${builtins.toString config.services.uptime-kuma.settings.PORT}" ];
}];
}
];
}

View file

@ -1,19 +0,0 @@
{ config, pkgs, ... }:
{
services.prometheus.exporters.zfs = {
enable = true;
listenAddress = "localhost";
extraFlags = [ "--collector.dataset-snapshot" ];
};
services.prometheus.scrapeConfigs = [
{
job_name = "zfs";
static_configs = [{
targets = [ "localhost:${toString config.services.prometheus.exporters.zfs.port}" ];
}];
}
];
}

View file

@ -1,28 +0,0 @@
{ config, ... }:
let
prometheus_user = config.systemd.services.prometheus.serviceConfig.User;
fullDataDirPath = "/var/lib/${config.services.prometheus.stateDir}";
filesetPath = config.mine.zfsMounts."rpool/safe/svcs/prometheus";
in {
services.prometheus = {
enable = true;
globalConfig.scrape_interval = "10s";
globalConfig.scrape_timeout = "10s";
listenAddress = "localhost";
# default is 15 days, we just set it to 14 to be explicit
retentionTime = "14d";
};
fileSystems."${filesetPath}".neededForBoot = true;
environment.persistence."${filesetPath}".directories = [
fullDataDirPath
];
systemd.tmpfiles.rules = [
"Z ${fullDataDirPath} 0770 ${prometheus_user} ${prometheus_user} -"
];
}

View file

@ -14,11 +14,7 @@ in {
environmentFile = config.age.secrets.murmur-env.path;
password = "$MURMUR_PASSWORD";
welcometext = ''Welcome to ${config.mine.shared.settings.brand}s Mumble server!
Feel free to join our Teeworlds server while waiting. Credentials can be found on the members website here ${config.mine.shared.meta.website-members.url}.
'';
bandwidth = 130000;
welcometext = "Welcome to Friclouds Mumble server!";
};
# set superpassword on start from secrets

View file

@ -3,7 +3,7 @@
let
svc_domain = "nextcloud.${config.mine.shared.settings.domain}";
default_storage_quota = "1mb";
default_storage_quota = "100MB";
# place data into own zfs dataset
stateDir = config.mine.zfsMounts."rpool/safe/svcs/nextcloud";
@ -49,7 +49,7 @@ let
ldapGroupFilter = config.mine.shared.lib.ldap.mkFilter (lconfig: llib:
llib.mkAnd [
(llib.mkOC lconfig.oc.groupOfUniqueNames)
(llib.mkOr [ "cn=${lconfig.groups.nextcloud_admin}" "cn=${lconfig.groups.member}"])
(llib.mkOr [ "cn=${lconfig.groups.admin}" "cn=${lconfig.groups.member}"])
]
);
ldapGroupFilterGroups = "admin;user";
@ -59,8 +59,6 @@ let
ldapUserFilterMode = 1;
ldapExpertUsernameAttr = config.mine.shared.settings.ldap.attr.uid;
ldapConfigurationActive = 1;
ldapQuotaDefault = 1;
ldapQuotaAttribute = config.mine.shared.settings.ldap.attr.nextcloudquota;
};
ldap_commands = lib.mapAttrsToList (n: v: "${occ} ldap:set-config $NEW_CONFIG_ID ${n} '${builtins.toString v}'") ldap_settings;
in pkgs.writeShellScript "nextcloud-add-ldap.sh" ''
@ -86,7 +84,7 @@ let
done
# promote ldap admin group to admins
${occ} ldap:promote-group ${config.mine.shared.settings.ldap.groups.nextcloud_admin} --yes -n
${occ} ldap:promote-group ${config.mine.shared.settings.ldap.groups.admin} --yes -n
'';
# script for resetting nextcloud admin password on each startup
@ -127,7 +125,7 @@ let
in {
services.nextcloud = {
enable = true;
package = pkgs.nextcloud31;
package = pkgs.nextcloud29;
datadir = stateDir;
config.adminpassFile = config.age.secrets.nextcloud-admin-pass.path;
@ -140,12 +138,12 @@ in {
# apps
extraAppsEnable = true;
extraApps = {
inherit (config.services.nextcloud.package.packages.apps) contacts calendar tasks gpoddersync;
inherit (config.services.nextcloud.package.packages.apps) contacts calendar tasks;
oidc_login = let
version = "3.2.2";
version = "3.1.1";
# TODO(eyJhb): add to niv
in pkgs.fetchNextcloudApp {
sha256 = "sha256-RLYquOE83xquzv+s38bahOixQ+y4UI6OxP9HfO26faI=";
sha256 = "sha256-b/tKk+y+ZypCHGNDtunDua2msYD6/TzA0haoC0k85F4=";
url = "https://github.com/pulsejet/nextcloud-oidc-login/releases/download/v${version}/oidc_login.tar.gz";
license = "agpl3Only";
};
@ -160,7 +158,7 @@ in {
config.dbtype = "pgsql";
# settings
settings = rec {
settings = {
# open connect/oidc
oidc_login_provider_url = "https://${config.mine.shared.settings.authelia.domain}";
oidc_login_client_id = AUTHELIA_AUTH_NAME;
@ -168,6 +166,7 @@ in {
oidc_login_proxy_ldap = true; # SUPER IMPORTANT!
oidc_login_auto_redirect = true;
oidc_login_button_text = "Log in with Authelia";
oidc_login_use_id_token = true;
oidc_login_attributes = {
id = "preferred_username";
ldap_uid = "preferred_username";
@ -177,32 +176,14 @@ in {
};
oidc_login_scope = "openid profile email groups";
oidc_login_code_challenge_method = "S256";
# mail
mail_from_address = "nextcloud";
mail_smtpmode = "smtp";
mail_sendmailmode = "smtp";
mail_domain = "${config.mine.shared.settings.domain}";
mail_smtphost = "${config.mine.shared.settings.mail.domain_smtp}";
mail_smtpport = config.mine.shared.settings.mail.ports.submissions;
mail_smtpsecure = "ssl";
mail_smtpname = mail_from_address;
# mail_smtppassword = "defined-in-the-secrets-file-and-in-a-separate-file-for-lldap";
};
};
# setup lldap user for nextcloud that can send emails
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
nextcloud = llib.mkProvisionUserSystem "nextcloud" config.age.secrets.nextcloud-smtp-pass.path;
});
systemd.services.nextcloud-setup = {
# runs this after all the main nextcloud-setup stuff
script = lib.mkAfter ''
${nextcloudSetupLdap}
# TODO(eyJhb): fix, broken by upstream here
# https://github.com/NixOS/nixpkgs/commit/2ce1e841032eac4913f2cd3dce416da3d5c799ef
# ${nextcloudSetupAdmin}
${nextcloudSetupAdmin}
${nextcloudSetupEncryption}
${nextcloudSetupStorageQuota}
'';
@ -222,7 +203,6 @@ in {
client_id = AUTHELIA_AUTH_NAME;
client_name = "Nextcloud";
client_secret = "$pbkdf2-sha512$310000$kLNQ/1A.uasSN4g8q94jUQ$8OKNUNNumHCh8dVG5/QWys7u.y1guqFXlrL.bMm7/HKTsWhpib/W.8qlU6VU7V1Be/h14Y.fJi3RLvbkEdo2kA";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/apps/oidc_login/oidc" ];
scopes = [
"openid"

View file

@ -1,414 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell --pure -i python3 -p "python3.withPackages (ps: with ps; [ flask apprise mnemonic wtforms jq ])"
from typing import Any
import apprise
from flask import Flask, request
from mnemonic import Mnemonic
import sqlite3
import os
import jq
import json
app = Flask(__name__)
app.url_map.strict_slashes = False
import logging
log = logging.getLogger("werkzeug")
log.setLevel(logging.ERROR)
ENV_PREFIX = "NOTIFIER_"
def getenv(key: str, default: Any = None) -> Any:
v = os.getenv(ENV_PREFIX + key, default)
if not v:
exit(f"{ENV_PREFIX+key} must be specified!")
return v
CONFIG_URL = getenv("URL", "http://127.0.0.1")
CONFIG_PORT = int(getenv("PORT", 8080))
CONFIG_DATABASE_PATH = getenv("DATABASE_PATH", "notifications.db")
CONFIG_MATRIX_BOT_NAME = getenv("MATRIX_BOT_NAME", "unset")
CONFIG_MATRIX_BOT_TOKEN = getenv("MATRIX_BOT_TOKEN")
CONFIG_MATRIX_HOST = getenv("MATRIX_HOST")
CONFIG_PROXY_AUTH_USERNAME_HEADER = getenv("PROXY_AUTH_USERNAME_HEADER", "Remote-User")
CONFIG_MAIL_USERNAME = getenv("MAIL_USERNAME")
CONFIG_MAIL_PASSWORD = getenv("MAIL_PASSWORD")
CONFIG_MAIL_DOMAIN = getenv("MAIL_DOMAIN")
CONFIG_MAIL_HOST = getenv("MAIL_HOST")
CONFIG_MAIL_PORT = int(getenv("MAIL_PORT", "465"))
CONFIG_MAIL_MODE = getenv("MAIL_MODE", "ssl")
script_example = rf"""#!/usr/bin/env bash
#!/usr/bin/env nix-shell
#!nix-shell --pure -i python3 -p "python3.withPackages (ps: with ps; [ requests ])"
import requests
import argparse
import sys
import os
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("--title", default="", help="Subject/title of the notification")
parser.add_argument("--body", default="", help="`-` for stdin")
parser.add_argument("--jq", default=".", help="Jq filter to use")
parser.add_argument("--type", default="matrix", help="mail or matrix")
parser.add_argument("--token", help="token to use")
parser.add_argument("--token-file", help="file to read token from")
parser.add_argument("--url", default="{CONFIG_URL}/notify", help="Notify endpoint")
args = parser.parse_args()
token: str = args.token
if args.token_file:
token = open(args.token_file, "r").read().strip()
if not token:
exit("No token or tokenfile specified, or empty")
data = args.body
if data == "-" and not sys.stdin.isatty():
data = "\n".join(sys.stdin.readlines())
headers = {{"Authorization": f"Bearer {{token}}"}}
params = {{
"jq": args.jq,
"type": args.type,
}}
if args.title:
params["title"] = args.title
req = requests.post(args.url, headers=headers, params=params, data=data)
exit(not req.status_code == 200)
"""
script_example_with_token = script_example.replace(
'--token"',
'--token", default="||TOKEN||"',
)
def get_db():
con = sqlite3.connect(CONFIG_DATABASE_PATH)
cur = con.cursor()
cur.execute(
"CREATE TABLE IF NOT EXISTS default_room(username TEXT PRIMARY KEY, room_id TEXT NOT NULL)"
)
cur.execute(
"CREATE TABLE IF NOT EXISTS tokens(username TEXT PRIMARY KEY, token TEXT NOT NULL)"
)
return con
@app.route("/", methods=["GET", "POST"])
def index():
username = request.headers.get(CONFIG_PROXY_AUTH_USERNAME_HEADER)
if not username:
return ("Not authenticated", 401)
# handle post stuff
if request.method == "POST":
action = request.form.get("action", "").lower()
if "token" in action:
generate_token_for_user(username)
elif "room id" in action:
roomid = request.form.get("room_id")
if not roomid:
return ("Room Id cannot be empty", 400)
set_user_default_matrix_room(username, roomid)
con = get_db()
cur = con.cursor()
res = cur.execute(
"SELECT token FROM tokens WHERE username = ?", (username,)
).fetchone()
token: str = ""
if res:
token = res[0]
res = cur.execute(
"SELECT room_id FROM default_room WHERE username = ?", (username,)
).fetchone()
room_id: str = ""
if res:
room_id = res[0]
# hack to make users confirm it
generate_token_name: str = "tmpaction"
generate_token_value: str = "Generate Token"
if request.form.get(generate_token_name):
generate_token_name = "action"
generate_token_value = "Generate Token. Are you sure?"
tmpl = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Notification Service</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
<div class="container my-5">
<h1>Hello {username}!</h1>
<div class="col-lg-8 px-0">
<form method="post">
<div class="mb-3">
<label class="form-label">Default Matrix Room ID</label>
<input type="text" name="room_id" value="{room_id}" placeholder="!yREJWHUMJhGROiHbtu:fricloud.dk" class="form-control">
</div>
<input type="submit" class="btn btn-primary" name="action" value="Set Default Room ID">
<hr>
<div class="mb-3">
<label class="form-label">Token (dashes are optional)</label>
<input type="text" value="{token}" placeholder="token-not-generated" readonly class="form-control" onclick="this.select();" >
</div>
<input type="submit" class="btn btn-primary" name="{generate_token_name}" value="{generate_token_value}">
</form>
<hr>
<p>The token can be given as a URL parameter 'token', as a auth header `Authorization: Bearer token`, HTTPAuth where username is ignored, and token is the password.
Can also be given a a path, e.g. {CONFIG_URL}/{token}.
</p>
<hr>
<div class="mb-3">
<label class="form-label">Quick URL</label>
<input type="text" value="curl {CONFIG_URL}/notify/{token.replace("-", "")}/mymessage" class="form-control" onclick="this.select();" >
</div>
<hr>
<p>
This notification service has support for both matrix and email.
Matrix notifications will be sent from {CONFIG_MATRIX_BOT_NAME}, be sure to invite them to the room/space if it is private.
If using email, they will only be sent to your member email, and can't be sent anywhere else.
You'll receive them from {CONFIG_MAIL_USERNAME}@{CONFIG_MAIL_DOMAIN}.
</p>
<hr>
<table class="table">
<thead>
<tr>
<th scope="col">Param</th>
<th scope="col">Description</th>
<th scope="col">Default</th>
<th scope="col">Example</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">title</th>
<td>Title of notification</td>
<td>Notification</td>
<td>Compilation Finished!</td>
</tr>
<tr>
<th scope="row">body</th>
<td>Body of notification</td>
<td>empty</td>
<td>Compilation result: success (default is nothing)</td>
</tr>
<tr>
<th scope="row">type</th>
<td>type of notification</td>
<td>matrix</td>
<td>matrix</td>
</tr>
<tr>
<th scope="row">room_id</th>
<td>Matrix room ID</td>
<td>default room</td>
<td>!yREJWHUMJhGROiHbtu:fricloud.dk or #na-offtopic:rend.al</td>
</tr>
<tr>
<th scope="row">token</th>
<td>Authorization Token</td>
<td>empty</td>
<td>enable-trade-decide or enabletradedecide</td>
</tr>
<tr>
<th scope="row">jq</th>
<td>jq filter</td>
<td>.</td>
<td>[.[].commits | .name ] | join("\\n")</td>
</tr>
</tbody>
</table>
<hr>
bash alias
<pre class="border"><code>notify() {{ curl "{CONFIG_URL}/notify/{token}" -g --data-urlencode "body=${{1:-}}"; }}</code></pre>
curl
<pre class="border"><code>curl "{CONFIG_URL}/notify" -H "Authorization: Bearer {token}"</code></pre>
curl w/ specific body/title
<pre class="border"><code>curl "{CONFIG_URL}/notify?title=MyTitle&body=MyBody" -H "Authorization: Bearer {token}"</code></pre>
Python
<pre class="border"><code>{script_example}</code></pre>
Python w/ <b>hardcoded token (DO NOT SHARE)</b>
<pre class="border"><code>{script_example_with_token}</code></pre>
Nix Python Script <b>HARDCODED TOKEN (DO NOT SHARE)</b>
<pre class="border"><code>pkgs.writers.writePython3Bin "notify" {{
libraries = with pkgs.python3Packages; [ requests ];
doCheck = false;
}} ''{script_example_with_token}'';
</code></pre>
<hr>
<h2>Notes</h2>
<p>
<b>jq</b> is very powerful, and can easily be used to turn webhook data into useful information in a notification.
Just append your `jq=<url_encoded_query>`, to your notification URL, and then watch the magic.
Below is an example for doing it with Forgejo, when new commits are pushed.
</p>
<pre class="border"><code>(.total_commits | tostring)
+ " commits pushed to "
+ .repository.full_name
+ "\n\n" +
# format commits
([.commits.[]
| "- "
+ (.message | gsub("[\n\t]"; ""))
+ " (" + .author.name + ")" ]
| join("\n"))
+ "\n\n"
+ "Changes: " + .compare_url</code></pre>
</div>
</div>
</body>
</html>
""".replace(
r"||TOKEN||", token
)
return tmpl
@app.route("/notify", methods=["GET", "POST"], defaults={"token": "", "body": ""})
@app.route("/notify/<token>", methods=["GET", "POST"], defaults={"body": ""})
@app.route("/notify/<token>/<body>", methods=["GET", "POST"])
def send_notification(token: str, body: str):
# default to this
ntype = request.args.get("type", "matrix")
title = request.args.get("title", "Notification")
body = request.args.get("body", request.get_data().decode("utf-8") or body)
if not body:
body = " "
if request.authorization:
token = request.authorization.token or request.authorization.password
if not token:
return (
"No token found, please either specify with Authorization: Bearer <token>, HTTPBasic or in the URL path",
401,
)
if (
request.method == "POST"
and "json" in request.headers.get("content-type", "")
or is_json(body)
):
try:
body = jq.compile(request.args.get("jq", ".")).input_text(body).first()
except Exception:
return ("Unable to compile JQ, please ensure it is correct", 400)
if not isinstance(body, str):
body = json.dumps(body, indent=4)
con = get_db()
cur = con.cursor()
res = cur.execute(
"SELECT username FROM tokens WHERE REPLACE(token, '-', '') = ? OR token = ? ",
(token, token),
).fetchone()
if not res:
return ("Access denied - invalid token", 401)
username = res[0]
if ntype not in ["matrix", "mail"]:
return ("Invalid type, only matrix or mail allowed", 400)
apobj = apprise.Apprise()
if ntype == "matrix":
# try to get a room_id
room_id = request.args.get("room_id")
if not room_id:
res = cur.execute(
"SELECT room_id FROM default_room WHERE username = ?", (username,)
).fetchone()
if res:
room_id = res[0]
if not room_id:
return ("No room_id specified, and no default saved", 400)
apobj.add(f"matrixs://{CONFIG_MATRIX_BOT_TOKEN}@{CONFIG_MATRIX_HOST}/{room_id}")
else:
apobj.add(
f"mailto://{CONFIG_MAIL_USERNAME}:{CONFIG_MAIL_PASSWORD}@{CONFIG_MAIL_HOST}:{CONFIG_MAIL_PORT}?mode={CONFIG_MAIL_MODE}&from={CONFIG_MAIL_USERNAME}@{CONFIG_MAIL_DOMAIN}&to={username}@{CONFIG_MAIL_DOMAIN}"
)
sent_notification = apobj.notify(
title=title,
body=body,
)
if not send_notification:
return ("Unable to send notification", 500)
return ("Notification sent!", 200)
def generate_token_for_user(username: str):
token = generate_token()
con = get_db()
cur = con.cursor()
t = cur.execute(
"INSERT INTO tokens (username, token) VALUES (?, ?) ON CONFLICT (username) DO UPDATE SET token = ?",
(
username,
token,
token,
),
)
con.commit()
def set_user_default_matrix_room(username: str, roomid: str):
con = get_db()
cur = con.cursor()
t = cur.execute(
"INSERT INTO default_room (username, room_id) VALUES (?, ?) ON CONFLICT (username) DO UPDATE SET room_id = ?",
(
username,
roomid,
roomid,
),
)
con.commit()
def generate_token(num_words: int = 3) -> str:
mnemo = Mnemonic("english")
words = mnemo.generate(strength=256)
return "-".join(words.split(" ")[0:num_words])
def is_json(potential_json: str) -> bool:
try:
json.loads(potential_json)
return True
except Exception:
return False
if __name__ == "__main__":
app.run(host="127.0.0.1", port=CONFIG_PORT)

View file

@ -1,90 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "notify.${config.mine.shared.settings.domain}";
port = 5055;
ldap_user = "notification";
stateDirName = "notify";
stateDir = "/var/lib/${stateDirName}";
in {
systemd.services.notifify = {
description = "notifications for members";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
environment = {
NOTIFIER_URL = "https://${svc_domain}";
NOTIFIER_PORT = builtins.toString port;
NOTIFIER_DATABASE_PATH = "${stateDir}/notify.db";
# NOTIFIER_MATRIX_BOT_TOKEN = "";
NOTIFIER_MATRIX_BOT_NAME = "@${ldap_user}:${config.mine.shared.settings.domain}";
NOTIFIER_MATRIX_HOST = config.mine.shared.settings.matrix-synapse.domain;
NOTIFIER_PROXY_AUTH_USERNAME_HEADER = config.mine.shared.lib.authelia.protectedHeaders.username;
NOTIFIER_MAIL_USERNAME = ldap_user;
# NOTIFIER_MAIL_PASSWORD = "";
NOTIFIER_MAIL_DOMAIN = config.mine.shared.settings.domain;
NOTIFIER_MAIL_HOST = config.mine.shared.settings.mail.domain;
NOTIFIER_MAIL_PORT = builtins.toString config.mine.shared.settings.mail.ports.submissions;
# production
FLASK_ENV = "production";
};
serviceConfig = {
EnvironmentFile = [ config.age.secrets.notify-env.path ];
StateDirectory = stateDirName;
DynamicUser = true;
ExecStart = let
pythonEnv = pkgs.python3.withPackages(ps: with ps; [ flask apprise mnemonic wtforms jq ]);
in "${pythonEnv}/bin/python ${./app.py}";
Restart = "always";
};
};
# setup notification user
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
"${ldap_user}" = llib.mkProvisionUserSystem ldap_user config.age.secrets.notify-ldap-pass.path;
});
# persistent files
environment.persistence.root.directories = [
{ directory = "/var/lib/private/${stateDirName}"; mode = "0700"; }
];
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = "http://localhost:${builtins.toString port}";
};
locations."/notify".proxyPass = "http://localhost:${builtins.toString port}";
};
# metada
mine.shared.meta.notify = {
name = "Notification Service";
description = "This website you are looking at right now, which is our members website.";
url = "https://${svc_domain}";
package = {
name = "notify-website";
version = "v0.0.1";
meta = with lib; {
description = "Notification website for ${config.mine.shared.settings.domain}";
license = licenses.free;
homepage = "https://git.fricloud.dk/fricloud/server-configs/src/branch/main/machines/gerd/services/notify/app.py";
platforms = platforms.all;
};
};
};
}

View file

@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -e
BODY="$1"
TITLE=${2:-Notification}
JQ_EXPR=${3:-.}
TYPE=${4:-matrix}
# TOKEN="$(cat ~/.config/notify/token)"
# TOKEN="$(cat /run/agenix/notify-token)"
TOKEN="$(cat token)"
URL="https://notify.fricloud.dk/notify"
# URL="||URL||"
# get stdin if needed
if [ "$BODY" = "-" ]; then
BODY="$(cat -)"
fi
# make request
curl -H "Authorization: Bearer $TOKEN" "$URL" \
--get \
--data-urlencode "title=$TITLE" \
--data-urlencode "body=$BODY" \
--data-urlencode "jq=$JQ_EXPR" \
--data-urlencode "type=$TYPE"

View file

@ -1,148 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_name = "rallly";
svc_domain = "${svc_name}.${config.mine.shared.settings.domain}";
psqlSocket = "/run/postgresql";
user = "rallly";
port = 7384;
ralllyPkgsOrig = pkgs.callPackage ./../../../../shared/pkgs/rallly {};
ralllyPkgs = ralllyPkgsOrig.overrideAttrs (old: {
patches = (old.patches or []) ++ [
./patches/remove-login-register.patch
];
});
in {
# setup rallly service
systemd.services.rallly = {
description = "rallly";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
# configuration
environment = let
rallly-prisma-engines = ralllyPkgs.passthru.rallly-prisma-engines;
in rec {
HOSTNAME = "localhost";
PORT = builtins.toString port;
DATABASE_URL = "postgresql://${user}@localhost/${user}?host=${psqlSocket}";
NEXT_PUBLIC_BASE_URL = "https://${svc_domain}";
NEXTAUTH_URL = NEXT_PUBLIC_BASE_URL;
# SECRET_PASSWORD = "specified-in-env";
# limit signup even further
ALLOWED_EMAILS = "*@${config.mine.shared.settings.domain}";
# email
SUPPORT_EMAIL = "${svc_name}@${config.mine.shared.settings.domain}";
SMTP_HOST = config.mine.shared.settings.mail.domain_smtp;
SMTP_PORT = builtins.toString config.mine.shared.settings.mail.ports.submissions;
SMTP_SECURE = "true";
SMTP_USER = svc_name;
# SMTP_PWD = "specified-in-env";
# OIDC
OIDC_NAME = "Authelia";
OIDC_DISCOVERY_URL = "https://${config.mine.shared.settings.authelia.domain}/.well-known/openid-configuration";
OIDC_CLIENT_ID = "rallly";
# OIDC_CLIENT_SECRET = "specified-in-env";
# prisma things (database will not work without, needs to match version in rallly deps as well)
PRISMA_SCHEMA_ENGINE_BINARY = "${rallly-prisma-engines}/bin/schema-engine";
PRISMA_QUERY_ENGINE_BINARY = "${rallly-prisma-engines}/bin/query-engine";
PRISMA_QUERY_ENGINE_LIBRARY = "${rallly-prisma-engines}/lib/libquery_engine.node";
PRISMA_INTROSPECTION_ENGINE_BINARY = "${rallly-prisma-engines}/bin/introspection-engine";
PRISMA_FMT_BINARY = "${rallly-prisma-engines}/bin/prisma-fmt";
};
# add, otherwise we get warnings
path = [ pkgs.openssl ];
serviceConfig = {
ExecStartPre = [
# clear cache on each boot, otherwise we might have
# issues when updating it.
"${pkgs.findutils}/bin/find -L /var/cache/${svc_name} -mindepth 1 -delete"
# run db migration each boot
"${ralllyPkgs}/bin/rallly-prisma migrate deploy"
];
ExecStart = "${ralllyPkgs}/bin/rallly";
# secret configurations
EnvironmentFile = [ config.age.secrets.rallly-env.path ];
CacheDirectory = svc_name;
CacheDirectoryMode = "0750";
User = user;
DynamicUser = true;
Restart = "always";
};
};
# setup postgresql
services.postgresql = {
ensureDatabases = [ user ];
ensureUsers = [{
name = user;
ensureDBOwnership = true;
}];
};
# setup ldap user for email
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
"${svc_name}" = llib.mkProvisionUserSystem "${svc_name}" config.age.secrets.rallly-ldap-pass.path;
});
# authelia
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "rallly";
client_name = "Rallly";
client_secret = "$pbkdf2-sha512$310000$KB4UqeuVr86lEOoISSE92w$i2YGpz3wRwceiRfYnMUhZ0MboutkDPPYVWnXqiw6tUt./mgZ5kfV1ES.kcdsHhMdavhCrJfWvVTPQRJKImuUrQ";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/api/auth/callback/oidc" ];
claims_policy = "default";
scopes = [
"openid"
"email"
"profile"
];
}];
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:${builtins.toString port}";
};
# try to disable registration
locations."/api/trpc/auth.requestRegistration" = {
root = pkgs.writeTextDir "index.html" ''
NO REGISTRATION!!
'';
};
};
# meta information!
mine.shared.meta.rallly = {
name = "Rallly";
description = ''Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier. Please do not try to use the register or normal login, only try to sign in using the SSO method. '';
url = "https://${svc_domain}";
package = let
pkg = ralllyPkgs;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,84 +0,0 @@
diff --git a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx
index d4a2adcf..8137a790 100644
--- a/apps/web/src/app/[locale]/(auth)/login/login-form.tsx
+++ b/apps/web/src/app/[locale]/(auth)/login/login-form.tsx
@@ -159,45 +159,7 @@ export function LoginForm() {
}
})}
>
- <div className="mb-1 text-2xl font-bold">{t("login")}</div>
- <p className="mb-4 text-gray-500">
- {t("stepSummary", {
- current: 1,
- total: 2,
- })}
- </p>
- <fieldset className="mb-2.5">
- <label htmlFor="email" className="mb-1 text-gray-500">
- {t("email")}
- </label>
- <Input
- className="w-full"
- id="email"
- size="lg"
- error={!!formState.errors.email}
- autoFocus={true}
- disabled={formState.isSubmitting}
- placeholder={t("emailPlaceholder")}
- {...register("email", { validate: validEmail })}
- />
- {formState.errors.email?.message ? (
- <div className="mt-2 text-sm text-rose-500">
- {formState.errors.email.message}
- </div>
- ) : null}
- </fieldset>
<div className="flex flex-col gap-2">
- <Button
- loading={formState.isSubmitting}
- type="submit"
- size="lg"
- variant="primary"
- className=""
- >
- {t("loginWith", {
- provider: t("email"),
- })}
- </Button>
{error === "OAuthAccountNotLinked" ? (
<Alert icon={AlertTriangleIcon} variant="destructive">
<AlertTitle>
@@ -216,12 +178,6 @@ export function LoginForm() {
) : null}
{alternativeLoginMethods.length > 0 ? (
<>
- <div className="relative my-4">
- <hr className="border-grey-500 absolute top-1/2 w-full border-t" />
- <span className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white px-2 text-center text-xs uppercase text-gray-400">
- {t("or", { defaultValue: "Or" })}
- </span>
- </div>
<div className="grid gap-2.5">
{alternativeLoginMethods.map((method, i) => (
<Button size="lg" key={i} onClick={method.login}>
diff --git a/apps/web/src/app/[locale]/(auth)/login/page.tsx b/apps/web/src/app/[locale]/(auth)/login/page.tsx
index 10caefed..28d6c85a 100644
--- a/apps/web/src/app/[locale]/(auth)/login/page.tsx
+++ b/apps/web/src/app/[locale]/(auth)/login/page.tsx
@@ -13,16 +13,6 @@ export default async function LoginPage({ params }: { params: Params }) {
<AuthCard>
<LoginForm />
</AuthCard>
- <div className="mt-4 pt-4 text-center text-gray-500 sm:text-base">
- <Trans
- t={t}
- i18nKey="notRegistered"
- defaults="Don't have an account? <a>Register</a>"
- components={{
- a: <Link href="/register" className="text-link" />,
- }}
- />
- </div>
</div>
);
}

View file

@ -1,48 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "searx.${config.mine.shared.settings.domain}";
port = 7378;
in {
services.searx = {
enable = true;
runInUwsgi = true;
redisCreateLocally = true;
environmentFile = config.age.secrets.searx-env.path;
uwsgiConfig.http = "127.0.0.1:${builtins.toString port}";
settings = {
general.debug = false;
server = {
base_url = "https://${svc_domain}";
secret_key = "@SECRET_KEY@";
};
};
};
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = "http://localhost:${builtins.toString port}";
};
};
# meta
mine.shared.meta.searx = {
name = "searXNG";
description = "We host our own searXNG, use it to search the web!";
url = "https://${svc_domain}";
package = let
pkg = config.services.searx.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -7,13 +7,7 @@
mine.shared.meta.stalwart = {
name = "Stalwart Mail";
description = ''
We host our own mailserver, which can be reached on ${config.mine.shared.settings.mail.domain} with your LDAP username and password.
From here encryption-at-rest can be setup with either OpenPGP or S/MIME. Keep in mind, Change Password, Two-factor Auth and App Passwords
do not work, as we are using LDAP for authentication instead.
'';
url = "https://${config.mine.shared.settings.mail.domain}";
description = "We host our own mailserver, which can be reached on ${config.mine.shared.settings.mail.domain} with your LDAP username and password.";
package = let
pkg = config.services.stalwart-mail.package;

View file

@ -1,10 +1,7 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "stalwart.${config.mine.shared.settings.domain}";
svc_domain_mail = "mail.${config.mine.shared.settings.domain}";
# TODO(eyJhb): in theory these domains are not used, they're just
# nice to have.. maybe.
svc_domain = "mail.${config.mine.shared.settings.domain}";
svc_domain_smtp = "smtp.${config.mine.shared.settings.domain}";
svc_domain_imap = "imap.${config.mine.shared.settings.domain}";
@ -23,14 +20,20 @@ let
stalwart_user = config.users.users.stalwart-mail.name;
stalwart_group = config.users.groups.stalwart-mail.name;
certLocation = config.security.acme.certs."${svc_domain_mail}".directory;
certLocation = config.security.acme.certs."${svc_domain}".directory;
in {
services.stalwart-mail = {
enable = true;
openFirewall = true;
package = pkgs.stalwart-mail.overrideAttrs (old: {
patches = old.patches ++ [
./patches/stalwart-cli-dns-records.patch
];
});
settings = {
lookup.default.hostname = svc_domain_mail;
lookup.default.hostname = svc_domain;
store.db.path = "${stateDir}/db";
@ -53,10 +56,7 @@ in {
filter = let
_mkFilter = attrs: ph: config.mine.shared.lib.ldap.mkFilter (lconfig: llib:
llib.mkAnd [
(llib.mkOr [
(llib.mkGroup lconfig.groups.member)
(llib.mkGroup lconfig.groups.system_mail)
])
(llib.mkGroup lconfig.groups.member)
(llib.mkOr (lib.forEach attrs (v: llib.mkSearch v ph)))
]
);
@ -64,27 +64,23 @@ in {
attrs = config.mine.shared.settings.ldap.attr // { emailAlias = "mailAlias"; emailList = "mailList"; };
in {
name = _mkFilter [ attrs.uid ] "?";
email = _mkFilter [ attrs.membermail ] "?";
email = _mkFilter [ attrs.email attrs.emailAlias attrs.emailList ] "?";
verify = _mkFilter [ attrs.email attrs.emailAlias ] "*?*";
expand = _mkFilter [ attrs.emailList ] "?";
domains = _mkFilter [ attrs.email attrs.emailAlias ] "*@?";
};
attributes = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
name = lconfig.attr.uid;
# name = lconfig.attr.member_mail;
description = lconfig.attr.firstname;
email = lconfig.attr.membermail;
quota = lconfig.attr.membermaildiskquota;
attributes = {
name = "uid";
class = "objectClass";
description = "givenName";
secret = "uid";
groups = "memberOf";
# we dont have access to this in lldap, and
# therefore we use secret-changed instead
# secret = lconfig.attr.stalwart_secret;
secret = lconfig.attr.creationdate;
# TODO(eyJhb): remove once LLDAP gets
# plugin support, so we can make a plugin
# that updates a attribute on password updates
# https://github.com/lldap/lldap/pull/1119
secret-changed = lconfig.attr.creationdate;
});
email = "mail";
# email-alias = "mailAlias";
# quota = "diskQuota";
};
};
storage.directory = "ldap";
@ -95,7 +91,7 @@ in {
submissions = { bind = [ "[::]:${builtins.toString ports.submissions}"]; protocol = "smtp"; tls.implicit = true; };
imaptls = { bind = [ "[::]:${builtins.toString ports.imaptls}"]; protocol = "imap"; tls.implicit = true; };
management = { bind = [ "[::]:${builtins.toString ports.http_management}" ]; protocol = "http"; };
management = { bind = [ "127.0.0.1:${builtins.toString ports.http_management}" ]; protocol = "http"; };
};
certificate.domain = {
@ -147,25 +143,17 @@ in {
];
# setup certs
services.nginx.virtualHosts."${svc_domain_mail}" = {
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
serverAliases = [ svc_domain_smtp svc_domain_imap ];
root = pkgs.writeTextDir "index.html" "Nothing.";
};
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString ports.http_management}";
};
# need to change group to stalwart-mail for cert + add nginx to stalwart-mail group to do HTTP ACME
users.users.nginx.extraGroups = [ stalwart_group ];
security.acme.certs."${svc_domain_mail}" = {
group = stalwart_group;
reloadServices = [ config.systemd.services.stalwart-mail.name ];
};
security.acme.certs."${svc_domain}".group = stalwart_group;
# setup secrets for stalwart
# setup access to ldap bind user credential
@ -174,7 +162,6 @@ in {
mine.shared.settings.mail = {
domain = svc_domain;
domain_mail = svc_domain_mail;
domain_smtp = svc_domain_smtp;
domain_imap = svc_domain_imap;

View file

@ -11,10 +11,10 @@
password = "$TEEWORLDS_PASSWORD";
};
mine.shared.meta.teeworlds = rec {
mine.shared.meta.teeworlds = {
name = "Teeworlds";
description = ''We host our own Teeworlds instance. Connect using `nix-shell -p teeworlds --run 'teeworlds "connect ${url}" "password {{secrets.TEEWORLDS_PASSWORD}}"'`, the password is {{secrets.TEEWORLDS_PASSWORD}}'';
url = "teeworlds.${config.mine.shared.settings.domain}";
description = ''We host our own Teeworlds instance. Connect using `nix-shell -p teeworlds --run 'teeworlds "connect teeworlds.fricloud.dk" "password {{secrets.TEEWORLDS_PASSWORD}}"'`, the password is {{secrets.TEEWORLDS_PASSWORD}}'';
url = "";
secrets.auth = config.age.secrets.teeworlds-env.path;

View file

@ -1,95 +0,0 @@
{ config, lib, pkgs, ... }:
let
svc_domain = "uptime-kuma.${config.mine.shared.settings.domain}";
in {
services.uptime-kuma = {
enable = true;
appriseSupport = true;
package = pkgs.uptime-kuma.overrideAttrs (old: rec {
pname = "uptime-kuma";
version = "2.0.0-dev";
src = pkgs.fetchFromGitHub {
owner = "M1CK431";
repo = "uptime-kuma";
rev = "5a16af40fdddcaa61d197242840344804a246d01";
hash = "sha256-W7ieVrfm/SZU/MNB7dJW3V3vq0RBrAJVqv0gK7H4Xik=";
};
npmDepsHash = "sha256-Q2u6ClG6g8yoGvSJ/LGlKTL4XkJGWY+DAojpM1xBwQ0=";
npmDeps = pkgs.fetchNpmDeps {
inherit src;
name = "${pname}-${version}-npm-deps";
hash = npmDepsHash;
};
patches = [
(pkgs.writeText "uptime-kuma-database-writeable.patch" ''
diff --git a/server/database.js b/server/database.js
index 3374aff9..9e890d28 100644
--- a/server/database.js
+++ b/server/database.js
@@ -221,6 +221,7 @@ class Database {
if (! fs.existsSync(Database.sqlitePath)) {
log.info("server", "Copying Database");
fs.copyFileSync(Database.templatePath, Database.sqlitePath);
+ fs.chmodSync(Database.path, 0o640);
}
const Dialect = require("knex/lib/dialects/sqlite3/index.js");
'')
# TODO(eyJhb): do we really want this?
(pkgs.writeText "uptime-kuma-disable-metrics-auth.patch" ''
diff --git a/server/server.js b/server/server.js
index db58ae82..d650a42a 100644
--- a/server/server.js
+++ b/server/server.js
@@ -292,7 +292,7 @@ let needSetup = false;
// Prometheus API metrics /metrics
// With Basic Auth using the first user's username/password
- app.get("/metrics", apiAuth, prometheusAPIMetrics());
+ app.use("/metrics", prometheusAPIMetrics());
app.use("/", expressStaticGzip("dist", {
enableBrotli: true,
'')
];
});
};
# setup persistence
environment.persistence.root.directories = [
{ directory = "/var/lib/private/uptime-kuma"; mode = "0700"; }
];
# setup ldap user for email
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
uptime-kuma = llib.mkProvisionUserSystem "uptime-kuma" config.age.secrets.uptime-kuma-ldap-pass.path;
});
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = "http://localhost:${builtins.toString config.services.uptime-kuma.settings.PORT}";
proxyWebsockets = true;
};
};
mine.shared.meta.uptime-kuma = {
name = "Uptime Kuma";
description = ''Fancy self-hosted monitoring tool, which supports VARIOUS methods of monitoring, as well as getting notifications. Multiple users is not officially support, so reach out to admins, and they will create a user for you. Abuse will NOT be tolerated. We have a SMTP account associated with Uptime Kuma, ask for details on how to use this (you're also allowed to to your own member email).'';
url = "https://${svc_domain}";
package = let
pkg = config.services.uptime-kuma.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,126 +0,0 @@
{ config, pkgs, lib, ... }:
let
svc_domain = "vikunja.${config.mine.shared.settings.domain}";
vikunjaOIDCName = "authelia";
in {
services.vikunja = {
enable = true;
package = pkgs.vikunja.overrideAttrs (old: {
# TODO(eyJhb): remove once vikunja updates past 0.24.6
# https://github.com/go-vikunja/vikunja/issues/623
patches = (old.patches or []) ++ [
(pkgs.writeText "vikunja-clientsecret-envvar.patch" ''
diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go
index 5e14c1b31..d9a5215c1 100644
--- a/pkg/modules/auth/openid/providers.go
+++ b/pkg/modules/auth/openid/providers.go
@@ -17,6 +17,8 @@
package openid
import (
+ "fmt"
+ "os"
"regexp"
"strconv"
"strings"
@@ -139,6 +141,10 @@ func getProviderFromMap(pi map[string]interface{}) (provider *Provider, err erro
Scope: scope,
}
+ if clientSecret, ok := os.LookupEnv(fmt.Sprintf("VIKUNJA_AUTH_OPENID_PROVIDERS_%s_CLIENTSECRET", strings.ToUpper(provider.Name))); ok {
+ provider.ClientSecret = clientSecret
+ }
+
cl, is := pi["clientid"].(int)
if is {
provider.ClientID = strconv.Itoa(cl)
'')
];
});
frontendScheme = "https";
frontendHostname = svc_domain;
database = {
type = "postgres";
host = "/run/postgresql";
};
environmentFiles = [
config.age.secrets.vikunja-env.path
];
settings = {
service.enableregistration = false;
auth.local.enabled = false;
auth.openid = {
enabled = true;
providers = [{
key = "authelia";
name = vikunjaOIDCName;
clientid = "vikunja";
authurl = "https://${config.mine.shared.settings.authelia.domain}";
clientsecret = "not-used-but-needs-to-be-set";
}];
};
};
};
# setup for oidc
services.authelia.instances.main.settings.identity_providers.oidc.clients = [{
client_id = "vikunja";
client_name = "Vikunja";
client_secret = "$pbkdf2-sha512$310000$GjslCZ8GAperXUFzmFGslA$QsQHK.HbuvMIiH5Q2vnM1cYR5N.yNjc6RDNU0RBnqVpJjySvjZBQa1dteceTNtvgQz7hXPlnSpRzKTGYj/k.Hw";
consent_mode = "implicit";
redirect_uris = [ "https://${svc_domain}/auth/openid/${vikunjaOIDCName}" ];
scopes = [
"openid"
"profile"
"email"
];
}];
# persistence
environment.persistence.root.directories = [
{ directory = "/var/lib/private/vikunja"; mode = "0700"; }
];
# setup postgresql
services.postgresql = let
user = config.services.vikunja.database.user;
in {
ensureDatabases = [ user ];
ensureUsers = [{
name = user;
ensureDBOwnership = true;
}];
};
# nginx
services.nginx.virtualHosts."${svc_domain}" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://localhost:${builtins.toString config.services.vikunja.port}";
};
# meta
mine.shared.meta.vikunja = rec {
name = "Vikunja";
description = ''
The to-do app to organize your life.
'';
url = "https://${svc_domain}";
package = let
pkg = config.services.vikunja.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,109 +0,0 @@
{ config, lib, ... }:
let
svc_domain = "wger.${config.mine.shared.settings.domain}";
port = config.services.wger.port;
in {
imports = [
./wgerpkg/module.nix
];
services.wger = {
enable = true;
configureRedis = true;
configurePostgres = true;
dataDir = config.mine.zfsMounts."rpool/safe/svcs/wger";
# wger specific settings
wgerSettings = {
EMAIL_FROM = "wger Workout Manager <wger@${config.mine.shared.settings.domain}>";
ALLOW_GUEST_USERS = false;
ALLOW_REGISTRATION = false;
};
# django specific settings
djangoSettings = let
headerToDjangoHeader = v: "HTTP_" + (lib.toUpper ((lib.replaceStrings [ "-" ] [ "_" ] v)));
in rec {
# setup site stuff
SITE_URL = "https://${svc_domain}";
CSRF_TRUSTED_ORIGINS = [ "https://${svc_domain}" ];
ALLOWED_HOSTS = [ svc_domain ];
# proxy auth
AUTH_PROXY_HEADER = headerToDjangoHeader config.mine.shared.lib.authelia.protectedHeaders.username;
AUTH_PROXY_USER_EMAIL_HEADER = headerToDjangoHeader config.mine.shared.lib.authelia.protectedHeaders.email;
AUTH_PROXY_USER_NAME_HEADER = headerToDjangoHeader config.mine.shared.lib.authelia.protectedHeaders.name;
AUTH_PROXY_TRUSTED_IPS = [ "127.0.0.1" ];
AUTH_PROXY_CREATE_UNKNOWN_USER = true;
# setup email
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend";
EMAIL_HOST = config.mine.shared.settings.mail.domain_smtp;
EMAIL_PORT = config.mine.shared.settings.mail.ports.submissions;
EMAIL_USE_SSL = true;
EMAIL_HOST_USER = "wger";
EMAIL_HOST_PASSWORD = "file:${config.age.secrets.wger-ldap-pass.path}";
EMAIL_FROM_ADDRESS = config.services.wger.wgerSettings.EMAIL_FROM;
EMAIL_PAGE_DOMAIN = SITE_URL;
# LOGGING = {
# version = 1;
# disable_existing_loggers = false;
# formatters.simple.format = "%(levelname)s %(asctime)s %(module)s %(message)s";
# handlers.console = {
# level = "DEBUG";
# class = "logging.StreamHandler";
# formatter = "simple";
# };
# loggers."" = {
# handlers = ["console"];
# level = "DEBUG";
# };
# };
};
};
# nginx
services.nginx.virtualHosts."${svc_domain}" = config.mine.shared.lib.authelia.mkProtectedWebsite {
forceSSL = true;
enableACME = true;
locations."/" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = "http://localhost:${builtins.toString port}";
};
locations."/api/v2/register" = config.mine.shared.lib.authelia.mkProtectedLocation {
proxyPass = "http://localhost:${builtins.toString port}";
};
locations."/static".root = "${config.services.wger.package}/share";
locations."/media".root = "${config.services.wger.dataDir}";
locations."/api".proxyPass = "http://localhost:${builtins.toString port}";
};
# setup lldap user for wger that can send emails
services.lldap.provision.users = config.mine.shared.lib.ldap.mkScope (lconfig: llib: {
wger = llib.mkProvisionUserSystem "wger" config.age.secrets.wger-ldap-pass.path;
});
# setup permissions
age.secrets.wger-ldap-pass.owner = config.services.wger.user;
# metadata
mine.shared.meta.wger = {
name = "Wger";
description = "We host Wger, which is a FLOSS fitness/workout/nutrition and weight tracker, with FLOSS apps, read more [here](https://wger.de/).";
url = "https://${svc_domain}";
package = let
pkg = config.services.wger.package;
in {
name = pkg.pname;
version = pkg.version;
meta = pkg.meta;
};
};
}

View file

@ -1,176 +0,0 @@
{
lib
, python
, fetchFromGitHub
, buildPythonPackage
, callPackage
, writeText
, fetchpatch
# build systems
, hatchling
# deps
, bleach
, celery
, django-crispy-bootstrap5
, django
, django-activity-stream ? callPackage ./django-activity-stream.nix {}
, django-axes
, django-compressor
, django-cors-headers
, django-crispy-forms
, django-email-verification ? callPackage ./django-email-verification.nix {}
, django-environ
, django-filter
, django-formtools
, django-prometheus
, django-recaptcha ? callPackage ./django-recaptcha.nix {}
, django-simple-history
, django-sortedm2m ? callPackage ./django-sortedm2m.nix {}
, django-storages
, djangorestframework
, djangorestframework-simplejwt
, drf-spectacular
, easy-thumbnails
, flower
, fontawesomefree
, icalendar
, invoke
, openfoodfacts ? callPackage ./openfoodfacts.nix {}
, pillow
, reportlab
, requests
, tqdm
, tzdata
# extra deps
, redis
, django-redis
, drf-spectacular-sidecar
, django-bootstrap-breadcrumbs ? callPackage ./django-bootstrap-breadcrumbs.nix {}
, psycopg2
}:
let
frontend = callPackage ./frontend.nix {};
in buildPythonPackage rec {
pname = "wger";
version = "2.3";
pyproject = true;
# src = fetchFromGitHub {
# owner = "wger-project";
# repo = "wger";
# rev = version;
# hash = "sha256-riJyVl0/GwAGkcHVzkJc666owPk1E4ca8DV5qTjEbjk=";
# };
# TMP: until it's merged
src = fetchFromGitHub {
owner = "eyJhb";
repo = "wger";
rev = "proxyauthheaderv2";
hash = "sha256-9GMU7CSMKcgBFYrUh6m9LFiJQ7XLkhaJ8EPt+FSZFqY=";
};
# src = /tmp/wger;
build-system = [
hatchling
];
patches = [
./patches/pyproject.patch
./patches/manage.patch
./patches/exercises-no-gifs.patch
];
propagatedBuildInputs = [
bleach
celery
django-crispy-bootstrap5
django
django-activity-stream
django-axes
django-compressor
django-cors-headers
django-crispy-forms
django-email-verification
django-environ
django-filter
django-formtools
django-prometheus
django-recaptcha
django-simple-history
django-sortedm2m
django-storages
djangorestframework
djangorestframework-simplejwt
drf-spectacular
easy-thumbnails
flower
fontawesomefree
icalendar
invoke
openfoodfacts
pillow
reportlab
requests
tqdm
tzdata
# extra??
redis
django-redis
drf-spectacular-sidecar
django-bootstrap-breadcrumbs
psycopg2
];
postPatch = ''
cp manage.py wger/manage.py
'';
# fixup compressed files
postBuild = let
staticSettings = writeText "static_settings.py" ''
import os
DEBUG = False
STATIC_ROOT = os.environ["static"]
COMPRESS_OFFLINE = True
# So we don't need postgres dependencies
DATABASES = {}
'';
in ''
# copy over static yarn things
# cp -a ${frontend}/static/yarn $out/${python.sitePackages}/wger/core/static
cp -a ${frontend}/static/yarn wger/core/static
python3 -m wger create-settings -s $PWD/tmp_settings.py
cat ${staticSettings} >> $PWD/tmp_settings.py
mkdir tmpstatic
pushd tmpstatic
static=. WGER_SETTINGS=../tmp_settings.py python ../manage.py collectstatic --no-input
static=. WGER_SETTINGS=../tmp_settings.py python ../manage.py compress --force
popd
'';
postInstall = ''
rm -rf $out/${python.sitePackages}/wger/core/static
cp -a tmpstatic $out/${python.sitePackages}/wger/core/static
mkdir $out/share
cp -a $out/${python.sitePackages}/wger/core/static $out/share
'';
pythonImportsCheck = [
"wger"
];
meta = {
description = "";
homepage = "https://github.com/wger-project/wger";
license = lib.licenses.agpl3Only;
maintainers = with lib.maintainers; [ eyjhb ];
mainProgram = "wger";
};
}

View file

@ -1,43 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
django,
}:
buildPythonPackage rec {
pname = "django-activity-stream";
version = "2.0.0";
pyproject = true;
src = fetchFromGitHub {
owner = "justquick";
repo = "django-activity-stream";
rev = version;
hash = "sha256-fZrZDCWBFx1R9GGcTkjos7blSBNx1JTdTIVLKz+E2+c=";
};
build-system = [
setuptools
wheel
];
dependencies = [
django
];
pythonImportsCheck = [
# "django_activity_stream"
"actstream"
];
meta = {
description = "Generate generic activity streams from the actions on your site. Users can follow any actors' activities for personalized streams";
homepage = "https://github.com/justquick/django-activity-stream";
changelog = "https://github.com/justquick/django-activity-stream/blob/${src.rev}/CHANGELOG.rst";
license = lib.licenses.bsd3;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,47 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
django,
six,
}:
buildPythonPackage rec {
pname = "bootstrap-breadcrumbs";
version = "0.9.2";
pyproject = true;
src = fetchFromGitHub {
owner = "prymitive";
repo = "bootstrap-breadcrumbs";
rev = version;
hash = "sha256-w6s3LL/skzz4EnWtdsa5GXeISrJzr4yQ8hm/gQMva1o=";
};
patches = [
./patches/breadcrumbs.patch
];
build-system = [
setuptools
wheel
];
dependencies = [
django
six
];
pythonImportsCheck = [
# "bootstrap_breadcrumbs"
];
meta = {
description = "Django template tags for easy breadcrumbs using twitter bootstrap css classes or custom template";
homepage = "https://github.com/prymitive/bootstrap-breadcrumbs";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,63 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
asgiref,
coverage,
deprecation,
django,
iniconfig,
packaging,
pluggy,
pyjwt,
pytest,
pytest-django,
sqlparse,
validators,
}:
buildPythonPackage rec {
pname = "django-email-verification";
version = "unstable-2024-07-12";
pyproject = true;
src = fetchFromGitHub {
owner = "LeoneBacciu";
repo = "django-email-verification";
rev = "49e841b96e8bd39f0ad359a75be4711508ac4879";
hash = "sha256-4hMSA1d6GOu7Xo7Qq1tBob4lW2zq1E4YaD8w0BnFfVc=";
};
build-system = [
setuptools
wheel
];
dependencies = [
asgiref
coverage
deprecation
django
iniconfig
packaging
pluggy
pyjwt
pytest
pytest-django
sqlparse
validators
];
pythonImportsCheck = [
# "django_email_verification"
];
meta = {
description = "A Django app that takes care of verifying a users's email address and activating their profile";
homepage = "https://github.com/LeoneBacciu/django-email-verification";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,46 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
django,
coveralls,
tox,
}:
buildPythonPackage rec {
pname = "django-recaptcha";
version = "4.0.0";
pyproject = true;
src = fetchFromGitHub {
owner = "django-recaptcha";
repo = "django-recaptcha";
rev = version;
hash = "sha256-B6Z9oKcMjSh+zE28k0ipoBppm9dD+Moa+PAZqXVabpA=";
};
build-system = [
setuptools
wheel
];
dependencies = [
django
coveralls
tox
];
pythonImportsCheck = [
# "django_recaptcha"
];
meta = {
description = "Django reCAPTCHA form field/widget integration app";
homepage = "https://github.com/django-recaptcha/django-recaptcha";
changelog = "https://github.com/django-recaptcha/django-recaptcha/blob/${src.rev}/CHANGELOG.md";
license = lib.licenses.bsd3;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,49 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
coverage,
isort,
pycodestyle,
pylint-django,
}:
buildPythonPackage rec {
pname = "django-sortedm2m";
version = "4.0.0";
pyproject = true;
src = fetchFromGitHub {
owner = "jazzband";
repo = "django-sortedm2m";
rev = version;
hash = "sha256-Jr3C6teU4On2PiJJV9vW4EEPEuknNCZRVMDMmrs6VY8=";
};
build-system = [
setuptools
wheel
];
dependencies = [
coverage
isort
pycodestyle
pylint-django
setuptools
];
pythonImportsCheck = [
# "django_sortedm2m"
];
meta = {
description = "A transparent sorted ManyToMany field for django";
homepage = "https://github.com/jazzband/django-sortedm2m";
changelog = "https://github.com/jazzband/django-sortedm2m/blob/${src.rev}/CHANGES.rst";
license = lib.licenses.bsd3;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,63 +0,0 @@
{
lib,
python3,
fetchFromGitHub,
mkYarnPackage,
fetchYarnDeps,
sass,
stdenv,
yarn,
fixup-yarn-lock,
}:
let
src = fetchFromGitHub {
owner = "wger-project";
repo = "wger";
rev = "bfca74e88f6c9ff6e917e0ba0e8e9c782ae0047b";
hash = "sha256-VuVKgkNp6Omiag72lOn6p51kC/jvApX/kRAPpK95U7w=";
};
offlineCache = fetchYarnDeps {
yarnLock = "${src}/yarn.lock";
hash = "sha256-olRU6ZGh6bpZ/WfwIKeREJRGd3oo7kEffFx8+4+7s5k=";
};
in
stdenv.mkDerivation {
pname = "tetrio-plus";
version = "1.0.0";
src = src;
nativeBuildInputs = [
yarn
fixup-yarn-lock
sass
];
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
yarn config --offline set yarn-offline-mirror ${offlineCache}
fixup-yarn-lock yarn.lock
yarn install --offline --frozen-lockfile --ignore-platform --ignore-scripts --no-progress --non-interactive
sass wger/core/static/scss/main.scss wger/core/static/yarn/bootstrap-compiled.css
runHook postBuild
'';
installPhase = ''
mkdir -p $out
cp -a wger/core/static $out/static
'';
meta = {
description = "";
homepage = "https://github.com/wger-project/wger";
license = lib.licenses.agpl3Only;
maintainers = with lib.maintainers; [ ];
mainProgram = "wger";
};
}

View file

@ -1,300 +0,0 @@
{ config, pkgs, lib, ... }:
# TODO: when DEBUG = False, serving static/media does not work, not sure why
with lib;
let
cfg = config.services.wger;
defaultUser = "wger";
wgerpkgs = pkgs.python3Packages.callPackage ./default.nix {};
# generate settings files
settingsFormat = pkgs.formats.json {};
wger_settings_file = pkgs.writeText "settings.json" (builtins.toJSON cfg.wgerSettings);
django_settings_file = pkgs.writeText "settings.json" (builtins.toJSON cfg.djangoSettings);
settingsFile = pkgs.writeText "settings.py" ''
from wger.settings_global import *
import json
import os
with open("${django_settings_file}") as f:
for k, v in json.load(f).items():
if isinstance(v, str) and v.startswith("$"):
v = os.environ[v[1:]]
if isinstance(v, str) and v.startswith("file:"):
v = open(v[5:], "r").read().strip()
globals()[k] = v
with open("${wger_settings_file}") as f:
for k, v in json.load(f).items():
if isinstance(v, str) and v.startswith("$"):
v = os.environ[v[1:]]
if isinstance(v, str) and v.startswith("file:"):
v = open(v[5:], "r").read().strip()
WGER_SETTINGS[k] = v
'';
settingsFileDir = pkgs.writeTextDir "settings.py" (builtins.readFile settingsFile);
in
{
meta.maintainers = with maintainers; [ eyjhb ];
options.services.wger = {
enable = mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable Wger.
'';
};
dataDir = mkOption {
type = types.str;
default = "/var/lib/wger";
description = "Directory to store the Wger data.";
};
mediaDir = mkOption {
type = types.str;
default = "${cfg.dataDir}/media";
defaultText = literalExpression ''"''${dataDir}/media"'';
description = "Directory to store the Wger media.";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/var/lib/teeworlds/teeworlds.env";
description = ''
Environment file as defined in {manpage}`systemd.exec(5)`.
Secrets may be passed to the service without adding them to the world-readable
Nix store, by specifying placeholder variables as the option value in Nix and
setting these variables accordingly in the environment file.
```
# snippet of teeworlds-related config
services.teeworlds.settings.SECRET_KEY = "$SECRETS_KEY";
```
```
# content of the environment file
SECRETS_KEY=verysecretpassword
```
Note that this file needs to be available on the host on which
`wger` is running.
'';
};
address = mkOption {
type = types.str;
default = "localhost";
description = "Web interface address.";
};
port = mkOption {
type = types.port;
default = 28391;
description = "Web interface port.";
};
djangoSettings = mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
};
default = { };
};
wgerSettings = mkOption {
type = lib.types.submodule {
freeformType = settingsFormat.type;
};
default = { };
};
user = mkOption {
type = types.str;
default = defaultUser;
# TODO: fix this, it is because of the database thing
readOnly = true;
description = "User under which Wger runs.";
};
configureRedis = lib.mkOption {
type = lib.types.bool;
default = true;
};
configurePostgres = lib.mkOption {
type = lib.types.bool;
default = true;
};
package = mkPackageOption { wger = wgerpkgs; } "wger" { };
};
config = mkIf cfg.enable {
services.wger.wgerSettings = {
EMAIL_FROM = mkDefault "wger Workout Manager <wger@example.com>";
ALLOW_REGISTRATION = mkDefault true;
ALLOW_GUEST_USERS = mkDefault true;
ALLOW_UPLOAD_VIDEOS = mkDefault false;
MIN_ACCOUNT_AGE_TO_TRUST = mkDefault 1;
EXERCISE_CACHE_TTL = mkDefault 3600; # 1 hour
};
services.wger.djangoSettings = rec {
DEBUG = mkDefault false;
# configure database as postgresql or sqlite
DATABASES.default = if cfg.configurePostgres then {
ENGINE = "django.db.backends.postgresql";
NAME = "wger";
USER = "wger";
PASSWORD = "";
HOST = "/run/postgresql";
PORT = "";
} else {
ENGINE = "django.db.backends.sqlite3";
NAME = "${cfg.dataDir}/database.db";
USER = "";
PASSWORD = "";
HOST = "";
PORT = "";
};
SECRET_KEY = "$SECRET_KEY";
MEDIA_ROOT = cfg.mediaDir;
MEDIA_URL = "/media/";
# EMAIL
EMAIL_BACKEND = mkDefault "django.core.mail.backends.console.EmailBackend";
# Cache - Redis
CACHES.default = mkIf cfg.configureRedis {
BACKEND = "django_redis.cache.RedisCache";
LOCATION = "unix://${config.services.redis.servers.wger.unixSocket}";
TIMEOUT = 15 * 24 * 60 * 60; # 15 days
OPTIONS.CLIENT_CLASS = "django_redis.client.DefaultClient";
};
# setup allowed hosts
# CSRF_TRUSTED_ORIGINS = [ "https://${svc_domain}" ];
# ALLOWED_HOSTS = [ svc_domain ];
# disable recaptcha
RECAPTCHA_PUBLIC_KEY = "";
RECAPTCHA_PRIVATE_KEY = "";
USE_RECAPTCHA = false;
# does not work
STATIC_ROOT = "${cfg.package}/share/static";
COMPRESS_ROOT = STATIC_ROOT;
COMPRESS_OFFLINE = true;
};
# main service
systemd.services.wger = {
description = "wger fitness";
wantedBy = [ "multi-user.target" ];
after = [ "networking.target" ];
script = let
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
gunicorn
# TODO: fix this, it should work with cfg.package
(ps.callPackage ./default.nix {})
]);
in ''
# initial setup
${cfg.package}/bin/wger migrate-db -s ${settingsFile} || true
# TODO: fix at some point
# ${cfg.package}/bin/wger load-fixtures -s ${settingsFile} || true
# run server
# ${cfg.package}/bin/wger start -s ${settingsFile}
PYTHONPATH="${pythonEnv}/${pkgs.python3.sitePackages}:${settingsFileDir}" ${pythonEnv}/bin/gunicorn wger.wsgi:application --bind ${cfg.address}:${builtins.toString cfg.port}
'';
serviceConfig = {
EnvironmentFile = config.age.secrets.wger-env.path;
Restart = "on-failure";
RestartSec = "5s";
PrivateTmp = "yes";
User = cfg.user;
# TODO: fix this, maybe
Group = cfg.user;
};
};
# periodic keep up-to-date
systemd.timers."wger-housekeeping" = {
wantedBy = [ "timers.target" ];
timerConfig.OnCalendar = "daily";
};
systemd.services."wger-housekeeping" = {
after = [ "wger.service" ];
requires = [ "wger.service" ];
script = ''
WGER_SETTINGS=${settingsFile} ${cfg.package}/bin/manage sync-exercises || true
WGER_SETTINGS=${settingsFile} ${cfg.package}/bin/manage download-exercise-images || true
WGER_SETTINGS=${settingsFile} ${cfg.package}/bin/manage download-exercise-videos || true
# WGER_SETTINGS=${settingsFile} ${cfg.package}/bin/manage sync-ingredients || true
${cfg.package}/bin/wger load-online-fixtures -s ${settingsFile} || true
WGER_SETTINGS=${settingsFile} ${cfg.package}/bin/manage exercises-health-check || true
'';
serviceConfig = {
EnvironmentFile = config.age.secrets.wger-env.path;
# Type = "oneshot";
User = cfg.user;
# TODO: fix this, maybe
Group = cfg.user;
};
};
# postgresql
services.postgresql = lib.mkIf cfg.configurePostgres {
ensureDatabases = [ cfg.user ];
ensureUsers = [{
name = cfg.user;
ensureDBOwnership = true;
}];
};
# redis
services.redis.servers.wger = lib.mkIf cfg.configureRedis {
enable = true;
user = cfg.user;
};
# setup user
users = optionalAttrs (cfg.user == defaultUser) {
users.${defaultUser} = {
group = defaultUser;
# TODO: fix this
# uid = config.ids.uids.paperless + 2;
uid = 738;
home = cfg.dataDir;
};
groups.${defaultUser} = {
# TODO: fix this
# gid = config.ids.gids.paperless + 2;
gid = 738;
};
};
};
}

View file

@ -1,55 +0,0 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
poetry-core,
pydantic,
requests,
tqdm,
pillow,
redis,
}:
buildPythonPackage rec {
pname = "openfoodfacts-python";
version = "2.2.0";
pyproject = true;
src = fetchFromGitHub {
owner = "openfoodfacts";
repo = "openfoodfacts-python";
rev = "v${version}";
hash = "sha256-aG+zbFr7lhh5OCdPe7h2XJSwok7sdrnpsEBzPgJ6Bas=";
};
build-system = [
poetry-core
];
dependencies = [
pydantic
requests
tqdm
];
optional-dependencies = {
Pillow = [
pillow
];
redis = [
redis
];
};
pythonImportsCheck = [
"openfoodfacts"
];
meta = {
description = "Python package for Open Food Facts";
homepage = "https://github.com/openfoodfacts/openfoodfacts-python";
changelog = "https://github.com/openfoodfacts/openfoodfacts-python/blob/${src.rev}/CHANGELOG.md";
license = with lib.licenses; [ mit asl20 ];
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -1,22 +0,0 @@
diff --git a/django_bootstrap_breadcrumbs/templatetags/django_bootstrap_breadcrumbs.py b/django_bootstrap_breadcrumbs/templatetags/django_bootstrap_breadcrumbs.py
index 0e98c65..4a4c13e 100644
--- a/django_bootstrap_breadcrumbs/templatetags/django_bootstrap_breadcrumbs.py
+++ b/django_bootstrap_breadcrumbs/templatetags/django_bootstrap_breadcrumbs.py
@@ -12,7 +12,7 @@ from inspect import ismethod
from django.utils.html import escape
from django.utils.safestring import mark_safe
-from django.utils.encoding import smart_text
+from django.utils.encoding import smart_str
from django.db.models import Model
from django.conf import settings
from django import template, VERSION
@@ -148,7 +148,7 @@ def render_breadcrumbs(context, *args):
kwargs=view_kwargs, current_app=current_app)
except NoReverseMatch:
url = viewname
- links.append((url, smart_text(label) if label else label))
+ links.append((url, smart_str(label) if label else label))
if not links:
return ''

View file

@ -1,32 +0,0 @@
diff --git a/wger/exercises/api/views.py b/wger/exercises/api/views.py
index d6387bb2b..86bca386b 100644
--- a/wger/exercises/api/views.py
+++ b/wger/exercises/api/views.py
@@ -374,12 +374,13 @@ def search(request):
image = image_obj.image.url
t = get_thumbnailer(image_obj.image)
thumbnail = None
- try:
- thumbnail = t.get_thumbnail(aliases.get('micro_cropped')).url
- except InvalidImageFormatError as e:
- logger.info(f'InvalidImageFormatError while processing a thumbnail: {e}')
- except OSError as e:
- logger.info(f'OSError while processing a thumbnail: {e}')
+ if not image.lower().endswith(".gif"):
+ try:
+ thumbnail = t.get_thumbnail(aliases.get('micro_cropped')).url
+ except InvalidImageFormatError as e:
+ logger.info(f'InvalidImageFormatError while processing a thumbnail: {e}')
+ except OSError as e:
+ logger.info(f'OSError while processing a thumbnail: {e}')
result_json = {
'value': translation.name,
@@ -393,6 +394,7 @@ def search(request):
},
}
results.append(result_json)
+
response['suggestions'] = results
return Response(response)

View file

@ -1,36 +0,0 @@
diff --git a/manage.py b/manage.py
index 873291be6..368de89fe 100644
--- a/manage.py
+++ b/manage.py
@@ -2,6 +2,7 @@
# Standard Library
import sys
+import os
# Django
from django.core.management import execute_from_command_line
@@ -12,13 +13,20 @@ from wger.tasks import (
setup_django_environment,
)
-
-if __name__ == '__main__':
+def main():
# If user passed the settings flag ignore the default wger settings
- if not any('--settings' in s for s in sys.argv):
+ settings_file = os.getenv("WGER_SETTINGS")
+
+ if not any('--settings' in s for s in sys.argv) and not settings_file:
setup_django_environment(get_path('settings.py'))
+ if settings_file:
+ setup_django_environment(get_path(settings_file))
+
# Alternative to above
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
execute_from_command_line(sys.argv)
+
+if __name__ == '__main__':
+ main()

View file

@ -1,22 +0,0 @@
diff --git a/pyproject.toml b/pyproject.toml
index 354f492f6..7163c4ffe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,8 @@ Changelog = "https://wger.readthedocs.io/en/latest/changelog.html"
[project.scripts]
wger = "wger.__main__:main"
+manage = "wger.manage:main"
+
[tool.setuptools]
include-package-data = false
@@ -53,6 +55,8 @@ universal = 1
# path = "wger/__init__.py"
# expression = "get_version()"
+[tool.hatch.build.targets.wheel.force-include]
+"wger/settings_global.py" = "wger/settings_global.py"
[tool.ruff]
# Exclude a variety of commonly ignored directories.

View file

@ -1,6 +1,2 @@
# Fricloud Server Configuration!
Bla bla bla, something better at some point, big TODO.

View file

@ -1,13 +1,9 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg tEIy1xIa5R5j/fEdG7bF+DqcHjAM8ECWdVod2VAxPQw
icCj7TE8HPoHKVKu6fCQf/xP960k+lThRssBbKDsgZA
-> X25519 1zS/IpbtMt6ou+IPqAWkl5ZgzIPuw7f2/P0oOEcNsQ0
lA3hqus1M7r9nfAemyVL17WRwaF9FPznt5L5J21Wy/s
-> ssh-ed25519 n8n9DQ PtaxammhSzlPG2mJqoPowrGOEhCG8zKhS778Vc8w1CM
MwTEogGIqX7tAWTiHWdoYg0prWf0iqRWupxQHllwZ3I
-> ssh-ed25519 BTp6UA Ay9Y/HNv1P5d4Y/yQ+IAumk6P8fRcVB/Lo0EDXg4WCk
68vCguKTvIvvyjD5jlsYe71wV/OjdTVzM3j6w5c1onk
--- K46hZrYKyLnC/zUbRQ48nAeyUFwrD7+JkMyMaKiecGg
ÍăŇbóÜ”š`f BŻ ĺoÎ(Č,•ąďsi}u).zŤ
L ·á îŠ<C3AE>LČ8- ŠŐ8ĄďS„÷×mRn=ĆőçÚç
aů3˝óKĐń l3´4ť¶¨(ň[]Ń:¤¨¬‰±ku
-> ssh-ed25519 QSDXqg 2i+hCYHZQ8bEtQJWnazPdAkDky907gzu1tMod6tIUkQ
c7AoKQEZERJziS+b89OP9v3j5BFG1FTcc5yK4U7wHtg
-> ssh-ed25519 n8n9DQ O1jM3fRClKiKGaJig/u+APxwi/MzIvs7l/HC+rDiQiw
+0VQR4gO/rxXZJRjfv/t+mfaDi0kUioTom8OoNoFDio
-> ssh-ed25519 BTp6UA 93ld1x4OCnO4GshJz3Hf7mB2jFVGYqZQ8AwvB7cOqzg
AMFa8ueIf3Fz8VQpWWrS6ncfrh+pdsU7RMR3ZjA8KLE
--- qDtFEysXwYfNfu63ufZFt2lARP72Gkx0Kp6zs81VkT8
Oj´}¼4VfĬj¢Ç\cBÁ!9ÏìÚYÚ¨­Ô(ìd2©\bÙs5…ïâ2ËhTRœ@êg¼ªÔ·®•„kì9¹S<½wq~ÕÞ%)º^B ÎõJS­ @Å©x±Í1[†Ì0œá>

View file

@ -1,12 +1,10 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg YoKK3e/O8rsIw9olLq0xOEbUHi/OWUlNTndiM6mW/x4
/F9jMFY31Brnj5UHzP6VmxUVvnsfvaM+rJc0gpn2Bc0
-> X25519 gGccDs1K9oQJYejMUS0EYJwgDcD0qMvNlgC+mYq0dTg
sXZshUFzMF/vlo6KLIAWZD5D/jI35/fS0wKTAehuZB8
-> ssh-ed25519 n8n9DQ 7NT1vUQu+HpRFULWtRc/o4J8O5Z3U8m3LlJ8Q2Bq9wo
UBDmhGTwtUmghywmzGTwhcu3fHExBFy0rif3pPfblrY
-> ssh-ed25519 BTp6UA oSpx5EIkXL8nvmw72WdCwFomtGfpq5+SyrtKCIMu33Q
00AEgN3W0kiUivhA+xnfoETHhn4tvgJQMc7caGZ5q4w
--- T+xLNMpqfWIaV9dkq8l+kBuADWmtCIFrZhOoOK1fyro
õãÑXÔÁ?5åŽ=Cžwf®´<15>5l;?2l ²¾YB]ªqãhêÇ¥P <E28099>o.¿ð±
„û§©–&.5—ÞÆxZ_!¶Ùð\ȸÒÈ=|è—ÍHtòìŠ KDëmúD&Ý«=
-> ssh-ed25519 QSDXqg s4bJfm5nhl8dESl1yXgQFkCT2nJdKeMVhOC10Z1e1TE
m1MEBzSr/GZRdNrw2ceFFVjFfcVOdO3D8dxsg4x/lUU
-> ssh-ed25519 n8n9DQ GwPbYmxKFHZ/JJtJV5o/MSi2mYyJtpupT6TF/QAUAjI
FZ0WMuYfq3e8Kcp7DAI6kkHVavfVFNm4mIwGbaw1VWk
-> ssh-ed25519 BTp6UA QcXiF+NIbadObCT3jK7KnVluDqjFev+XA5xQJwk2cA4
/FKzec70a9cuKq3FStESSwbbgUi3Zf5k5xfa45eMB5g
--- lwDjO24aMTssxFfekozBYCnigZJ7ztklFwFh0Gn10pA
cïPvýqÕÿœæT‰Ï_Kt ``\˜1_Ô0^S¬ôBQ8Þ<38>uÃ}òEËÒϬ¿â3{))š<3®uwCµjëý„R ¡ÏÉ#û@g0xk TÍ8ÊR<C38A>Un·¨$
æ³µ

View file

@ -1,11 +1,9 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg f5BkTN2OAhqz73HpxPFRKynlnOtzvTr7Xiwfrt5OblY
vyVQDs1RCxStxJcdL23b7xKJZM7kzx6/CafYbXcvZqA
-> X25519 i75r1tQHsdp8/znywa9dPJ290PYZtz6r37pobJd4rF0
xk/B0oIOT3C16pZMWzo2ab2507rkVjCDf5JTocHL9uw
-> ssh-ed25519 n8n9DQ LCWwNu2knTWEVyM95r7JZRa1ewrbLG8jfkJDcGuCFgM
3Oe9WnBHLvOvWdHBHX1/w6oHZAgcAWVLI+3WWNEcJUo
-> ssh-ed25519 BTp6UA g2xh6AGZTvC4wl6V9HRZSM/np12aRMI0bhB2r/tRBkI
Stk2VwOZL/Mr3jhBEG4GLn5Gve1qjsRltdPn/XQQZBI
--- 5pvauehP6HZ7uOzWT2vYN3T9JKLYZ9lVhBOyOC22/YM
×ZÛÛÃP<C383><50>¤Ú%[bãcçµø€ª,—6BÏ}îÄZÆÎjc¢¤íE8O/íå&D}/ f9·²ºåÔD
-> ssh-ed25519 QSDXqg ukkpdyQwjxQ5ZSDRNp3scWW/UaL9KkvYjgOohagME2E
TBz/F6ki/WRQ36dwWGya/+jk6d/CVit0uk6ftUGwkM0
-> ssh-ed25519 n8n9DQ 4tAfBISscjJkXdT2ze7qwjSgXsKVORQdJ6BU2FWziBI
KsgjA34+cX5JP5zQDJu2S42T07L1bUH6rFNLnGpGsCk
-> ssh-ed25519 BTp6UA MN57AovukP5h7xP0TtdZJnbGUVGem9Ag4yrXeQPLOmc
XvlsXC6kI6gbzrujxfGQII2bwPoXd7pAQfP3oXGqe4U
--- qWkj5My+16z9Qjge9GR0ezFkzi4zONiEny+I/5j9qpQ
*ヲ{|<7C>o訒<6F>i_k}oセ暉<EFBDBE>ー乕z)ァ讐ネQウオク、ニc弥戻€ヲH「、<]約・g\{W<><57>7:

View file

@ -1,11 +1,10 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg 73fdkNhCNfHaCy86Ec3KL5oaZfzsn189LX5VpJSZVCM
MdBa9ufGc1Zh7bIph+wQkfwrAlkTLE4KtC59nD9bjvk
-> X25519 3uJmCujV7zE6Lf9mbcGCoHrUjceLyRIg4RrJQHoLcQ0
P3OP2PH5i/bRTuxeMGOeKsTuN8j9i+1DqT4m3oqLl5g
-> ssh-ed25519 n8n9DQ KOQn0fTCgfO/CaBPmlsTERgByfkMBR/g+MAzdCsg5C0
C3hY9QIr2DsNk1xz/58h5gKMRb18IXr13NWbKrvxdAA
-> ssh-ed25519 BTp6UA SLKD+FrhoE+SIDpV+WdBq9epMHTZsrMEKLvBNc8XQwc
DZyFUjhZHuelH5Rs0XV49aqc5PlHZyUqSdy/GgHJ9+g
--- 09dMjfzGZgH1Ol73FQxDx/Lg51TFMLePxrOE4WDThFo
J—m'Ú¤öî¢Ë_£e»ÞHfPSë¢ áØÖ-ìG4K銔0Ð¸Æ åtβÒw×0JUóß9ÞÙŸEóeIëºíÑ#ËÊêðåq+¦3a¢‰ñQ¬ÑXHXŽØà$õëÈÉ­
-> ssh-ed25519 QSDXqg /Ywa18VQyXbCgwIBWGRDB0m9mNd7TtQH4HEQvJpxLkU
NdigMBP4yDz1v6Q8OXGu7lOd4JpxnBJuaWj5xgz/I/w
-> ssh-ed25519 n8n9DQ yAQO33Csz6+h8dEKmOvVbZUgxN+nPY6+OvE2W3wBNmI
5v8JM8vHAmWUlnYiK+eBhp+BIKwbGSOS4UzFpxuvzEo
-> ssh-ed25519 BTp6UA VnmGREd7Rn1c4sYJRo85cvnuH1QBTQxG6P+c/tdat1M
0TBJ+a1BBtFBo4beFx5671hIq/pluFJ9wiUK59dZEc0
--- qzbsERkRBc+PLfAg8/+MiwO2Rh2bWQi6YD0B1QiyzJ0
<EFBFBD>ra•ËteX PœZ¥Á Ê!Y *ð§aþ™ß;‰í±ˆYöÏá&¶
4<>¡¹ÿéì¥UÉz )ºº2«  Ê’ «¤>íº8SßozRÈÁ@·Âè(UÒ´rܹË$åUVóÆßäw

View file

@ -17,7 +17,6 @@
group = "secrets-lldap-bind-user-pass";
mode = "0440";
};
lldap-user-emails-env.file = ./lldap/user-emails-env.age;
lldap-bind-user-pass-hedgedoc-env.file = ./lldap/bind-user-pass-hedgedoc-env.age;
# mumble
@ -33,49 +32,12 @@
# nextcloud
nextcloud-admin-pass.file = ./nextcloud/admin-pass.age;
nextcloud-secrets.file = ./nextcloud/secrets.age;
nextcloud-smtp-pass.file = ./nextcloud/smtp-pass.age;
nextcloud-serverinfo-token.file = ./nextcloud/serverinfo-token.age;
# stalwart
stalwart-admin-fallback-password.file = ./stalwart/admin-fallback-password.age;
# matrix-synapse
matrix-synapse-config-authelia-secret.file = ./matrix-synapse/config-authelia-secret.age;
# wger
wger-env.file = ./wger/env.age;
wger-ldap-pass.file = ./wger/ldap-pass.age;
# restic
restic-env.file = ./restic/env.age;
restic-pass.file = ./restic/pass.age;
# searx
searx-env.file = ./searx/env.age;
# uptime-kuma
uptime-kuma-ldap-pass.file = ./uptime-kuma/ldap-pass.age;
# rallly
rallly-ldap-pass.file = ./rallly/ldap-pass.age;
rallly-env.file = ./rallly/env.age;
# notify
notify-ldap-pass.file = ./notify/ldap-pass.age;
notify-env.file = ./notify/env.age;
# grafana
grafana-authelia-secret.file = ./grafana/authelia-secret.age;
# drasl
drasl-env.file = ./drasl/env.age;
# vikunja
vikunja-env.file = ./vikunja/env.age;
# headscale/headplan
headscale-authelia-secret.file = ./headscale/headscale-authelia-secret.age;
headplane-env.file = ./headscale/headplane-env.age;
};
users.groups.secrets-lldap-bind-user-pass = {};

View file

@ -1,12 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg ebgFyJS5wy6KlYd+FCwIr8E8f9BGXVS+buEXS/+h0xE
VsUaM8sZzvhBidHvmhlf8VBEHTWmY1+R/5gKF3BWQyA
-> X25519 zKGYd4fUcN0WOm29enxa9sdu2IASPyjZ2RMpH8AAMAA
tbZofRRRnTKaKwI5GBw3gf0gvIsWcH3mv8jr4v9Okd4
-> ssh-ed25519 n8n9DQ u2gNkt7dggt++rZGevmIKVjX73M9v04opNq2YuAynD8
7YHtRXzVmD1LQeJtcWnSsKKUAL/DKTxfGDFUTC+nNMM
-> ssh-ed25519 BTp6UA fESw3HOP8rvsUgeDKm+BCT5h5HnMbzjlrzU6en6mfGo
Bz0BOmOgDz3wrSaHz7eDe1Y70dpzuRLOdjALmCN14UA
--- vDJkQ31TTcesjWK6t5LNIjPQp3d10i2NRU1lITQDZEI
%ü~Òf¶éÁ Ѳ¿qÓ?Gk…É<46>þç±yª<79>Çâ7ÛÜ“(»¾<<3C>U±åE¯ãH%,ð¹@o€½¤c
ìd“Áö=?zS†ÛPŒl¯þµ~…JÌôVØ[JúŽ;”-ßþvfHÙ¢©ô•

Binary file not shown.

View file

@ -1,12 +1,10 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg Z2carBfkWnq3pSVRwIsaOK4LtUwQylMzl6qhJrVHPFk
84ho1ewfxZLddbwoTS8OfK2+DqQee+ew1QfkM1WodZM
-> X25519 zNMm+jdN0pdifxqkCRrwSMlwnDth31GojIrVROyUCEo
fpgNEoxiMGDDvj/xvznNRdaS1Q4gHxu02MoqH7zqjN8
-> ssh-ed25519 n8n9DQ wIJ3wnHibsg0UrxrzDhKXeXzDgH3wNraL1DTa9K2clU
myZVNdu74xFFZ2NiljKHq8/KlJwyhXWB+rkCFTz1IlY
-> ssh-ed25519 BTp6UA 7R89FE3UckXyVnSqnxv+fI9FQ8FTeuRIQAy4crC6USc
wBWGkwiF4b+fjS7VMDlqLIzMh7oj2VyYujIRDFUo41o
--- L89Yp8X8Pte6KefTjNtz4YX+2d66w71EOg6dHHeiPys
šÑ  ÌÅ¥lº·g$+ŠG%œÏ0®(¸AÊÊÿÊ(7DÒñ®Ž$º f|Ú'£D«böRÚæL¥š"q¥i„y$A"Q î÷"ô
ý¸®Ýœ÷>,¥½ho”8"š›Š!óºoL<E28093>ª‰
-> ssh-ed25519 QSDXqg 6QDsf2ACEhUdb2IUxDaILcvoPLPdpXupn7DDTQCREWA
8VYqwj6UoIee6DHG+bF3lHnY+NxLP8KkjjxynSiEUEk
-> ssh-ed25519 n8n9DQ wAIhE1M0sMi+0tdc2fZDFzLJLlaZkhZOXIEkB2lm3UE
iG5tvWLkYfv/klmc0LjUn+96RuYTRUrLbMpPicpVchc
-> ssh-ed25519 BTp6UA GSOxjXyCvjwg/jS3pG3gegh9jcYbVHy3y4v/dUM7plQ
ItU5OQJswLZ3nCpfUcLMhkdEVF6Iu+lPP0qGj9MU7h0
--- ONOkuJRp+quOeMUJ6kf+j4Bweks4FAGurAE3xgk3wIw
ðà7‡h#p<>·ŒØ˜ÿ,ŒÙîúàSV|^8"‰)¢š§†‡è]ýbiàڡܶġTWêi Aÿ
¹\›ò¾%X¨ v « 4Fè”9{<7B>FY<59>"Ä5`wZ¬¼¸MÓ$eý='€a

View file

@ -1,11 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg mcA7aWulfqHTARfxzs9ECZaJRMZKLxZgl4uYXrsL6Tk
IOKrdtTiG/Wc8qQb5zip1F3B4BHAGkEw8hjz22UY80k
-> X25519 kqD2VC9Vw/2rrd/C1TR5He/78anx3UYXNbjs0vNXCz4
ZYenf1LK+YAlil/oiZIfGGyaK9S6pt8LLpCbmlaKn9s
-> ssh-ed25519 n8n9DQ PlW/1TA71RhclXIC2RlKUUOnqOq3qWy8yshqgM3Nu10
2j6c3UjFc/RJJrqeWIezHx53DcPHFPi5a8WXnyqkXhU
-> ssh-ed25519 BTp6UA n2idpPd9RFDbzvD2svo3A0NU7kx1nUEYzwFs0gpxn3Q
/4F5l1dXBvF0nWXvT8nxPPCAxB4heeUMSBrGMY3gfng
--- 7xw3+Ket2jYmH8wsoG2ivWUYLkyoR0et5FELrn+zzMo
9XzvèäJºEŠó«y⺈†è}\šÙ©‰ï\xÓºeè”11ûõ¯ƒô7XÒÑb%„á Õ˜.…ïj‰!‹Ä6œšBÃ[/ÆÀx!8Àâ‹ÕÔÿÿÍ´¤'2ŠvRúž§4W:]k

View file

@ -1,11 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg U2hjU5FeVfd+/agny133FUmLJ47zqpww10DgzHbkKW8
k6k+WH6fZ1UZjnOwe3LS6cXYVcucE+kBhuPRMcNFabI
-> X25519 JfLnC3qBptSdDwBRo1Zj/rDqKqWFvxWqBANIKhCIVlc
iuqSt9gdPjQVRuv9+iUub1zkecSUjZD1XKvtvkNAaOE
-> ssh-ed25519 n8n9DQ eVkQODcS4wG+5xliWzqfEcRKvkHaHTzz8BWXH/ld/3k
9q0TB7ZLluGIQhFjMcXUmXlGiof5/ACrrHQRWEq4jik
-> ssh-ed25519 BTp6UA hGuGXru5pgShG15hEH6B8fYF4x1SK6blgP+3NSfvklI
UZYG5ZfErwlBtux5fWh3CPrT/9oWoDjGXX1M0ojtY8c
--- NlksmWK4pF3hQkpCkZaCBWvEv7I9TB0zgYRiJpuLB18
±±-ke(~ñ…"KktWv´&«í´~ÔúšžÝ¶û¿Žè½é HÆ)ûjQÔÅÐtRe/<2F>C_s.“ƒÓéýÚÚà/º~21ŸòÜ«"æ!á²ÎÓ<C38E>7IƒÅüÙìŒCj¥KÝÛ¼H«ˆ³o!È{ÁüÏþYhÉzcÀ¢³¬go?ßåtˆäãÔÃ<C394><s ­ì©`ÌÇñŠMZgôÙ¨R:þñ3-i@dVÕDÂ^¸…'{R6{Œ€ÑóIĹѯҬ¼`UÜSJ& E¯/WâÏ >¾JS @`Ùm±h|‰ˆ±Áe3é¨U!;sñíãÔ}õ¼©L—í Àb¿ê$<24>·-R8Vq:ÏíÚ£uûÉ&üÊK\¥™ÉÛiªð¸8¬‡TALO•ÍÂùOñõgñ3ÒfˆêàT½€

View file

@ -1,11 +1,10 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg n5RiJL05MGTaHFNBOq4V7aJDdnw2wNI9S4rgHwLLIkE
M67SzRRJzEbWzlLp4QJG71dbQ5HEp8R5n6Tmxl//PtQ
-> X25519 O3ZSSvjwXHqZbQcrIAiMbbscdYpC29NQKNP+jIX2GW4
8fbx+Dfgg+Huueeic272Nz9hXkntkMSt8zoYaUCgzy0
-> ssh-ed25519 n8n9DQ RUKMRjuVi4pjCqJO9ZqP5txbBNnq8yQ84Rvqq0ooG3g
JuEioORVfSLsOHqSmdQaJBd+xp/yYl/wuJUvhTQEmDY
-> ssh-ed25519 BTp6UA NEPujkvmlk9mHl7KeaCKh7lOSanfKlU2lm9dqMy1yEc
v2LSptbFd/bEwh+AKsXGnNoALLwyjRYEO8wYaxSp+x8
--- vjwuX+eM6Hbzd5URUnhWYArVBNd7q/5+DM6FiTc/0OQ
<«×è ݵ.ŒÇ|þÏî2y%Û—ô¡ @´@—¦£LRQ4SΈhâqSzÝßÜc¥”€Cßå„2)æ>«‰,ðý
-> ssh-ed25519 QSDXqg GGeJqXdtwwxIlkG/yl4DfkKykQ3uJyWqLguJ680vZlY
LS19/W+IHFSAeog3c2qAzvgE2VDWF81B5ehqo2xoCVk
-> ssh-ed25519 n8n9DQ 8xOzOWPQEwAAslYAg71Hf8sf67+QGFKeX280ueXrYVk
ZdzT710/gB1N7eosXQbyRdyzQvQDuLeCFS6ocpkvooU
-> ssh-ed25519 BTp6UA RyRdwb7gHk74LgqEmWUJ8SpiS94IHczpO2ZokCFO0QY
c3t3vZyRqSIWiFnt0slV8AjACKW44PgUvwijLTNigck
--- emrYR6UhtLGsqpz7q+KAivD5e0sAf6zaA5qh3vD/13A
Ùüù×H@ø­|² >á3­C‰Œ&*µ_8
~ç7RÛ†)°$<24>çü]éD©y±±}ß.Ê:

View file

@ -1,12 +1,10 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg UVHeL31U+0oPD6WkJcgaGJKn6RCLchQwi4RbV3t1ViY
HndUvlRyE4ewwN0I+fjR8/V8i2hpdm2T77T5mkGDwqs
-> X25519 471FQ92/VNQ0rMpA4TYpa4XLhl5neg/Rvly+1qMznxk
mfGl3rlfbcwYgUusDDt6tpdD5p+/Qqlx47NHDmluqfM
-> ssh-ed25519 n8n9DQ Or6JhMO4kbyvjReIeHPk+Qy/mZv3Aq0jmuL+lxGCdk4
Z1i3PDxXR3hBxH5JjhQip0KbLJOBDDtG2fftvjA7Urs
-> ssh-ed25519 BTp6UA TfNnysrP9cXIHILbD9anYH70wfiloFcZUTX2yavpfkM
/Jn50hf5TK8DnOZGniK1Klung9O4SVal3QhUjxXweq0
--- m4HRBmPMsLUnS4n1IJwi0+bVPAIdXjgGUDS91dd5kbk
Û;)—©ã#^VðA¿<41>®!vˆ‚‚û)4ÇR4z¦<C2A6>ÇÔ²;üHl£_y|„Iü}¿…¤ÜÅ·'3ê]ˆ„?ì+í³£NkÅv¿7_
ã;,«Êµºa9[§Á
-> ssh-ed25519 QSDXqg lB8SsqHApg4Bmrg+YP4Gns7+UtUb8jOjxEXzTRHT4A8
psFKfMdO5j54Q1ISyA+FgWZCRHVmEWXNNkjweqWZ1qs
-> ssh-ed25519 n8n9DQ WGAMIZbfqukK5mrTlKYy8rUNy+DEwxFijZiCxOGlX1o
7/YPyZbUfJ7SB9T38JwgcW2LAnZSLgMFDbf6N7NbRbI
-> ssh-ed25519 BTp6UA ZkEWRVFiMWnQQap3CGb+FihCw/y8funz6UFuxPYsmQU
e9h5DaikoTLmsWPIC82DA7EUUOX7X1ZrSmBKeMk9T04
--- Uif4qB0IT17YXBP1r36yXjUzO2rd6wuVi9wP0x0D1WY
ËÔèw¥Zhõ(È‚ùSú¯ÝFø+;•LÛ Õ%þúГvÌ<76>4ßh?ÞàýdǤ¯_
ŸÎô&Ê%Kì<4B>-ïO+ªš™ü¯"R™¨A#_£Ø.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,11 +1,9 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg MSy+VhPuDpa27TVJiIMdTjEoso41n768EhR3xzQQpgY
vmzBPGJ0p10rq8I7dC3+aIZ0BnI9Y9s55kaPTVLM/Ls
-> X25519 Y9q6F+lxCywb/EKVGr23o3lDariEA2gT1+mlUI75yBQ
FS86DVHDG1iwb5EoY+agq7cgpTzC94QwUEL859UvNoM
-> ssh-ed25519 n8n9DQ H67QnwjkUMV5vBXTVNonnNuNUfqDjeT1jlh+frpkUgk
UyGk2g8qmkl8oLTFPaPg+drNMKHtbDoPrZ8AgMvUWzM
-> ssh-ed25519 BTp6UA nuSVT7cP/UdyZPFzFjuo4kWcUzWM9zibc+4KN4gPBDM
BBx6wq5MKk5KoFcGt3SEuDNC1jaITZpy3pCE/oUF7vE
--- hCZOhBFtLe/I8tRJNtRB2F7fNxLTi7sOo5ZQxCNUha4
R-¸¾ê\v¹á/ǨÿÌñ9¬ Ùgñø1·O8ŒÏ&x˜ÔjÈÜ”ŒËâØÁùà8êXvL@6wëj?šfô
-> ssh-ed25519 QSDXqg LoWIvj4OQjNPaGbtQYSUEKtkqvcVa2pPisjyXL6ajy0
ZfLdRcsWa4Nc6HdiWO1GCgSgHm7aZeUdEDCjUCn6CuY
-> ssh-ed25519 n8n9DQ e7DWlUZdaDPgoS0Ylnxtf80IN+QMtCJ48oI4Z4U9+0I
/2ZleHBcAkWh8Udt6D2QgBOCTKkqH3GIsGsGexpAaxA
-> ssh-ed25519 BTp6UA bgTa1+cFzW07nPhe/5GKW1RreVO5IqIzvPZTYpnrGjY
7F4HnAnHVZX+dfOpc5mPB4/TTgPgw8hiIyVTEbffRQw
--- IrCqHtOIS3c5By3cBTPQAGpM2GzCu61AhiavRjozk7o
<EFBFBD>h<15>ネ都・ケ5+RBi}マ黍<EFBE8F>ヘ瀨 ハN$wト:カ![オs<EFBDB5>、゙<EFBDA4>ホア、<EFBDB1>.ョgR・ト「>

Binary file not shown.

Binary file not shown.

View file

@ -1,11 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg nLdvh4Rh7NRfCpubsUOaSTwL+uQYa9jpiWWHmq8tBzo
jIgDAQZAmpoOqShDWMZZC3m/go+DImfYbg+gOlbbLu4
-> X25519 jJ3QUtYdo6FM/xncqZeJMg5JJh2PKhe8rDw46ZrbqWo
uoDuPBJDplDoRiJGi2NFNJqDlo/fRGUqPiD0Jk6AX1c
-> ssh-ed25519 n8n9DQ +3vT7Jfx+kUFbHbEAWFN0hiDn0c0m+65brjuM5M4HRI
+jGGD9trmPr0BV2Ev1PvcdTAbzEyrHtHGleuheuYrIY
-> ssh-ed25519 BTp6UA Da7JqYJiJToDKhRelrwbXCj35URUi9T/Zzr0fLAZX1A
Kyi0O0Wog/VYlnCezm9qyxHiEU606kVHZfp17NKxXQk
--- 2t7lCNkYh/E4RyFx7sAtup5z9z/UFcxvk4XHhfJK+4I
òÊ¡ "<22>V˜nê¢ú]«„þ·ÁhQYŽs¡Y9ÎY®^€rã®ÔÑ6lƒ6*@G{vœRf÷°IÐù7

View file

@ -1,11 +0,0 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg sB2scYcZRgd5E/6R27PdImZnUWkJupnHczQQYGZsCD0
FzInWenN2BSOcxXo+vfTEUuMyRfyAuGqyFSp+ByC/U4
-> X25519 a+pbMWxxfI9QYkoXQFojS0FD8WnF+uyGXVlW2zZvaxo
xVZQXcCrW7GIPDa6/n+Zh+Iua5wLO886cgn0aFOzGNQ
-> ssh-ed25519 n8n9DQ LCG5mLWudd8IIFPB/N2ZjnWmNvClDq4p1VSsmxhVdHA
Lv4a9eDu7pZhgYdYziXnIXZEovk0zlkRoweH5zIRW8s
-> ssh-ed25519 BTp6UA dQBcqLui7exxZYoPEtOFtDUFXQUhpxf/XOCkrR1fCSI
ysZfoPv2q6E0dbHxgdsPqCXs2d7ZUMbTJbma3y285E8
--- VhWbBMj96n+u7SuNCMRhI0NF1q4g0yGbyDFL6u5ivrk
“„Ú<LûS¨ô¢3øZGò"aÌ„•ZBÉ.Q•¤Ä ½Ã+‡Ôª„<C2AA>½.¹`z$SÑY_“r×ê߉µWГµñVf«™·€Þ¢ÀßÐɰzĺ<]Bß<<3C> »”å‰à; ^É^²°®o€*å0úa0ë<7F>.6ˆ=k¬Gæê~HÁêŠfb¯0û¦vâÓ 7_¦Äá™~FÎdœÇ•

Some files were not shown because too many files have changed in this diff Show more