Imported sources.
This commit is contained in:
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM ubuntu:26.04
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
echo "slapd slapd/no_configuration boolean true" | debconf-set-selections && \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
|
slapd \
|
||||||
|
ldap-utils \
|
||||||
|
python3-jinja2 \
|
||||||
|
python3-ldap3 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint
|
||||||
|
COPY bootstrap/ /bootstrap/
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint
|
||||||
|
|
||||||
|
EXPOSE 389 636
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint"]
|
||||||
212
README.md
Normal file
212
README.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# OpenLDAP
|
||||||
|
|
||||||
|
OpenLDAP 2.6 container running on Ubuntu 26.04 with cn=config (slapd-config) database.
|
||||||
|
|
||||||
|
## Optional Bootstrap Accounts CSV Files
|
||||||
|
|
||||||
|
Bootstrap reads account files from `/bootstrap/accounts` inside the container.
|
||||||
|
|
||||||
|
With the current compose setup, this maps to:
|
||||||
|
|
||||||
|
`~/app-data/openldap/accounts`
|
||||||
|
|
||||||
|
Optional files:
|
||||||
|
|
||||||
|
- `users.csv`: `uid,gn,sn,mail`
|
||||||
|
- `admins.csv`: `uid,gn,sn,mail`
|
||||||
|
- `posix-users.csv`: `uid,gn,sn,mail,uidNumber,gidNumber`
|
||||||
|
|
||||||
|
You can provide any subset of these files; missing files are skipped.
|
||||||
|
|
||||||
|
Rows starting with `#` are ignored.
|
||||||
|
|
||||||
|
## Changing the password
|
||||||
|
|
||||||
|
Use `ldappasswd` to change the password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE_DN="dc=koszewscy,dc=waw,dc=pl"
|
||||||
|
USER_DN="cn=admin,$BASE_DN"
|
||||||
|
ldappasswd -x -D "$USER_DN" -W -S "$USER_DN"
|
||||||
|
```
|
||||||
|
|
||||||
|
or use a oneliner:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
DN="cn=admin,dc=koszewscy,dc=waw,dc=pl" ldappasswd -x -D "$DN" -W -S "$DN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the password for the Admin:
|
||||||
|
|
||||||
|
`change_password.ldif`:
|
||||||
|
|
||||||
|
```ldif
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcRootPW
|
||||||
|
olcRootPW: {SSHA}newhashedpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapmodify -Q -Y EXTERNAL -H ldapi:/// -f change_password.ldif
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## LDIF file format
|
||||||
|
|
||||||
|
The basic form of an entry is:
|
||||||
|
|
||||||
|
```
|
||||||
|
# comment
|
||||||
|
dn: <distinguished name>
|
||||||
|
<attrdesc>: <attrvalue>
|
||||||
|
<attrdesc>: <attrvalue>
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Lines may be continued by starting the next line with a single space or tab.
|
||||||
|
|
||||||
|
```
|
||||||
|
dn: cn=Barbara J Jensen,dc=example,dc=
|
||||||
|
com
|
||||||
|
cn: Barbara J
|
||||||
|
Jensen
|
||||||
|
```
|
||||||
|
|
||||||
|
is equivalent to:
|
||||||
|
|
||||||
|
```
|
||||||
|
dn: cn=Barbara J Jensen,dc=example,dc=com
|
||||||
|
cn: Barbara J Jensen
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple values for the same attribute are represented by repeating the attribute description:
|
||||||
|
|
||||||
|
```
|
||||||
|
dn: cn=Barbara J Jensen,dc=example,dc=com
|
||||||
|
cn: Barbara J Jensen
|
||||||
|
cn: Babs Jensen
|
||||||
|
```
|
||||||
|
|
||||||
|
If an attribute value contains a non-printable character, it must be base64-encoded and prefixed with a single colon:
|
||||||
|
|
||||||
|
```
|
||||||
|
dn: cn=Barbara J Jensen,dc=example,dc=com
|
||||||
|
cn:: QmFyYmFyYSBKIEplbnNlbgo=
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary files (e.g. images) can be included in the LDIF file by using the "file:" prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
dn: cn=Barbara J Jensen,dc=example,dc=com
|
||||||
|
jpegPhoto:< file:///home/bjensen/photo.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple entries are separated by a blank line. Binary files like the one above may also be included in as Base64-encoded values.
|
||||||
|
|
||||||
|
The full specification is available at https://datatracker.ietf.org/doc/html/rfc2849.
|
||||||
|
|
||||||
|
## Accessing cn=config
|
||||||
|
|
||||||
|
SASL EXTERNAL authenticates via the Unix socket — uid=0 maps to the cn=config superuser.
|
||||||
|
The commands must run inside the container where the socket is accessible.
|
||||||
|
|
||||||
|
### Browse the entire cn=config tree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b cn=config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browse a specific database entry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "olcDatabase={1}mdb,cn=config"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modify cn=config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapmodify -Q -Y EXTERNAL -H ldapi:/// <<'EOF'
|
||||||
|
dn: cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcLogLevel
|
||||||
|
olcLogLevel: stats
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify EXTERNAL identity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapwhoami -Q -Y EXTERNAL -H ldapi:///
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `dn:gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth`
|
||||||
|
|
||||||
|
## Editing Access Control Rules
|
||||||
|
|
||||||
|
1. Inserting a New Rule at the Top
|
||||||
|
|
||||||
|
```ldif
|
||||||
|
# filename: insert_rule.ldif
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcAccess
|
||||||
|
olcAccess: {0}to *
|
||||||
|
by dn.exact="cn=security-scanner,dc=example,dc=com" read break
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Deleting a Specific Rule
|
||||||
|
|
||||||
|
```ldif
|
||||||
|
# filename: delete_rule.ldif
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
delete: olcAccess
|
||||||
|
olcAccess: {2}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Updating an Existing Rule (In-Place)
|
||||||
|
|
||||||
|
```ldif
|
||||||
|
# filename: update_rule.ldif
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcAccess
|
||||||
|
olcAccess: {1}to attrs=userPassword
|
||||||
|
by self write
|
||||||
|
by anonymous auth
|
||||||
|
by group.exact="cn=it-admins,dc=example,dc=com" write
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Reordering the Entire Stack
|
||||||
|
|
||||||
|
```ldif
|
||||||
|
# filename: reorder_rules.ldif
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcAccess
|
||||||
|
olcAccess: {0}to *
|
||||||
|
by dn.exact="cn=security-scanner,dc=example,dc=com" read break
|
||||||
|
olcAccess: {1}to attrs=userPassword
|
||||||
|
by self write
|
||||||
|
by anonymous auth
|
||||||
|
by group.exact="cn=it-admins,dc=example,dc=com" write
|
||||||
|
olcAccess: {2}to *
|
||||||
|
by self read
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing the DIT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapsearch -x -H ldap://localhost \
|
||||||
|
-D "cn=admin,dc=koszewscy,dc=waw,dc=pl" -W \
|
||||||
|
-b "dc=koszewscy,dc=waw,dc=pl"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify readonly service account bind
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ldapwhoami -x -H ldap://localhost \
|
||||||
|
-D "cn=readonly,ou=service-accounts,dc=koszewscy,dc=waw,dc=pl" -W
|
||||||
|
```
|
||||||
2
admins-example.csv
Normal file
2
admins-example.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# uid,gn,sn,mail
|
||||||
|
admin,Admin,Example,admin@example.com
|
||||||
|
205
bootstrap/init.py
Normal file
205
bootstrap/init.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
|
LDAPI_SOCKET = "/var/run/slapd/ldapi"
|
||||||
|
LDIF_DIR = Path("/bootstrap/ldif")
|
||||||
|
CSV_DIR = Path("/bootstrap/accounts")
|
||||||
|
INITIALIZED_FLAG = Path("/var/lib/ldap/.initialized")
|
||||||
|
SLAPD_D = Path("/etc/ldap/slapd.d")
|
||||||
|
|
||||||
|
|
||||||
|
base_dn = os.environ["LDAP_BASE_DN"]
|
||||||
|
password = os.environ["LDAP_PASSWORD"]
|
||||||
|
tls_enabled = os.environ.get("TLS_ENABLED") == "1"
|
||||||
|
admin_dn = f"cn=admin,{base_dn}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["slappasswd", "-h", "{SSHA}", "-s", password],
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd: list, input: str) -> None:
|
||||||
|
proc = subprocess.run(cmd, input=input, text=True, capture_output=True, check=False)
|
||||||
|
if proc.stdout:
|
||||||
|
print(proc.stdout, end="")
|
||||||
|
if proc.stderr:
|
||||||
|
print(proc.stderr, end="")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise subprocess.CalledProcessError(proc.returncode, proc.args, output=proc.stdout, stderr=proc.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ldif(path: Path, env: Environment, command: str = "ldapadd", **ctx) -> None:
|
||||||
|
print(f"Applying LDIF: {path.name}")
|
||||||
|
rendered = env.get_template(path.name).render(**ctx)
|
||||||
|
run_command([command, "-Q", "-Y", "EXTERNAL", "-H", "ldapi:///"], rendered)
|
||||||
|
print(f"Finished LDIF: {path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_slapd(timeout: int = 30) -> None:
|
||||||
|
for _ in range(timeout):
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.connect(LDAPI_SOCKET)
|
||||||
|
sock.close()
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
time.sleep(1)
|
||||||
|
raise RuntimeError("slapd did not become ready in time")
|
||||||
|
|
||||||
|
|
||||||
|
def stop_slapd(proc: subprocess.Popen, timeout: int = 30) -> None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def group_exists(name: str, group_ou: str) -> bool:
|
||||||
|
# fmt: off
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"ldapsearch", "-Q", "-Y", "EXTERNAL", "-H", "ldapi:///",
|
||||||
|
"-b", f"ou={group_ou},{base_dn}", "-s", "one",
|
||||||
|
f"(&(objectClass=groupOfNames)(cn={name}))",
|
||||||
|
"cn",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
return "numEntries: 1" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def read_users(path: Path, fields: list[str]) -> list[dict]:
|
||||||
|
users = []
|
||||||
|
with open(path, newline="") as f:
|
||||||
|
for row in csv.reader(f):
|
||||||
|
if not row or row[0].strip().startswith("#"):
|
||||||
|
continue
|
||||||
|
users.append(dict(zip(fields, (c.strip() for c in row))))
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("First run — starting initialisation...")
|
||||||
|
env = Environment(loader=FileSystemLoader(str(LDIF_DIR)), keep_trailing_newline=True)
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
proc = subprocess.Popen([
|
||||||
|
"slapd",
|
||||||
|
"-d", "0",
|
||||||
|
"-F", str(SLAPD_D),
|
||||||
|
"-u", "openldap",
|
||||||
|
"-g", "openldap",
|
||||||
|
"-h", "ldapi:///",
|
||||||
|
])
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
wait_for_slapd()
|
||||||
|
print("slapd is ready.")
|
||||||
|
|
||||||
|
apply_ldif(LDIF_DIR / "config-memberof-module.ldif", env)
|
||||||
|
apply_ldif(LDIF_DIR / "config-memberof-overlay.ldif", env)
|
||||||
|
apply_ldif(LDIF_DIR / "config-indexes.ldif", env)
|
||||||
|
apply_ldif(LDIF_DIR / "config-performance.ldif", env)
|
||||||
|
apply_ldif(LDIF_DIR / "config-acl.ldif", env, base_dn=base_dn, admin_dn=admin_dn)
|
||||||
|
if tls_enabled:
|
||||||
|
apply_ldif(LDIF_DIR / "config-tls.ldif", env)
|
||||||
|
|
||||||
|
print("cn=config updated.")
|
||||||
|
|
||||||
|
password_hash = hash_password(password)
|
||||||
|
|
||||||
|
ctx = dict(
|
||||||
|
base_dn=base_dn,
|
||||||
|
password_hash=password_hash,
|
||||||
|
)
|
||||||
|
apply_ldif(LDIF_DIR / "base.ldif", env, **ctx)
|
||||||
|
apply_ldif(LDIF_DIR / "service-accounts.ldif", env, **ctx)
|
||||||
|
|
||||||
|
# Process optional account CSV files.
|
||||||
|
user_files = {
|
||||||
|
"users.csv": {
|
||||||
|
"name": "users",
|
||||||
|
"description": "Regular users",
|
||||||
|
"fields": ["uid", "gn", "sn", "mail"],
|
||||||
|
"group_ou": "groups",
|
||||||
|
"member_ou": "users",
|
||||||
|
},
|
||||||
|
"admins.csv": {
|
||||||
|
"name": "admins",
|
||||||
|
"description": "Administrators",
|
||||||
|
"fields": ["uid", "gn", "sn", "mail"],
|
||||||
|
"group_ou": "privileged-groups",
|
||||||
|
"member_ou": "users",
|
||||||
|
},
|
||||||
|
"posix-users.csv": {
|
||||||
|
"name": "users",
|
||||||
|
"description": "POSIX users",
|
||||||
|
"fields": ["uid", "gn", "sn", "mail", "uidNumber", "gidNumber"],
|
||||||
|
"group_ou": "groups",
|
||||||
|
"member_ou": "users",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for filename, group in user_files.items():
|
||||||
|
path = CSV_DIR / filename
|
||||||
|
if not path.exists():
|
||||||
|
# Backward-compatible fallback for older bind-mount layout.
|
||||||
|
path = Path("/bootstrap") / filename
|
||||||
|
if path.exists():
|
||||||
|
print(f' Reading {group["name"]} from {filename}...')
|
||||||
|
users = read_users(path, group["fields"])
|
||||||
|
if users:
|
||||||
|
ldif = Path(filename).stem + ".ldif"
|
||||||
|
for user in users:
|
||||||
|
print(f' Adding user: {user["uid"]}')
|
||||||
|
apply_ldif(
|
||||||
|
LDIF_DIR / ldif,
|
||||||
|
env,
|
||||||
|
base_dn=base_dn,
|
||||||
|
password_hash=password_hash,
|
||||||
|
**user,
|
||||||
|
)
|
||||||
|
|
||||||
|
uids = [u["uid"] for u in users]
|
||||||
|
if group_exists(group["name"], group["group_ou"]):
|
||||||
|
apply_ldif(
|
||||||
|
LDIF_DIR / "add-member.ldif",
|
||||||
|
env,
|
||||||
|
base_dn=base_dn,
|
||||||
|
name=group["name"],
|
||||||
|
group_ou=group["group_ou"],
|
||||||
|
member_ou=group["member_ou"],
|
||||||
|
uids=uids,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
apply_ldif(
|
||||||
|
LDIF_DIR / "group.ldif",
|
||||||
|
env,
|
||||||
|
base_dn=base_dn,
|
||||||
|
name=group["name"],
|
||||||
|
description=group["description"],
|
||||||
|
group_ou=group["group_ou"],
|
||||||
|
member_ou=group["member_ou"],
|
||||||
|
uids=uids,
|
||||||
|
)
|
||||||
|
|
||||||
|
INITIALIZED_FLAG.touch()
|
||||||
|
stop_slapd(proc)
|
||||||
|
print("Bootstrap complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6
bootstrap/ldif/add-member.ldif
Normal file
6
bootstrap/ldif/add-member.ldif
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
dn: cn={{ name }},ou={{ group_ou }},{{ base_dn }}
|
||||||
|
changeType: modify
|
||||||
|
add: member
|
||||||
|
{% for uid in uids -%}
|
||||||
|
member: uid={{ uid }},ou={{ member_ou }},{{ base_dn }}
|
||||||
|
{% endfor %}
|
||||||
9
bootstrap/ldif/admins.ldif
Normal file
9
bootstrap/ldif/admins.ldif
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
dn: uid={{ uid }},ou=users,{{ base_dn }}
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: shadowAccount
|
||||||
|
uid: {{ uid }}
|
||||||
|
givenName: {{ gn }}
|
||||||
|
sn: {{ sn }}
|
||||||
|
cn: {{ gn }} {{ sn }}
|
||||||
|
mail: {{ mail }}
|
||||||
|
userPassword: {{ password_hash }}
|
||||||
19
bootstrap/ldif/base.ldif
Normal file
19
bootstrap/ldif/base.ldif
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
dn: ou=users,{{ base_dn }}
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: users
|
||||||
|
description: All users
|
||||||
|
|
||||||
|
dn: ou=groups,{{ base_dn }}
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: groups
|
||||||
|
description: Regular groups
|
||||||
|
|
||||||
|
dn: ou=privileged-groups,{{ base_dn }}
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: privileged-groups
|
||||||
|
description: Privileged groups
|
||||||
|
|
||||||
|
dn: ou=service-accounts,{{ base_dn }}
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
ou: service-accounts
|
||||||
|
description: Service accounts
|
||||||
44
bootstrap/ldif/config-acl.ldif
Normal file
44
bootstrap/ldif/config-acl.ldif
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
dn: olcDatabase={-1}frontend,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcAccess
|
||||||
|
olcAccess: {0}to *
|
||||||
|
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||||
|
by dn.exact="cn=admin,{{ base_dn }}" manage
|
||||||
|
by group.exact="cn=admins,ou=privileged-groups,{{ base_dn }}" manage
|
||||||
|
by * break
|
||||||
|
olcAccess: {1}to dn.exact=""
|
||||||
|
by * read
|
||||||
|
olcAccess: {2}to dn.base="cn=Subschema"
|
||||||
|
by * read
|
||||||
|
|
||||||
|
dn: olcDatabase={0}config,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcAccess
|
||||||
|
olcAccess: {0}to *
|
||||||
|
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||||
|
by dn.exact="cn=admin,{{ base_dn }}" manage
|
||||||
|
by group.exact="cn=admins,ou=privileged-groups,{{ base_dn }}" manage
|
||||||
|
by * break
|
||||||
|
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcAccess
|
||||||
|
olcAccess: {0}to attrs=userPassword
|
||||||
|
by self write
|
||||||
|
by anonymous auth
|
||||||
|
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||||
|
by dn.exact="{{ admin_dn }}" manage
|
||||||
|
by group.exact="cn=admins,ou=privileged-groups,{{ base_dn }}" manage
|
||||||
|
by * none
|
||||||
|
olcAccess: {1}to attrs=shadowLastChange
|
||||||
|
by self write
|
||||||
|
by * read
|
||||||
|
olcAccess: {2}to dn.base=""
|
||||||
|
by * read
|
||||||
|
olcAccess: {3}to *
|
||||||
|
by dn.exact="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
|
||||||
|
by dn.exact="{{ admin_dn }}" manage
|
||||||
|
by dn.exact="cn=readonly,ou=service-accounts,{{ base_dn }}" read
|
||||||
|
by group.exact="cn=admins,ou=privileged-groups,{{ base_dn }}" manage
|
||||||
|
by self read
|
||||||
|
by * none
|
||||||
7
bootstrap/ldif/config-indexes.ldif
Normal file
7
bootstrap/ldif/config-indexes.ldif
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcDbIndex
|
||||||
|
olcDbIndex: mail eq,pres
|
||||||
|
-
|
||||||
|
add: olcDbIndex
|
||||||
|
olcDbIndex: memberOf eq
|
||||||
4
bootstrap/ldif/config-memberof-module.ldif
Normal file
4
bootstrap/ldif/config-memberof-module.ldif
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dn: cn=module{0},cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcModuleLoad
|
||||||
|
olcModuleLoad: memberof
|
||||||
10
bootstrap/ldif/config-memberof-overlay.ldif
Normal file
10
bootstrap/ldif/config-memberof-overlay.ldif
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: add
|
||||||
|
objectClass: olcOverlayConfig
|
||||||
|
objectClass: olcMemberOf
|
||||||
|
olcOverlay: memberof
|
||||||
|
olcMemberOfDangling: error
|
||||||
|
olcMemberOfRefint: TRUE
|
||||||
|
olcMemberOfGroupOC: groupOfNames
|
||||||
|
olcMemberOfMemberAD: member
|
||||||
|
olcMemberOfMemberOfAD: memberOf
|
||||||
9
bootstrap/ldif/config-performance.ldif
Normal file
9
bootstrap/ldif/config-performance.ldif
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
dn: cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcThreads
|
||||||
|
olcThreads: 4
|
||||||
|
|
||||||
|
dn: olcDatabase={1}mdb,cn=config
|
||||||
|
changetype: modify
|
||||||
|
replace: olcDbMaxSize
|
||||||
|
olcDbMaxSize: 134217728
|
||||||
13
bootstrap/ldif/config-tls.ldif
Normal file
13
bootstrap/ldif/config-tls.ldif
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
dn: cn=config
|
||||||
|
changetype: modify
|
||||||
|
add: olcTLSCACertificateFile
|
||||||
|
olcTLSCACertificateFile: /etc/ldap/certs/ca_cert.pem
|
||||||
|
-
|
||||||
|
add: olcTLSCertificateFile
|
||||||
|
olcTLSCertificateFile: /etc/ldap/certs/server_cert.pem
|
||||||
|
-
|
||||||
|
add: olcTLSCertificateKeyFile
|
||||||
|
olcTLSCertificateKeyFile: /etc/ldap/certs/server_key.pem
|
||||||
|
-
|
||||||
|
add: olcTLSVerifyClient
|
||||||
|
olcTLSVerifyClient: never
|
||||||
7
bootstrap/ldif/group.ldif
Normal file
7
bootstrap/ldif/group.ldif
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dn: cn={{ name }},ou={{ group_ou }},{{ base_dn }}
|
||||||
|
objectClass: groupOfNames
|
||||||
|
cn: {{ name }}
|
||||||
|
description: {{ description }}
|
||||||
|
{% for uid in uids -%}
|
||||||
|
member: uid={{ uid }},ou={{ member_ou }},{{ base_dn }}
|
||||||
|
{% endfor %}
|
||||||
14
bootstrap/ldif/posix-user.ldif
Normal file
14
bootstrap/ldif/posix-user.ldif
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
dn: uid={{ uid }},ou=users,{{ base_dn }}
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: posixAccount
|
||||||
|
objectClass: shadowAccount
|
||||||
|
uid: {{ uid }}
|
||||||
|
givenName: {{ gn }}
|
||||||
|
sn: {{ sn }}
|
||||||
|
cn: {{ gn }} {{ sn }}
|
||||||
|
mail: {{ mail }}
|
||||||
|
userPassword: {{ password_hash }}
|
||||||
|
uidNumber: {{ uidNumber }}
|
||||||
|
gidNumber: {{ gidNumber }}
|
||||||
|
homeDirectory: /home/{{ uid }}
|
||||||
|
loginShell: /usr/bin/bash
|
||||||
14
bootstrap/ldif/posix-users.ldif
Normal file
14
bootstrap/ldif/posix-users.ldif
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
dn: uid={{ uid }},ou=users,{{ base_dn }}
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: posixAccount
|
||||||
|
objectClass: shadowAccount
|
||||||
|
uid: {{ uid }}
|
||||||
|
givenName: {{ gn }}
|
||||||
|
sn: {{ sn }}
|
||||||
|
cn: {{ gn }} {{ sn }}
|
||||||
|
mail: {{ mail }}
|
||||||
|
userPassword: {{ password_hash }}
|
||||||
|
uidNumber: {{ uidNumber }}
|
||||||
|
gidNumber: {{ gidNumber }}
|
||||||
|
homeDirectory: /home/{{ uid }}
|
||||||
|
loginShell: /usr/bin/bash
|
||||||
6
bootstrap/ldif/service-accounts.ldif
Normal file
6
bootstrap/ldif/service-accounts.ldif
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
dn: cn=readonly,ou=service-accounts,{{ base_dn }}
|
||||||
|
objectClass: simpleSecurityObject
|
||||||
|
objectClass: organizationalRole
|
||||||
|
cn: readonly
|
||||||
|
description: Read-only service account
|
||||||
|
userPassword: {{ password_hash }}
|
||||||
9
bootstrap/ldif/users.ldif
Normal file
9
bootstrap/ldif/users.ldif
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
dn: uid={{ uid }},ou=users,{{ base_dn }}
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
objectClass: shadowAccount
|
||||||
|
uid: {{ uid }}
|
||||||
|
givenName: {{ gn }}
|
||||||
|
sn: {{ sn }}
|
||||||
|
cn: {{ gn }} {{ sn }}
|
||||||
|
mail: {{ mail }}
|
||||||
|
userPassword: {{ password_hash }}
|
||||||
78
entrypoint.sh
Normal file
78
entrypoint.sh
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CERTS_DIR="/etc/ldap/certs"
|
||||||
|
DATA_DIR="/var/lib/ldap"
|
||||||
|
SLAPD_D="/etc/ldap/slapd.d"
|
||||||
|
INITIALIZED_FLAG="$DATA_DIR/.initialized"
|
||||||
|
CA_CERT_NAME="ca_cert.pem"
|
||||||
|
SERVER_CERT_NAME="server_cert.pem"
|
||||||
|
SERVER_KEY_NAME="server_key.pem"
|
||||||
|
|
||||||
|
echo "Starting OpenLDAP entrypoint..."
|
||||||
|
|
||||||
|
base_dn="${LDAP_BASE_DN:-dc=example,dc=org}"
|
||||||
|
domain="${LDAP_DOMAIN:-example.org}"
|
||||||
|
org="${LDAP_ORG:-Example Org}"
|
||||||
|
password="${LDAP_PASSWORD:-changeit}"
|
||||||
|
admin_password="${LDAP_ADMIN_PASSWORD:-$password}"
|
||||||
|
|
||||||
|
echo "Base DN : $base_dn"
|
||||||
|
echo "Domain : $domain"
|
||||||
|
echo "Org : $org"
|
||||||
|
|
||||||
|
tls_enabled="0"
|
||||||
|
if [ -f "$CERTS_DIR/$CA_CERT_NAME" ] && [ -f "$CERTS_DIR/$SERVER_CERT_NAME" ] && [ -f "$CERTS_DIR/$SERVER_KEY_NAME" ]; then
|
||||||
|
tls_enabled="1"
|
||||||
|
fi
|
||||||
|
if [ "$tls_enabled" = "1" ]; then
|
||||||
|
echo "TLS : enabled"
|
||||||
|
else
|
||||||
|
echo "TLS : disabled"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Ensuring slapd runtime directory..."
|
||||||
|
mkdir -p /var/run/slapd
|
||||||
|
chown openldap:openldap /var/run/slapd
|
||||||
|
|
||||||
|
if [ ! -f "$INITIALIZED_FLAG" ]; then
|
||||||
|
echo "First run - configuring slapd via debconf..."
|
||||||
|
cat <<EOF | debconf-set-selections
|
||||||
|
slapd slapd/no_configuration boolean false
|
||||||
|
slapd slapd/dump_database select when needed
|
||||||
|
slapd slapd/dump_database_destdir string /var/backups/slapd-VERSION
|
||||||
|
slapd slapd/move_old_database boolean false
|
||||||
|
slapd slapd/domain string $domain
|
||||||
|
slapd shared/organization string $org
|
||||||
|
slapd slapd/password1 password $admin_password
|
||||||
|
slapd slapd/password2 password $admin_password
|
||||||
|
slapd slapd/purge_database boolean false
|
||||||
|
slapd slapd/internal/adminpw1 password $admin_password
|
||||||
|
slapd slapd/internal/generated_adminpw password $admin_password
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Running dpkg-reconfigure slapd..."
|
||||||
|
DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive slapd
|
||||||
|
echo "dpkg-reconfigure complete."
|
||||||
|
|
||||||
|
echo "Running bootstrap init..."
|
||||||
|
LDAP_BASE_DN="$base_dn" \
|
||||||
|
LDAP_PASSWORD="$password" \
|
||||||
|
TLS_ENABLED="$tls_enabled" \
|
||||||
|
python3 -u /bootstrap/init.py
|
||||||
|
else
|
||||||
|
echo "Already initialised - skipping bootstrap."
|
||||||
|
fi
|
||||||
|
|
||||||
|
slapd_url="ldapi:/// ldap://:389/"
|
||||||
|
if [ "$tls_enabled" = "1" ]; then
|
||||||
|
slapd_url="$slapd_url ldaps://:636/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Launching slapd (URLs: $slapd_url)..."
|
||||||
|
exec slapd \
|
||||||
|
-F "$SLAPD_D" \
|
||||||
|
-u openldap \
|
||||||
|
-g openldap \
|
||||||
|
-d 0 \
|
||||||
|
-h "$slapd_url"
|
||||||
5
env.example
Normal file
5
env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
LDAP_DOMAIN=example.com
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_ORG=Example Organization
|
||||||
|
LDAP_PASSWORD=ChangeMe123!
|
||||||
|
LDAP_ADMIN_PASSWORD=ChangeMe123!
|
||||||
11587
local-guide/guide.html
Normal file
11587
local-guide/guide.html
Normal file
File diff suppressed because it is too large
Load Diff
3
posix-users-example.csv
Normal file
3
posix-users-example.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# uid,gn,sn,mail,uidNumber,gidNumber
|
||||||
|
alice,Alice,Example,alice@example.com,10000,10000
|
||||||
|
bob,Bob,Example,bob@example.com,10001,10000
|
||||||
|
132
scripts/accounts_editor.py
Normal file
132
scripts/accounts_editor.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
|
APP_TITLE = "OpenLDAP Accounts CSV Editor"
|
||||||
|
|
||||||
|
|
||||||
|
FILES = {
|
||||||
|
"Users": {
|
||||||
|
"filename": "users.csv",
|
||||||
|
"keys": ["uid", "gn", "sn", "mail"],
|
||||||
|
"labels": {
|
||||||
|
"uid": "UID",
|
||||||
|
"gn": "Given Name",
|
||||||
|
"sn": "Surname",
|
||||||
|
"mail": "Email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Admins": {
|
||||||
|
"filename": "admins.csv",
|
||||||
|
"keys": ["uid", "gn", "sn", "mail"],
|
||||||
|
"labels": {
|
||||||
|
"uid": "UID",
|
||||||
|
"gn": "Given Name",
|
||||||
|
"sn": "Surname",
|
||||||
|
"mail": "Email",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"POSIX Users": {
|
||||||
|
"filename": "posix-users.csv",
|
||||||
|
"keys": ["uid", "gn", "sn", "mail", "uidNumber", "gidNumber"],
|
||||||
|
"labels": {
|
||||||
|
"uid": "UID",
|
||||||
|
"gn": "Given Name",
|
||||||
|
"sn": "Surname",
|
||||||
|
"mail": "Email",
|
||||||
|
"uidNumber": "POSIX UID",
|
||||||
|
"gidNumber": "POSIX GID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv(path: Path, keys: list[str]) -> pd.DataFrame:
|
||||||
|
if not path.exists():
|
||||||
|
return pd.DataFrame(columns=keys)
|
||||||
|
|
||||||
|
df = pd.read_csv(
|
||||||
|
path,
|
||||||
|
header=None,
|
||||||
|
names=keys,
|
||||||
|
comment="#",
|
||||||
|
skip_blank_lines=True,
|
||||||
|
dtype=str,
|
||||||
|
keep_default_na=False,
|
||||||
|
usecols=range(len(keys)),
|
||||||
|
)
|
||||||
|
return df.apply(lambda col: col.str.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(path: Path, keys: list[str], df: pd.DataFrame) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cleaned = df.fillna("").astype(str)
|
||||||
|
cleaned = cleaned.apply(lambda col: col.str.strip())
|
||||||
|
cleaned = cleaned[cleaned.ne("").any(axis=1)]
|
||||||
|
|
||||||
|
with path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
f.write(f"# {','.join(keys)}\n")
|
||||||
|
cleaned.to_csv(f, index=False, header=False)
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(accounts_dir: Path, page_name: str) -> None:
|
||||||
|
spec = FILES[page_name]
|
||||||
|
csv_path = accounts_dir / spec["filename"]
|
||||||
|
|
||||||
|
st.subheader(page_name)
|
||||||
|
st.caption(f"File: {csv_path}")
|
||||||
|
|
||||||
|
df = read_csv(csv_path, spec["keys"])
|
||||||
|
|
||||||
|
column_config = {
|
||||||
|
key: st.column_config.TextColumn(label=spec["labels"].get(key, key), width="medium")
|
||||||
|
for key in spec["keys"]
|
||||||
|
}
|
||||||
|
|
||||||
|
edited = st.data_editor(
|
||||||
|
df,
|
||||||
|
width="stretch",
|
||||||
|
num_rows="dynamic",
|
||||||
|
column_config=column_config,
|
||||||
|
hide_index=True,
|
||||||
|
key=f"editor-{page_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
with st.container(horizontal=True):
|
||||||
|
if st.button("Save", type="primary", width=120, key=f"save-{page_name}"):
|
||||||
|
write_csv(csv_path, spec["keys"], edited)
|
||||||
|
st.success(f"Saved {spec['filename']}")
|
||||||
|
|
||||||
|
if st.button("Reload", width=120, key=f"reload-{page_name}"):
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
st.set_page_config(page_title=APP_TITLE, layout="wide")
|
||||||
|
st.title(APP_TITLE)
|
||||||
|
|
||||||
|
default_dir = os.environ.get("OPENLDAP_ACCOUNTS_DIR", "~/app-data/openldap/accounts")
|
||||||
|
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("Settings")
|
||||||
|
accounts_dir_raw = st.text_input("Accounts directory", value=default_dir)
|
||||||
|
page_name = st.radio("Page", list(FILES.keys()))
|
||||||
|
|
||||||
|
accounts_dir = Path(accounts_dir_raw).expanduser()
|
||||||
|
st.caption("This editor manages the CSV files used by OpenLDAP bootstrap.")
|
||||||
|
|
||||||
|
if not accounts_dir.exists():
|
||||||
|
st.warning(f"Directory does not exist yet: {accounts_dir}")
|
||||||
|
|
||||||
|
render_page(accounts_dir, page_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
scripts/login.py
Normal file
26
scripts/login.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from getpass import getpass
|
||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
from ldap3 import ObjectDef, AttrDef, Reader, Writer, Entry, Attribute, OperationalAttribute
|
||||||
|
import keyring
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="LDAP Test Script")
|
||||||
|
|
||||||
|
# Generic LDAP connection parameters
|
||||||
|
parser.add_argument("--host", "-H", default="gra-01.koszewscy.waw.pl", help="LDAP server host")
|
||||||
|
parser.add_argument("--port", "-p", type=int, default=389, help="LDAP server port")
|
||||||
|
parser.add_argument("--base-dn", "-b", default="dc=koszewscy,dc=waw,dc=pl", help="Base DN for LDAP operations")
|
||||||
|
parser.add_argument("--user", "-u", default="cn=admin,dc=koszewscy,dc=waw,dc=pl", help="Bind DN for LDAP connection")
|
||||||
|
|
||||||
|
# Specific parameters
|
||||||
|
parser.add_argument("--object-class", "-c", nargs='*', default=["inetOrgPerson"], help="Object class(es) to work with")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
server = Server(args.host, port=args.port, get_info=ALL)
|
||||||
|
password = keyring.get_password("Home Lab Password", "admin") or getpass("Password: ")
|
||||||
|
|
||||||
|
conn = Connection(server, user=args.user, password=password, auto_bind=True)
|
||||||
|
person = ObjectDef(args.object_class, conn)
|
||||||
|
print(person)
|
||||||
|
conn.unbind()
|
||||||
31
scripts/search.py
Normal file
31
scripts/search.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from ldap3 import Server, Connection, ALL
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
BASE_DN = "dc=koszewscy,dc=waw,dc=pl"
|
||||||
|
LDAP_HOST = "gra-01.koszewscy.waw.pl"
|
||||||
|
LDAP_PORT = 389
|
||||||
|
|
||||||
|
st.set_page_config(page_title="LDAP Search", layout="wide")
|
||||||
|
|
||||||
|
host = st.text_input("Host", LDAP_HOST)
|
||||||
|
port = st.number_input("Port", value=LDAP_PORT, step=1)
|
||||||
|
bind_dn = st.text_input("Bind DN", f"cn=admin,{BASE_DN}")
|
||||||
|
password = st.text_input("Password", type="password")
|
||||||
|
base_dn = st.text_input("Base DN", BASE_DN)
|
||||||
|
search_filter = st.text_input("Filter", "(objectClass=*)")
|
||||||
|
|
||||||
|
if st.button("Search"):
|
||||||
|
server = Server(host, port=port, get_info=ALL)
|
||||||
|
conn = Connection(server, user=bind_dn, password=password, auto_bind=True)
|
||||||
|
ok = conn.search(
|
||||||
|
search_base=base_dn,
|
||||||
|
search_filter=search_filter,
|
||||||
|
attributes=["*"]
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
st.write(f"Entries: {len(conn.entries)}")
|
||||||
|
for e in conn.entries:
|
||||||
|
st.code(e.entry_to_ldif())
|
||||||
|
else:
|
||||||
|
st.error(conn.result)
|
||||||
|
conn.unbind()
|
||||||
4
users-example.csv
Normal file
4
users-example.csv
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# uid,gn,sn,mail
|
||||||
|
admin,Admin,Example,admin@example.com
|
||||||
|
alice,Alice,Example,alice@example.com
|
||||||
|
bob,Bob,Example,bob@example.com
|
||||||
|
Reference in New Issue
Block a user