--- - name: Install Java runtime (full — required for GUI mode) ansible.builtin.apt: name: - openjdk-21-jre - openjdk-21-jdk state: present update_cache: true - name: Create JMRI system group ansible.builtin.group: name: "{{ jmri_group }}" state: present system: true - name: Create JMRI service user ansible.builtin.user: name: "{{ jmri_user }}" group: "{{ jmri_group }}" home: "{{ jmri_home }}" shell: /bin/bash system: true create_home: true state: present - name: Deploy SSH authorized key for jmri user ansible.posix.authorized_key: user: "{{ jmri_user }}" key: "{{ jmri_ssh_authorized_key }}" state: present when: jmri_ssh_authorized_key | length > 0 - name: Deploy extra SSH authorized keys for jmri user ansible.posix.authorized_key: user: "{{ jmri_user }}" key: "{{ item }}" state: present loop: "{{ jmri_ssh_authorized_keys_extra }}" - name: Add JMRI user to dialout group (serial device access) ansible.builtin.user: name: "{{ jmri_user }}" groups: dialout append: true # --------------------------------------------------------------------------- # Phase 1 — Stable USB device symlinks # Creates /dev/jmri/loconet and /dev/jmri/nce via udev ID_SERIAL matching. # --------------------------------------------------------------------------- - name: Deploy udev rules for JMRI USB devices ansible.builtin.template: src: 99-jmri-devices.rules.j2 dest: /etc/udev/rules.d/99-jmri-devices.rules owner: root group: root mode: '0644' notify: - Reload udev - Trigger udev # --------------------------------------------------------------------------- # Phase 2 — LocoNet traffic monitor # Installs jmri-monitor: watches /dev/jmri/loconet, starts/stops jmri.service # --------------------------------------------------------------------------- - name: Install python3-venv (monitor virtualenv support) ansible.builtin.apt: name: python3-venv state: present - name: Create virtualenv for jmri-monitor ansible.builtin.command: cmd: python3 -m venv /opt/jmri-monitor creates: /opt/jmri-monitor/bin/python3 - name: Install decora_wifi into jmri-monitor virtualenv ansible.builtin.pip: name: decora_wifi state: present virtualenv: /opt/jmri-monitor - name: Deploy jmri-monitor script ansible.builtin.template: src: jmri-monitor.py.j2 dest: /usr/local/bin/jmri-monitor owner: root group: root mode: '0755' notify: Restart jmri-monitor - name: Deploy jmri-monitor systemd unit ansible.builtin.template: src: jmri-monitor.service.j2 dest: /etc/systemd/system/jmri-monitor.service owner: root group: root mode: '0644' notify: - Reload systemd - Restart jmri-monitor - name: Enable jmri-monitor service ansible.builtin.systemd: name: jmri-monitor enabled: true state: started daemon_reload: true # --------------------------------------------------------------------------- # Phase 2b — LCRR config repo (clone/pull .jmri from Gitea) # --------------------------------------------------------------------------- - name: Ensure jmri .ssh directory exists ansible.builtin.file: path: /home/jmri/.ssh state: directory owner: "{{ jmri_user }}" group: "{{ jmri_group }}" mode: '0700' - name: Deploy jmri SSH config for Gitea ansible.builtin.copy: dest: /home/jmri/.ssh/config owner: "{{ jmri_user }}" group: "{{ jmri_group }}" mode: '0600' content: | Host gitea.mk-labs.cloud HostName gitea.mk-labs.cloud User git Port 2221 IdentityFile {{ jmri_gitea_key }} StrictHostKeyChecking accept-new - name: Clone LCRR config repo if not present ansible.builtin.git: repo: "{{ jmri_lcrr_repo }}" dest: "{{ jmri_home }}/LCRR" version: "{{ jmri_lcrr_branch }}" accept_hostkey: true key_file: "{{ jmri_gitea_key }}" update: false become_user: "{{ jmri_user }}" when: jmri_lcrr_repo | length > 0 notify: Restart jmri - name: Remove auto-generated .jmri dir if it exists (will be replaced by symlink) ansible.builtin.file: path: "{{ jmri_home }}/.jmri" state: absent when: - jmri_lcrr_repo | length > 0 - name: Link .jmri config from LCRR repo ansible.builtin.file: src: "{{ jmri_home }}/LCRR/.jmri" dest: "{{ jmri_home }}/.jmri" state: link owner: "{{ jmri_user }}" group: "{{ jmri_group }}" force: true when: jmri_lcrr_repo | length > 0 notify: Restart jmri # --------------------------------------------------------------------------- # JMRI install / upgrade — version-marker pattern # Writes {{ jmri_install_dir }}/.jmri_installed_version after each install. # On subsequent runs: read the marker, skip everything if it matches # jmri_version. If it differs (or is absent), stop JMRI cleanly, wipe the # old install, download the new archive, extract, and write the new marker. # To upgrade: bump jmri_version + jmri_build_hash in defaults/main.yml (or # host_vars) and re-run the playbook. # --------------------------------------------------------------------------- - name: Read installed JMRI version marker (if present) ansible.builtin.slurp: src: "{{ jmri_install_dir }}/.jmri_installed_version" register: jmri_version_marker ignore_errors: true - name: Determine whether JMRI install/upgrade is needed ansible.builtin.set_fact: jmri_needs_install: >- {{ jmri_version_marker is failed or (jmri_version_marker.content | b64decode | trim) != jmri_version }} - name: Stop JMRI service before upgrade (if running) ansible.builtin.systemd: name: jmri state: stopped failed_when: false # service may not exist yet on first install when: jmri_needs_install - name: Remove existing JMRI install directory (upgrade path) ansible.builtin.file: path: "{{ jmri_install_dir }}" state: absent when: jmri_needs_install - name: Remove stale JMRI archive from /tmp (if version changed) ansible.builtin.file: path: "/tmp/JMRI-{{ jmri_version }}.tgz" state: absent when: jmri_needs_install - name: Download JMRI release archive ansible.builtin.get_url: url: "{{ jmri_download_url }}" dest: /tmp/JMRI-{{ jmri_version }}.tgz mode: '0644' when: jmri_needs_install - name: Create JMRI install directory ansible.builtin.file: path: "{{ jmri_install_dir }}" state: directory owner: "{{ jmri_user }}" group: "{{ jmri_group }}" mode: '0755' - name: Extract JMRI archive ansible.builtin.unarchive: src: /tmp/JMRI-{{ jmri_version }}.tgz dest: "{{ jmri_install_dir }}" remote_src: true owner: "{{ jmri_user }}" group: "{{ jmri_group }}" extra_opts: ['--strip-components=1'] when: jmri_needs_install - name: Write installed version marker ansible.builtin.copy: content: "{{ jmri_version }}\n" dest: "{{ jmri_install_dir }}/.jmri_installed_version" owner: "{{ jmri_user }}" group: "{{ jmri_group }}" mode: '0644' when: jmri_needs_install - name: Restore JMRI config from backup ansible.builtin.copy: src: "{{ jmri_config_src }}/" dest: "{{ jmri_home }}/.jmri/" owner: "{{ jmri_user }}" group: "{{ jmri_group }}" mode: '0644' directory_mode: '0755' when: jmri_config_src | length > 0 notify: Restart jmri - name: Deploy JmriFaceless systemd unit ansible.builtin.template: src: jmri.service.j2 dest: /etc/systemd/system/jmri.service owner: root group: root mode: '0644' notify: - Reload systemd - Restart jmri - name: Enable jmri service (monitor manages start/stop — do not start directly) ansible.builtin.systemd: name: jmri enabled: false daemon_reload: true # --------------------------------------------------------------------------- # Phase 3 — X11 remote GUI access (retained for fallback; VNC preferred) # --------------------------------------------------------------------------- - name: Install xauth (required for SSH X11 forwarding fallback) ansible.builtin.apt: name: xauth state: present - name: Remove old jmri X11 sshd drop-in if present (renamed) ansible.builtin.file: path: /etc/ssh/sshd_config.d/20-jmri-x11.conf state: absent notify: Restart sshd - name: Deploy sshd drop-in to enable X11 forwarding (must load before hardening) ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/09-jmri-x11.conf owner: root group: root mode: '0644' content: | # Allow X11 forwarding for JMRI GUI sessions (jmri role) # Must be numbered below 10-mk-labs-hardening.conf — first match wins. X11Forwarding yes X11UseLocalhost yes notify: Restart sshd # --------------------------------------------------------------------------- # Phase 4 — Xpra virtual display # Replaces TigerVNC. Rootless mode: each JMRI window appears as a native # window on the client. Sessions are persistent across disconnects. # Connect: xpra attach ssh://jmri@main-street-station/{{ jmri_xpra_display }} # --------------------------------------------------------------------------- - name: Remove TigerVNC (replaced by Xpra) ansible.builtin.apt: name: tigervnc-standalone-server state: absent notify: Reload systemd - name: Disable and stop jmri-vnc service if present ansible.builtin.systemd: name: jmri-vnc enabled: false state: stopped failed_when: false - name: Remove jmri-vnc systemd unit if present ansible.builtin.file: path: /etc/systemd/system/jmri-vnc.service state: absent notify: Reload systemd - name: Remove jmri VNC password directory if present ansible.builtin.file: path: "{{ jmri_home }}/.vnc" state: absent - name: Remove Ubuntu-packaged xpra (too old — v3.x, incompatible with v6 client) ansible.builtin.apt: name: xpra state: absent when: "'xpra' in ansible_facts.packages and ansible_facts.packages['xpra'][0].source != 'xpra.org'" - name: Install xpra.org apt signing key ansible.builtin.get_url: url: https://xpra.org/gpg.asc dest: /usr/share/keyrings/xpra.asc mode: '0644' - name: Add xpra.org upstream apt repository ansible.builtin.copy: dest: /etc/apt/sources.list.d/xpra.list owner: root group: root mode: '0644' content: | deb [arch=amd64 signed-by=/usr/share/keyrings/xpra.asc] https://xpra.org/ noble main - name: Install Xpra from upstream repo (v6.x) ansible.builtin.apt: name: xpra state: latest update_cache: true - name: Deploy jmri-xpra systemd unit ansible.builtin.template: src: jmri-xpra.service.j2 dest: /etc/systemd/system/jmri-xpra.service owner: root group: root mode: '0644' notify: - Reload systemd - Restart jmri-xpra - name: Enable and start jmri-xpra service ansible.builtin.systemd: name: jmri-xpra enabled: true state: started daemon_reload: true - name: Deploy jmri-gui script ansible.builtin.template: src: jmri-gui.j2 dest: /usr/local/bin/jmri-gui owner: root group: root mode: '0755' - name: Deploy sudoers drop-in for jmri-gui (wed can manage jmri service) ansible.builtin.copy: dest: /etc/sudoers.d/jmri-gui owner: root group: root mode: '0440' validate: 'visudo -cf %s' content: | # jmri user can manage its own service (for jmri-gui interactive sessions) jmri ALL=(root) NOPASSWD: /usr/bin/systemctl start jmri.service jmri ALL=(root) NOPASSWD: /usr/bin/systemctl stop jmri.service jmri ALL=(root) NOPASSWD: /opt/jmri-monitor/bin/python3 # wed retains service control for automation/admin use wed ALL=(root) NOPASSWD: /usr/bin/systemctl start jmri.service wed ALL=(root) NOPASSWD: /usr/bin/systemctl stop jmri.service wed ALL=(root) NOPASSWD: /opt/jmri-monitor/bin/python3