first progress

This commit is contained in:
Ludwig Lehnert
2026-02-18 11:46:35 +01:00
parent d2f78548f5
commit eb090abf4e
9 changed files with 1091 additions and 0 deletions

9
.env.example Normal file
View File

@@ -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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

30
Dockerfile Normal file
View File

@@ -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"]

208
README.md Normal file
View File

@@ -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/<objectGUID>`
- 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/<objectGUID>`, 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_<ShareName>`
## 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/<samAccountName>`
- 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/<objectGUID>`
- 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`).

145
app/init.sh Executable file
View File

@@ -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 <<EOF
[libdefaults]
default_realm = ${REALM}
dns_lookup_realm = false
dns_lookup_kdc = true
rdns = false
ticket_lifetime = 24h
forwardable = true
[realms]
${REALM} = {
kdc = ${DOMAIN}
admin_server = ${DOMAIN}
}
[domain_realm]
.${DOMAIN} = ${REALM}
${DOMAIN} = ${REALM}
EOF
}
render_smb_conf() {
envsubst < /app/smb.conf.template > /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

538
app/reconcile_shares.py Executable file
View File

@@ -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())

31
docker-compose.yml Normal file
View File

@@ -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:

60
etc/samba/smb.conf Normal file
View File

@@ -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

69
setup Executable file
View File

@@ -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" <<EOF
REALM=${realm}
WORKGROUP=${workgroup}
DOMAIN=${domain}
JOIN_USER=${join_user}
JOIN_PASSWORD=${join_password}
PUBLIC_GROUP=Domain Users
# SAMBA_HOSTNAME=ad-samba-file-server
# Optional overrides:
# LDAP_URI=ldaps://${domain}
# LDAP_BASE_DN=DC=example,DC=com
EOF
chmod 600 "$ENV_FILE"
printf "Created %s\n" "$ENV_FILE"
}
if [[ -f "$ENV_FILE" ]]; then
read -r -p ".env already exists. Overwrite? [y/N]: " overwrite
case "$overwrite" in
y|Y|yes|YES)
write_env_file
;;
*)
printf "Keeping existing .env\n"
;;
esac
else
write_env_file
fi
docker compose up -d
printf "Samba service started.\n"