excluding expired/locked users from having a private folder; fixed groups (sqlite3)
This commit is contained in:
@@ -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/*
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user