From eb090abf4eedf819f8c174435ab0597bfb70cc43 Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Wed, 18 Feb 2026 11:46:35 +0100 Subject: [PATCH] first progress --- .env.example | 9 + .gitignore | 1 + Dockerfile | 30 +++ README.md | 208 ++++++++++++++++ app/init.sh | 145 +++++++++++ app/reconcile_shares.py | 538 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 31 +++ etc/samba/smb.conf | 60 +++++ setup | 69 ++++++ 9 files changed, 1091 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 app/init.sh create mode 100755 app/reconcile_shares.py create mode 100644 docker-compose.yml create mode 100644 etc/samba/smb.conf create mode 100755 setup diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd68dd4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +REALM=EXAMPLE.COM +WORKGROUP=EXAMPLE +DOMAIN=example.com +JOIN_USER=administrator +JOIN_PASSWORD=ChangeMe +PUBLIC_GROUP=Domain Users +# SAMBA_HOSTNAME=ad-samba-file-server +# LDAP_URI=ldaps://example.com +# LDAP_BASE_DN=DC=example,DC=com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..53bb462 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + acl \ + cron \ + gettext-base \ + krb5-user \ + ldap-utils \ + libnss-winbind \ + libpam-winbind \ + python3 \ + samba \ + tini \ + winbind \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /app /data/private /data/public /data/groups /state /etc/samba/generated + +COPY app/reconcile_shares.py /app/reconcile_shares.py +COPY app/init.sh /app/init.sh +COPY etc/samba/smb.conf /app/smb.conf.template + +RUN chmod +x /app/init.sh /app/reconcile_shares.py \ + && touch /etc/samba/generated/shares.conf + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/app/init.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec40059 --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +# AD-Integrated Containerized Samba File Server + +This repository provides a production-oriented Samba file server container that joins an existing Active Directory domain, exposes static and dynamic SMB shares, and persists share identity by AD `objectGUID`. + +## Architecture + +- Samba runs in ADS mode with `winbind` identity mapping. +- Static shares: + - `\\server\Private` -> `/data/private` + - `\\server\Public` -> `/data/public` +- Dynamic shares are generated from AD groups matching `FileShare_*` and written to `/etc/samba/generated/shares.conf`. +- Dynamic share records are persisted in SQLite at `/state/shares.db`. +- Backing storage is GUID-based and stable across group rename: + - `/data/groups/` +- Samba machine trust/key material is persisted in `/var/lib/samba` to survive container recreation. +- Container hostname is fixed (`SAMBA_HOSTNAME`) to keep AD computer identity stable. +- Reconciliation is executed: + - once on startup + - every 5 minutes via cron + +## Dynamic Share Lifecycle (objectGUID-first) + +The reconciliation script (`/app/reconcile_shares.py`) enforces these rules: + +1. New matching group -> insert DB row, create `/data/groups/`, expose share. +2. Group renamed but still `FileShare_` -> update `samAccountName` and `shareName`, keep same path. +3. Group removed or no longer matches prefix -> set `isActive=0`, remove Samba exposure, keep data. +4. Previously inactive group returns -> set `isActive=1`, reuse existing path and data. + +## SQLite State Database + +Database path: `/state/shares.db` + +Table schema: + +```sql +CREATE TABLE shares ( + objectGUID TEXT PRIMARY KEY, + samAccountName TEXT NOT NULL, + shareName TEXT NOT NULL, + path TEXT NOT NULL, + createdAt TIMESTAMP NOT NULL, + lastSeenAt TIMESTAMP NOT NULL, + isActive INTEGER NOT NULL +); +``` + +## AD Requirements + +- Existing AD DS domain reachable from the Docker host. +- A service account with rights to join computers to the domain (`net ads join`). +- Dynamic group discovery primarily uses machine-account LDAP (`net ads search -P`); join credentials are only used as a fallback LDAP bind path. +- Group naming convention for dynamic shares: + - `FileShare_` + +## DNS Requirements + +- Container must resolve AD DNS records (especially SRV records for domain controllers). +- `DOMAIN` should resolve from inside the container. +- Preferred setup: Docker host uses AD-integrated DNS or forwards to AD DNS. + +## Time Sync Requirements + +Kerberos requires close time alignment. + +- Docker host clock must be synchronized (NTP/chrony/systemd-timesyncd). +- AD domain controllers must also be time-synchronized. +- If join/authentication fails unexpectedly, check time skew first. + +## Repository Layout + +- `Dockerfile` +- `docker-compose.yml` +- `setup` +- `.env.example` +- `README.md` +- `app/init.sh` +- `app/reconcile_shares.py` +- `etc/samba/smb.conf` +- `etc/samba/generated/` + +## Setup + +1. Run interactive setup: + + ```bash + ./setup + ``` + +2. If `.env` is missing, you will be prompted for: + - `REALM` + - `WORKGROUP` + - `DOMAIN` + - `JOIN_USER` + - `JOIN_PASSWORD` + +3. The setup script writes `.env` and starts the service with: + + ```bash + docker compose up -d + ``` + +4. After startup: + - container joins AD (idempotent) + - startup reconciliation runs + - cron runs reconciliation every 5 minutes + +## SMB Shares + +### Private + +- Share: `\\server\Private` +- Root path: `/data/private` +- Per-user path: `/data/private/` +- Script ensures user directories exist and assigns ownership through winbind identity resolution. +- Permissions: + - owner user: full control + - Domain Admins: ACL full control + - mode: `700` +- `hide unreadable = yes` + ACLs enforce that users only see their own folder. + +### Public + +- Share: `\\server\Public` +- Path: `/data/public` +- Read/write for authenticated users in configurable `PUBLIC_GROUP` (default `Domain Users`). +- No guest access. + +### Dynamic Group Shares + +- AD groups: `FileShare_*` +- Share name: prefix removed (`FileShare_Finance` -> `\\server\Finance`) +- Backing path: `/data/groups/` +- Share exposure generated in `/etc/samba/generated/shares.conf` +- Dynamic share names are validated for SMB compatibility and deduplicated case-insensitively. + +## Useful Commands + +```bash +docker compose logs -f samba +docker compose exec samba python3 /app/reconcile_shares.py +docker compose exec samba sqlite3 /state/shares.db 'SELECT * FROM shares;' +docker compose exec samba testparm -s +``` + +## Troubleshooting + +### Domain join fails + +- Verify credentials in `.env`. +- Verify DNS resolution from container: + + ```bash + docker compose exec samba getent hosts "$DOMAIN" + ``` + +- Verify time sync on host and AD DCs. + +### Winbind user/group resolution fails + +- Check trust: + + ```bash + docker compose exec samba wbinfo -t + ``` + +- List users/groups: + + ```bash + docker compose exec samba wbinfo -u + docker compose exec samba wbinfo -g + ``` + +### Dynamic shares not appearing + +- Confirm AD groups match `FileShare_*`. +- Run manual reconciliation and inspect logs: + + ```bash + docker compose exec samba python3 /app/reconcile_shares.py + docker compose exec samba tail -n 100 /var/log/reconcile.log + ``` + +- Validate generated config: + + ```bash + docker compose exec samba cat /etc/samba/generated/shares.conf + ``` + +### Permissions in Private share are incorrect + +- Re-run reconciliation to rebuild private directories and ACLs: + + ```bash + docker compose exec samba python3 /app/reconcile_shares.py + ``` + +- Check identity resolution for a user: + + ```bash + docker compose exec samba id 'EXAMPLE\\alice' + ``` + +## Notes + +- User data is never automatically deleted. +- Inactive/deleted groups only stop being exposed as shares. +- Data and state survive container restarts via named Docker volumes (`/data/*`, `/state`, `/var/lib/samba`). diff --git a/app/init.sh b/app/init.sh new file mode 100755 index 0000000..cfd4dc2 --- /dev/null +++ b/app/init.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[init] %s\n' "$*" +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + printf '[init] ERROR: missing required env var %s\n' "$name" >&2 + exit 1 + fi +} + +append_winbind_to_nss() { + sed -ri '/^passwd:/ { /winbind/! s/$/ winbind/ }' /etc/nsswitch.conf + sed -ri '/^group:/ { /winbind/! s/$/ winbind/ }' /etc/nsswitch.conf +} + +render_krb5_conf() { + cat > /etc/krb5.conf < /etc/samba/smb.conf + testparm -s /etc/samba/smb.conf >/dev/null +} + +write_runtime_env_file() { + { + printf 'export REALM=%q\n' "$REALM" + printf 'export WORKGROUP=%q\n' "$WORKGROUP" + printf 'export DOMAIN=%q\n' "$DOMAIN" + if [[ -n "${JOIN_USER:-}" ]]; then + printf 'export JOIN_USER=%q\n' "$JOIN_USER" + fi + if [[ -n "${JOIN_PASSWORD:-}" ]]; then + printf 'export JOIN_PASSWORD=%q\n' "$JOIN_PASSWORD" + fi + printf 'export PUBLIC_GROUP=%q\n' "$PUBLIC_GROUP" + if [[ -n "${LDAP_URI:-}" ]]; then + printf 'export LDAP_URI=%q\n' "$LDAP_URI" + fi + if [[ -n "${LDAP_BASE_DN:-}" ]]; then + printf 'export LDAP_BASE_DN=%q\n' "$LDAP_BASE_DN" + fi + } > /app/runtime.env + chmod 600 /app/runtime.env +} + +join_domain_if_needed() { + if net ads testjoin >/dev/null 2>&1; then + log 'Domain join already present; skipping join.' + return + fi + + require_env JOIN_USER + require_env JOIN_PASSWORD + + log "Joining AD domain ${REALM}" + if ! printf '%s\n' "$JOIN_PASSWORD" | net ads join -U "$JOIN_USER" -S "$DOMAIN"; then + log 'Join using explicit server failed, retrying automatic DC discovery.' + printf '%s\n' "$JOIN_PASSWORD" | net ads join -U "$JOIN_USER" + fi +} + +wait_for_winbind() { + local tries=0 + local max_tries=30 + until wbinfo -t >/dev/null 2>&1; do + tries=$((tries + 1)) + if [[ "$tries" -ge "$max_tries" ]]; then + printf '[init] ERROR: winbind trust test failed after %d attempts\n' "$max_tries" >&2 + return 1 + fi + sleep 2 + done + return 0 +} + +install_cron_job() { + cat > /etc/cron.d/reconcile-shares <<'EOF' +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +*/5 * * * * root source /app/runtime.env && /usr/bin/python3 /app/reconcile_shares.py >> /var/log/reconcile.log 2>&1 +EOF + chmod 0644 /etc/cron.d/reconcile-shares +} + +require_env REALM +require_env WORKGROUP +require_env DOMAIN + +export REALM WORKGROUP DOMAIN +export PUBLIC_GROUP="${PUBLIC_GROUP:-Domain Users}" +if [[ -n "${JOIN_USER:-}" ]]; then + export JOIN_USER +fi +if [[ -n "${JOIN_PASSWORD:-}" ]]; then + export JOIN_PASSWORD +fi + +mkdir -p /data/private /data/public /data/groups /state /etc/samba/generated /var/log/samba +touch /etc/samba/generated/shares.conf /var/log/reconcile.log + +append_winbind_to_nss +write_runtime_env_file +render_krb5_conf +render_smb_conf +join_domain_if_needed + +log 'Starting winbindd' +winbindd -F --no-process-group & + +wait_for_winbind + +log 'Running startup reconciliation' +python3 /app/reconcile_shares.py + +install_cron_job + +log 'Starting cron daemon' +cron -f & + +log 'Starting smbd in foreground' +exec smbd -F --no-process-group diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py new file mode 100755 index 0000000..fa7b28e --- /dev/null +++ b/app/reconcile_shares.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 + +import base64 +import datetime as dt +import fcntl +import grp +import os +import pwd +import re +import sqlite3 +import subprocess +import sys +import tempfile +import uuid +from typing import Dict, List, Optional, Tuple + + +DB_PATH = "/state/shares.db" +LOCK_PATH = "/state/reconcile.lock" +GROUP_ROOT = "/data/groups" +PRIVATE_ROOT = "/data/private" +PUBLIC_ROOT = "/data/public" +GENERATED_CONF = "/etc/samba/generated/shares.conf" + +LDAP_FILTER = "(&(objectClass=group)(sAMAccountName=FileShare_*))" +GROUP_PREFIX = "FileShare_" + +REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] +ATTR_RE = re.compile(r"^([^:]+)(::?):\s*(.*)$") +SHARE_NAME_INVALID_RE = re.compile(r"[\\/:*?\"<>|;\[\],+=]") + + +def now_utc() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + + +def log(message: str) -> None: + print(f"[reconcile] {message}", flush=True) + + +def ensure_required_env() -> None: + missing = [key for key in REQUIRED_ENV if not os.getenv(key)] + if missing: + raise RuntimeError(f"Missing required env vars: {', '.join(missing)}") + + +def realm_to_base_dn(realm: str) -> str: + parts = [part for part in realm.split(".") if part] + if not parts: + raise RuntimeError("REALM is invalid and cannot be converted to base DN") + return ",".join(f"DC={part}" for part in parts) + + +def parse_guid(raw_value: str, is_b64: bool) -> str: + if is_b64: + raw = base64.b64decode(raw_value) + if len(raw) != 16: + raise ValueError("objectGUID has invalid binary length") + return str(uuid.UUID(bytes_le=raw)) + + candidate = raw_value.strip().strip("{}") + return str(uuid.UUID(candidate)) + + +def run_command(command: List[str], check: bool = True) -> subprocess.CompletedProcess: + result = subprocess.run(command, capture_output=True, text=True) + if check and result.returncode != 0: + raise RuntimeError( + f"Command failed ({' '.join(command)}): {result.stderr.strip() or result.stdout.strip()}" + ) + return result + + +def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]: + entries: List[Dict[str, Tuple[str, bool]]] = [] + current: Dict[str, Tuple[str, bool]] = {} + + for line in output.splitlines(): + stripped = line.strip() + if not stripped: + if current: + entries.append(current) + current = {} + continue + if stripped.startswith("#") or stripped.startswith("dn:"): + continue + + match = ATTR_RE.match(stripped) + if not match: + continue + + key, delimiter, value = match.groups() + current[key] = (value, delimiter == "::") + + if current: + entries.append(current) + + groups: List[Dict[str, str]] = [] + for entry in entries: + if "objectGUID" not in entry or "sAMAccountName" not in entry: + continue + + sam_value, _ = entry["sAMAccountName"] + sam = sam_value.strip() + if not sam.startswith(GROUP_PREFIX): + continue + + share_name = sam[len(GROUP_PREFIX) :] + if not share_name: + continue + + guid_value, is_b64 = entry["objectGUID"] + guid = parse_guid(guid_value.strip(), is_b64) + + groups.append( + { + "objectGUID": guid, + "samAccountName": sam, + "shareName": share_name, + } + ) + + deduped: Dict[str, Dict[str, str]] = {} + for group in groups: + deduped[group["objectGUID"]] = group + + return list(deduped.values()) + + +def fetch_groups_via_net_ads() -> List[Dict[str, str]]: + result = run_command( + ["net", "ads", "search", "-P", LDAP_FILTER, "objectGUID", "sAMAccountName"], + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + result.stderr.strip() or result.stdout.strip() or "net ads search failed" + ) + return parse_groups_from_ldap_output(result.stdout) + + +def fetch_groups_via_ldap_bind() -> List[Dict[str, str]]: + realm = os.environ["REALM"] + join_user = os.getenv("JOIN_USER", "") + join_password = os.getenv("JOIN_PASSWORD", "") + if not join_user or not join_password: + raise RuntimeError( + "JOIN_USER/JOIN_PASSWORD are required for LDAP credential fallback" + ) + + bind_dn = f"{join_user}@{realm}" + ldap_uri = os.getenv("LDAP_URI", f"ldaps://{os.environ['DOMAIN']}") + base_dn = os.getenv("LDAP_BASE_DN", realm_to_base_dn(realm)) + + pw_file = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle: + pw_file = handle.name + handle.write(join_password) + handle.write("\n") + os.chmod(pw_file, 0o600) + + result = run_command( + [ + "ldapsearch", + "-LLL", + "-x", + "-H", + ldap_uri, + "-D", + bind_dn, + "-y", + pw_file, + "-b", + base_dn, + LDAP_FILTER, + "objectGUID", + "sAMAccountName", + ] + ) + return parse_groups_from_ldap_output(result.stdout) + finally: + if pw_file and os.path.exists(pw_file): + os.remove(pw_file) + + +def fetch_fileshare_groups() -> List[Dict[str, str]]: + try: + return fetch_groups_via_net_ads() + except Exception as net_exc: # pylint: disable=broad-except + log(f"net ads search failed, falling back to LDAP bind: {net_exc}") + return fetch_groups_via_ldap_bind() + + +def open_db() -> sqlite3.Connection: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute( + """ + CREATE TABLE IF NOT EXISTS shares ( + objectGUID TEXT PRIMARY KEY, + samAccountName TEXT NOT NULL, + shareName TEXT NOT NULL, + path TEXT NOT NULL, + createdAt TIMESTAMP NOT NULL, + lastSeenAt TIMESTAMP NOT NULL, + isActive INTEGER NOT NULL + ) + """ + ) + conn.commit() + return conn + + +def ensure_group_path(path: str) -> None: + os.makedirs(path, exist_ok=True) + os.chmod(path, 0o2770) + + +def reconcile_db(conn: sqlite3.Connection, ad_groups: List[Dict[str, str]]) -> None: + timestamp = now_utc() + seen = set() + + for group in ad_groups: + guid = group["objectGUID"] + sam = group["samAccountName"] + share_name = group["shareName"] + seen.add(guid) + + row = conn.execute( + "SELECT objectGUID, path FROM shares WHERE objectGUID = ?", (guid,) + ).fetchone() + + if row is None: + path = os.path.join(GROUP_ROOT, guid) + ensure_group_path(path) + conn.execute( + """ + INSERT INTO shares (objectGUID, samAccountName, shareName, path, createdAt, lastSeenAt, isActive) + VALUES (?, ?, ?, ?, ?, ?, 1) + """, + (guid, sam, share_name, path, timestamp, timestamp), + ) + log(f"Discovered new share group {sam} ({guid})") + continue + + path = row["path"] + ensure_group_path(path) + conn.execute( + """ + UPDATE shares + SET samAccountName = ?, + shareName = ?, + lastSeenAt = ?, + isActive = 1 + WHERE objectGUID = ? + """, + (sam, share_name, timestamp, guid), + ) + + if seen: + placeholders = ",".join("?" for _ in seen) + conn.execute( + f"UPDATE shares SET isActive = 0, lastSeenAt = ? WHERE isActive = 1 AND objectGUID NOT IN ({placeholders})", + (timestamp, *seen), + ) + else: + conn.execute( + "UPDATE shares SET isActive = 0, lastSeenAt = ? WHERE isActive = 1", + (timestamp,), + ) + + conn.commit() + + +def qualify_group(group_name: str) -> str: + workgroup = os.getenv("WORKGROUP", "").strip() + if workgroup: + return f'@"{workgroup}\\{group_name}"' + return f"@{group_name}" + + +def is_valid_share_name(share_name: str) -> bool: + if not share_name or share_name.casefold() in {"global", "homes", "printers"}: + return False + if SHARE_NAME_INVALID_RE.search(share_name): + return False + return True + + +def render_dynamic_shares(conn: sqlite3.Connection) -> None: + rows = conn.execute( + """ + SELECT objectGUID, samAccountName, shareName, path + FROM shares + WHERE isActive = 1 + ORDER BY shareName COLLATE NOCASE + """ + ).fetchall() + + stanzas: List[str] = [ + "# This file is generated by /app/reconcile_shares.py.", + "# Manual changes will be overwritten.", + "", + ] + used_share_names = set() + + for row in rows: + share_name = row["shareName"].strip() + if not share_name: + continue + folded_name = share_name.casefold() + if folded_name in used_share_names: + log( + f"Skipping duplicate share name '{share_name}' for objectGUID {row['objectGUID']}" + ) + continue + + if not is_valid_share_name(share_name): + log( + f"Skipping invalid SMB share name '{share_name}' for objectGUID {row['objectGUID']}" + ) + continue + + used_share_names.add(folded_name) + valid_users = qualify_group(row["samAccountName"]) + stanzas.extend( + [ + f"[{share_name}]", + f"path = {row['path']}", + "read only = no", + "browseable = yes", + "guest ok = no", + f"valid users = {valid_users}", + "create mask = 0660", + "directory mask = 2770", + "inherit permissions = yes", + "access based share enumeration = yes", + "", + ] + ) + + content = "\n".join(stanzas).rstrip() + "\n" + os.makedirs(os.path.dirname(GENERATED_CONF), exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=os.path.dirname(GENERATED_CONF), delete=False + ) as tmp_file: + tmp_file.write(content) + temp_path = tmp_file.name + + os.replace(temp_path, GENERATED_CONF) + + +def reload_samba() -> None: + result = run_command(["smbcontrol", "all", "reload-config"], check=False) + if result.returncode != 0: + log("smbcontrol reload-config failed; will retry on next run") + + +def resolve_user_uid(qualified_user: str) -> Optional[int]: + try: + return pwd.getpwnam(qualified_user).pw_uid + except KeyError: + return None + + +def resolve_group_gid(qualified_group: str) -> Optional[int]: + try: + return grp.getgrnam(qualified_group).gr_gid + except KeyError: + return None + + +def set_acl(path: str, user_uid: int, admin_gid: Optional[int]) -> None: + run_command(["setfacl", "-b", path], check=False) + acl_entries = [f"u:{user_uid}:rwx", f"d:u:{user_uid}:rwx"] + if admin_gid is not None: + acl_entries.extend([f"g:{admin_gid}:rwx", f"d:g:{admin_gid}:rwx"]) + + result = run_command( + ["setfacl", "-m", ",".join(acl_entries), path], + check=False, + ) + if result.returncode != 0: + log( + f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" + ) + + +def set_group_acl(path: str, group_gid: int) -> None: + acl_entries = [f"g:{group_gid}:rwx", f"d:g:{group_gid}:rwx"] + result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False) + if result.returncode != 0: + log( + f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" + ) + + +def set_group_acl_with_admin( + path: str, group_gid: int, admin_gid: Optional[int] +) -> None: + run_command(["setfacl", "-b", path], check=False) + acl_entries = [f"g:{group_gid}:rwx", f"d:g:{group_gid}:rwx"] + if admin_gid is not None: + acl_entries.extend([f"g:{admin_gid}:rwx", f"d:g:{admin_gid}:rwx"]) + result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False) + if result.returncode != 0: + log( + f"setfacl failed for {path}: {result.stderr.strip() or result.stdout.strip()}" + ) + + +def list_domain_users() -> List[str]: + result = run_command(["wbinfo", "-u"], check=False) + if result.returncode != 0: + log("wbinfo -u failed; skipping private directory sync") + return [] + + users = [] + for line in result.stdout.splitlines(): + candidate = line.strip() + if not candidate: + continue + if "\\" in candidate: + candidate = candidate.split("\\", 1)[1] + if not candidate or candidate.endswith("$"): + continue + users.append(candidate) + return sorted(set(users)) + + +def sync_public_directory() -> None: + workgroup = os.environ["WORKGROUP"] + public_group = os.getenv("PUBLIC_GROUP", "Domain Users") + qualified_group = f"{workgroup}\\{public_group}" + + os.makedirs(PUBLIC_ROOT, exist_ok=True) + gid = resolve_group_gid(qualified_group) + + if gid is not None: + os.chown(PUBLIC_ROOT, 0, gid) + run_command(["setfacl", "-b", PUBLIC_ROOT], check=False) + set_group_acl(PUBLIC_ROOT, gid) + else: + log(f"Unable to resolve GID for {qualified_group}; public ACLs unchanged") + + os.chmod(PUBLIC_ROOT, 0o2770) + + +def sync_private_directories() -> None: + workgroup = os.environ["WORKGROUP"] + admin_group = f"{workgroup}\\Domain Admins" + admin_gid = resolve_group_gid(admin_group) + + os.makedirs(PRIVATE_ROOT, exist_ok=True) + os.chmod(PRIVATE_ROOT, 0o755) + + users = list_domain_users() + for username in users: + qualified_user = f"{workgroup}\\{username}" + uid = resolve_user_uid(qualified_user) + if uid is None: + log(f"Unable to resolve UID for {qualified_user}, skipping private folder") + continue + + user_path = os.path.join(PRIVATE_ROOT, username) + os.makedirs(user_path, exist_ok=True) + os.chown(user_path, uid, -1) + os.chmod(user_path, 0o700) + set_acl(user_path, uid, admin_gid) + + +def sync_dynamic_directory_permissions(conn: sqlite3.Connection) -> None: + workgroup = os.environ["WORKGROUP"] + admin_gid = resolve_group_gid(f"{workgroup}\\Domain Admins") + + rows = conn.execute( + "SELECT samAccountName, path FROM shares WHERE isActive = 1" + ).fetchall() + for row in rows: + sam = row["samAccountName"] + path = row["path"] + os.makedirs(path, exist_ok=True) + os.chmod(path, 0o2770) + + gid = resolve_group_gid(f"{workgroup}\\{sam}") + if gid is None: + log(f"Unable to resolve GID for {workgroup}\\{sam}; leaving existing ACLs") + continue + + os.chown(path, 0, gid) + set_group_acl_with_admin(path, gid, admin_gid) + + +def with_lock() -> bool: + os.makedirs(os.path.dirname(LOCK_PATH), exist_ok=True) + lock_file = open(LOCK_PATH, "w", encoding="utf-8") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + log("Another reconciliation instance is running; skipping this cycle") + lock_file.close() + return False + + try: + ensure_required_env() + os.makedirs(GROUP_ROOT, exist_ok=True) + + conn = open_db() + try: + groups = fetch_fileshare_groups() + reconcile_db(conn, groups) + sync_dynamic_directory_permissions(conn) + render_dynamic_shares(conn) + finally: + conn.close() + + sync_public_directory() + sync_private_directories() + reload_samba() + log("Reconciliation completed") + return True + finally: + lock_file.close() + + +def main() -> int: + try: + ok = with_lock() + return 0 if ok else 0 + except Exception as exc: # pylint: disable=broad-except + log(f"ERROR: {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b99375c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + samba: + build: + context: . + container_name: ad-samba-file-server + hostname: ${SAMBA_HOSTNAME:-ad-samba-file-server} + restart: unless-stopped + env_file: + - .env + ports: + - "445:445" + - "139:139" + volumes: + - private_data:/data/private + - public_data:/data/public + - group_data:/data/groups + - state_data:/state + - samba_lib_data:/var/lib/samba + healthcheck: + test: ["CMD-SHELL", "wbinfo -t >/dev/null 2>&1 && testparm -s >/dev/null 2>&1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 45s + +volumes: + private_data: + public_data: + group_data: + state_data: + samba_lib_data: diff --git a/etc/samba/smb.conf b/etc/samba/smb.conf new file mode 100644 index 0000000..d7b4683 --- /dev/null +++ b/etc/samba/smb.conf @@ -0,0 +1,60 @@ +[global] + security = ADS + kerberos method = secrets and keytab + realm = ${REALM} + workgroup = ${WORKGROUP} + + idmap config * : backend = tdb + idmap config * : range = 3000-7999 + + idmap config ${WORKGROUP} : backend = rid + idmap config ${WORKGROUP} : range = 10000-999999 + + winbind use default domain = yes + winbind enum users = yes + winbind enum groups = yes + + vfs objects = acl_xattr + map acl inherit = yes + store dos attributes = yes + + server min protocol = SMB2 + client min protocol = SMB2 + + access based share enumeration = yes + + dedicated keytab file = /var/lib/samba/private/krb5.keytab + kerberos encryption types = all + + load printers = no + printcap name = /dev/null + disable spoolss = yes + + log file = /var/log/samba/log.%m + max log size = 10000 + logging = file + + include = /etc/samba/generated/shares.conf + +[Private] + path = /data/private + read only = no + browseable = yes + guest ok = no + valid users = @"${WORKGROUP}\\Domain Users" + admin users = @"${WORKGROUP}\\Domain Admins" + hide unreadable = yes + access based share enumeration = yes + ea support = yes + +[Public] + path = /data/public + read only = no + browseable = yes + guest ok = no + valid users = @"${WORKGROUP}\\${PUBLIC_GROUP}" + force group = "${WORKGROUP}\\${PUBLIC_GROUP}" + create mask = 0660 + directory mask = 2770 + inherit permissions = yes + access based share enumeration = yes diff --git a/setup b/setup new file mode 100755 index 0000000..ec51b03 --- /dev/null +++ b/setup @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE=".env" + +prompt_value() { + local var_name="$1" + local prompt_text="$2" + local is_secret="${3:-false}" + local value="" + + while [[ -z "$value" ]]; do + if [[ "$is_secret" == "true" ]]; then + read -r -s -p "$prompt_text: " value + printf "\n" + else + read -r -p "$prompt_text: " value + fi + done + + printf -v "$var_name" '%s' "$value" +} + +write_env_file() { + local realm="" + local workgroup="" + local domain="" + local join_user="" + local join_password="" + + prompt_value realm "REALM (e.g. EXAMPLE.COM)" + prompt_value workgroup "WORKGROUP (NetBIOS, e.g. EXAMPLE)" + prompt_value domain "DOMAIN (AD DNS name or reachable DC FQDN)" + prompt_value join_user "JOIN_USER (AD account with join rights)" + prompt_value join_password "JOIN_PASSWORD" true + + cat > "$ENV_FILE" <