first progress
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal 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
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
208
README.md
Normal 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
145
app/init.sh
Executable 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
538
app/reconcile_shares.py
Executable 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
31
docker-compose.yml
Normal 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
60
etc/samba/smb.conf
Normal 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
69
setup
Executable 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"
|
||||||
Reference in New Issue
Block a user