Compare commits

...

2 Commits

Author SHA1 Message Date
Ludwig Lehnert
b0e4916710 finalizing permissions; renamed Public -> Shared 2026-02-18 20:19:47 +01:00
Ludwig Lehnert
c340e79ee3 attempted fix on group shares not appearing (GID not found) (4) 2026-02-18 19:57:08 +01:00
4 changed files with 144 additions and 82 deletions

View File

@@ -7,7 +7,7 @@ This repository provides a production-oriented Samba file server container that
- Samba runs in ADS mode with `winbind` identity mapping.
- Static shares:
- `\\server\Private` -> `/data/private`
- `\\server\Public` -> `/data/public`
- `\\server\Shared` -> `/data/public`
- 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:
@@ -132,20 +132,22 @@ Kerberos requires close time alignment.
- Per-user path: `/data/private/<samAccountName>`
- 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`).
- SMB-side ACL changes on `\\server\Private` are blocked (`nt acl support = no`).
- Auto-creation skips well-known/service/non-login accounts (disabled, locked, or expired).
- Each private user tree is reconciled recursively to homogeneous permissions (dirs `0700`, files `0600`, user/admin ACLs).
- 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
### Shared
- Share: `\\server\Public`
- Share: `\\server\Shared`
- Path: `/data/public`
- Read/write for authenticated users in configurable `PUBLIC_GROUP_SID` (default: `DOMAIN_USERS_SID`, resolved through winbind).
- No guest access.
- Permissions are reconciled recursively so all descendants remain homogeneous (dirs `2770`, files `0660`, shared group/admin ACLs).
### Dynamic Group Shares
@@ -154,7 +156,8 @@ Kerberos requires close time alignment.
- 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.
- Group membership changes are refreshed during each reconciliation cycle (winbind cache flush + Samba config reload).
- Group membership changes are refreshed continuously via winbind cache updates (`winbind cache time = 60`) and Samba config reload during reconciliation.
- Dynamic share trees are reconciled recursively so all descendants keep homogeneous permissions.
## Useful Commands

View File

@@ -64,21 +64,8 @@ derive_netbios_name() {
resolve_sid_to_group() {
local sid="$1"
local gid=""
local gid_entry=""
local resolved_name=""
local sid_output=""
local candidate=""
local lower_candidate=""
gid="$(wbinfo --sid-to-gid "$sid" 2>/dev/null || true)"
if [[ -n "$gid" ]]; then
gid_entry="$(getent group "$gid" || true)"
if [[ -n "$gid_entry" ]]; then
printf '%s\n' "${gid_entry%%:*}"
return 0
fi
fi
if sid_output="$(wbinfo --sid-to-fullname "$sid" 2>/dev/null)"; then
resolved_name="${sid_output%%$'\t'*}"
@@ -94,25 +81,30 @@ resolve_sid_to_group() {
return 1
fi
for candidate in "$resolved_name" "${resolved_name#*\\}"; do
if [[ -z "$candidate" ]]; then
continue
if [[ "$resolved_name" != *\\* ]]; then
resolved_name="${WORKGROUP}\\${resolved_name}"
fi
if getent group "$candidate" >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
lower_candidate="${candidate,,}"
if [[ "$lower_candidate" != "$candidate" ]] && getent group "$lower_candidate" >/dev/null 2>&1; then
printf '%s\n' "$lower_candidate"
return 0
fi
done
printf '[init] WARN: SID %s resolved to %s but NSS lookup failed; using raw name.\n' "$sid" "$resolved_name" >&2
printf '%s\n' "$resolved_name"
}
ensure_machine_keytab() {
local keytab_path="/var/lib/samba/private/krb5.keytab"
mkdir -p /var/lib/samba/private
if [[ ! -s /etc/krb5.keytab ]]; then
if ! net ads keytab create -P >/dev/null 2>&1; then
if [[ -n "${JOIN_USER:-}" && -n "${JOIN_PASSWORD:-}" ]]; then
printf '%s\n' "$JOIN_PASSWORD" | net ads keytab create -U "$JOIN_USER" >/dev/null 2>&1 || true
fi
fi
fi
if [[ -s /etc/krb5.keytab ]]; then
cp /etc/krb5.keytab "$keytab_path"
chmod 600 "$keytab_path"
fi
}
resolve_share_groups_from_sids() {
export DOMAIN_USERS_GROUP
DOMAIN_USERS_GROUP="$(resolve_sid_to_group "$DOMAIN_USERS_SID")"
@@ -249,6 +241,7 @@ derive_netbios_name
render_krb5_conf
render_smb_conf
join_domain_if_needed
ensure_machine_keytab
log 'Starting winbindd'
winbindd -F --no-process-group &

View File

@@ -386,7 +386,12 @@ def reconcile_db(conn: sqlite3.Connection, ad_groups: List[Dict[str, str]]) -> N
def qualify_group(group_name: str) -> str:
return f'+"{group_name}"'
if "\\" in group_name:
return f'@"{group_name}"'
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:
@@ -535,38 +540,28 @@ def resolve_gid_from_sid(sid: str) -> Optional[int]:
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]
def apply_group_permissions(
path: str, group_gid: int, admin_gid: Optional[int], is_dir: bool
) -> None:
if os.path.islink(path):
return
mode = 0o2770 if is_dir else 0o660
group_perms = "rwx" if is_dir else "rw-"
os.chown(path, 0, group_gid)
os.chmod(path, mode)
run_command(["setfacl", "-b", path], check=False)
acl_entries = [f"g:{group_gid}:rwx", f"d:g:{group_gid}:rwx"]
acl_entries = [f"g:{group_gid}:{group_perms}"]
if admin_gid is not None:
acl_entries.extend([f"g:{admin_gid}:rwx", f"d:g:{admin_gid}:rwx"])
acl_entries.append(f"g:{admin_gid}:{group_perms}")
if is_dir:
acl_entries.append(f"d:g:{group_gid}:rwx")
if admin_gid is not None:
acl_entries.append(f"d:g:{admin_gid}:rwx")
result = run_command(["setfacl", "-m", ",".join(acl_entries), path], check=False)
if result.returncode != 0:
log(
@@ -574,6 +569,80 @@ def set_group_acl_with_admin(
)
def apply_private_permissions(
path: str, user_uid: int, user_gid: int, admin_gid: Optional[int], is_dir: bool
) -> None:
if os.path.islink(path):
return
mode = 0o700 if is_dir else 0o600
user_perms = "rwx" if is_dir else "rw-"
os.chown(path, user_uid, user_gid)
os.chmod(path, mode)
run_command(["setfacl", "-b", path], check=False)
acl_entries = [f"u:{user_uid}:{user_perms}"]
if admin_gid is not None:
acl_entries.append(f"g:{admin_gid}:{user_perms}")
if is_dir:
acl_entries.append(f"d:u:{user_uid}:rwx")
if admin_gid is not None:
acl_entries.append(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 enforce_group_tree_permissions(
root_path: str, group_gid: int, admin_gid: Optional[int]
) -> None:
apply_group_permissions(root_path, group_gid, admin_gid, is_dir=True)
for current_root, dirnames, filenames in os.walk(root_path):
for dirname in dirnames:
apply_group_permissions(
os.path.join(current_root, dirname), group_gid, admin_gid, is_dir=True
)
for filename in filenames:
apply_group_permissions(
os.path.join(current_root, filename), group_gid, admin_gid, is_dir=False
)
def resolve_user_primary_gid(uid: int) -> Optional[int]:
try:
return pwd.getpwuid(uid).pw_gid
except KeyError:
return None
def enforce_private_tree_permissions(
root_path: str, user_uid: int, user_gid: int, admin_gid: Optional[int]
) -> None:
apply_private_permissions(root_path, user_uid, user_gid, admin_gid, is_dir=True)
for current_root, dirnames, filenames in os.walk(root_path):
for dirname in dirnames:
apply_private_permissions(
os.path.join(current_root, dirname),
user_uid,
user_gid,
admin_gid,
is_dir=True,
)
for filename in filenames:
apply_private_permissions(
os.path.join(current_root, filename),
user_uid,
user_gid,
admin_gid,
is_dir=False,
)
def list_domain_users(non_login_users: set) -> List[str]:
result = run_command(["wbinfo", "-u"], check=False)
if result.returncode != 0:
@@ -639,15 +708,12 @@ def sync_public_directory() -> None:
gid = resolve_gid_from_sid(public_group_sid)
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)
admin_gid = resolve_gid_from_sid(os.getenv("DOMAIN_ADMINS_SID", ""))
enforce_group_tree_permissions(PUBLIC_ROOT, gid, admin_gid)
else:
group_display = qualified_group or public_group_sid or "<unset>"
log(f"Unable to resolve GID for {group_display}; public ACLs unchanged")
os.chmod(PUBLIC_ROOT, 0o2770)
def sync_private_directories() -> None:
workgroup = os.environ["WORKGROUP"]
@@ -671,11 +737,16 @@ def sync_private_directories() -> None:
log(f"Unable to resolve UID for {username}, skipping private folder")
continue
user_gid = resolve_user_primary_gid(uid)
if user_gid is None:
log(
f"Unable to resolve primary GID for {username}, 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)
enforce_private_tree_permissions(user_path, uid, user_gid, admin_gid)
def sync_dynamic_directory_permissions(conn: sqlite3.Connection) -> None:
@@ -701,8 +772,7 @@ def sync_dynamic_directory_permissions(conn: sqlite3.Connection) -> None:
log(f"Unable to resolve GID for {sam}; leaving existing ACLs")
continue
os.chown(path, 0, gid)
set_group_acl_with_admin(path, gid, admin_gid)
enforce_group_tree_permissions(path, gid, admin_gid)
def with_lock() -> bool:
@@ -731,7 +801,6 @@ def with_lock() -> bool:
sync_public_directory()
sync_private_directories()
refresh_winbind_cache()
reload_samba()
log("Reconciliation completed")
return True

View File

@@ -14,6 +14,7 @@
winbind use default domain = yes
winbind enum users = yes
winbind enum groups = yes
winbind cache time = 60
vfs objects = acl_xattr
map acl inherit = yes
@@ -48,17 +49,13 @@
full_audit:success = all
full_audit:failure = all
full_audit:syslog = false
valid users = +"${DOMAIN_USERS_GROUP}"
valid users = @"${DOMAIN_USERS_GROUP}"
hide unreadable = yes
access based share enum = yes
ea support = yes
nt acl support = no
security mask = 0000
force security mode = 0000
directory security mask = 0000
force directory security mode = 0000
[Public]
[Shared]
path = /data/public
read only = no
browseable = yes
@@ -68,7 +65,7 @@
full_audit:success = all
full_audit:failure = all
full_audit:syslog = false
valid users = +"${PUBLIC_GROUP}"
valid users = @"${PUBLIC_GROUP}"
force group = "${PUBLIC_GROUP}"
create mask = 0660
directory mask = 2770