- name: Contest firewall (nftables allowlist and local proxy) hosts: all become: yes vars: # Start the firewall at the end of the run. The current SSH session keeps # working, but new inbound connections are dropped once it is active. start_firewall: true timezone: America/Moncton dns_servers: "{{ lookup('file', 'dns_servers.txt').splitlines() | map('regex_replace', '#.*$', '') | map('trim') | reject('equalto', '') | list }}" ntp_servers: "{{ lookup('file', 'ntp_servers.txt').splitlines() | map('regex_replace', '#.*$', '') | map('trim') | reject('equalto', '') | list }}" tasks: - name: Install nftables, dig, chrony and the nginx stream module apt: name: - nftables - dnsutils - chrony - nginx - libnginx-mod-stream - dnsmasq-base state: present update_cache: yes cache_valid_time: 3600 - name: Create the account of the proxy user: name: contest-proxy system: yes create_home: no shell: /usr/sbin/nologin - name: Create the configuration directory file: path: /etc/contest-firewall state: directory mode: '0755' - name: Copy the domain, DNS and NTP lists copy: src: "{{ item }}" dest: "/etc/contest-firewall/{{ item }}" mode: '0644' loop: - allowed_domains.txt - dns_servers.txt - ntp_servers.txt - name: Copy the configuration of the proxy copy: src: files/contest-proxy.conf dest: /etc/contest-firewall/contest-proxy.conf mode: '0644' - name: Copy the test script copy: src: test_firewall.sh dest: /test_firewall.sh mode: '0755' - name: Copy the firewall scripts copy: src: "files/{{ item }}" dest: "/{{ item }}" mode: '0500' loop: - firewall.sh - refresh_rules.sh - enable_firewall.sh - disable_firewall.sh - name: Copy the systemd units copy: src: "files/{{ item }}" dest: "/etc/systemd/system/{{ item }}" mode: '0644' loop: - firewall.service - refresh_rules.service - refresh_rules.timer - contest-proxy.service - contest-dns.service # The proxy reads the site name, which encrypted client hello (ECH) hides - name: Turn off encrypted client hello in Firefox shell: | python3 - /usr/lib/firefox/distribution/policies.json <<'PY' import json, os, sys path = sys.argv[1] if not os.path.exists(path): sys.exit(0) data = json.load(open(path)) prefs = data.setdefault("policies", {}).setdefault("Preferences", {}) before = json.dumps(data, sort_keys=True) for name in ("network.dns.echconfig.enabled", "network.dns.http3_echconfig.enabled"): prefs[name] = {"Value": False, "Status": "locked"} if json.dumps(data, sort_keys=True) != before: json.dump(data, open(path, "w"), indent=2) print("changed") PY register: ech changed_when: "'changed' in ech.stdout" - name: Configure DNS servers in resolved.conf lineinfile: path: /etc/systemd/resolved.conf regexp: '^#?DNS=' line: "DNS={{ dns_servers | join(' ') }}" state: present - name: Disable the DNS stub listener lineinfile: path: /etc/systemd/resolved.conf regexp: '^#?DNSStubListener=' line: 'DNSStubListener=no' state: present - name: Configure chrony NTP servers blockinfile: path: /etc/chrony/chrony.conf marker: "# {mark} CONTEST FIREWALL NTP SERVERS" block: | {% for ntp in ntp_servers %} pool {{ ntp }} iburst {% endfor %} insertbefore: BOF - name: Remove the default NTP pools from chrony.conf lineinfile: path: /etc/chrony/chrony.conf regexp: '^(pool|server)\s+(?!.*iburst$).*' state: absent # Ubuntu 26.04 adds NTS time sources that the firewall blocks, and chrony # does not use the plain NTP servers while those sources are configured - name: Disable the Ubuntu NTS time sources command: mv /etc/chrony/sources.d/ubuntu-ntp-pools.sources /etc/chrony/sources.d/ubuntu-ntp-pools.sources.disabled args: removes: /etc/chrony/sources.d/ubuntu-ntp-pools.sources # The contest machines may also run Windows, which keeps the hardware # clock in local time. chrony must not write its time into that clock. - name: Do not write the time into the hardware clock lineinfile: path: /etc/chrony/chrony.conf regexp: '^rtcsync' state: absent - name: Step the clock at boot whatever the offset lineinfile: path: /etc/chrony/chrony.conf regexp: '^makestep' line: makestep 1 3 - name: Copy the clock fallback (time from the contest site if NTP fails) copy: src: "files/time/{{ item.src }}" dest: "{{ item.dest }}" mode: "{{ item.mode }}" loop: - { src: time_from_contest.sh, dest: /time_from_contest.sh, mode: '0500' } - { src: time_from_contest.service, dest: /etc/systemd/system/time_from_contest.service, mode: '0644' } - { src: time_from_contest.timer, dest: /etc/systemd/system/time_from_contest.timer, mode: '0644' } - name: Enable NTP synchronization command: timedatectl set-ntp true changed_when: false - name: Set the timezone file: src: "/usr/share/zoneinfo/{{ timezone }}" dest: /etc/localtime state: link force: yes - name: Block open.kattis.com (past submissions) via the hosts file lineinfile: path: /etc/hosts line: "127.0.0.1\topen.kattis.com" state: present - name: Restart systemd-resolved systemd: name: systemd-resolved state: restarted - name: Enable and restart chrony systemd: name: chrony enabled: yes state: restarted - name: Reload systemd systemd: daemon_reload: yes - name: Enable the firewall and refresh services systemd: name: "{{ item }}" enabled: yes loop: - firewall.service - refresh_rules.service - name: Enable the clock fallback systemd: name: time_from_contest.timer enabled: yes - name: Enable and start the refresh timer systemd: name: refresh_rules.timer enabled: yes state: started # Last task, launched in the background: once the firewall is active, # Ansible cannot open a new SSH connection to collect a result. - name: Start the firewall shell: sleep 2 && systemctl start firewall.service && /refresh_rules.sh async: 60 poll: 0 when: start_firewall | bool