excluding expired/locked users from having a private folder; fixed groups (sqlite3)

This commit is contained in:
Ludwig Lehnert
2026-02-18 19:13:25 +01:00
parent 6d9476c578
commit 43bcb08548
3 changed files with 96 additions and 14 deletions

View File

@@ -14,6 +14,7 @@ RUN apt-get update \
python3 \ python3 \
samba \ samba \
samba-vfs-modules \ samba-vfs-modules \
sqlite3 \
tini \ tini \
winbind \ winbind \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -8,7 +8,7 @@ This repository provides a production-oriented Samba file server container that
- Static shares: - Static shares:
- `\\server\Private` -> `/data/private` - `\\server\Private` -> `/data/private`
- `\\server\Public` -> `/data/public` - `\\server\Public` -> `/data/public`
- Dynamic shares are generated from AD groups matching `FileShare_*` and written to `/etc/samba/generated/shares.conf`. - Dynamic shares are generated from AD groups matching `FileShare_*` or `FS_*` and written to `/etc/samba/generated/shares.conf`.
- Dynamic share records are persisted in SQLite at `/state/shares.db`. - Dynamic share records are persisted in SQLite at `/state/shares.db`.
- Backing storage is GUID-based and stable across group rename: - Backing storage is GUID-based and stable across group rename:
- `/data/groups/<objectGUID>` - `/data/groups/<objectGUID>`
@@ -57,7 +57,7 @@ CREATE TABLE shares (
- `FileShare_ServiceAccount` must be allowed to join computers to the domain (`net ads join`) in your AD policy. - `FileShare_ServiceAccount` must be allowed to join computers to the domain (`net ads join`) in your AD policy.
- Dynamic group discovery primarily uses machine-account LDAP (`net ads search -P`); join credentials are only used as a fallback LDAP bind path. - 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: - Group naming convention for dynamic shares:
- `FileShare_<ShareName>` - `FileShare_<ShareName>` or `FS_<ShareName>`
## DNS Requirements ## DNS Requirements
@@ -133,6 +133,7 @@ Kerberos requires close time alignment.
- Script ensures user directories exist and assigns ownership through winbind identity resolution. - Script ensures user directories exist and assigns ownership through winbind identity resolution.
- Root `/data/private` is enforced read/execute-only (`0555`) to prevent folder creation directly under `\\server\Private`. - Root `/data/private` is enforced read/execute-only (`0555`) to prevent folder creation directly under `\\server\Private`.
- SMB-side permission changes on `\\server\Private` are blocked (`nt acl support = no` and security masks set to `0000`). - SMB-side permission changes on `\\server\Private` are blocked (`nt acl support = no` and security masks set to `0000`).
- Auto-creation skips well-known/service/non-login accounts (disabled, locked, or expired).
- Permissions: - Permissions:
- owner user: full control - owner user: full control
- Domain Admins: ACL full control - Domain Admins: ACL full control
@@ -148,8 +149,8 @@ Kerberos requires close time alignment.
### Dynamic Group Shares ### Dynamic Group Shares
- AD groups: `FileShare_*` - AD groups: `FileShare_*` and `FS_*`
- Share name: prefix removed (`FileShare_Finance` -> `\\server\Finance`) - Share name: prefix removed (`FileShare_Finance` -> `\\server\Finance`, `FS_Finance` -> `\\server\Finance`)
- Backing path: `/data/groups/<objectGUID>` - Backing path: `/data/groups/<objectGUID>`
- Share exposure generated in `/etc/samba/generated/shares.conf` - Share exposure generated in `/etc/samba/generated/shares.conf`
- Dynamic share names are validated for SMB compatibility and deduplicated case-insensitively. - Dynamic share names are validated for SMB compatibility and deduplicated case-insensitively.
@@ -199,7 +200,7 @@ docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*'
### Dynamic shares not appearing ### Dynamic shares not appearing
- Confirm AD groups match `FileShare_*`. - Confirm AD groups match `FileShare_*` or `FS_*`.
- Run manual reconciliation and inspect logs: - Run manual reconciliation and inspect logs:
```bash ```bash

View File

@@ -22,8 +22,11 @@ PRIVATE_ROOT = "/data/private"
PUBLIC_ROOT = "/data/public" PUBLIC_ROOT = "/data/public"
GENERATED_CONF = "/etc/samba/generated/shares.conf" GENERATED_CONF = "/etc/samba/generated/shares.conf"
LDAP_FILTER = "(&(objectClass=group)(sAMAccountName=FileShare_*))" LDAP_FILTER = (
GROUP_PREFIX = "FileShare_" "(&(objectClass=group)(|(sAMAccountName=FileShare_*)(sAMAccountName=FS_*)))"
)
GROUP_PREFIXES = ("FileShare_", "FS_")
USER_STATUS_FILTER = "(&(objectClass=user)(!(objectClass=computer))(sAMAccountName=*))"
REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"]
ATTR_RE = re.compile(r"^([^:]+)(::?):\s*(.*)$") ATTR_RE = re.compile(r"^([^:]+)(::?):\s*(.*)$")
@@ -32,12 +35,16 @@ PRIVATE_SKIP_EXACT = {
"krbtgt", "krbtgt",
"administrator", "administrator",
"guest", "guest",
"gast",
"defaultaccount", "defaultaccount",
"wdagutilityaccount", "wdagutilityaccount",
"fileshare_serviceacc", "fileshare_serviceacc",
"fileshare_serviceaccount", "fileshare_serviceaccount",
} }
PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_") PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_")
UAC_ACCOUNTDISABLE = 0x0002
UAC_LOCKOUT = 0x0010
AD_NEVER_EXPIRES_VALUES = {0, 9223372036854775807}
def now_utc() -> str: def now_utc() -> str:
@@ -81,7 +88,7 @@ def run_command(command: List[str], check: bool = True) -> subprocess.CompletedP
return result return result
def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]: def parse_ldap_entries(output: str) -> List[Dict[str, Tuple[str, bool]]]:
entries: List[Dict[str, Tuple[str, bool]]] = [] entries: List[Dict[str, Tuple[str, bool]]] = []
current: Dict[str, Tuple[str, bool]] = {} current: Dict[str, Tuple[str, bool]] = {}
@@ -105,6 +112,20 @@ def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]:
if current: if current:
entries.append(current) entries.append(current)
return entries
def derive_share_name(sam_account_name: str) -> Optional[str]:
for prefix in GROUP_PREFIXES:
if sam_account_name.startswith(prefix):
share_name = sam_account_name[len(prefix) :]
return share_name if share_name else None
return None
def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]:
entries = parse_ldap_entries(output)
groups: List[Dict[str, str]] = [] groups: List[Dict[str, str]] = []
for entry in entries: for entry in entries:
if "objectGUID" not in entry or "sAMAccountName" not in entry: if "objectGUID" not in entry or "sAMAccountName" not in entry:
@@ -112,10 +133,7 @@ def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]:
sam_value, _ = entry["sAMAccountName"] sam_value, _ = entry["sAMAccountName"]
sam = sam_value.strip() sam = sam_value.strip()
if not sam.startswith(GROUP_PREFIX): share_name = derive_share_name(sam)
continue
share_name = sam[len(GROUP_PREFIX) :]
if not share_name: if not share_name:
continue continue
@@ -202,6 +220,65 @@ def fetch_fileshare_groups() -> List[Dict[str, str]]:
return fetch_groups_via_ldap_bind() return fetch_groups_via_ldap_bind()
def windows_filetime_now() -> int:
unix_epoch_seconds = int(dt.datetime.now(dt.timezone.utc).timestamp())
return (unix_epoch_seconds + 11644473600) * 10000000
def parse_int(value: str, default: int = 0) -> int:
try:
return int(value.strip())
except (ValueError, AttributeError):
return default
def fetch_non_login_users() -> set:
command = [
"net",
"ads",
"search",
"-P",
USER_STATUS_FILTER,
"sAMAccountName",
"userAccountControl",
"accountExpires",
"lockoutTime",
]
result = run_command(command, check=False)
if result.returncode != 0:
log(
"net ads search for account status failed; private folder filtering will use static skip rules only"
)
return set()
blocked = set()
now_filetime = windows_filetime_now()
for entry in parse_ldap_entries(result.stdout):
if "sAMAccountName" not in entry:
continue
username = entry["sAMAccountName"][0].strip().lower()
if not username:
continue
uac = parse_int(entry.get("userAccountControl", ("0", False))[0], 0)
account_expires = parse_int(entry.get("accountExpires", ("0", False))[0], 0)
lockout_time = parse_int(entry.get("lockoutTime", ("0", False))[0], 0)
is_disabled = bool(uac & UAC_ACCOUNTDISABLE)
is_locked = bool(uac & UAC_LOCKOUT) or lockout_time > 0
is_expired = (
account_expires not in AD_NEVER_EXPIRES_VALUES
and account_expires <= now_filetime
)
if is_disabled or is_locked or is_expired:
blocked.add(username)
return blocked
def open_db() -> sqlite3.Connection: def open_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
@@ -455,7 +532,7 @@ def set_group_acl_with_admin(
) )
def list_domain_users() -> List[str]: def list_domain_users(non_login_users: set) -> List[str]:
result = run_command(["wbinfo", "-u"], check=False) result = run_command(["wbinfo", "-u"], check=False)
if result.returncode != 0: if result.returncode != 0:
log("wbinfo -u failed; skipping private directory sync") log("wbinfo -u failed; skipping private directory sync")
@@ -472,6 +549,8 @@ def list_domain_users() -> List[str]:
continue continue
if should_skip_private_user(candidate): if should_skip_private_user(candidate):
continue continue
if candidate.lower() in non_login_users:
continue
users.append(candidate) users.append(candidate)
return sorted(set(users)) return sorted(set(users))
@@ -532,7 +611,8 @@ def sync_private_directories() -> None:
run_command(["setfacl", "-b", PRIVATE_ROOT], check=False) run_command(["setfacl", "-b", PRIVATE_ROOT], check=False)
os.chmod(PRIVATE_ROOT, 0o555) os.chmod(PRIVATE_ROOT, 0o555)
users = list_domain_users() non_login_users = fetch_non_login_users()
users = list_domain_users(non_login_users)
for username in users: for username in users:
uid = resolve_user_uid_flexible(workgroup, username) uid = resolve_user_uid_flexible(workgroup, username)
if uid is None: if uid is None: