# 경로/파일: realnas_setup.yml
# 목적: RealNAS 서버 자동 설치/배포 + nginx/samba + elFinder 로컬배포 + php-fpm 감지 + timeshift 래퍼 + DDNS 자동갱신 + CrowdSec(Docker)
#      + ✅ 하드/USB “추가 장착 후” 자동 감지/자동 마운트(/mnt/storage 우선, 나머지는 /mnt/usb1..8) + 권한 자동 봉합
# 붙여넣기: 이 파일 전체를 통째로 교체

- name: RealNAS 서버 자동 설치 및 설정 (UI ZIP 배포 + nginx/samba + elFinder 로컬배포 + php-fpm 감지 + timeshift 래퍼 + DDNS 자동갱신 + CrowdSec(Docker) + 오류로그 + 네트워크 없는 경우 스킵)
  hosts: realnas_servers
  become: yes

  vars:
    app_user: realnas

    base_dir: /opt/realcom-nas
    app_dir: /opt/realcom-nas/realcom-nas-ui
    myenv_dir: /opt/realcom-nas/myenv

    config_dir: /opt/realcom-nas/config
    runtime_dir: /opt/realcom-nas/runtime
    log_dir: /opt/realcom-nas/logs

    # ===== Storage 정책 =====
    storage_mount: /mnt/storage
    storage_device: ""          # 예: /dev/sdb1  (비워두면 fstab 등록 스킵)
    storage_uuid_override: ""   # 예: "1a2b-3c4d"
    storage_fstype_override: "" # 예: ext4

    # ===== USB 정책 =====
    usb_slots: [1,2,3,4,5,6,7,8]
    usb_base: /mnt
    usb_mount_prefix: "usb"
    enable_usb_automount: true

    # ===== 권한 통합 정책 =====
    nas_group: "nasusers"     # Samba/elFinder 공통 그룹
    nas_dir_mode: "2775"      # setgid 포함
    nas_file_mode: "0664"

    # ===== 사용자별 RW 분리(옵션) =====
    enable_rw_split: true
    ro_user: "viewer"
    rw_user: "editor"
    samba_ro_share_name: "STORAGE_RO"
    samba_rw_share_name: "STORAGE"

    nginx_htpasswd_file: /etc/nginx/.htpasswd
    nginx_sites_available: /etc/nginx/sites-available
    nginx_sites_enabled: /etc/nginx/sites-enabled
    nginx_shares_conf: /etc/nginx/conf.d/realnas_shares.conf

    samba_conf_path: /etc/samba/nas_smb.conf

    app_service_name: realnas-app.service
    app_script_path: "{{ app_dir }}/app.py"

    error_log: /var/log/realnas_setup_errors.log

    nas_shares_json: "{{ config_dir }}/nas_shares.json"
    nas_users_json: "{{ config_dir }}/nas_users.json"
    nas_default_share: "{{ config_dir }}/nas_default_share"
    elfinder_conf: "{{ config_dir }}/elfinder.json"
    elfinder_conf_primary: /etc/realnas/elfinder.json

    ddns_config_path: /etc/nas_ddns.json

    ui_zip_src: "realcom-nas-ui.zip"
    ui_zip_dest: "{{ base_dir }}/realcom-nas-ui.zip"
    ui_force_deploy: true

    nginx_server_names: "_"

    elfinder_web_root: /var/www/html/elfinder

    php_fpm_pkg_candidates:
      - php8.4-fpm
      - php8.3-fpm
      - php8.2-fpm
      - php-fpm

    php_fpm_service_candidates:
      - php8.4-fpm
      - php8.3-fpm
      - php8.2-fpm
      - php-fpm

    php_fpm_sock_default: "/run/php/php-fpm.sock"

    disable_storage_alias: true
    storage_alias_use_basic_auth: false
    storage_alias_basic_auth_realm: "RealNAS Storage"
    storage_alias_basic_auth_file: "/etc/nginx/.htpasswd"

    htpasswd_admin_user: "admin"
    htpasswd_admin_pass: "1"

    # ✅ htpasswd 공유 그룹 (앱+nginx 둘 다 읽게)
    htpasswd_group: "realnas-auth"

    # ✅ /mnt/tmp tmpfs size (VM 작으면 8G 위험)
    tmpfs_size: "2G"

    # =========================================================
    # ✅ DDNS 자동 갱신 (systemd timer)
    # =========================================================
    enable_ddns_autoupdate: true
    ddns_update_script: /usr/local/bin/ddns_update.sh
    ddns_timer_name: realnas-ddns-update
    ddns_interval_minutes: 5
    ddns_log_file: /var/log/realnas_ddns_update.log

    # =========================================================
    # ✅ CrowdSec (Docker)
    # - questing에서 packagecloud(apt) 방식 금지(404로 apt 깨짐)
    # =========================================================
    enable_crowdsec: true
    crowdsec_runtime_dir: "{{ runtime_dir }}/crowdsec"
    crowdsec_compose_dir: "{{ runtime_dir }}/crowdsec"
    crowdsec_compose_file: "{{ runtime_dir }}/crowdsec/docker-compose.yml"
    crowdsec_env_file: "{{ runtime_dir }}/crowdsec/.env"
    crowdsec_data_dir: "{{ runtime_dir }}/crowdsec/data"
    crowdsec_config_dir: "{{ runtime_dir }}/crowdsec/config"
    crowdsec_bouncer_dir: "{{ runtime_dir }}/crowdsec/bouncers"

    crowdsec_container_name: "crowdsec"
    crowdsec_nginx_bouncer_name: "crowdsec-nginx-bouncer"
    enable_crowdsec_nginx_bouncer: true
    crowdsec_nginx_bouncer_key_file: "{{ runtime_dir }}/crowdsec/bouncers/nginx_bouncer.key"
    crowdsec_collections: "crowdsecurity/nginx"

    # =========================================================
    # ✅ NEW: “서버 사용 중 하드 추가” 자동 감지/마운트 (systemd timer)
    # - /mnt/storage 비어있으면 1번 우선 채움(가능할 때)
    # - 나머지는 /mnt/usb1..8에 빈 슬롯 찾아 자동 마운트
    # - NTFS/exFAT는 uid/gid/umask 옵션으로 “권한 없음” 방지
    # =========================================================
    enable_hotplug_scan_mount: true
    scan_mount_script: /usr/local/bin/realnas-scan-mount.sh
    scan_mount_service: realnas-scan-mount
    scan_mount_interval_seconds: 30
    scan_mount_log: /var/log/realnas_scan_mount.log

  pre_tasks:
    - name: (SAFETY) base_dir 검증 (/ 금지)
      fail:
        msg: "FATAL: base_dir is '/', refuse to run."
      when: base_dir == "/"

    - name: 오류 로그 파일 준비
      file:
        path: "{{ error_log }}"
        state: touch
        owner: root
        group: root
        mode: "0644"

    # =========================================================
    # ✅ FIX: dpkg 꼬임 자동 복구 (apt/dist-upgrade 전에 선행)
    # =========================================================
    - name: (FIX) dpkg interrupted 자동 복구 시도
      shell: |
        set -e
        dpkg --configure -a || true
        apt-get -f install -y || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: (CHECK) DNS/네트워크 확인 (kr.archive.ubuntu.com resolve)
      shell: |
        set -e
        getent ahosts kr.archive.ubuntu.com >/dev/null 2>&1 && echo "OK" || echo "NO"
      args:
        executable: /bin/bash
      register: dns_check
      changed_when: false
      failed_when: false

    - name: (FACT) 네트워크 사용 가능 여부 set_fact
      set_fact:
        net_ok: "{{ (dns_check.stdout | trim) == 'OK' }}"

    - name: (LOG) 네트워크 없으면 경고 기록
      shell: |
        echo "[{{ ansible_date_time.iso8601 }}] (WARN) DNS/NET not ready. apt install/update will be skipped." >> "{{ error_log }}"
      args:
        executable: /bin/bash
      when: not net_ok
      changed_when: false
      failed_when: false

    - name: (CHECK) openssl exists
      shell: |
        set -e
        command -v openssl >/dev/null 2>&1 && echo "OK" || echo "NO"
      args:
        executable: /bin/bash
      register: openssl_check
      changed_when: false
      failed_when: false

    # ✅ elfinder/ vs elfinder.zip 둘다 지원 (컨트롤 머신에서 존재 여부 확인)
    - name: (LOCAL CHECK) elfinder directory exists?
      stat:
        path: "elfinder"
      delegate_to: localhost
      become: false
      register: elfinder_dir_stat
      failed_when: false

    - name: (LOCAL CHECK) elfinder.zip exists?
      stat:
        path: "elfinder.zip"
      delegate_to: localhost
      become: false
      register: elfinder_zip_stat
      failed_when: false

  tasks:
    - name: 시스템 업데이트 및 업그레이드 (net_ok일 때만)
      when: net_ok
      block:
        - name: apt update/upgrade
          apt:
            update_cache: yes
            upgrade: dist
            cache_valid_time: 3600
      rescue:
        - name: log apt upgrade/update failed
          shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (FAILED) apt upgrade/update" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    - name: 필수 패키지 설치 (net_ok일 때만)  # ✅ docker-compose-plugin 제거(환경에 없으면 터지니까)
      when: net_ok
      block:
        - name: apt install base pkgs
          apt:
            name:
              - build-essential
              - curl
              - wget
              - git
              - unzip
              - vim
              - python3
              - python3-pip
              - python3-venv
              - nginx
              - apache2-utils
              - davfs2
              - zip
              - smartmontools
              - logrotate
              - htop
              - samba
              - smbclient
              - pciutils
              - software-properties-common
              - rsync
              - timeshift
              - php-cli
              - php-common
              - php-mbstring
              - php-xml
              - php-zip
              - php-gd
              - php-curl
              - openssl
              - acl
              - udev
              - util-linux
              - ntfs-3g
              - exfatprogs
              # ✅ CrowdSec(Docker)용
              - docker.io
            state: present
            update_cache: yes
      rescue:
        - name: log apt install failed
          shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (FAILED) apt install base pkgs" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # ✅ Docker Compose 설치 (플러그인 있으면 플러그인 / 없으면 docker-compose)
    # =========================================================
    - name: (CHECK) docker-compose-plugin 패키지 존재 여부
      when: net_ok
      shell: |
        set -e
        apt-cache show docker-compose-plugin >/dev/null 2>&1 && echo "YES" || echo "NO"
      args:
        executable: /bin/bash
      register: compose_plugin_avail
      changed_when: false
      failed_when: false

    - name: Docker Compose Plugin 설치 (가능할 때만)
      when: net_ok and (compose_plugin_avail.stdout | trim) == "YES"
      apt:
        name:
          - docker-compose-plugin
        state: present
        update_cache: yes
      failed_when: false

    - name: (FALLBACK) docker-compose 설치 (plugin 없을 때)
      when: net_ok and (compose_plugin_avail.stdout | trim) != "YES"
      apt:
        name:
          - docker-compose
        state: present
        update_cache: yes
      failed_when: false

    # =========================================================
    # Docker enable/start + compose_cmd 결정
    # =========================================================
    - name: docker 서비스 enable/start (net_ok일 때만)
      when: net_ok
      shell: |
        systemctl enable --now docker 2>/dev/null || true
        systemctl restart docker 2>/dev/null || true
        docker version >/dev/null 2>&1 || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: (FACT) compose_cmd 자동 선택 (docker compose 우선, 없으면 docker-compose)
      shell: |
        set -e
        if docker compose version >/dev/null 2>&1; then
          echo "docker compose"
          exit 0
        fi
        if command -v docker-compose >/dev/null 2>&1; then
          echo "docker-compose"
          exit 0
        fi
        echo ""
        exit 0
      args:
        executable: /bin/bash
      register: compose_cmd_out
      changed_when: false
      failed_when: false

    - name: compose_cmd set_fact
      set_fact:
        compose_cmd: "{{ (compose_cmd_out.stdout | trim) if (compose_cmd_out.stdout | trim | length) > 0 else 'docker compose' }}"

    - name: 리눅스 realnas 사용자 계정 생성 (없으면)
      user:
        name: "{{ app_user }}"
        shell: /bin/bash
        system: no
        create_home: yes
        home: "/home/{{ app_user }}"
        state: present
      failed_when: false

    - name: 리눅스 admin 사용자 계정 생성 (시스템 계정)
      user:
        name: admin
        shell: /usr/sbin/nologin
        system: yes
        create_home: no
        password_lock: yes
        state: present
      failed_when: false

    - name: app_user UID 조회
      command: "id -u {{ app_user }}"
      register: app_uid_out
      changed_when: false
      failed_when: false

    - name: app_user GID 조회
      command: "id -g {{ app_user }}"
      register: app_gid_out
      changed_when: false
      failed_when: false

    - name: app_uid/app_gid set_fact
      set_fact:
        app_uid: "{{ (app_uid_out.stdout | trim) if (app_uid_out.stdout is defined and (app_uid_out.stdout | trim | length) > 0) else '0' }}"
        app_gid: "{{ (app_gid_out.stdout | trim) if (app_gid_out.stdout is defined and (app_gid_out.stdout | trim | length) > 0) else '0' }}"

    - name: base_dir 존재 보장 (root 소유)
      file:
        path: "{{ base_dir }}"
        state: directory
        owner: root
        group: root
        mode: "0775"

    - name: /opt/realcom-nas 하위 디렉토리 생성 (config/runtime/logs)
      file:
        path: "{{ item }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ app_user }}"
        mode: "0775"
      loop:
        - "{{ config_dir }}"
        - "{{ runtime_dir }}"
        - "{{ log_dir }}"
      failed_when: false

    - name: /opt/realcom-nas/backup_logs 보장 생성 (앱 크래시 방지)
      file:
        path: "{{ base_dir }}/backup_logs"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ app_user }}"
        mode: "0775"
      failed_when: false

    # =========================================================
    # ✅ FIX: htpasswd 권한/그룹 봉합 (앱+nginx 둘 다 읽게)
    # =========================================================
    - name: htpasswd 공유 그룹 생성
      group:
        name: "{{ htpasswd_group }}"
        state: present
      failed_when: false

    - name: htpasswd 그룹에 app_user 추가
      user:
        name: "{{ app_user }}"
        groups: "{{ htpasswd_group }}"
        append: yes
      failed_when: false

    - name: htpasswd 그룹에 www-data 추가 (nginx 읽기용)
      user:
        name: "www-data"
        groups: "{{ htpasswd_group }}"
        append: yes
      failed_when: false

    - name: htpasswd 파일 생성/갱신 (openssl apr1)
      when: (openssl_check.stdout | trim) == "OK"
      shell: |
        set -e
        HASH="$(openssl passwd -apr1 "{{ htpasswd_admin_pass }}")"
        umask 027
        echo "{{ htpasswd_admin_user }}:${HASH}" > "{{ nginx_htpasswd_file }}"
        chown root:"{{ htpasswd_group }}" "{{ nginx_htpasswd_file }}"
        chmod 0640 "{{ nginx_htpasswd_file }}"
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: (WARN) openssl 없음 -> htpasswd 생성 스킵 로그
      when: (openssl_check.stdout | trim) != "OK"
      shell: |
        echo "[{{ ansible_date_time.iso8601 }}] (WARN) openssl not found -> htpasswd not generated: {{ nginx_htpasswd_file }}" >> "{{ error_log }}"
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # ✅ 권한 통합 그룹 생성 + www-data 포함
    # =========================================================
    - name: NAS 공통 그룹 생성
      group:
        name: "{{ nas_group }}"
        state: present
      failed_when: false

    - name: NAS 공통 그룹에 app_user 추가
      user:
        name: "{{ app_user }}"
        groups: "{{ nas_group }}"
        append: yes
      failed_when: false

    - name: NAS 공통 그룹에 www-data 추가 (elFinder가 읽기/쓰기 가능하게)
      user:
        name: "www-data"
        groups: "{{ nas_group }}"
        append: yes
      failed_when: false

    # =========================================================
    # CONFIG 기본 파일
    # =========================================================
    - name: CONFIG_DIR 기본 파일 존재 확인
      stat:
        path: "{{ item }}"
      loop:
        - "{{ nas_shares_json }}"
        - "{{ nas_users_json }}"
        - "{{ nas_default_share }}"
      register: cfg_stats
      failed_when: false

    - name: nas_shares.json 없으면 초기화
      copy:
        dest: "{{ nas_shares_json }}"
        content: "[]"
        owner: "{{ app_user }}"
        group: www-data
        mode: "0660"
      when: not (cfg_stats.results[0].stat.exists | default(false))
      failed_when: false

    - name: nas_users.json 없으면 생성
      copy:
        dest: "{{ nas_users_json }}"
        content: "{}"
        owner: "{{ app_user }}"
        group: www-data
        mode: "0660"
      when: not (cfg_stats.results[1].stat.exists | default(false))
      failed_when: false

    - name: nas_default_share 없으면 생성
      file:
        path: "{{ nas_default_share }}"
        state: touch
        owner: "{{ app_user }}"
        group: www-data
        mode: "0660"
      when: not (cfg_stats.results[2].stat.exists | default(false))
      failed_when: false

    - name: /etc/realnas 디렉토리 보장
      file:
        path: /etc/realnas
        state: directory
        owner: root
        group: root
        mode: "0755"
      failed_when: false

    # =========================================================
    # elfinder.json (최소만)
    # =========================================================
    - name: elfinder.json (config_dir) 존재 확인
      stat:
        path: "{{ elfinder_conf }}"
      register: elfinder_stat
      failed_when: false

    - name: elfinder.json 기본 배포 (config_dir, 없을 때만)
      copy:
        dest: "{{ elfinder_conf }}"
        owner: "{{ app_user }}"
        group: www-data
        mode: "0640"
        content: |
          {
            "roots": [
              { "alias": "스토리지", "path": "/mnt/storage" },
              { "alias": "기본 저장소", "path": "/mnt/tmp" }
            ]
          }
      when: not (elfinder_stat.stat.exists | default(false))
      failed_when: false

    - name: /etc/realnas/elfinder.json 1순위 파일 보장 (심링크)
      file:
        src: "{{ elfinder_conf }}"
        dest: "{{ elfinder_conf_primary }}"
        state: link
        force: yes
      failed_when: false

    # =========================================================
    # ✅ Storage mountpoint 보장 + (옵션) UUID 고정
    # =========================================================
    - name: /mnt/storage 디렉토리 보장 생성
      file:
        path: "{{ storage_mount }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ nas_group }}"
        mode: "2775"
      failed_when: false

    - name: (FACT) storage_device 지정 여부
      set_fact:
        storage_dev_set: "{{ (storage_device | default('') | trim | length) > 0 }}"
      changed_when: false

    - name: (FIX) storage_device에서 UUID/FSTYPE 조회 (storage_device 지정된 경우)
      when: storage_dev_set
      block:
        - command: "blkid -s UUID -o value {{ storage_device }}"
          register: storage_uuid
          changed_when: false
          failed_when: false
        - command: "blkid -s TYPE -o value {{ storage_device }}"
          register: storage_fstype
          changed_when: false
          failed_when: false
      rescue:
        - shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (WARN) blkid failed for {{ storage_device }}" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    - name: (FACT) storage_uuid_final/storage_fstype_final 결정
      set_fact:
        storage_uuid_final: >-
          {{
            (storage_uuid_override | default('') | trim)
            if (storage_uuid_override | default('') | trim | length) > 0 else
            ((storage_uuid.stdout | default('') | trim) if (storage_uuid is defined) else '')
          }}
        storage_fstype_final: >-
          {{
            (storage_fstype_override | default('') | trim)
            if (storage_fstype_override | default('') | trim | length) > 0 else
            ((storage_fstype.stdout | default('ext4') | trim) if (storage_fstype is defined) else 'ext4')
          }}

    - name: (FIX) fstab에 UUID 라인 등록 (/mnt/storage)
      when: (storage_uuid_final | trim | length) > 0
      lineinfile:
        path: /etc/fstab
        create: yes
        regexp: "^UUID={{ storage_uuid_final }}\\s+{{ storage_mount }}\\s+"
        line: "UUID={{ storage_uuid_final }} {{ storage_mount }} {{ storage_fstype_final }} defaults,nofail,x-systemd.device-timeout=10 0 2"
        state: present
      failed_when: false

    - name: /mnt/storage 마운트 시도
      shell: |
        systemctl daemon-reload || true
        mount "{{ storage_mount }}" || mount -a || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # ✅ tmpfs /mnt/tmp (OS 기본 용량용 “임시 저장소”)
    # - “tmp에는 권한이 없네” 방지: 소유/그룹/nas 권한으로 고정
    # =========================================================
    - name: /mnt/tmp 디렉토리 보장 생성
      file:
        path: /mnt/tmp
        state: directory
        owner: "{{ app_user }}"
        group: "{{ nas_group }}"
        mode: "2775"
      failed_when: false

    - name: fstab에 /mnt/tmp tmpfs 등록
      lineinfile:
        path: /etc/fstab
        create: yes
        regexp: '^tmpfs\s+/mnt/tmp\s+tmpfs\s+'
        line: "tmpfs /mnt/tmp tmpfs rw,nosuid,nodev,noatime,size={{ tmpfs_size }},mode=2775,uid={{ app_uid }},gid={{ app_gid }} 0 0"
        state: present
      failed_when: false

    - name: /mnt/tmp 마운트 시도 + 권한 봉합
      shell: |
        systemctl daemon-reload || true
        mount /mnt/tmp || true
        chown {{ app_user }}:{{ nas_group }} /mnt/tmp || true
        chmod 2775 /mnt/tmp || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # ✅ USB mount roots + udev automount
    # =========================================================
    - name: USB mount roots 준비 (/mnt/usb1..usb8)
      file:
        path: "{{ usb_base }}/{{ usb_mount_prefix }}{{ item }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ nas_group }}"
        mode: "2775"
      loop: "{{ usb_slots }}"
      failed_when: false

    - name: USB automount 스크립트 배포
      when: enable_usb_automount | bool
      copy:
        dest: /usr/local/bin/realnas-usb-mount.sh
        owner: root
        group: root
        mode: "0755"
        content: |
          #!/usr/bin/env bash
          set -euo pipefail

          DEV="${1:-}"
          ACTION="${2:-}"

          BASE="{{ usb_base }}"
          PREFIX="{{ usb_mount_prefix }}"
          USER="{{ app_user }}"
          GROUP="{{ nas_group }}"

          if [[ -z "$DEV" || -z "$ACTION" ]]; then exit 0; fi

          log() { echo "[$(date -Iseconds)] $*" >> /var/log/realnas_usb_automount.log; }
          is_mountpoint() { mountpoint -q "$1" >/dev/null 2>&1; }

          if [[ "$ACTION" == "add" ]]; then
            for i in {{ usb_slots | join(' ') }}; do
              MP="$BASE/${PREFIX}${i}"
              mkdir -p "$MP"
              if ! is_mountpoint "$MP"; then
                if mount "$DEV" "$MP" 2>/dev/null; then
                  chown -R "$USER:$GROUP" "$MP" || true
                  chmod -R 2775 "$MP" || true
                  log "mounted $DEV -> $MP"
                  exit 0
                else
                  log "mount failed: $DEV -> $MP"
                  exit 0
                fi
              fi
            done
            log "no free slot for $DEV"
            exit 0
          fi

          if [[ "$ACTION" == "remove" ]]; then
            for i in {{ usb_slots | join(' ') }}; do
              MP="$BASE/${PREFIX}${i}"
              if is_mountpoint "$MP"; then
                umount "$MP" 2>/dev/null || true
                log "umounted $MP"
              fi
            done
            exit 0
          fi

          exit 0
      failed_when: false

    - name: udev rules 배포 (USB automount)
      when: enable_usb_automount | bool
      copy:
        dest: /etc/udev/rules.d/99-realnas-usb.rules
        owner: root
        group: root
        mode: "0644"
        content: |
          ACTION=="add", SUBSYSTEM=="block", ENV{DEVTYPE}=="partition", RUN+="/usr/local/bin/realnas-usb-mount.sh /dev/%k add"
          ACTION=="remove", SUBSYSTEM=="block", RUN+="/usr/local/bin/realnas-usb-mount.sh /dev/%k remove"
      failed_when: false

    - name: udev reload/trigger
      when: enable_usb_automount | bool
      shell: |
        udevadm control --reload || true
        udevadm trigger || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: 권한 통합 적용 (storage + usb + tmp)
      shell: |
        set -e
        chown -R {{ app_user }}:{{ nas_group }} "{{ storage_mount }}" || true
        chmod -R 2775 "{{ storage_mount }}" || true

        chown {{ app_user }}:{{ nas_group }} /mnt/tmp || true
        chmod 2775 /mnt/tmp || true

        for i in {{ usb_slots | join(' ') }}; do
          p="{{ usb_base }}/{{ usb_mount_prefix }}${i}"
          chown -R {{ app_user }}:{{ nas_group }} "$p" || true
          chmod -R 2775 "$p" || true
        done
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # ✅ 사용자별 RW 분리 (ACL)
    # =========================================================
    - name: RO/RW 사용자 생성 (리눅스)
      when: enable_rw_split | bool
      block:
        - user:
            name: "{{ ro_user }}"
            shell: /usr/sbin/nologin
            system: no
            create_home: no
            state: present
          failed_when: false

        - user:
            name: "{{ rw_user }}"
            shell: /usr/sbin/nologin
            system: no
            create_home: no
            state: present
          failed_when: false

        - user:
            name: "{{ ro_user }}"
            groups: "{{ nas_group }}"
            append: yes
          failed_when: false

        - user:
            name: "{{ rw_user }}"
            groups: "{{ nas_group }}"
            append: yes
          failed_when: false

        - shell: |
            set -e
            command -v smbpasswd >/dev/null 2>&1 || exit 0
            command -v pdbedit  >/dev/null 2>&1 || exit 0

            if ! pdbedit -L | grep -q '^{{ ro_user }}:'; then
              (echo "{{ htpasswd_admin_pass }}"; echo "{{ htpasswd_admin_pass }}") | smbpasswd -a -s "{{ ro_user }}" || true
            fi
            if ! pdbedit -L | grep -q '^{{ rw_user }}:'; then
              (echo "{{ htpasswd_admin_pass }}"; echo "{{ htpasswd_admin_pass }}") | smbpasswd -a -s "{{ rw_user }}" || true
            fi
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

        - shell: |
            set -e
            setfacl -R -m u:{{ ro_user }}:rx "{{ storage_mount }}" || true
            setfacl -R -m d:u:{{ ro_user }}:rx "{{ storage_mount }}" || true

            setfacl -R -m u:{{ rw_user }}:rwx "{{ storage_mount }}" || true
            setfacl -R -m d:u:{{ rw_user }}:rwx "{{ storage_mount }}" || true

            # tmp도 동일하게(임시지만 UI에서 쓰니까)
            setfacl -R -m u:{{ ro_user }}:rx /mnt/tmp || true
            setfacl -R -m d:u:{{ ro_user }}:rx /mnt/tmp || true
            setfacl -R -m u:{{ rw_user }}:rwx /mnt/tmp || true
            setfacl -R -m d:u:{{ rw_user }}:rwx /mnt/tmp || true
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false
      rescue:
        - shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (WARN) RW split/ACL setup failed" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # ✅ NEW: “사용 중 하드 추가” 자동 감지/자동 마운트 (timer)
    # - 핵심: 내부 SATA/추가 디스크는 udev만으론 100%가 아니라서 timer로 안정화
    # =========================================================
    - name: scan/mount 로그 파일 준비
      when: enable_hotplug_scan_mount | bool
      file:
        path: "{{ scan_mount_log }}"
        state: touch
        owner: root
        group: root
        mode: "0644"
      failed_when: false

    - name: realnas-scan-mount.sh 배포
      when: enable_hotplug_scan_mount | bool
      copy:
        dest: "{{ scan_mount_script }}"
        owner: root
        group: root
        mode: "0755"
        content: !unsafe |

          #!/usr/bin/env bash
          set -euo pipefail

          LOG="{{ scan_mount_log }}"
          USER="{{ app_user }}"
          GROUP="{{ nas_group }}"
          STORAGE="{{ storage_mount }}"
          USB_BASE="{{ usb_base }}"
          USB_PREFIX="{{ usb_mount_prefix }}"
          USB_SLOTS="{{ usb_slots | join(' ') }}"
          RW_SPLIT="{{ '1' if (enable_rw_split | bool) else '0' }}"
          RO_USER="{{ ro_user }}"
          RW_USER="{{ rw_user }}"

          log(){ echo "[$(date -Iseconds)] $*" >> "$LOG"; }

          # root 디스크(=OS 설치 디스크)에는 손대지 않기
          root_src="$(findmnt -n -o SOURCE / 2>/dev/null || true)"
          root_disk=""
          if [[ "$root_src" =~ ^/dev/ ]]; then
            if [[ "$root_src" =~ ^/dev/nvme ]]; then
              root_disk="$(echo "$root_src" | sed -E 's/p[0-9]+$//')"
            else
              root_disk="$(echo "$root_src" | sed -E 's/[0-9]+$//')"
            fi
          fi

          is_mounted(){ mountpoint -q "$1" >/dev/null 2>&1; }
          dev_mounted(){ findmnt -n -S "$1" >/dev/null 2>&1; }

          first_free_usb(){
            for i in $USB_SLOTS; do
              mp="$USB_BASE/${USB_PREFIX}${i}"
              mkdir -p "$mp"
              if ! is_mounted "$mp"; then
                echo "$mp"
                return 0
              fi
            done
            echo ""
            return 0
          }

          apply_perms(){
            local mp="$1"
            chown -R "$USER:$GROUP" "$mp" 2>/dev/null || true
            chmod -R 2775 "$mp" 2>/dev/null || true

            if [[ "$RW_SPLIT" == "1" ]]; then
              # 기본은 그룹권한 + ACL 보강(있으면)
              setfacl -R -m "u:${RO_USER}:rx" "$mp" 2>/dev/null || true
              setfacl -R -m "d:u:${RO_USER}:rx" "$mp" 2>/dev/null || true
              setfacl -R -m "u:${RW_USER}:rwx" "$mp" 2>/dev/null || true
              setfacl -R -m "d:u:${RW_USER}:rwx" "$mp" 2>/dev/null || true
            fi
          }

          mount_with_opts(){
            local dev="$1"
            local mp="$2"
            local fstype="$3"

            mkdir -p "$mp"

            # 이미 다른곳에 붙어있으면 스킵
            if dev_mounted "$dev"; then
              log "SKIP: already mounted dev=$dev"
              return 0
            fi
            if is_mounted "$mp"; then
              log "SKIP: mountpoint already used mp=$mp"
              return 0
            fi

            # OS 디스크 제외
            if [[ -n "$root_disk" ]]; then
              pk="$(lsblk -no PKNAME "$dev" 2>/dev/null || true)"
              if [[ -n "$pk" && "/dev/$pk" == "$root_disk" ]]; then
                log "SKIP: on root disk dev=$dev root_disk=$root_disk"
                return 0
              fi
            fi

            # fstype별 옵션(권한 문제 방지)
            if [[ "$fstype" == "ntfs" || "$fstype" == "ntfs3" ]]; then
              mount -t ntfs3 -o "rw,uid=$(id -u "$USER"),gid=$(getent group "$GROUP" | cut -d: -f3),umask=0002,windows_names" "$dev" "$mp" 2>/dev/null \
                || mount -t ntfs -o "rw,uid=$(id -u "$USER"),gid=$(getent group "$GROUP" | cut -d: -f3),umask=0002" "$dev" "$mp" 2>/dev/null \
                || { log "FAIL: mount ntfs dev=$dev mp=$mp"; return 0; }
            elif [[ "$fstype" == "exfat" ]]; then
              mount -t exfat -o "rw,uid=$(id -u "$USER"),gid=$(getent group "$GROUP" | cut -d: -f3),fmask=0002,dmask=0002" "$dev" "$mp" 2>/dev/null \
                || { log "FAIL: mount exfat dev=$dev mp=$mp"; return 0; }
            else
              mount -o rw,nofail "$dev" "$mp" 2>/dev/null || { log "FAIL: mount dev=$dev mp=$mp fstype=$fstype"; return 0; }
            fi

            apply_perms "$mp"
            log "MOUNT: dev=$dev fstype=$fstype -> $mp"
          }

          # 1) storage가 비어있고(마운트 아님), 새 파티션이 “딱 1개”면 storage에 우선 붙임
          storage_is_mounted="0"
          if is_mounted "$STORAGE"; then storage_is_mounted="1"; fi

          mapfile -t CANDS < <(
            lsblk -rpn -o NAME,TYPE,FSTYPE,MOUNTPOINT 2>/dev/null \
              | awk '$2=="part" {print $1 "|" $3 "|" $4}' \
              | grep -vE '\|$' || true
          )

          # “미마운트”만 추림
          mapfile -t NEWPARTS < <(
            for row in "${CANDS[@]}"; do
              dev="${row%%|*}"
              rest="${row#*|}"
              fstype="${rest%%|*}"
              mp="${rest#*|}"

              [[ -n "$fstype" ]] || continue
              [[ -z "$mp" ]] || continue

              # swap/crypto/LVM 느낌은 스킵
              if [[ "$fstype" =~ ^(swap|crypto_LUKS|LVM2_member)$ ]]; then
                continue
              fi

              echo "$dev|$fstype"
            done
          )

          # storage 자동 채우기 조건
          if [[ "$storage_is_mounted" == "0" && "${#NEWPARTS[@]}" -eq 1 ]]; then
            dev="${NEWPARTS[0]%%|*}"
            fstype="${NEWPARTS[0]#*|}"
            mount_with_opts "$dev" "$STORAGE" "$fstype"
            exit 0
          fi

          # 2) 나머지는 usb 슬롯에 순서대로
          for row in "${NEWPARTS[@]}"; do
            dev="${row%%|*}"
            fstype="${row#*|}"

            # storage가 아직 비어있고, 이번 dev가 “첫 후보”면 storage 우선(여러개 있을 때도 한 번 시도)
            if [[ "$storage_is_mounted" == "0" && ! is_mounted "$STORAGE" ]]; then
              mount_with_opts "$dev" "$STORAGE" "$fstype"
              if is_mounted "$STORAGE"; then
                storage_is_mounted="1"
                continue
              fi
            fi

            mp="$(first_free_usb)"
            if [[ -z "$mp" ]]; then
              log "NO_FREE_USB_SLOT: dev=$dev"
              continue
            fi
            mount_with_opts "$dev" "$mp" "$fstype"
          done

          exit 0
      failed_when: false

    - name: systemd service/timer for scan/mount
      when: enable_hotplug_scan_mount | bool
      block:
        - name: scan/mount service
          copy:
            dest: "/etc/systemd/system/{{ scan_mount_service }}.service"
            owner: root
            group: root
            mode: "0644"
            content: |
              [Unit]
              Description=RealNAS scan & auto-mount new disks
              After=multi-user.target

              [Service]
              Type=oneshot
              ExecStart={{ scan_mount_script }}
              StandardOutput=append:{{ scan_mount_log }}
              StandardError=append:{{ scan_mount_log }}

        - name: scan/mount timer
          copy:
            dest: "/etc/systemd/system/{{ scan_mount_service }}.timer"
            owner: root
            group: root
            mode: "0644"
            content: |
              [Unit]
              Description=Run RealNAS scan/mount every {{ scan_mount_interval_seconds }} seconds

              [Timer]
              OnBootSec=20s
              OnUnitActiveSec={{ scan_mount_interval_seconds }}s
              AccuracySec=5s
              Persistent=true

              [Install]
              WantedBy=timers.target

        - name: enable/start scan timer + 1회 즉시 실행
          shell: |
            set -e
            systemctl daemon-reload
            systemctl enable --now {{ scan_mount_service }}.timer
            systemctl restart {{ scan_mount_service }}.timer
            systemctl start {{ scan_mount_service }}.service || true
          args:
            executable: /bin/bash
          changed_when: false
      rescue:
        - shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (WARN) scan/mount timer install failed" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # UI Deploy
    # =========================================================
    - name: RealNAS UI ZIP 업로드
      copy:
        src: "{{ ui_zip_src }}"
        dest: "{{ ui_zip_dest }}"
        owner: root
        group: root
        mode: "0644"
      failed_when: false

    - name: 기존 app_dir 제거 (ui_force_deploy=true)
      when: ui_force_deploy | bool
      file:
        path: "{{ app_dir }}"
        state: absent
      failed_when: false

    - name: UI ZIP 압축 해제
      when: ui_force_deploy | bool
      unarchive:
        src: "{{ ui_zip_dest }}"
        dest: "{{ base_dir }}/"
        remote_src: yes
        owner: "{{ app_user }}"
        group: "{{ app_user }}"
      failed_when: false

    - name: app_dir 권한 정리
      shell: |
        chown -R {{ app_user }}:{{ app_user }} "{{ app_dir }}" || true
        find "{{ app_dir }}" -type d -exec chmod 0775 {} \; || true
        find "{{ app_dir }}" -type f -exec chmod 0664 {} \; || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: app.py 존재 확인
      stat:
        path: "{{ app_script_path }}"
      register: app_py_stat
      failed_when: false

    - name: (FATAL) app.py missing -> stop
      fail:
        msg: "FATAL: app.py not found at {{ app_script_path }}. Check ZIP structure."
      when: not (app_py_stat.stat.exists | default(false))

    # =========================================================
    # Python venv + deps
    # =========================================================
    - name: Python 가상환경 생성 (없으면)
      command: "python3 -m venv {{ myenv_dir }}"
      args:
        creates: "{{ myenv_dir }}/bin/activate"
      failed_when: false

    - name: pip/패키지 설치 (net_ok일 때만)
      when: net_ok
      block:
        - name: ensurepip
          command: "{{ myenv_dir }}/bin/python -m ensurepip --upgrade"
          failed_when: false
        - name: pip upgrade
          command: "{{ myenv_dir }}/bin/pip install --upgrade pip setuptools wheel"
          failed_when: false
        - name: pip deps
          command: "{{ myenv_dir }}/bin/pip install -U Flask flask-login passlib requests psutil"
          failed_when: false
      rescue:
        - name: log pip install deps failed
          shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (FAILED) pip install deps" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # sudoers for tooling
    # =========================================================
    - name: sudoers - nginx reload + Samba restart + smartctl + lsblk/blkid/findmnt
      copy:
        dest: /etc/sudoers.d/realnas-nginx-samba
        owner: root
        group: root
        mode: "0440"
        content: |
          {{ app_user }} ALL=(ALL) NOPASSWD: /usr/sbin/nginx -s reload, /usr/bin/systemctl restart smbd, /usr/bin/systemctl restart nmbd, /usr/sbin/smartctl, /usr/bin/lsblk, /usr/sbin/blkid, /usr/bin/findmnt
      failed_when: false

    - name: sudoers - Samba extra (smbpasswd/testparm/pdbedit)
      copy:
        dest: /etc/sudoers.d/realnas-samba-extra
        owner: root
        group: root
        mode: "0440"
        content: |
          {{ app_user }} ALL=(ALL) NOPASSWD: /usr/bin/smbpasswd, /usr/sbin/testparm, /usr/bin/pdbedit, /usr/bin/systemctl restart smbd, /usr/bin/systemctl restart nmbd
      failed_when: false

    # =========================================================
    # Samba (RO/RW 분리 포함)
    # =========================================================
    - name: Samba - nas_smb.conf 생성
      copy:
        dest: "{{ samba_conf_path }}"
        owner: root
        group: root
        mode: "0644"
        content: |
          # /etc/samba/nas_smb.conf

          [{{ samba_rw_share_name }}]
              path = {{ storage_mount }}
              browseable = yes
              writable = yes
              read only = no

              force group = {{ nas_group }}
              create mask = 0664
              directory mask = 2775

              guest ok = no
              valid users = admin {{ app_user }}{% if enable_rw_split %} {{ rw_user }}{% endif %}

              hide files = /lost+found/

          {% if enable_rw_split %}
          [{{ samba_ro_share_name }}]
              path = {{ storage_mount }}
              browseable = yes
              writable = no
              read only = yes

              force group = {{ nas_group }}
              create mask = 0664
              directory mask = 2775

              guest ok = no
              valid users = {{ ro_user }}

              hide files = /lost+found/
          {% endif %}
      failed_when: false

    - name: Samba - smb.conf 생성(include)
      copy:
        dest: /etc/samba/smb.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          [global]
            workgroup = WORKGROUP
            server string = RealNAS Server
            log file = /var/log/samba/log.%m
            max log size = 1000
            map to guest = never
            logging = file
            guest account = nobody
            panic action = /usr/share/samba/panic-action %d
            server role = standalone server
            obey pam restrictions = yes
            unix password sync = no
            security = user

            netbios name = realnas

            server min protocol = SMB2
            server max protocol = SMB3
            client min protocol = SMB2
            client max protocol = SMB3

            follow symlinks = yes
            wide links = yes
            unix extensions = no

          include = {{ samba_conf_path }}
      failed_when: false

    - name: smbd 재시작(가능하면)
      shell: |
        systemctl restart smbd 2>/dev/null || true
        systemctl restart nmbd 2>/dev/null || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: (Samba) smbpasswd 사용자 등록(있으면) - admin/app_user
      shell: |
        set -e
        command -v smbpasswd >/dev/null 2>&1 || exit 0
        command -v pdbedit  >/dev/null 2>&1 || exit 0

        if ! pdbedit -L | grep -q '^admin:'; then
          (echo "{{ htpasswd_admin_pass }}"; echo "{{ htpasswd_admin_pass }}") | smbpasswd -a -s admin || true
        fi

        if ! pdbedit -L | grep -q '^{{ app_user }}:'; then
          (echo "{{ htpasswd_admin_pass }}"; echo "{{ htpasswd_admin_pass }}") | smbpasswd -a -s "{{ app_user }}" || true
        fi
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # elFinder deploy (dir or zip)
    # =========================================================
    - name: elFinder web root 디렉토리 준비
      file:
        path: "{{ elfinder_web_root }}"
        state: directory
        owner: www-data
        group: www-data
        mode: "0755"
      failed_when: false

    - name: elFinder 폴더 배포 (컨트롤 머신 elfinder/ → 서버)
      when: (elfinder_dir_stat.stat.exists | default(false))
      copy:
        src: "elfinder/"
        dest: "{{ elfinder_web_root }}/"
        owner: www-data
        group: www-data
        mode: "0755"
      failed_when: false

    - name: elFinder ZIP 업로드(elfinder/ 없고 zip만 있을 때)
      when: not (elfinder_dir_stat.stat.exists | default(false)) and (elfinder_zip_stat.stat.exists | default(false))
      copy:
        src: "elfinder.zip"
        dest: "/tmp/elfinder.zip"
        owner: root
        group: root
        mode: "0644"
      failed_when: false

    - name: elFinder ZIP 압축해제(elfinder/ 없고 zip만 있을 때)
      when: not (elfinder_dir_stat.stat.exists | default(false)) and (elfinder_zip_stat.stat.exists | default(false))
      unarchive:
        src: "/tmp/elfinder.zip"
        dest: "{{ elfinder_web_root }}/"
        remote_src: yes
        owner: www-data
        group: www-data
      failed_when: false

    - name: (WARN) elfinder/도 zip도 없음 -> 경고 로그
      when: not (elfinder_dir_stat.stat.exists | default(false)) and not (elfinder_zip_stat.stat.exists | default(false))
      shell: |
        echo "[{{ ansible_date_time.iso8601 }}] (WARN) elfinder/ and elfinder.zip not found on control machine. /files may be empty." >> "{{ error_log }}"
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: elFinder 파일 권한 정리
      shell: |
        chown -R www-data:www-data "{{ elfinder_web_root }}" || true
        find "{{ elfinder_web_root }}" -type d -exec chmod 0755 {} \; || true
        find "{{ elfinder_web_root }}" -type f -exec chmod 0644 {} \; || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: elFinder connector.minimal.php 강제 배포 (기본판)
      copy:
        dest: "{{ elfinder_web_root }}/php/connector.minimal.php"
        owner: www-data
        group: www-data
        mode: "0644"
        content: |
          <?php
          error_reporting(E_ALL & ~E_NOTICE);

          $primary = '/etc/realnas/elfinder.json';
          $fallback = '/opt/realcom-nas/config/elfinder.json';
          $cfgPath = file_exists($primary) ? $primary : $fallback;

          $cfgRaw = @file_get_contents($cfgPath);
          $cfg = json_decode($cfgRaw, true);
          if (!is_array($cfg)) { $cfg = []; }

          $allowedRoots = [
            '/mnt/storage',
            '/mnt/tmp',
            '/mnt/usb1','/mnt/usb2','/mnt/usb3','/mnt/usb4','/mnt/usb5','/mnt/usb6','/mnt/usb7','/mnt/usb8'
          ];

          function is_allowed_root($path, $allowedRoots) {
            $rp = realpath($path);
            if ($rp === false) return false;
            foreach ($allowedRoots as $ar) {
              $rr = realpath($ar);
              if ($rr && strpos($rp, $rr) === 0) return true;
            }
            return false;
          }

          $roots = [];
          if (isset($cfg['roots']) && is_array($cfg['roots'])) {
            foreach ($cfg['roots'] as $r) {
              if (!isset($r['path'])) continue;
              $p = $r['path'];
              if (!is_allowed_root($p, $allowedRoots)) continue;
              if (!is_dir($p) || !is_readable($p)) continue;

              $roots[] = [
                'driver'        => 'LocalFileSystem',
                'path'          => $p,
                'alias'         => isset($r['alias']) ? $r['alias'] : basename($p),
                'accessControl' => 'access',
                'uploadAllow'   => ['all'],
                'uploadDeny'    => ['all'],
                'uploadOrder'   => ['deny','allow'],
                'attributes'    => [
                  [ 'pattern' => '/^\./', 'read' => false, 'write' => false, 'hidden' => true, 'locked' => true ],
                  [ 'pattern' => '/^lost\+found$/', 'read' => false, 'write' => false, 'hidden' => true, 'locked' => true ],
                ],
              ];
            }
          }

          $seen = [];
          foreach ($roots as $rr) { $seen[realpath($rr['path'])] = true; }

          for ($i=1; $i<=8; $i++) {
            $p = "/mnt/usb".$i;
            $rp = realpath($p);
            if ($rp === false) continue;
            if (isset($seen[$rp])) continue;
            if (!is_dir($rp) || !is_readable($rp)) continue;

            $roots[] = [
              'driver'        => 'LocalFileSystem',
              'path'          => $p,
              'alias'         => "USB".$i,
              'accessControl' => 'access',
              'uploadAllow'   => ['all'],
              'uploadDeny'    => ['all'],
              'uploadOrder'   => ['deny','allow'],
            ];
          }

          if (count($roots) === 0) {
            if (is_dir('/mnt/storage') && is_readable('/mnt/storage')) {
              $roots[] = [
                'driver'        => 'LocalFileSystem',
                'path'          => '/mnt/storage',
                'alias'         => '스토리지',
                'accessControl' => 'access'
              ];
            }
          }

          require_once __DIR__ . '/elFinderConnector.class.php';
          require_once __DIR__ . '/elFinder.class.php';
          require_once __DIR__ . '/elFinderVolumeDriver.class.php';
          require_once __DIR__ . '/elFinderVolumeLocalFileSystem.class.php';

          function access($attr, $path, $data, $volume, $isDir, $relpath) {
            $basename = basename($path);
            if ($basename[0] === '.') {
              return !($attr === 'read' || $attr === 'write');
            }
            return null;
          }

          $opts = [ 'roots' => $roots ];
          $connector = new elFinderConnector(new elFinder($opts));
          $connector->run();
          ?>
      failed_when: false

    # =========================================================
    # PHP-FPM detect/install
    # =========================================================
    - name: PHP-FPM 설치 (net_ok일 때만)
      when: net_ok
      shell: |
        set -e
        for p in {{ php_fpm_pkg_candidates | join(' ') }}; do
          if apt-cache show "$p" >/dev/null 2>&1; then
            apt-get update -y
            DEBIAN_FRONTEND=noninteractive apt-get install -y "$p"
            echo "$p"
            exit 0
          fi
        done
        exit 0
      args:
        executable: /bin/bash
      register: php_install
      changed_when: false
      failed_when: false

    - name: php-fpm 서비스 이름 자동 선택
      shell: |
        set -e
        for s in {{ php_fpm_service_candidates | join(' ') }}; do
          if systemctl list-unit-files | grep -q "^${s}\.service"; then
            echo "$s"
            exit 0
          fi
        done
        echo ""
        exit 0
      args:
        executable: /bin/bash
      register: php_fpm_svc_out
      changed_when: false
      failed_when: false

    - name: php_fpm_service set_fact
      set_fact:
        php_fpm_service: "{{ (php_fpm_svc_out.stdout | trim) if (php_fpm_svc_out.stdout | trim | length) > 0 else 'php-fpm' }}"

    - name: php-fpm 소켓 자동 감지
      shell: |
        set -e
        for sock in /run/php/php8.4-fpm.sock /run/php/php8.3-fpm.sock /run/php/php8.2-fpm.sock /run/php/php-fpm.sock; do
          if [ -S "$sock" ]; then
            echo "$sock"
            exit 0
          fi
        done
        s="$(ls -1 /run/php/*.sock 2>/dev/null | head -n 1 || true)"
        echo "${s}"
        exit 0
      args:
        executable: /bin/bash
      register: php_sock_out
      changed_when: false
      failed_when: false

    - name: php_fpm_sock set_fact
      set_fact:
        php_fpm_sock: "{{ (php_sock_out.stdout | trim) if (php_sock_out.stdout | trim | length) > 0 else php_fpm_sock_default }}"

    - name: php-fpm enable/start (있을 때만)
      shell: |
        systemctl enable {{ php_fpm_service }} 2>/dev/null || true
        systemctl restart {{ php_fpm_service }} 2>/dev/null || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # Nginx config
    # =========================================================
    - name: nginx - default site 비활성화
      file:
        path: "{{ nginx_sites_enabled }}/default"
        state: absent
      failed_when: false

    - name: nas_shares.conf 최소 파일 생성
      block:
        - stat:
            path: "{{ nginx_shares_conf }}"
          register: shares_conf_stat
          failed_when: false

        - copy:
            dest: "{{ nginx_shares_conf }}"
            owner: root
            group: root
            mode: "0644"
            content: |
              # /etc/nginx/conf.d/realnas_shares.conf
              # UI가 필요 시 location 블록을 생성/갱신
          when: not (shares_conf_stat.stat.exists | default(false))
          failed_when: false
      rescue:
        - shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (FAILED) create {{ nginx_shares_conf }}" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    - name: realnas.conf 생성 (sites-available)
      copy:
        dest: "{{ nginx_sites_available }}/realnas.conf"
        owner: root
        group: root
        mode: "0644"
        content: |
          server {
              listen 80 default_server;
              server_name {{ nginx_server_names }};
              client_max_body_size 100000M;

              {% if disable_storage_alias %}
              location ^~ /storage/ { return 403; }
              {% else %}
              location ^~ /storage/ {
                  alias /mnt/storage/;
                  autoindex off;
                  {% if storage_alias_use_basic_auth %}
                  auth_basic "{{ storage_alias_basic_auth_realm }}";
                  auth_basic_user_file {{ storage_alias_basic_auth_file }};
                  {% endif %}
                  limit_except GET HEAD { deny all; }
                  charset utf-8;
                  types { }
                  default_type application/octet-stream;
              }
              {% endif %}

              location = /files { return 301 /files/; }

              # ✅ PHP-FPM 처리 (alias + request_filename 조합)
              location ^~ /files/php/ {
                  auth_basic off;
                  alias {{ elfinder_web_root }}/php/;

                  include fastcgi_params;

                  # 🔴 핵심: alias 환경에서는 request_filename
                  fastcgi_param SCRIPT_FILENAME $request_filename;
                  fastcgi_param SCRIPT_NAME     $fastcgi_script_name;

                  fastcgi_pass unix:{{ php_fpm_sock }};
              }

              # ✅ 정적 파일 영역 (여기엔 ^~ 쓰면 안 됨)
              location /files/ {
                  auth_basic off;
                  alias {{ elfinder_web_root }}/;
                  index elfinder.html index.html;
                  try_files $uri $uri/ =404;
              }

              location = /elfinder { return 301 /files/; }
              location /elfinder/  { return 301 /files/; }

              location / {
                  proxy_pass http://127.0.0.1:5001;
                  proxy_set_header Host $host;
                  proxy_set_header X-Real-IP $remote_addr;
                  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                  proxy_set_header X-Forwarded-Proto $scheme;
              }

              include /etc/nginx/conf.d/realnas_shares.conf;
          }
      failed_when: false

    - name: nginx - sites-enabled 링크
      file:
        src: "{{ nginx_sites_available }}/realnas.conf"
        dest: "{{ nginx_sites_enabled }}/realnas.conf"
        state: link
        force: yes
      failed_when: false

    - name: nginx 설정 테스트 + 재시작 (FATAL on nginx -t fail)
      shell: |
        set -e
        nginx -t
        systemctl restart nginx
      args:
        executable: /bin/bash
      changed_when: false

    # =========================================================
    # Timeshift wrapper + sudoers
    # =========================================================
    - name: Timeshift 래퍼 생성 (/usr/local/sbin/realnas-timeshift)
      copy:
        dest: /usr/local/sbin/realnas-timeshift
        owner: root
        group: root
        mode: "0755"
        content: |
          #!/usr/bin/env bash
          set -euo pipefail
          TS="/usr/bin/timeshift"
          case "${1:-}" in
            status) exec "$TS" --check ;;
            list)   exec "$TS" --list ;;
            create) exec "$TS" --create --comments "RealNAS UI" ;;
            delete)
              SNAP="${2:-}"
              [[ -n "$SNAP" ]] || { echo "snapshot name required" >&2; exit 2; }
              exec "$TS" --delete --snapshot "$SNAP"
              ;;
            *) echo "Usage: realnas-timeshift {status|list|create|delete <name>}" >&2; exit 2 ;;
          esac
      failed_when: false

    - name: sudoers - realnas-timeshift 래퍼만 NOPASSWD 허용
      block:
        - copy:
            dest: /etc/sudoers.d/realnas-timeshift
            owner: root
            group: root
            mode: "0440"
            content: |
              {{ app_user }} ALL=(root) NOPASSWD: /usr/local/sbin/realnas-timeshift *
        - shell: |
            set -e
            visudo -cf /etc/sudoers.d/realnas-timeshift
          args:
            executable: /bin/bash
          changed_when: false
      rescue:
        - shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (FAILED) sudoers realnas-timeshift" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # DDNS 자동 갱신 스크립트 + systemd timer
    # =========================================================
    - name: DDNS config 파일 없으면 기본 생성
      when: enable_ddns_autoupdate | bool
      copy:
        dest: "{{ ddns_config_path }}"
        owner: root
        group: root
        mode: "0644"
        content: |
          {
            "domain": "",
            "last_ip": "",
            "last_update": ""
          }
      failed_when: false

    - name: ddns_update.sh 배포
      when: enable_ddns_autoupdate | bool
      copy:
        dest: "{{ ddns_update_script }}"
        owner: root
        group: root
        mode: "0755"
        content: |
          #!/usr/bin/env bash
          set -euo pipefail
          CFG="{{ ddns_config_path }}"
          LOG="{{ ddns_log_file }}"

          now="$(date -Iseconds)"
          pubip="$(curl -4 -s --max-time 4 https://api.ipify.org || true)"
          if [[ -z "${pubip}" ]]; then
            pubip="$(curl -4 -s --max-time 4 https://icanhazip.com | tr -d '\n' || true)"
          fi

          if [[ -z "${pubip}" ]]; then
            echo "[$now] ERROR: cannot fetch public ip" >> "$LOG"
            exit 0
          fi

          CFG="$CFG" PUBIP="$pubip" NOW="$now" python3 - <<'PY'
          import json, os
          cfg = os.environ.get("CFG", "/etc/nas_ddns.json")
          pubip = os.environ.get("PUBIP", "")
          now = os.environ.get("NOW", "")
          data = {}
          try:
            if os.path.exists(cfg):
              with open(cfg, "r", encoding="utf-8") as f:
                data = json.load(f) or {}
          except Exception:
            data = {}
          data["last_ip"] = pubip
          data["last_update"] = now
          data.setdefault("domain", "")
          os.makedirs(os.path.dirname(cfg) or "/", exist_ok=True)
          with open(cfg, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
          print("ok")
          PY

          echo "[$now] OK pubip=$pubip" >> "$LOG"
      failed_when: false

    - name: systemd service/timer for DDNS update
      when: enable_ddns_autoupdate | bool
      block:
        - name: ddns update service
          copy:
            dest: "/etc/systemd/system/{{ ddns_timer_name }}.service"
            owner: root
            group: root
            mode: "0644"
            content: |
              [Unit]
              Description=RealNAS DDNS Update
              After=network-online.target
              Wants=network-online.target

              [Service]
              Type=oneshot
              ExecStart={{ ddns_update_script }}
              StandardOutput=append:{{ ddns_log_file }}
              StandardError=append:{{ ddns_log_file }}

        - name: ddns update timer
          copy:
            dest: "/etc/systemd/system/{{ ddns_timer_name }}.timer"
            owner: root
            group: root
            mode: "0644"
            content: |
              [Unit]
              Description=Run RealNAS DDNS Update every {{ ddns_interval_minutes }} minutes

              [Timer]
              OnBootSec=1min
              OnUnitActiveSec={{ ddns_interval_minutes }}min
              AccuracySec=30s
              Persistent=true

              [Install]
              WantedBy=timers.target

        - name: enable/start timer
          shell: |
            systemctl daemon-reload
            systemctl enable --now {{ ddns_timer_name }}.timer
            systemctl restart {{ ddns_timer_name }}.timer
          args:
            executable: /bin/bash
          changed_when: false
      rescue:
        - name: log ddns timer failed
          shell: |
            echo "[{{ ansible_date_time.iso8601 }}] (WARN) DDNS timer install failed" >> "{{ error_log }}"
          args:
            executable: /bin/bash
          changed_when: false
          failed_when: false

    # =========================================================
    # ✅ CrowdSec (Docker)
    # =========================================================
    - name: CrowdSec runtime dirs 준비
      when: enable_crowdsec | bool
      file:
        path: "{{ item }}"
        state: directory
        owner: root
        group: root
        mode: "0755"
      loop:
        - "{{ crowdsec_runtime_dir }}"
        - "{{ crowdsec_data_dir }}"
        - "{{ crowdsec_config_dir }}"
        - "{{ crowdsec_bouncer_dir }}"
      failed_when: false

    - name: CrowdSec acquisitions 설정 (nginx 로그)
      when: enable_crowdsec | bool
      copy:
        dest: "{{ crowdsec_config_dir }}/acquis.yaml"
        owner: root
        group: root
        mode: "0644"
        content: |
          filenames:
            - /var/log/nginx/access.log
            - /var/log/nginx/error.log
          labels:
            type: nginx
      failed_when: false

    - name: CrowdSec .env 준비
      when: enable_crowdsec | bool
      copy:
        dest: "{{ crowdsec_env_file }}"
        owner: root
        group: root
        mode: "0644"
        content: |
          COLLECTIONS={{ crowdsec_collections }}
      failed_when: false

    - name: CrowdSec docker-compose.yml 배포
      when: enable_crowdsec | bool
      copy:
        dest: "{{ crowdsec_compose_file }}"
        owner: root
        group: root
        mode: "0644"
        content: |
          services:
            crowdsec:
              image: crowdsecurity/crowdsec:latest
              container_name: {{ crowdsec_container_name }}
              restart: unless-stopped
              env_file:
                - ./.env
              environment:
                - TZ=Asia/Seoul
              volumes:
                - {{ crowdsec_data_dir }}:/var/lib/crowdsec/data
                - {{ crowdsec_config_dir }}:/etc/crowdsec
                - /var/log/nginx:/var/log/nginx:ro
              networks:
                - csnet

            {% if enable_crowdsec_nginx_bouncer %}
            nginx-bouncer:
              image: crowdsecurity/nginx-bouncer:latest
              container_name: {{ crowdsec_nginx_bouncer_name }}
              restart: unless-stopped
              depends_on:
                - crowdsec
              environment:
                - TZ=Asia/Seoul
                - CROWDSEC_BOUNCER_API_KEY_FILE=/etc/crowdsec/bouncer_key
                - CROWDSEC_AGENT_HOST=crowdsec:8080
                - CROWDSEC_MODE=stream
              volumes:
                - {{ crowdsec_nginx_bouncer_key_file }}:/etc/crowdsec/bouncer_key:ro
              networks:
                - csnet
            {% endif %}

          networks:
            csnet:
              driver: bridge
      failed_when: false

    - name: CrowdSec 컨테이너 기동 (compose up -d)
      when: enable_crowdsec | bool
      shell: |
        set -e
        cd "{{ crowdsec_compose_dir }}"
        {{ compose_cmd }} up -d
        docker ps --format '{{ "{{.Names}}" }}' | grep -q '^{{ crowdsec_container_name }}$' || exit 1
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: (WAIT) CrowdSec API 준비 대기
      when: enable_crowdsec | bool
      shell: |
        set -e
        for i in $(seq 1 40); do
          if docker exec {{ crowdsec_container_name }} cscli version >/dev/null 2>&1; then
            echo "OK"
            exit 0
          fi
          sleep 1
        done
        echo "NO"
        exit 0
      args:
        executable: /bin/bash
      register: cscli_ready
      changed_when: false
      failed_when: false

    - name: nginx bouncer key 파일 존재 확인
      when: enable_crowdsec | bool and (enable_crowdsec_nginx_bouncer | bool)
      stat:
        path: "{{ crowdsec_nginx_bouncer_key_file }}"
      register: bouncer_key_stat
      failed_when: false

    - name: nginx bouncer key 생성 (없을 때만)
      when: enable_crowdsec | bool and (enable_crowdsec_nginx_bouncer | bool) and not (bouncer_key_stat.stat.exists | default(false))
      shell: |
        set -e
        key="$(docker exec {{ crowdsec_container_name }} cscli bouncers add realnas-nginx -o raw 2>/dev/null || true)"
        if [[ -z "$key" ]]; then
          key="$(docker exec {{ crowdsec_container_name }} cscli bouncers list -o raw 2>/dev/null | awk '/realnas-nginx/ {print $NF; exit}')"
        fi
        if [[ -z "$key" ]]; then
          echo "[{{ ansible_date_time.iso8601 }}] (WARN) cannot get bouncer key" >> "{{ error_log }}"
          exit 0
        fi
        umask 077
        echo "$key" > "{{ crowdsec_nginx_bouncer_key_file }}"
        chown root:root "{{ crowdsec_nginx_bouncer_key_file }}"
        chmod 0400 "{{ crowdsec_nginx_bouncer_key_file }}"
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    - name: nginx bouncer 컨테이너 재기동 (키 반영)
      when: enable_crowdsec | bool and (enable_crowdsec_nginx_bouncer | bool)
      shell: |
        set -e
        cd "{{ crowdsec_compose_dir }}"
        {{ compose_cmd }} up -d
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false

    # =========================================================
    # systemd app service
    # =========================================================
    - name: systemd 서비스 파일 생성 - realnas-app.service
      copy:
        dest: "/etc/systemd/system/{{ app_service_name }}"
        owner: root
        group: root
        mode: "0644"
        content: |
          [Unit]
          Description=RealNAS App
          After=network.target

          [Service]
          Type=simple
          User={{ app_user }}
          Group={{ app_user }}
          WorkingDirectory={{ app_dir }}

          Environment=PYTHONUNBUFFERED=1
          Environment=BASE_DIR={{ base_dir }}
          Environment=CONFIG_DIR={{ config_dir }}
          Environment=RUNTIME_DIR={{ runtime_dir }}
          Environment=LOG_DIR={{ log_dir }}
          Environment=HTPASSWD_FILE={{ nginx_htpasswd_file }}

          ExecStartPre=/usr/bin/test -f {{ app_script_path }}
          ExecStartPre=/usr/bin/test -x {{ myenv_dir }}/bin/python
          ExecStartPre=/bin/bash -lc 'if ss -lntp | grep -q ":5001"; then echo "[realnas-app] 5001 already in use" >&2; ss -lntp | grep ":5001" >&2; exit 1; fi'

          ExecStart={{ myenv_dir }}/bin/python {{ app_script_path }}

          Restart=always
          RestartSec=2
          StandardOutput=journal
          StandardError=journal

          [Install]
          WantedBy=multi-user.target
      failed_when: false

    - name: systemctl 데몬 리로드 + 서비스 enable/start/restart
      shell: |
        set -e
        systemctl daemon-reload
        systemctl enable {{ app_service_name }} 2>/dev/null || true
        systemctl restart {{ app_service_name }}
        systemctl is-active --quiet {{ app_service_name }}
      args:
        executable: /bin/bash
      changed_when: false

    - name: (FATAL) service not active -> stop
      shell: |
        set -e
        systemctl is-active --quiet "{{ app_service_name }}"
      args:
        executable: /bin/bash
      changed_when: false

    # =========================================================
    # CHECK summary
    # =========================================================
    - name: (CHECK) 서비스/포트/마운트/권한 점검
      shell: |
        echo "=== net_ok={{ net_ok }} ==="
        echo "=== compose_cmd={{ compose_cmd }} ==="

        echo "=== storage ==="
        findmnt {{ storage_mount }} || true
        ls -ld {{ storage_mount }} || true

        echo "=== tmp ==="
        findmnt /mnt/tmp || true
        ls -ld /mnt/tmp || true

        echo "=== usb slots ==="
        for i in {{ usb_slots | join(' ') }}; do
          p="{{ usb_base }}/{{ usb_mount_prefix }}${i}"
          if mountpoint -q "$p"; then
            echo "MOUNTED: $p"
          else
            echo "EMPTY: $p"
          fi
        done

        echo "=== users/groups ==="
        id "{{ app_user }}" || true
        getent group "{{ nas_group }}" || true

        echo "=== samba shares ==="
        testparm -s 2>/dev/null | sed -n '1,120p' || true

        echo "=== nginx ==="
        nginx -t || true
        systemctl status nginx --no-pager -l | sed -n '1,20p' || true

        echo "=== crowdsec docker ==="
        docker ps --format '{{ "{{.Names}}\t{{.Status}}" }}' | sed -n '1,50p' || true

        echo "=== hotplug scan timer ==="
        systemctl status {{ scan_mount_service }}.timer --no-pager -l 2>/dev/null | sed -n '1,25p' || true

        echo "=== service ==="
        systemctl status "{{ app_service_name }}" --no-pager -l | sed -n '1,45p' || true

        echo "=== port 5001 ==="
        ss -lntp | grep ":5001" || true

        echo "=== local http ==="
        curl -I http://127.0.0.1:5001/ | head -n 12 || true
        curl -I http://127.0.0.1/files/elfinder.html | head -n 12 || true
        curl -I http://127.0.0.1/files/php/connector.minimal.php | head -n 12 || true
      args:
        executable: /bin/bash
      changed_when: false
      failed_when: false
      register: chk

    - name: (CHECK) summary
      debug:
        msg:
          - "{{ chk.stdout | default('') }}"
          - "에러 로그: {{ error_log }}"
          - "scan/mount 로그: {{ scan_mount_log }}"
          - "NOTE: net_ok=false면 apt/pip/docker pull이 스킵/불안정할 수 있음. DNS/인터넷 복구 후 playbook 다시 실행하면 이어서 진행됨."
