diff --git a/Dockerfile b/Dockerfile index 180d1d4..c181c15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update \ python3 \ samba \ samba-vfs-modules \ + sqlite3 \ tini \ winbind \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index be94f34..f7d3477 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This repository provides a production-oriented Samba file server container that - 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 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`. - Backing storage is GUID-based and stable across group rename: - `/data/groups/` @@ -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. - 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_` + - `FileShare_` or `FS_` ## DNS Requirements @@ -133,6 +133,7 @@ Kerberos requires close time alignment. - 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`. - 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: - owner user: full control - Domain Admins: ACL full control @@ -148,8 +149,8 @@ Kerberos requires close time alignment. ### Dynamic Group Shares -- AD groups: `FileShare_*` -- Share name: prefix removed (`FileShare_Finance` -> `\\server\Finance`) +- AD groups: `FileShare_*` and `FS_*` +- Share name: prefix removed (`FileShare_Finance` -> `\\server\Finance`, `FS_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. @@ -199,7 +200,7 @@ docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*' ### Dynamic shares not appearing -- Confirm AD groups match `FileShare_*`. +- Confirm AD groups match `FileShare_*` or `FS_*`. - Run manual reconciliation and inspect logs: ```bash diff --git a/app/reconcile_shares.py b/app/reconcile_shares.py index ccfed0d..6877af5 100755 --- a/app/reconcile_shares.py +++ b/app/reconcile_shares.py @@ -22,8 +22,11 @@ PRIVATE_ROOT = "/data/private" PUBLIC_ROOT = "/data/public" GENERATED_CONF = "/etc/samba/generated/shares.conf" -LDAP_FILTER = "(&(objectClass=group)(sAMAccountName=FileShare_*))" -GROUP_PREFIX = "FileShare_" +LDAP_FILTER = ( + "(&(objectClass=group)(|(sAMAccountName=FileShare_*)(sAMAccountName=FS_*)))" +) +GROUP_PREFIXES = ("FileShare_", "FS_") +USER_STATUS_FILTER = "(&(objectClass=user)(!(objectClass=computer))(sAMAccountName=*))" REQUIRED_ENV = ["REALM", "WORKGROUP", "DOMAIN"] ATTR_RE = re.compile(r"^([^:]+)(::?):\s*(.*)$") @@ -32,12 +35,16 @@ PRIVATE_SKIP_EXACT = { "krbtgt", "administrator", "guest", + "gast", "defaultaccount", "wdagutilityaccount", "fileshare_serviceacc", "fileshare_serviceaccount", } PRIVATE_SKIP_PREFIXES = ("msol_", "fileshare_service", "aad_") +UAC_ACCOUNTDISABLE = 0x0002 +UAC_LOCKOUT = 0x0010 +AD_NEVER_EXPIRES_VALUES = {0, 9223372036854775807} def now_utc() -> str: @@ -81,7 +88,7 @@ def run_command(command: List[str], check: bool = True) -> subprocess.CompletedP 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]]] = [] current: Dict[str, Tuple[str, bool]] = {} @@ -105,6 +112,20 @@ def parse_groups_from_ldap_output(output: str) -> List[Dict[str, str]]: if 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]] = [] for entry in entries: 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 = sam_value.strip() - if not sam.startswith(GROUP_PREFIX): - continue - - share_name = sam[len(GROUP_PREFIX) :] + share_name = derive_share_name(sam) if not share_name: continue @@ -202,6 +220,65 @@ def fetch_fileshare_groups() -> List[Dict[str, str]]: 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: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) 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) if result.returncode != 0: log("wbinfo -u failed; skipping private directory sync") @@ -472,6 +549,8 @@ def list_domain_users() -> List[str]: continue if should_skip_private_user(candidate): continue + if candidate.lower() in non_login_users: + continue users.append(candidate) return sorted(set(users)) @@ -532,7 +611,8 @@ def sync_private_directories() -> None: run_command(["setfacl", "-b", PRIVATE_ROOT], check=False) 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: uid = resolve_user_uid_flexible(workgroup, username) if uid is None: