more work on ldap bootstrapping

This commit is contained in:
eyjhb 2025-02-02 00:12:38 +01:00
parent 19cd1b3255
commit ae3c110e18
Signed by: eyjhb
GPG key ID: 609F508E3239F920
10 changed files with 362 additions and 11 deletions

View file

@ -5,7 +5,7 @@ import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport from gql.transport.aiohttp import AIOHTTPTransport
from .utils import to_camelcase, to_snakecase from .utils import to_camelcase, to_snakecase, get_value
import logging import logging
@ -100,6 +100,7 @@ class LLDAPGroups:
def update(self, groupId: int, attrs: dict[str, str | list[str]]): def update(self, groupId: int, attrs: dict[str, str | list[str]]):
insertAttributes: list[dict[str, str | list[str]]] = [] insertAttributes: list[dict[str, str | list[str]]] = []
for k, v in attrs.items(): for k, v in attrs.items():
v = get_value(v)
if isinstance(v, str): if isinstance(v, str):
v = [v] v = [v]

View file

@ -0,0 +1,148 @@
{ config, lib, pkgs, ... }:
let
inherit (lib) types;
cfg = config.mine.lldap_provision;
# helpers
_configFile = {
user_attributes = lib.mapAttrsToList (n: v: v) cfg.user_attributes;
group_attributes = lib.mapAttrsToList (n: v: v) cfg.group_attributes;
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 = (pkgs.formats.json {}).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?";
};
};
};
in {
options = {
mine.lldap_provision = {
enable = lib.mkEnableOption "LLDAP declarative setup";
url = lib.mkOption {
type = types.str;
default = config.services.lldap.settings.http_url;
description = "URL for the LLDAP instance";
};
username = lib.mkOption {
type = types.str;
description = "Username to use when signing into lldap";
};
passwordFile = lib.mkOption {
type = types.path;
description = "Path for the password file to authenticate the user";
};
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.anything;
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.anything;
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 {
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.url;
LLDAP_USERNAME = cfg.username;
LLDAP_PASSWORD = "file:${cfg.passwordFile}";
};
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 ${./.}/. $out/bootstrap
'';
in ''
cd ${pythonDir}
${pythonEnv}/bin/python -m bootstrap.main ${configFile}
'';
};
};
}

View file

@ -3,8 +3,10 @@
from typing import Any from typing import Any
import subprocess import subprocess
import requests
import secrets import secrets
import json import json
import sys
import os import os
import gql import gql
@ -26,9 +28,19 @@ logger.setLevel(logging.DEBUG)
class LLDAP: class LLDAP:
def __init__(self, server_url: str, auth_token: str): 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_url: str = server_url
self._server_auth_token: str = auth_token
self._server_refresh_token: str | None = None
self._simple_login(username, password)
self._client: gql.Client = self._init_gql_client() self._client: gql.Client = self._init_gql_client()
@ -37,6 +49,35 @@ class LLDAP:
self._attrsUser = LLDAPAttributes(self._client, using_user_attributes=True) self._attrsUser = LLDAPAttributes(self._client, using_user_attributes=True)
self._attrsGroup = LLDAPAttributes(self._client, using_group_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: def _init_gql_client(self) -> gql.Client:
# Select your transport with a defined url endpoint # Select your transport with a defined url endpoint
transport = AIOHTTPTransport( transport = AIOHTTPTransport(
@ -269,8 +310,8 @@ class LLDAP:
] ]
) )
def run(self): def run(self, provision_file_path: str):
data = json.load(open("test2.json", "r")) data = json.load(open(provision_file_path, "r"))
self._run_ensure_attrs_user(self._attrsUser, data["user_attributes"]) self._run_ensure_attrs_user(self._attrsUser, data["user_attributes"])
self._run_ensure_attrs_user(self._attrsGroup, data["group_attributes"]) self._run_ensure_attrs_user(self._attrsGroup, data["group_attributes"])
@ -279,9 +320,26 @@ class LLDAP:
if __name__ == "__main__": if __name__ == "__main__":
auth_token = os.getenv("LLDAP_TOKEN") url = os.getenv("LLDAP_URL")
if not auth_token: if not url:
raise Exception("No LLDAP_TOKEN provided. please set") raise Exception("No LLDAP_URL provided. please set")
x = LLDAP("https://ldap.fricloud.dk", auth_token) auth_username = os.getenv("LLDAP_USERNAME")
x.run() 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

@ -5,7 +5,7 @@ import gql
from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation from gql.dsl import DSLQuery, DSLSchema, dsl_gql, DSLMutation
from gql.transport.aiohttp import AIOHTTPTransport from gql.transport.aiohttp import AIOHTTPTransport
from .utils import to_camelcase, to_snakecase from .utils import to_camelcase, to_snakecase, get_value
import logging import logging
@ -91,6 +91,7 @@ class LLDAPUsers:
def update(self, userId: str, attrs: dict[str, str | list[str]]): def update(self, userId: str, attrs: dict[str, str | list[str]]):
insertAttributes: list[dict[str, str | list[str]]] = [] insertAttributes: list[dict[str, str | list[str]]] = []
for k, v in attrs.items(): for k, v in attrs.items():
v = get_value(v)
if isinstance(v, str): if isinstance(v, str):
v = [v] v = [v]

View file

@ -1,4 +1,8 @@
import logging
import re import re
import os
logger = logging.getLogger(__name__)
def to_camelcase(s): def to_camelcase(s):
@ -8,3 +12,21 @@ def to_camelcase(s):
def to_snakecase(s): def to_snakecase(s):
return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower() 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

@ -21,6 +21,10 @@ index 6f42473..b3746a1 100644
pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {}; pkgLLDAPCli = pkgs.callPackage ./../../../../shared/pkgs/lldap-cli.nix {};
in { in {
imports = [
# ./test.nix
];
environment.systemPackages = [ environment.systemPackages = [
pkgLLDAPCli pkgLLDAPCli
]; ];
@ -102,6 +106,8 @@ in {
groups = { groups = {
admin = "lldap_admin"; admin = "lldap_admin";
member = "base_member"; member = "base_member";
system = "system_service";
system_email = "system_email";
}; };
ou = { ou = {
@ -116,6 +122,10 @@ in {
email = "mail"; email = "mail";
avatar = "jpegPhoto"; avatar = "jpegPhoto";
groupname = "cn"; groupname = "cn";
# custom
member_email = "member_email";
mail_disk_quota = "mail_disk_quota";
}; };
age_secret = config.age.secrets.lldap-bind-user-pass.path; age_secret = config.age.secrets.lldap-bind-user-pass.path;

View file

@ -0,0 +1,98 @@
{ config, lib, ... }:
let
mkEmail = name: "${name}@${config.mine.shared.settings.domain}";
mkUserNormal = name: {
user_id = name;
member_email = mkEmail name;
mail = "env:EMAIL_${lib.toUpper name}";
groups = [ "base_member" ];
mail_disk_quota = 100*1024*1024; # mb
};
mkUserSystem = name: password_file: {
user_id = name;
member_email = mkEmail name;
password = "file:${password_file}";
# TODO: remove base_member in the future, or have
# more granular controls for emails and shit
groups = [ "base_member" "system_service" ];
mail_disk_quota = 10*1024*1024; # mb
};
mkUserAdmin = name: {
user_id = name;
member_email = mkEmail name;
groups = [ "base_member" "lldap_admin" ];
mail_disk_quota = 100*1024*1024; # mb
};
in {
imports = [
./bootstrap/lldap-state-module.nix
];
mine.lldap_provision = {
enable = true;
url = config.mine.shared.meta.lldap.url;
username = "admin";
passwordFile = config.age.secrets.lldap-admin-user-pass.path;
# username = "testusername";
# passwordFile = ./test.txt;
group_attributes = {
group_foo = {
attributeType = "STRING";
isEditable = true;
isVisible = true;
};
};
user_attributes = {
member_email = {
attributeType = "STRING";
isEditable = false;
isVisible = true;
};
mail_disk_quota = {
attributeType = "INTEGER";
};
};
groups = let
gs = [
"base_member"
"system_service"
"system_email"
];
in lib.listToAttrs (lib.forEach gs (v: lib.nameValuePair v { display_name = v; }));
users = {
# normal users
testusername = {
member_email = "env:USER1_EMAIL";
};
user1 = mkUserNormal "thief420";
# admin users
admin = mkUserAdmin "admin";
eyjhb = mkUserAdmin "eyjhb";
rasmus = mkUserAdmin "rasmus";
# system users
authelia = mkUserSystem "authelia" config.age.secrets.authelia-smtp-password.path;
wger = mkUserSystem "wger" config.age.secrets.wger-ldap-pass.path;
# bind user
bind_user = {
groups = [ "lldap_password_manager" "lldap_strict_readonly" ];
};
};
};
systemd.services.lldapsetup.environment = {
USER1_EMAIL = "eyjhbbbbbbb@fricloud.dk";
EMAIL_THIEF420 = "someemail@gmail.com";
};
}

View file

@ -41,6 +41,7 @@
# wger # wger
wger-env.file = ./wger/env.age; wger-env.file = ./wger/env.age;
wger-ldap-pass.file = ./wger/ldap-pass.age;
# restic # restic
restic-env.file = ./restic/env.age; restic-env.file = ./restic/env.age;

View file

@ -51,6 +51,7 @@ in
# wger # wger
"wger/env.age".publicKeys = defaultAccess; "wger/env.age".publicKeys = defaultAccess;
"wger/ldap-pass.age".publicKeys = defaultAccess;
# restic # restic
"restic/env.age".publicKeys = defaultAccess; "restic/env.age".publicKeys = defaultAccess;

View file

@ -0,0 +1,11 @@
age-encryption.org/v1
-> ssh-ed25519 QSDXqg uYPdHX2B6acTsvvU49DtkE6seTek1FT/+WIeexfKIDI
koEouYTBn6/VencZc4HmooytCR7zcdrSL/77ScL47yQ
-> X25519 sTTQGj2XPszrqQrUSiXVYlbQsxGYLn9Ee46isVezMQI
1obJbY9FMAmCPlPWdaJdCjYm9Z01WdiGv72Dy9NTZNM
-> ssh-ed25519 n8n9DQ 5+yZMCzJNRvpLYWDOof40LSVX31DdawJfKzjPhwiUy8
bUUVYjDaJ3kvB/gc6wAjLX090YGG4VigulNwc3kioMo
-> ssh-ed25519 BTp6UA 2WxuEBEe12Bx0hJaLmfrJhN5HKLZIQtpzekTprvTdTc
LdxlDEXOGsYgBB8p+qj/Twv7F1RK6W3DXiM+cwBvU+o
--- RdzYVsyAfuRcvj9nz+f57KbfNV+MG5EkIzDmBbzlT1A
—ù6T®ñ…ŠMåò*ÞÂtqƒr³eIù/µØ`q¦aÜ<>Áýû-p0ÕJb0ðQ³$~qÁzɃôŽ(