#!/usr/bin/with-contenv bashio set -e bashio::log.info "Starting Simple NAS..." export WORKGROUP WORKGROUP=$(bashio::config 'workgroup') bashio::log.info "Workgroup: ${WORKGROUP}" export NAS_NAME NAS_NAME=$(bashio::config 'nas_name') bashio::log.info "NAS Name: ${NAS_NAME}" export WEB_PORT WEB_PORT=$(bashio::config 'web_port') bashio::log.info "Web GUI Port: ${WEB_PORT}" # Home Assistant assigns the Ingress port itself (ingress_port: 0 in config.yaml). A fixed 8100 clashed # with other host-network add-ons such as Dnsmasq-DHCP (issue #10); the GUI listens on both ports. # bashio renamed addon.* to app.*; the old name only warns for now. export INGRESS_PORT if declare -F bashio::app.ingress_port > /dev/null; then INGRESS_PORT=$(bashio::app.ingress_port 2>/dev/null || true) else INGRESS_PORT=$(bashio::addon.ingress_port 2>/dev/null || true) fi case "${INGRESS_PORT}" in ''|*[!0-9]*) INGRESS_PORT="" bashio::log.warning "Ingress port unknown - the Open button in Home Assistant will not work, use http://HA-IP:${WEB_PORT}" ;; *) bashio::log.info "Ingress Port: ${INGRESS_PORT}" ;; esac export ADMIN_PASSWORD_ENABLED ADMIN_PASSWORD_ENABLED=$(bashio::config 'admin_password_enabled') export ADMIN_USERNAME ADMIN_USERNAME=$(bashio::config 'admin_username') export ADMIN_PASSWORD ADMIN_PASSWORD=$(bashio::config 'admin_password') export SMB_PORT SMB_PORT=$(bashio::config 'smb_port') bashio::log.info "SMB Port: ${SMB_PORT}" export WEB_GUI_ENABLED WEB_GUI_ENABLED=$(bashio::config 'web_gui_enabled') bashio::log.info "Web GUI enabled: ${WEB_GUI_ENABLED}" export HDD_IDLE_SECONDS HDD_IDLE_SECONDS=$(bashio::config 'hdd_idle_seconds') # Auto-restore settings from /config/.simplenas/auto (reinstall-safe backup) python3 - << 'PYEOF' import os, shutil, json AUTO_DIR = "/config/.simplenas/auto" DATA_DIR = "/data" meta = os.path.join(AUTO_DIR, "meta.json") shares = os.path.join(DATA_DIR, "shares.json") # Restore only when auto-backup exists AND /data is empty/missing if os.path.exists(meta) and (not os.path.exists(shares) or os.path.getsize(shares) <= 5): print("[RESTORE] Fresh install detected – restoring settings from /config/.simplenas/auto ...") os.makedirs(DATA_DIR, exist_ok=True) for fname in ("shares.json", "users.json", "groups.json", "mounts.json", "backups.json", "admin_auth.json", "file_access.json", "share_links.json", "share_accounts.json", "share_auth.json", "crowdsec_setup.json"): src = os.path.join(AUTO_DIR, fname) if os.path.exists(src): shutil.copy2(src, os.path.join(DATA_DIR, fname)) print(f"[RESTORE] {fname} restored") samba_src = os.path.join(AUTO_DIR, "samba") if os.path.isdir(samba_src): samba_dst = os.path.join(DATA_DIR, "samba") os.makedirs(samba_dst, exist_ok=True) for f in os.listdir(samba_src): shutil.copy2(os.path.join(samba_src, f), os.path.join(samba_dst, f)) print("[RESTORE] Done.") PYEOF # Initialize persistent data files (only if still missing after restore) for f in shares users mounts groups backups share_links share_accounts; do if [ ! -f "/data/${f}.json" ]; then echo '[]' > "/data/${f}.json" fi done chmod 600 /data/share_accounts.json 2>/dev/null || true # Generate smb.conf python3 /app/generate_smb_conf.py "$WORKGROUP" "$NAS_NAME" "$SMB_PORT" # Restore system users and groups from persistent JSON files # (Container is ephemeral - Linux users are lost on restart) bashio::log.info "Restoring users and groups from persistent storage..." python3 - << 'PYEOF' import json, subprocess, os, shutil # Restore groups try: groups = json.load(open("/data/groups.json")) for g in groups: name = g.get("name", "") if name: subprocess.run(["addgroup", name], capture_output=True) for m in g.get("members", []): # Create user first if needed (for group membership) subprocess.run(["adduser", "-D", "-H", "-s", "/sbin/nologin", m], capture_output=True) subprocess.run(["addgroup", m, name], capture_output=True) print(f" Group restored: {name} ({len(g.get('members',[]))} members)") except Exception as e: print(f" Groups: {e}") # Restore system users try: users = json.load(open("/data/users.json")) for u in users: username = u.get("username", "") if username: subprocess.run(["adduser", "-D", "-H", "-s", "/sbin/nologin", username], capture_output=True) print(f" User restored: {username}") except Exception as e: print(f" Users: {e}") # Restore Samba password database from persistent storage samba_dir = "/var/lib/samba/private" os.makedirs(samba_dir, exist_ok=True) restored = 0 for fname in ["passdb.tdb", "secrets.tdb", "smbpasswd"]: src = f"/data/samba/{fname}" dst = f"{samba_dir}/{fname}" if os.path.exists(src): shutil.copy2(src, dst) restored += 1 # Also check /etc/samba/smbpasswd if os.path.exists("/data/samba/smbpasswd"): shutil.copy2("/data/samba/smbpasswd", "/etc/samba/smbpasswd") if restored > 0: print(f" Samba password DB restored ({restored} files)") else: print(" No Samba password DB backup found (passwords need to be set via GUI)") print(" Done.") PYEOF # Ensure Samba runtime directories mkdir -p /var/log/samba /var/run/samba ln -sf /config /homeassistant # Start privileged mount helper daemon (communicates via FIFO) bashio::log.info "Starting mount helper..." /app/mount_helper.sh & # Wait for FIFO to be ready sleep 1 # Migrate mounts.json: replace raw /dev/sdX paths with stable /dev/disk/by-id/ python3 - << 'PYEOF' import json, os, re MOUNTS_FILE = "/data/mounts.json" BY_ID_DIR = "/dev/disk/by-id" def get_by_id_path(dev_path): """Resolve a /dev/sdX (or similar) path to a stable /dev/disk/by-id/ symlink.""" if not os.path.isdir(BY_ID_DIR): return None try: real_dev = os.path.realpath(dev_path) except Exception: return None candidates = [] try: for link in os.listdir(BY_ID_DIR): link_path = os.path.join(BY_ID_DIR, link) try: if os.path.realpath(link_path) == real_dev: candidates.append(link) except Exception: pass except Exception: return None if not candidates: return None for prefix in ("usb-", "ata-", "nvme-", "mmc-", "scsi-"): for c in sorted(candidates): if c.startswith(prefix): return os.path.join(BY_ID_DIR, c) return os.path.join(BY_ID_DIR, sorted(candidates)[0]) try: mounts = json.load(open(MOUNTS_FILE)) except Exception: mounts = [] changed = 0 # Clean up leftover bind-mount entries/fields from versions 3.0.38–3.0.47. # The bind-mount feature was removed in 3.0.48 because it was ineffective # (HA OS slave namespaces) AND dangerous (rm -rf /share/ would recurse # through the bind and wipe the underlying drive). Make sure no leftover # entry can ever re-create such a bind mount on startup. BIND_FIELDS = ("share_bind", "share_bind_name", "bind_path", "bind", "bind_target") cleaned_mounts = [] for m in mounts: if m.get("type") == "bind" or m.get("kind") == "bind": print(f"[MIGRATE] removed obsolete bind-mount entry for {m.get('mountpoint') or m.get('device')}") changed += 1 continue removed = [k for k in BIND_FIELDS if k in m] for k in removed: m.pop(k, None) if removed: print(f"[MIGRATE] removed obsolete bind-mount fields {removed} from {m.get('device')}") changed += 1 cleaned_mounts.append(m) mounts = cleaned_mounts for m in mounts: dev = m.get("device", "") # Only migrate raw /dev/sd*, /dev/hd*, /dev/vd* paths — leave by-id and others alone if re.match(r"^/dev/[shv]d[a-z]\d*$", dev): by_id = get_by_id_path(dev) if by_id: print(f"[MIGRATE] {dev} → {by_id}") m["dev_path"] = dev # keep original for reference m["device"] = by_id changed += 1 else: print(f"[MIGRATE] {dev}: no by-id path found (device not connected?)") if changed: with open(MOUNTS_FILE, "w") as f: json.dump(mounts, f, indent=2) print(f"[MIGRATE] {changed} change(s) written to mounts.json.") else: print("[MIGRATE] No migration needed.") PYEOF # Restore saved mounts via mount helper python3 /app/restore_mounts.py # Regenerate smb.conf AFTER mounts are restored so that shares under /media/ # are correctly marked available = yes instead of available = no bashio::log.info "Regenerating smb.conf after mount restore..." python3 /app/generate_smb_conf.py "$WORKGROUP" "$NAS_NAME" "$SMB_PORT" # ── HDD Spindown (hd-idle) ───────────────────────────────────── # Spin down user-mounted drives after inactivity. Started AFTER restore_mounts # so the devices exist. Global default '-i 0' means no disk is touched unless # it is explicitly named below — this guarantees the HA system disk keeps # spinning (it is never in mounts.json). if [ "${HDD_IDLE_SECONDS:-0}" -gt 0 ] 2>/dev/null; then if command -v hd-idle > /dev/null 2>&1; then HD_IDLE_ARGS=$(python3 /app/hd_idle_args.py "${HDD_IDLE_SECONDS}") if [ -n "${HD_IDLE_ARGS}" ]; then hd-idle -i 0 ${HD_IDLE_ARGS} 2>&1 | python3 -u /app/hd_idle_log.py & bashio::log.info "hd-idle started: spin down mounted drives after ${HDD_IDLE_SECONDS}s idle (${HD_IDLE_ARGS})" else bashio::log.info "hd-idle: no user-mounted drives found — not starting" fi else bashio::log.warning "hd-idle not installed — spindown unavailable" fi else bashio::log.info "HDD spindown disabled (hdd_idle_seconds: 0)" fi # Start Samba daemons bashio::log.info "Starting Samba daemons (smbd + nmbd)..." smbd --foreground --no-process-group & nmbd --foreground --no-process-group & sleep 2 # ── Network Discovery ────────────────────────────────────────── bashio::log.info "Starting network discovery..." # 1) DBus (required by avahi) mkdir -p /var/run/dbus if [ ! -S /var/run/dbus/system_bus_socket ]; then dbus-daemon --system --nofork --nopidfile & sleep 1 bashio::log.info "DBus started" fi # 2) Avahi – mDNS/DNS-SD (Linux Nautilus/Dolphin, macOS Finder) if command -v avahi-daemon > /dev/null 2>&1; then # Fix avahi config for container environment sed -i 's/^#*enable-dbus=.*/enable-dbus=yes/' /etc/avahi/avahi-daemon.conf 2>/dev/null || true sed -i 's/^#*use-ipv6=.*/use-ipv6=no/' /etc/avahi/avahi-daemon.conf 2>/dev/null || true sed -i 's/^rlimit-nproc=.*/#rlimit-nproc=3/' /etc/avahi/avahi-daemon.conf 2>/dev/null || true AVAHI_ERR=$(avahi-daemon --daemonize --no-chroot 2>&1) if [ $? -eq 0 ]; then bashio::log.info "Avahi daemon started" sleep 1 # Explicitly publish SMB service with friendly name (container hostname is ugly) avahi-publish -s "${NAS_NAME}" _smb._tcp "${SMB_PORT}" & bashio::log.info "Avahi mDNS: published _smb._tcp on port ${SMB_PORT} as '${NAS_NAME}'" else bashio::log.warning "Avahi daemon error: ${AVAHI_ERR}" # Try avahi-publish directly (works if HA OS already runs avahi) avahi-publish -s "${NAS_NAME}" _smb._tcp "${SMB_PORT}" & bashio::log.info "Avahi: service published via avahi-publish (using system Avahi)" fi else bashio::log.warning "avahi-daemon not installed" fi # 3) WSDD – WS-Discovery (Windows 10/11 network browser) # Bound to the LAN interface (default route) only. Home Assistant OS always has hassio, docker0 and # veth interfaces besides the LAN one; bound to all of them, the multicast announcements never reached # the LAN and the NAS did not show up in the Windows "Network" folder (issue #13, fix by @mirican). if command -v wsdd > /dev/null 2>&1; then WSDD_IFACE="$(ip route show default 2>/dev/null | awk '{for (i = 1; i < NF; i++) if ($i == "dev") {print $(i + 1); exit}}')" if [ -n "${WSDD_IFACE}" ]; then python3 /usr/local/bin/wsdd --workgroup "${WORKGROUP}" --hostname "${NAS_NAME}" --interface "${WSDD_IFACE}" & bashio::log.info "WSDD started (WS-Discovery for Windows 10/11, interface=${WSDD_IFACE}, hostname=${NAS_NAME})" else python3 /usr/local/bin/wsdd --workgroup "${WORKGROUP}" --hostname "${NAS_NAME}" & bashio::log.warning "WSDD started on all interfaces (no default route found)" fi else bashio::log.warning "wsdd not installed" fi bashio::log.info "Network discovery active" # 4) LLMNR responder – answers Windows 11 name queries for \\NAS_NAME # without any client-side configuration (UDP multicast on port 5355) python3 /app/llmnr_responder.py "${NAS_NAME}" & bashio::log.info "LLMNR responder started (\\\\${NAS_NAME} name resolution for Windows)" # ── Delayed smb.conf reload for slow-mounting USB drives ─────── # Some USB drives take 60-120s to mount. The smb.conf generated above # may have marked those shares as inactive. This background job waits, # then regenerates smb.conf and reloads Samba so late-mounting shares # become available without a manual restart. ( sleep 90 NEW_CONF=$(python3 /app/generate_smb_conf.py "$WORKGROUP" "$NAS_NAME" "$SMB_PORT" 2>&1) if echo "$NEW_CONF" | grep -q "inactive"; then # Still inactive — try again after another 90s sleep 90 python3 /app/generate_smb_conf.py "$WORKGROUP" "$NAS_NAME" "$SMB_PORT" fi smbcontrol smbd reload-config 2>/dev/null || pkill -HUP smbd 2>/dev/null echo "[delayed-reload] smb.conf regenerated and Samba reloaded" ) & # ── Web GUI ──────────────────────────────────────────────────── # The public sharing site must never run without a working admin password. # app.py checks this again on its own. if bashio::config.true 'sharing_enabled'; then if ! bashio::config.true 'admin_password_enabled' || bashio::config.is_empty 'admin_password'; then bashio::log.fatal "sharing_enabled verlangt admin_password_enabled und ein gesetztes admin_password." bashio::log.fatal "Die oeffentliche Freigabe-Seite wird NICHT gestartet." elif bashio::config.is_empty 'share_public_url'; then bashio::log.warning "share_public_url ist leer - Freigabe-Links zeigen auf die LAN-Adresse." fi fi # Request bodies are buffered to a temp file before the app sees them. On # Home Assistant OS /tmp is RAM, so point TMPDIR at the data partition. mkdir -p /data/tmp export TMPDIR=/data/tmp rm -f /data/tmp/tmp* 2>/dev/null || true if [ "${WEB_GUI_ENABLED}" = "true" ]; then bashio::log.info "Starting Web GUI on port ${WEB_PORT}..." exec python3 /app/app.py else bashio::log.info "Web GUI disabled (web_gui_enabled: false) – Samba-only mode." exec tail -f /dev/null fi