From 243e92a9ddcad9839befbd5920d699291c153c45 Mon Sep 17 00:00:00 2001 From: "Prof. Andy Couturier" Date: Thu, 15 Oct 2026 20:44:05 -0300 Subject: [PATCH] First version --- .gitignore | 7 + README.md | 304 ++++++++++++++++++ accounts.yml | 111 +++++++ allowed_domains.txt | 22 ++ build.sh | 247 ++++++++++++++ desktop.yml | 146 +++++++++ dns_servers.txt | 4 + docs.yml | 97 ++++++ docs/TechNotes.pdf | Bin 0 -> 27890 bytes docs/TechNotes.tex | 222 +++++++++++++ files/contest-dns.service | 11 + files/contest-proxy.conf | 50 +++ files/contest-proxy.service | 14 + files/desktop/configure_ides.py | 296 +++++++++++++++++ files/desktop/jetbrains_first_run.sh | 46 +++ files/disable_firewall.sh | 19 ++ files/enable_firewall.sh | 12 + files/firewall.service | 13 + files/firewall.sh | 90 ++++++ files/help/index.html | 104 ++++++ files/ide/acpc-cc | 44 +++ files/ide/acpc-eclipse | 13 + files/ide/acpc-solution | 39 +++ files/languages/compilec | 3 + files/languages/compilecpp | 3 + files/languages/compilejava | 3 + files/languages/compilepython3 | 3 + files/languages/runc | 5 + files/languages/runcpp | 5 + files/languages/runjava | 6 + files/languages/runpython3 | 3 + .../lockdown/10-acpc-no-network-changes.rules | 9 + files/lockdown/10-acpc-no-power-actions.rules | 14 + .../lockdown/10-acpc-no-removable-media.rules | 8 + files/lockdown/72-acpc-no-phone-access.rules | 7 + files/lockdown/90-acpc-keys.conf | 8 + files/lockdown/90-acpc-sysrq.conf | 2 + files/lockdown/contest-restrictions | 133 ++++++++ files/lockdown/contest-restrictions-warning | 41 +++ .../contest-restrictions-warning.service | 11 + files/lockdown/reset_team_home.sh | 38 +++ files/lockdown/wait-for-time.service | 14 + files/refresh_rules.service | 10 + files/refresh_rules.sh | 38 +++ files/refresh_rules.timer | 9 + files/security/team-vscode-extensions | 19 ++ files/security/team-vscode-extensions.service | 12 + files/time/time_from_contest.service | 7 + files/time/time_from_contest.sh | 21 ++ files/time/time_from_contest.timer | 9 + finish.yml | 39 +++ firefox.yml | 38 +++ firewall.yml | 222 +++++++++++++ install_firewall.sh | 106 ++++++ languages.yml | 126 ++++++++ lockdown.yml | 183 +++++++++++ ntp_servers.txt | 5 + run_vm.sh | 29 ++ site.yml | 9 + test_firewall.sh | 77 +++++ tests/README.md | 46 +++ tests/add_test_tools.sh | 12 + tests/check_vm.sh | 90 ++++++ tests/click.sh | 11 + tests/feature_check.sh | 8 + tests/feature_check_vm.sh | 123 +++++++ tests/gui.sh | 3 + tests/ide_check.sh | 7 + tests/ide_check_vm.py | 129 ++++++++ tests/ide_samples/Sum.java | 16 + tests/ide_samples/sum.c | 15 + tests/ide_samples/sum.cpp | 12 + tests/ide_samples/sum.py | 8 + tests/key.sh | 4 + tests/make_ide_report.py | 67 ++++ tests/make_report.py | 31 ++ tests/monitor.py | 10 + tests/shot.sh | 8 + tests/technotes_check.sh | 82 +++++ tests/test-ssh.conf | 3 + tests/type.py | 36 +++ tests/usb.sh | 19 ++ tests/vssh.sh | 11 + write_sticks.sh | 75 +++++ 84 files changed, 4012 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 accounts.yml create mode 100644 allowed_domains.txt create mode 100755 build.sh create mode 100644 desktop.yml create mode 100644 dns_servers.txt create mode 100644 docs.yml create mode 100644 docs/TechNotes.pdf create mode 100644 docs/TechNotes.tex create mode 100644 files/contest-dns.service create mode 100644 files/contest-proxy.conf create mode 100644 files/contest-proxy.service create mode 100644 files/desktop/configure_ides.py create mode 100755 files/desktop/jetbrains_first_run.sh create mode 100755 files/disable_firewall.sh create mode 100755 files/enable_firewall.sh create mode 100644 files/firewall.service create mode 100755 files/firewall.sh create mode 100644 files/help/index.html create mode 100644 files/ide/acpc-cc create mode 100644 files/ide/acpc-eclipse create mode 100644 files/ide/acpc-solution create mode 100755 files/languages/compilec create mode 100755 files/languages/compilecpp create mode 100755 files/languages/compilejava create mode 100755 files/languages/compilepython3 create mode 100755 files/languages/runc create mode 100755 files/languages/runcpp create mode 100755 files/languages/runjava create mode 100755 files/languages/runpython3 create mode 100644 files/lockdown/10-acpc-no-network-changes.rules create mode 100644 files/lockdown/10-acpc-no-power-actions.rules create mode 100644 files/lockdown/10-acpc-no-removable-media.rules create mode 100644 files/lockdown/72-acpc-no-phone-access.rules create mode 100644 files/lockdown/90-acpc-keys.conf create mode 100644 files/lockdown/90-acpc-sysrq.conf create mode 100644 files/lockdown/contest-restrictions create mode 100644 files/lockdown/contest-restrictions-warning create mode 100644 files/lockdown/contest-restrictions-warning.service create mode 100755 files/lockdown/reset_team_home.sh create mode 100644 files/lockdown/wait-for-time.service create mode 100644 files/refresh_rules.service create mode 100755 files/refresh_rules.sh create mode 100644 files/refresh_rules.timer create mode 100644 files/security/team-vscode-extensions create mode 100644 files/security/team-vscode-extensions.service create mode 100644 files/time/time_from_contest.service create mode 100644 files/time/time_from_contest.sh create mode 100644 files/time/time_from_contest.timer create mode 100644 finish.yml create mode 100644 firefox.yml create mode 100644 firewall.yml create mode 100755 install_firewall.sh create mode 100644 languages.yml create mode 100644 lockdown.yml create mode 100644 ntp_servers.txt create mode 100755 run_vm.sh create mode 100644 site.yml create mode 100755 test_firewall.sh create mode 100644 tests/README.md create mode 100755 tests/add_test_tools.sh create mode 100755 tests/check_vm.sh create mode 100755 tests/click.sh create mode 100755 tests/feature_check.sh create mode 100755 tests/feature_check_vm.sh create mode 100755 tests/gui.sh create mode 100755 tests/ide_check.sh create mode 100755 tests/ide_check_vm.py create mode 100644 tests/ide_samples/Sum.java create mode 100644 tests/ide_samples/sum.c create mode 100644 tests/ide_samples/sum.cpp create mode 100644 tests/ide_samples/sum.py create mode 100755 tests/key.sh create mode 100644 tests/make_ide_report.py create mode 100755 tests/make_report.py create mode 100755 tests/monitor.py create mode 100755 tests/shot.sh create mode 100755 tests/technotes_check.sh create mode 100644 tests/test-ssh.conf create mode 100755 tests/type.py create mode 100755 tests/usb.sh create mode 100755 tests/vssh.sh create mode 100755 write_sticks.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..932d914 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +test_report/ +tests/work/ + +# Local organizer notes must not be committed or included in handoff archives. +/Notes.local.md +/Notes.local.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..38d76f3 --- /dev/null +++ b/README.md @@ -0,0 +1,304 @@ +# ACPC 2026 contest image + +This repository builds bootable USB sticks for ACPC 2026 at `atlantic-canada26.kattis.com`. It starts from the ICPC image `icpc2027-2026-09-29`, based on Ubuntu 26.04.1. Teams use an account without administrator rights, with compilers configured for the contest and local copies of the language documentation. The network and device restrictions run on each contest machine, so campus staff do not have to install a separate network firewall. + +## Building the image + +The build machine needs `qemu-base`, `qemu-img`, `edk2-ovmf`, `ansible`, `sshpass`, `e2fsprogs`, `fuse3` and `curl`. These are the Arch Linux package names. Hardware virtualization through KVM and about 30 GB of free disk space are also required. Run the build from a regular account, with a private administrator password in place of `PASSWORD`: + +``` +./build.sh --admin-pass 'PASSWORD' +``` + +The build downloads the ICPC image from image.icpc.global and compares its SHA-256 checksum with the value recorded in `build.sh`. This detects a download that differs from the expected image. Changes are written to `build/acpc2026.qcow2`, which stores the differences from the original image. Ansible configures that copy inside a virtual machine, then shuts it down. + +| Option | Default | Meaning | +| --- | --- | --- | +| `--admin-pass PASS` | required | Password for the `icpc` administrator account and for editing the boot menu | +| `--team-pass PASS` | `contest` | Password for the `team` account | +| `--base-image FILE` | download | Use an existing, decompressed ICPC image, in raw `.img` format | +| `--fresh` | | Discard the previous build and start from the ICPC image | +| `--export` | | Also produce `build/acpc2026.img` for tools that require a raw disk image | + +Without `--fresh`, the playbooks are applied to the existing build. Use this to incorporate changes such as an updated domain list. To inspect the result, run `./run_vm.sh`. Changes made in that test session are discarded when the virtual machine closes. + +## Writing the sticks + +Run the following command on the build machine, replacing the device names with the intended USB disks. It lists the disks and requires the word `ERASE` before overwriting them. Every file on those disks will be lost. + +``` +sudo ./write_sticks.sh build/acpc2026.qcow2 /dev/sdb /dev/sdc /dev/sdd +``` + +Only USB disks are accepted, and the selected disks are written in parallel. Omit the device names to list the USB disks without writing them. Keep the original ICPC image at its existing path because the qcow2 file depends on it. + +## Accounts and administrator access + +The full build separates the team account from the administrator account. Removing administrator rights from `team` prevents contestants from changing system files, installing system software or turning off the contest restrictions. Keep the administrator password with site staff. + +| Account | Password | Access | +| --- | --- | --- | +| `team` | `contest`, or the value of `--team-pass` | Automatic desktop login, no sudo | +| `icpc` | The value of `--admin-pass` | Administrator access through sudo | +| `root` | Password login locked | Staff obtain root privileges through sudo from `icpc` | + +During the build, Ansible receives passwords through a temporary variables file that only the build user can read. The file is deleted when the build script exits, and the tasks that set passwords suppress their values in Ansible output. These measures limit exposure of administrator credentials in build files and logs. + +The build removes `team` from the sudo group and replaces the administrator password inherited from the ICPC image. It also removes the temporary SSH configuration used during the build. In the finished image, SSH password authentication is disabled, SSH login is denied to the `teams` and `judges` groups, and the firewall rejects new incoming SSH connections. These measures prevent remote access through the temporary build login or a contestant account. + +### VS Code extension setup + +The ICPC image includes a helper named `/usr/local/bin/vscode-extension-install`. It makes the preinstalled VS Code extensions available in a user's home directory and imports the certificate for the image's local extension server. This requires privileged filesystem mounts and ownership changes. The original image allowed every account to run that helper through sudo without a password, independently of membership in the sudo group. + +The helper operates inside the calling user's home directory. A symbolic link is a filesystem reference to another path, and a user can create one in their home. A privileged helper that follows such a reference can modify files outside the intended directory. Removing `team` from the sudo group would therefore leave a separate route to privileged filesystem operations unless that special sudo permission was removed too. + +`accounts.yml` deletes the special sudo rule and disables the helper's desktop autostart entry. The replacement, `team-vscode-extensions.service`, runs before the desktop starts and always prepares the account `team`. Its wrapper removes symbolic links at the certificate and extension paths it checks, refuses a home directory that is itself a symbolic link, and supplies the fixed user and group IDs of `team` to the original helper. Staff retain the preinstalled extensions without giving contestants permission to invoke the helper as root. The service also runs again after a staff member explicitly resets the team account. + +### Boot menu and protected files + +GRUB is the program that starts Linux before the login screen. The build requires the GRUB user `admin` and the administrator password to edit boot entries or use the GRUB command line. This prevents contestants from changing the Linux startup options to bypass the normal login and account restrictions. Normal Ubuntu startup requires no password, and recovery entries are removed. + +The file `/etc/grub.d/01_acpc_password` contains a PBKDF2 hash derived from the administrator password. A hash lets GRUB check a password without storing the password itself. Someone with a copy could still test password guesses against it, so the file is readable and executable only by root. The same administrator password protects the `icpc` account, which is another reason to keep this file private and choose a strong password. + +The installed firewall, maintenance and reset helpers are owned by root, with no read or execute permission for the team account. Staff run them through sudo. These permissions prevent contestants from invoking the privileged maintenance commands or replacing their installed contents. + +## Languages + +The contest languages are C, C++, Java and Python 3. Their compiler and interpreter versions match those displayed on the [ACPC Kattis language pages](https://atlantic-canada26.kattis.com/contests/atlantic-canada26/languages), checked on October 15, 2026. The expected versions and compiler options are recorded in `languages.yml` and `files/languages/`. The build checks the installed versions against those recorded values and stops if they differ. + +| Language | Version | Commands | +| --- | --- | --- | +| C | gcc-15 15.2.0 | `compilec`, `runc` | +| C++ | g++-15 15.2.0 | `compilecpp`, `runcpp` | +| Java | javac and java 25.0.4.1 | `compilejava`, `runjava` | +| Python 3 | PyPy 7.3.23, implementing Python 3.11.15 | `compilepython3`, `runpython3` | + +The ICPC image includes Java 25.0.4.1. Java is held at that version, and automatic package updates are disabled to keep compiler versions consistent across machines. When an existing build has a different Java version, the build installs the matching packages from the ICPC package repository and verifies each download against its recorded checksum. + +The `compilec`, `compilecpp` and `compilejava` helpers add the compiler flags shown on the contest's Kattis pages. Teams can therefore compile with the judge's language standard, optimization, linking and encoding options without typing those flags themselves. For example, `compilecpp main.cpp` includes the GNU C++23, optimization and static linking flags used by Kattis. `compilegcc` and `compileg++` are aliases for the C and C++ helpers. The Python helper checks syntax with the configured PyPy interpreter. + +The run helpers use the published runtime options. Java uses a maximum heap of 2048 MB by default, which can be changed for a local run with a command such as `MEMLIM=1024 runjava Main`. Local runs do not reproduce the judge's time and memory enforcement. + +## IDEs and documentation + +The image includes CLion, Code::Blocks, Eclipse, IntelliJ IDEA, PyCharm, VS Code and Geany. `desktop.yml` configures all 19 supported combinations of these IDEs and the contest languages. Defaults are installed in the team home and in `/etc/skel`, the directory used to create a replacement home after an explicitly requested reset. + +| IDE | Contest languages | Configured build and run actions | +| --- | --- | --- | +| Geany | C, C++, Java, Python | F9 builds and F5 runs. C and C++ use the GCC 15 drivers, Java uses `compilejava` and `runjava`, and Python uses PyPy. | +| Code::Blocks | C, C++ | F9 uses the default ACPC GCC 15 profile, with separate C and C++ drivers. | +| VS Code | C, C++, Java, Python | F6 runs **ACPC: Build and run** for the active file. Ctrl+Shift+B builds it. Java 25 and PyPy are configured, and the menu opens `~/contest` because tasks require an open folder. | +| CLion | C, C++, Python | The Run action uses the GCC 15 toolchain and single-file templates for C and C++. New Python projects inherit PyPy. | +| IntelliJ IDEA | Java | The Run action uses the Java 25 SDK, UTF-8 encoding and the contest Java options. | +| PyCharm | Python | New projects inherit PyPy for the Run action. | +| Eclipse | C, C++, Java, Python | The menu prepares the contest workspace. Use **ACPC Build and run** for the active source file. Java 25, PyDev with PyPy, and GCC 15 drivers are configured. | + +`files/desktop/configure_ides.py` writes the IDE preferences and launch configurations. The C and C++ drivers in `files/ide/` apply the contest language standard, optimization and static linking options before calling the corresponding system compiler. The `acpc-solution` command builds the selected source and runs it only if the build succeeds. Java compilation uses `javac`, and Python syntax checking and execution use PyPy. + +The ordinary Java actions in Eclipse and VS Code use the Eclipse Java compiler, also called ECJ or JDT. Use their named ACPC actions to compile with the contest's `javac` command. For imported projects, alternative workspaces or Python virtual environments, follow the compiler checks in the technical notes. The notes also describe the remaining startup dialogs and where to enter program input. + +JetBrains user agreements are accepted during preparation, data sharing is declined, and `/home/team` is marked as a trusted project location. The offline AI completion models and inference programs supplied with IntelliJ IDEA and PyCharm are removed to prevent their use for generated code during the contest. Access to online AI services, including those used by editor extensions, is restricted by the website allowlist. + +Language documentation is available offline at `https://devdocs/`. The local DevDocs copy includes C, C++, OpenJDK 25 and Python 3.11. Kotlin documentation and the prompt to open the external DevDocs site are removed. The C manual pages, such as `man 3 qsort`, and Python documentation through `pypy3 -m pydoc` also work offline. + +The **Contest Help (offline)** bookmark opens `/usr/share/doc/acpc/help/index.html`. It contains 12 searchable articles on input and output, end of file, local testing, judging and common C, C++, Java and Python questions. These are concise summaries of 17 selected Kattis help articles, with source links and instructions for this image. The text, search and layout are stored locally, so the page needs no external scripts, fonts or help widgets. The online help portal, support tickets and status subscriptions are outside the approved contest access. + +The team instructions are in `docs/TechNotes.pdf`, built from `docs/TechNotes.tex` with LuaLaTeX. The build installs the PDF at `/usr/share/doc/icpc/TechNotes.pdf`, accessible from the **Technical Notes** bookmark. After changing the source, run `lualatex TechNotes.tex` in `docs/` and rebuild the image. + +## Menus and browser + +The original World Finals links and PC² submission commands are replaced with the ACPC pages on Kattis. The contest and documentation entries are available from the Applications menu and Firefox bookmarks. The `submit` and `pc2submit` commands print the Kattis submission address. + +| Location | Content | +| --- | --- | +| Firefox home page | The ACPC contest site | +| Firefox bookmarks | Contest, Problems, Standings, Contest Help (offline), Language Documentation, Technical Notes | +| Applications, Contest | Contest (Kattis), Problems, Standings, Contest Help (offline) | +| Applications, Documentation | Language Documentation, Technical Notes | + +Firefox's home page is locked to the contest site. Extension installation is blocked to prevent contestants from adding browser tools, and access to `about:config` is blocked to prevent changes through Firefox's advanced preferences screen. The preferences needed for website filtering are also locked by policy. + +The image retains `printfile` for sites that provide printing. Staff must configure the printer and the firewall rules for its address and protocol before handing over a machine. Test printing from the team account with the contest restrictions enabled. + +## Clock + +The system clock is synchronized by chrony, the time service. It uses the servers in `ntp_servers.txt`, with the timezone set to `America/Moncton`. At startup, the desktop waits up to about 30 seconds for synchronization. Correct time prevents misleading clock displays and browser certificate errors caused by an incorrect date. + +The Ubuntu defaults use Network Time Security (NTS), which authenticates replies from time servers. NTS requires an additional connection on TCP port 4460, which this firewall does not permit. When those sources are configured but unreachable, chrony does not simply fall back to the ordinary time servers added for the contest. The installer disables Ubuntu's default source file so that chrony can use the servers in `ntp_servers.txt`. The protocol and source selection rules are described in the [Ubuntu time synchronization guide](https://ubuntu.com/server/docs/how-to/networking/chrony-client/) and the [chrony configuration manual](https://chrony-project.org/doc/4.7/chrony.conf.html#authselectmode). + +If chrony still has no selected time server two minutes after boot, `time_from_contest.timer` checks once a minute and adjusts the clock from the date returned by the contest website. This provides a fallback when the campus network blocks ordinary time synchronization traffic. Chrony is configured not to copy its time to the hardware clock, to avoid changing the clock convention used by Windows on shared lab machines. Run `sudo /test_firewall.sh` to check the current synchronization status. + +## Network restrictions + +The firewall permits HTTPS access to the names in `allowed_domains.txt`. DNS lookups, which translate names into addresses, go through a local filter, and time synchronization is restricted to chrony. Other new outgoing connections, new unsolicited incoming connections and forwarded traffic are blocked. The base ICPC image disables IPv6. + +| Control | What it is intended to prevent | +| --- | --- | +| Website allowlist applied to every program | Access to messaging, external code repositories, AI websites and other unapproved sites through a browser or command-line program | +| Local HTTPS proxy that checks the requested hostname | Access to an unapproved website merely because it shares a server address with Kattis or another allowed site | +| Local DNS filter and restricted access to DNS servers | Use of arbitrary DNS queries or an alternative resolver to communicate with an outside server | +| NTP access limited to chrony and the configured server addresses | Use of the time synchronization exception by contestant programs to contact other systems | +| Incoming and forwarding rules | Remote connections into a contest workstation and use of it to relay another device's network traffic | +| Rules applied across network interfaces | Avoiding the restrictions by sending traffic through another network connection | +| `open.kattis.com` excluded from the web allowlist and redirected locally in `/etc/hosts` | Access to the public Kattis practice site and past submissions through that hostname | + +The contest site uses Cloudflare, whose [IP addresses are shared by many websites](https://developers.cloudflare.com/fundamentals/concepts/cloudflare-ip-addresses/). Allowing those addresses alone would permit connections to unrelated sites hosted at the same addresses. The local proxy therefore checks the hostname sent at the start of an encrypted connection, called TLS Server Name Indication (SNI). It accepts only a listed hostname, resolves that name itself and opens the external connection under a dedicated service account. Contestant programs cannot open external HTTPS connections directly. Firefox's Encrypted Client Hello feature is disabled so that the proxy can read the hostname. + +The proxy checks the connection's hostname without decrypting the page contents. Plain HTTP requests receive a local redirect to HTTPS. The DNS filter forwards lookups for the approved web domains and time servers to the resolvers in `dns_servers.txt`. Only the proxy and DNS service account can contact those resolvers directly. + +### Allowed domains and verification + +The approved access covers the contest pages, Kattis password sign-in and status viewing. Teams sign in with their Kattis username or email address and password. External sign-in providers and webmail are blocked. Staff should ensure that issued team accounts have working passwords before the contest, including any password recovery that requires email. + +The eight hostnames in `allowed_domains.txt` serve these pages and their supporting resources. The October 15, 2026 scan covered public contest pages, signed-in Open Kattis pages, login-related pages and service status. The table identifies where each hostname was observed during that scan. + +| Domain | Service | Evidence from the October 15, 2026 scan | +| --- | --- | --- | +| `atlantic-canada26.kattis.com` | ACPC 2026 contest | Contest navigation, standings, rules, team pages and password login form | +| `status.kattis.com` | Kattis service status | Status page, history and charts | +| `1v5nt68l4ppj.statuspage.io` | Kattis status banner hosted by Atlassian | Status banner requests from Kattis pages | +| `dka575ofm4ao0.cloudfront.net` | Scripts, style sheets, fonts and images for the status page | Resources loaded by the status page | +| `js.sentry-cdn.com`, `browser.sentry-cdn.com` | Sentry browser error reporting code | Scripts loaded by public and signed-in Kattis pages | +| `challenges.cloudflare.com` | Cloudflare browser verification | Verification resources on registration and password-recovery pages | +| `licensebuttons.net` | License badges on problem pages | License badge in the Hello World problem's Metadata tab | + +Each web entry is an exact hostname. The Statuspage identifier and CloudFront name stayed the same across three fresh browser sessions in the October 15 scan. File paths, version hashes and query parameters can change within those hostnames without requiring a firewall edit. + +To investigate a broken contest page, use the Network tab in Firefox's developer tools, opened with F12. `sudo journalctl -t contest_proxy` lists hostnames recorded by the proxy, including refused connections. Review any proposed addition before changing `allowed_domains.txt` and rebuilding the image. If the campus requires its own DNS or time servers, put their addresses or names in `dns_servers.txt` and `ntp_servers.txt` before building. + +### Connecting a workstation to Wi-Fi + +Staff can connect Wi-Fi from the `icpc` administrator account while the contest restrictions remain enabled. NetworkManager saves the connection for automatic reconnection, including after a reboot. The `team` account cannot use the network controls to switch connections, edit saved profiles or turn Wi-Fi off. A USB Wi-Fi adapter should be connected before startup so that USBGuard accepts it with the other approved equipment. + +For a network with a shared Wi-Fi password, open a terminal on the team desktop and run `su - icpc`. Enter the administrator password, then replace `SSID` with the approved network's name in these commands: + +``` +sudo nmcli radio wifi on +sudo nmcli device wifi list +sudo nmcli --ask device wifi connect "SSID" name "ACPC Wi-Fi" private no +sudo nmcli connection modify "ACPC Wi-Fi" connection.autoconnect yes connection.permissions "" +``` + +The `--ask` option prompts for the Wi-Fi password so it is not included in the command history. The connection is saved for the machine, and its credentials are stored in files readable only by root. Allowing the machine to reconnect does not grant the team account permission to change networks. These options are described in the [NetworkManager command reference](https://networkmanager.dev/docs/api/latest/nmcli.html). + +Review saved connections with `sudo nmcli -f NAME,TYPE,AUTOCONNECT connection show`. Remove any unapproved Wi-Fi profile with `sudo nmcli connection delete "PROFILE NAME"` so that the machine cannot reconnect to it automatically. If the approved profile already exists, use `sudo nmcli connection up "ACPC Wi-Fi"` to reconnect. There is no need to run `contest-restrictions off network` for this procedure. + +Before handing over the workstation, check the active connection with `nmcli connection show --active`, run `sudo contest-restrictions status` and `sudo /test_firewall.sh`, then type `exit` to leave the administrator shell. The status must show the firewall, USB and network restrictions all on. Confirm that the approved Wi-Fi reconnects after a supervised restart on the actual contest hardware. + +Campus networks that require an institutional username and certificate use an enterprise Wi-Fi profile. Have campus IT provide the authentication settings, trusted CA certificate and expected authentication-server name, and configure that profile before the contest. For a network with a web sign-in page, arrange contest access with campus IT in advance so teams do not need to open that page during the contest. + +## Devices and local restrictions + +The device restrictions are installed by `lockdown.yml`. They prevent contestants from importing files through removable media, attaching devices that enter prepared text, or changing local settings that staff need to control. Staff can temporarily lift the USB and network restrictions with the maintenance command. + +| Control | What it is intended to prevent | +| --- | --- | +| USBGuard rejects USB devices connected after startup | Importing files from a new USB drive or entering prepared code through a device that poses as a keyboard | +| Desktop authorization rules deny disk and disk-image mounting to non-administrators | Opening removable drives or disk images through the desktop's storage service | +| Device rules remove automatic access to phones, cameras and media players | Copying files through phone or camera protocols that do not require a mounted disk | +| Network and modem configuration restricted to administrators | Changing Wi-Fi, hotspot, VPN or modem settings during the contest | +| Restart, shutdown, suspend and hibernation denied to non-administrators | Interrupting the workstation or restarting it with newly attached devices | +| Ctrl+Alt+Del, SysRq shortcuts, power and sleep keys, and lid actions disabled | Triggering those power actions through keyboard shortcuts or desktop power management | +| `team` removed from the printer administration group | Changing printers or writing printer administration files while retaining ordinary printing access | + +USB devices present when USBGuard starts are accepted, including the boot stick, keyboard and mouse. Staff should attach the intended equipment before startup. If a keyboard or mouse needs replacement during the contest, inspect the replacement and use `sudo usbguard list-devices` to find its number, then `sudo usbguard allow-device NUMBER` to authorize it. + +## Maintenance + +Use the `icpc` administrator account for maintenance. `contest-restrictions` controls the firewall, USB restrictions and network configuration permissions. Restrict the change to the part needed for the repair. + +``` +sudo contest-restrictions status +sudo contest-restrictions off firewall +sudo contest-restrictions on firewall +sudo contest-restrictions report +``` + +| Part | Effect of `off` | +| --- | --- | +| `firewall` | Allow network traffic and restore ordinary DNS resolution | +| `usb` | Stop USBGuard and allow the team account to use removable disks and phones | +| `network` | Allow the team account to change network settings | +| `all` | Apply all three changes | + +Use `usb`, `network` or `all` in place of `firewall` as needed. Omitting the part applies the command to all three. A disabled restriction stays disabled after a reboot until staff explicitly enable it again. After USB maintenance, disconnect temporary devices before running `on usb`, then check the devices that remain connected. + +While any of these restrictions is off, a red banner appears across the top of the desktop. It reads “CONTEST RESTRICTIONS ARE OFF ON THIS MACHINE - CALL THE STAFF”. The banner process runs as root, has no close button and is restarted if it stops. Its purpose is to alert staff before a machine is returned to a team with maintenance access still enabled. + +`report` summarizes refused website connections, DNS lookups and USB devices since midnight. To choose another starting time, use a command such as `sudo contest-restrictions report "2026-10-23 12:00"`. Common background lookups from Firefox and the IDEs are counted separately. Use the report to investigate an incident or a page that failed to load, with the corresponding service logs for details. + +The commands `/disable_firewall.sh` and `/enable_firewall.sh` are the firewall helpers called by `contest-restrictions off firewall` and `contest-restrictions on firewall`. They control the same firewall and preserve its state across reboots. Use `contest-restrictions` on the full image for a consistent maintenance procedure. The separate helpers remain available for installations that include only the firewall. + +### Manual deletion of the team's files + +> Running `sudo /reset_team_home.sh` permanently deletes the team's work on that machine. It stops the desktop session, so unsaved work in open applications is lost too. The script makes no backup and offers no undo. + +This command is reserved for a deliberate staff decision to erase a team account. It is never started by boot, login, a timer, cron, the build playbooks or `contest-restrictions`. Do not add it to an automatic cleanup procedure, maintenance sequence or automated test. Routine troubleshooting, changing a restriction and rebooting a machine do not require a reset. + +Before using it, identify the machine and team, obtain authorization from the staff member responsible for their work, and save and verify a copy of every file that must be retained. Check `/tmp`, `/var/tmp` and any other work directories as well as the home directory. The deletion includes `/home/team` and other files owned by `team` on the root filesystem. Treat team-owned temporary files, the team's crontab and any other team-owned work directory on that filesystem as part of the deletion. The script then recreates the home from `/etc/skel` and starts a new team desktop. + +From an administrator terminal, run `sudo /reset_team_home.sh` only after those checks. The script asks for the exact word `RESET` before proceeding. After confirmation, a separate system service continues the deletion even when the terminal closes or the desktop disappears. Starting it through `su - icpc` in a team terminal has the same consequences and will terminate that desktop session. Treat confirmation as the point after which the team's files will be erased. + +## Onsite supervision checklist + +These checks are for the staff supervising contestants and their workstations. + +- [ ] Keep the prepared contest sticks under staff control until the workstations are ready for the teams. +- [ ] Supervise startup and any restart. Check that no unauthorized USB device is connected during boot. Confirm that the machine starts from the supplied contest stick, and have staff handle device replacements. +- [ ] Check that each team uses its assigned workstation, the `team` desktop account and its issued Kattis contest account. +- [ ] Confirm that each workstation uses the approved wired or Wi-Fi connection. Refer connection problems to technical staff instead of allowing teams to use personal hotspots. +- [ ] Keep administrator credentials private. Close administrator terminals and sessions before returning a workstation to a team. +- [ ] If the red restrictions banner appears, call technical staff and keep the workstation out of use until the restrictions are restored. +- [ ] Supervise maintenance and remove temporary devices afterward. Confirm that the warning banner has disappeared before the team resumes work. +- [ ] Report unexpected access to websites, personal accounts or devices to the contest organizers, with the workstation and time concerned. + +## Installing only the firewall + +On a machine already running the ICPC image, staff can install the firewall separately. This installs the network filter, DNS filter, proxy, time configuration and firewall administration commands. Accounts, boot protection, USB restrictions and the desktop warning banner are configured by the full image build. For a separate installation, the site is responsible for those controls. From a local administrator console in a copy of this repository, run: + +``` +sudo ./install_firewall.sh --start +``` + +The installer needs Internet access to obtain `nftables`, `dnsutils`, `chrony`, `nginx`, `libnginx-mod-stream` and `dnsmasq-base`. With `--start`, the firewall becomes active during the installation and rejects new incoming connections, including SSH. Without that option, it is enabled for the next boot and can also be started with `/enable_firewall.sh`. + +Both installation methods, the shell installer and `firewall.yml`, install the following commands directly in the filesystem root. They remain available even when `contest-restrictions` is not installed. Use them from the machine's administrator account: + +``` +sudo /disable_firewall.sh +sudo /enable_firewall.sh +sudo /test_firewall.sh +``` + +The disable command allows network traffic and restores ordinary DNS resolution until the enable command is run, including after reboots. The enable command starts the firewall, proxy, DNS filter and address refresh services, and enables the firewall for subsequent boots. The test command reports allowed and blocked website access, local DevDocs access, DNS filtering, proxy status and clock synchronization. It does not install or toggle the firewall. + +For installation with Ansible over SSH, replace `HOST` with the machine's address and `ADMIN_USER` with an account that has sudo access. Run this from the repository on the administrator's computer. The target must be reachable by SSH before starting: + +``` +ansible-playbook -i "HOST," -u ADMIN_USER --ask-pass -K -e ansible_become_exe=sudo.ws firewall.yml +``` + +The `sudo.ws` setting selects the sudo implementation whose password prompt Ansible recognizes on this image. The firewall starts at the end of the playbook, after which new SSH connections are refused. Add `-e start_firewall=false` to defer activation until the next boot or a local enable command. The same enable, disable and test scripts are installed in either case. + +## Testing + +The procedures in `tests/README.md` use a disposable virtual machine to check the image as a team member and an administrator would use it. They include service checks, desktop interaction and compilation in every supported IDE and language. Graphical reports are generated locally in `test_report/`, with the IDE compilation report at `test_report/ide-config/index.html`. + +## Files + +| File | Purpose | +| --- | --- | +| `build.sh` | Build and configure the contest disk image | +| `run_vm.sh` | Open a disposable session for inspecting the built image | +| `write_sticks.sh` | Overwrite the selected USB disks with the built image | +| `site.yml` | Apply the account, language, browser, desktop, documentation, firewall and device playbooks, then finish the build | +| `finish.yml` | Restore the SSH restrictions, set passwords, remove team sudo membership and shut down the build VM | +| `allowed_domains.txt`, `dns_servers.txt`, `ntp_servers.txt` | Network lists copied to `/etc/contest-firewall/` | +| `install_firewall.sh` | Install the firewall on the running ICPC machine with shell commands, without Ansible | +| `firewall.yml` | Install the same firewall through Ansible | +| `test_firewall.sh` | Check an installed firewall. Available as `/test_firewall.sh` on the contest machine | +| `files/enable_firewall.sh`, `files/disable_firewall.sh` | Installed as `/enable_firewall.sh` and `/disable_firewall.sh` by both firewall installers | +| `files/help/index.html`, `docs.yml` | Offline contest help and its installation alongside the local language documentation | +| `files/` | Scripts, configuration and system services installed by the playbooks | +| `docs/` | Technical notes for contestants | +| `tests/` | Tools and instructions for checking a disposable copy of the image | diff --git a/accounts.yml b/accounts.yml new file mode 100644 index 0000000..11879d5 --- /dev/null +++ b/accounts.yml @@ -0,0 +1,111 @@ +- name: Accounts (team for the teams, icpc for the administrator) + hosts: all + become: yes + vars: + # admin_pass comes from build.sh. team_pass is optional: without it, the + # team account keeps the password of the ICPC image (contest). + team_pass: "" + autologin: true + + tasks: + # The password of icpc changes and team leaves the sudo group in finish.yml, + # at the very end, because Ansible may be using those accounts for sudo + - name: Check that the administrator password is given + assert: + that: admin_pass is defined and admin_pass | length > 0 + fail_msg: "Give the new password of the icpc account with -e admin_pass=..." + + - name: Make sure the root account stays locked (no root password) + command: passwd -l root + register: lock_root + changed_when: "'unchanged' not in lock_root.stdout" + + - name: Log the teams in automatically (team account) + copy: + dest: /etc/lightdm/lightdm.conf.d/90-acpc-autologin.conf + mode: '0644' + content: | + [Seat:*] + autologin-user=team + autologin-user-timeout=0 + when: autologin | bool + + - name: No automatic login + file: + path: /etc/lightdm/lightdm.conf.d/90-acpc-autologin.conf + state: absent + when: not (autologin | bool) + + # The ICPC image lets every account run vscode-extension-install as root + # without a password, at login. That script mounts and changes paths in + # the home directory, which the account controls. Here it runs at boot for + # the team account only, after a check of those paths. + - name: Remove the sudo rule of vscode-extension-install + file: + path: /etc/sudoers.d/vscode-extension-install + state: absent + + - name: Do not run vscode-extension-install at login + lineinfile: + path: /etc/xdg/autostart/vscode-extension-install.desktop + line: Hidden=true + + - name: Install the boot-time VS Code setup of the team account + copy: + src: "files/security/{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + loop: + - { src: team-vscode-extensions, dest: /usr/local/sbin/team-vscode-extensions, mode: '0700' } + - { src: team-vscode-extensions.service, dest: /etc/systemd/system/team-vscode-extensions.service, mode: '0644' } + + - name: Enable it + systemd: + name: team-vscode-extensions.service + enabled: yes + daemon_reload: yes + + # The boot menu is hidden, but Esc shows it, and editing an entry gives a + # root shell (init=/bin/bash). Editing and the other entries need the user + # admin with the administrator password. The normal boot needs nothing. + - name: Hash the boot menu password + shell: printf '%s\n%s\n' "$PASS" "$PASS" | grub-mkpasswd-pbkdf2 | sed -n 's/.* is //p' + environment: + PASS: "{{ admin_pass }}" + register: grub_hash + changed_when: false + no_log: true + + - name: Protect the boot menu with a password + copy: + dest: /etc/grub.d/01_acpc_password + # Root only: the file holds the password hash (also the icpc password) + mode: '0700' + content: | + #!/bin/sh + cat <<'EOF' + set superusers="admin" + password_pbkdf2 admin {{ grub_hash.stdout }} + EOF + no_log: true + + - name: Let the Ubuntu entries boot without the password + replace: + path: /etc/grub.d/10_linux + regexp: '^CLASS="--class gnu-linux --class gnu --class os"$' + replace: 'CLASS="--class gnu-linux --class gnu --class os --unrestricted"' + + - name: No recovery entries in the boot menu + copy: + dest: /etc/default/grub.d/90-acpc.cfg + mode: '0644' + content: | + GRUB_DISABLE_RECOVERY="true" + + - name: Rebuild the boot menu + command: update-grub + changed_when: true + + - name: Check the boot menu + shell: grep -c -- '--unrestricted' /boot/grub/grub.cfg && grep -q 'set superusers="admin"' /boot/grub/grub.cfg + changed_when: false diff --git a/allowed_domains.txt b/allowed_domains.txt new file mode 100644 index 0000000..1a5aff6 --- /dev/null +++ b/allowed_domains.txt @@ -0,0 +1,22 @@ +# Web access (HTTPS) is allowed only to these domains. +# One domain per line. Lines starting with # are comments. +# The local proxy checks the site name of each connection against this list. + +# Contest site (Kattis) +atlantic-canada26.kattis.com + +# Kattis service status and the embedded status banner +status.kattis.com +1v5nt68l4ppj.statuspage.io +# Status-page scripts, styles, fonts and images (one CloudFront name only) +dka575ofm4ao0.cloudfront.net + +# Browser error-reporting libraries loaded by the Kattis pages +js.sentry-cdn.com +browser.sentry-cdn.com + +# Cloudflare challenge, shown if Cloudflare decides to check the browser +challenges.cloudflare.com + +# Licence badges on the problem pages +licensebuttons.net diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..3537fe5 --- /dev/null +++ b/build.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# Builds the ACPC 2026 contest image from the official ICPC image. +# +# 1. Download the ICPC image (streamed: checksum, decompression and a +# sparse raw file in one pass, the .gz is not kept). +# 2. Create build/acpc2026.qcow2, an overlay on top of the ICPC image. +# The ICPC image itself is never modified. +# 3. Allow SSH password login in the overlay, offline (debugfs through a +# qemu-storage-daemon FUSE export, no root needed). +# 4. Boot the overlay in QEMU/KVM and configure it with Ansible (site.yml). +# 5. Power off. The result is written to USB sticks with write_sticks.sh, +# or exported to a raw image with --export. +# +# Usage: ./build.sh --admin-pass PASSWORD [options] (./build.sh -h for the list) +set -euo pipefail + +IMG_URL=${IMG_URL:-https://image.icpc.global/icpc2027/icpc2027-2026-09-29_image-amd64.img.gz} +IMG_SHA256=${IMG_SHA256:-e81f1cb46abd19782c2bcbccbad2dca63a198650b38ba5b4f7b54f3f29fd1045} +BASE_IMG=${BASE_IMG:-} +ADMIN_PASS=${ADMIN_PASS:-} +TEAM_PASS=${TEAM_PASS:-} +STOCK_PASS=contest # password of the team account in the ICPC image +RAM=${RAM:-4096} +CPUS=${CPUS:-4} +PORT_SSH=${PORT_SSH:-2222} +FRESH=0 +EXPORT=0 + +usage() { + cat <<'EOF' +Usage: ./build.sh --admin-pass PASSWORD [options] + + --admin-pass PASS New password of the administrator account (icpc), also + asked to edit the boot menu (user admin). Required. + --team-pass PASS New password of the team account, used by the teams. + Default: unchanged (contest, as in the ICPC image). + --base-image FILE Use an ICPC image already decompressed (raw .img) + instead of downloading it. + --fresh Start again from the ICPC image (drops build/acpc2026.qcow2). + --export After the build, also write build/acpc2026.img (raw, + sparse), for tools that cannot read qcow2. + -h, --help Show this help. + +Environment: IMG_URL, IMG_SHA256, RAM (MB), CPUS, PORT_SSH. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --admin-pass) ADMIN_PASS=$2; shift 2 ;; + --team-pass) TEAM_PASS=$2; shift 2 ;; + --base-image) BASE_IMG=$2; shift 2 ;; + --fresh) FRESH=1; shift ;; + --export) EXPORT=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +cd "$(dirname "$0")" +ROOT=$(pwd) +BUILD=$ROOT/build +mkdir -p "$BUILD" +[ -n "$BASE_IMG" ] || BASE_IMG=$BUILD/$(basename "${IMG_URL%.gz}") +BASE_IMG=$(realpath -m "$BASE_IMG") +OVERLAY=$BUILD/acpc2026.qcow2 +VARS=$BUILD/OVMF_VARS.fd +MON=$BUILD/qemu-monitor.sock +FUSE=$BUILD/fuse.raw + +log() { printf '\033[1;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; } +fail() { printf '\033[1;31m[%s] ERROR\033[0m %s\n' "$(date +%H:%M:%S)" "$*" >&2; exit 1; } + +[ -n "$ADMIN_PASS" ] || { usage >&2; fail "--admin-pass is required"; } +for tool in qemu-system-x86_64 qemu-img qemu-storage-daemon fusermount3 debugfs \ + sfdisk ansible-playbook sshpass curl python3; do + command -v "$tool" >/dev/null || fail "missing tool: $tool" +done +[ -w /dev/kvm ] || fail "/dev/kvm is not available or not writable" + +OVMF_CODE="" +for dir in /usr/share/edk2/x64 /usr/share/OVMF /usr/share/edk2/ovmf; do + for code in OVMF_CODE.4m.fd OVMF_CODE_4M.fd OVMF_CODE.fd; do + if [ -f "$dir/$code" ]; then + OVMF_CODE=$dir/$code + OVMF_VARS=$dir/${code/CODE/VARS} + break 2 + fi + done +done +[ -n "$OVMF_CODE" ] && [ -f "$OVMF_VARS" ] || fail "UEFI firmware (OVMF) not found, install edk2-ovmf" + +vm_running() { pgrep -f "^qemu-system-x86_64 -name acpc2026-build" >/dev/null; } +monitor() { + python3 -I - "$MON" "$1" <<'PY' +import socket, sys, time +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +time.sleep(0.2) +s.sendall((sys.argv[2] + "\n").encode()) +time.sleep(0.5) +PY +} +stop_vm() { + vm_running || return 0 + monitor system_powerdown 2>/dev/null || true + for _ in $(seq 1 100); do vm_running || return 0; sleep 3; done + monitor quit 2>/dev/null || true + sleep 2 +} +cleanup() { + stop_vm + if mountpoint -q "$FUSE" 2>/dev/null; then fusermount3 -u "$FUSE"; fi + pkill -f "^qemu-storage-daemon .*$OVERLAY" 2>/dev/null || true + rm -f "$BUILD/vars.yml" +} +trap cleanup EXIT + +# --- 1. ICPC image ------------------------------------------------------------ +if [ -f "$BASE_IMG" ]; then + log "1. ICPC image already present: $BASE_IMG" +else + log "1. Downloading $IMG_URL" + curl -fL --retry 5 "$IMG_URL" \ + | tee >(sha256sum | awk '{print $1}' > "$BASE_IMG.sha256") \ + | gzip -dc | dd of="$BASE_IMG.part" bs=4M conv=sparse status=none + sleep 1 + got=$(cat "$BASE_IMG.sha256") + [ "$got" = "$IMG_SHA256" ] || fail "SHA-256 mismatch: got $got, expected $IMG_SHA256" + mv "$BASE_IMG.part" "$BASE_IMG" + log " SHA-256 OK" +fi + +# --- 2. Overlay --------------------------------------------------------------- +if [ $FRESH -eq 1 ] || [ ! -f "$OVERLAY" ]; then + log "2. Creating the overlay $OVERLAY" + qemu-img create -q -f qcow2 -b "$BASE_IMG" -F raw "$OVERLAY" + cp "$OVMF_VARS" "$VARS" +else + log "2. Reusing the overlay $OVERLAY (--fresh to start again)" + [ -f "$VARS" ] || cp "$OVMF_VARS" "$VARS" +fi + +# --- 3. Offline changes for the build ----------------------------------------- +# The ICPC image refuses SSH password logins and denies the group "teams", to +# which the team account belongs. For the build, icpc.conf is removed and +# 00-build.conf allows password logins. finish.yml puts icpc.conf back. The +# firewall is turned off for the boot, in case the overlay was already built +# (site.yml turns it back on). +log "3. Allowing SSH password login in the overlay" +touch "$FUSE" +qemu-storage-daemon \ + --blockdev driver=file,node-name=file,filename="$OVERLAY" \ + --blockdev driver=qcow2,node-name=disk,file=file \ + --export type=fuse,id=exp,node-name=disk,mountpoint="$FUSE",writable=on \ + > "$BUILD/qsd.log" 2>&1 & +for _ in $(seq 1 20); do mountpoint -q "$FUSE" && break; sleep 0.5; done +mountpoint -q "$FUSE" || fail "qemu-storage-daemon did not start (see $BUILD/qsd.log)" +# The root file system is the largest partition +start=$(sfdisk -J "$FUSE" | python3 -I -c ' +import json, sys +parts = json.load(sys.stdin)["partitiontable"]["partitions"] +print(max(parts, key=lambda p: p["size"])["start"])') +DEV="$FUSE?offset=$((start * 512))" +printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication yes\nPermitRootLogin no\n' > "$BUILD/00-build.conf" +dfs() { debugfs -w -R "$1" "$DEV" >/dev/null 2>&1 || true; } +dfs "rm /etc/ssh/sshd_config.d/icpc.conf" +dfs "rm /etc/ssh/sshd_config.d/00-build.conf" +dfs "write $BUILD/00-build.conf /etc/ssh/sshd_config.d/00-build.conf" +for field in "mode 0100644" "uid 0" "gid 0"; do + dfs "set_inode_field /etc/ssh/sshd_config.d/00-build.conf $field" +done +dfs "rm /etc/systemd/system/multi-user.target.wants/firewall.service" +debugfs -R "ls /etc/ssh/sshd_config.d" "$DEV" 2>/dev/null | grep -q 00-build.conf \ + || fail "could not write 00-build.conf in the image" +fusermount3 -u "$FUSE" +pkill -f "^qemu-storage-daemon .*$OVERLAY" || true +sleep 2 +rm -f "$FUSE" "$BUILD/00-build.conf" + +# --- 4. Boot and configure ---------------------------------------------------- +log "4. Booting the image (SSH on 127.0.0.1:$PORT_SSH)" +vm_running && fail "a build VM is already running" +qemu-system-x86_64 -name acpc2026-build -machine q35,accel=kvm -cpu host \ + -m "$RAM" -smp "$CPUS" \ + -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ + -drive if=pflash,format=raw,file="$VARS" \ + -drive id=disk0,file="$OVERLAY",format=qcow2,if=none \ + -device virtio-blk-pci,drive=disk0,bootindex=1 \ + -netdev user,id=net0,hostfwd=tcp:127.0.0.1:"$PORT_SSH"-:22 \ + -device virtio-net-pci,netdev=net0 \ + -display none -monitor unix:"$MON",server,nowait \ + -serial file:"$BUILD/serial.log" & + +ssh_ok() { + sshpass -p "$2" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=5 -o PreferredAuthentications=password \ + -p "$PORT_SSH" "$1@127.0.0.1" 'sudo -n true 2>/dev/null; id -nG' 2>/dev/null | grep -qw sudo +} +# First build: the team account of the ICPC image (password contest, sudo). +# Next builds: the icpc account with the new password (team lost sudo). +LOGIN_USER="" +for _ in $(seq 1 120); do + vm_running || fail "QEMU stopped (see $BUILD/serial.log)" + if ssh_ok icpc "$ADMIN_PASS"; then LOGIN_USER=icpc; LOGIN_PASS=$ADMIN_PASS; break; fi + if ssh_ok team "$STOCK_PASS"; then LOGIN_USER=team; LOGIN_PASS=$STOCK_PASS; break; fi + sleep 5 +done +[ -n "$LOGIN_USER" ] || fail "no SSH login with sudo after 10 minutes (icpc with --admin-pass, or team/contest)" +log " SSH login OK ($LOGIN_USER)" + +# Passwords go through a file readable only by the user, not the command line +( umask 077; python3 -I - "$BUILD/vars.yml" "$LOGIN_PASS" "$ADMIN_PASS" "$TEAM_PASS" <<'PY' +import json, sys +path, login, admin, team = sys.argv[1:] +json.dump({"ansible_password": login, "ansible_become_password": login, + "admin_pass": admin, "team_pass": team}, open(path, "w")) +PY +) +log " Running site.yml" +ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook -i "127.0.0.1," -u "$LOGIN_USER" \ + -e ansible_port="$PORT_SSH" -e ansible_become_exe=sudo.ws \ + -e "@$BUILD/vars.yml" -e start_firewall=false site.yml +rm -f "$BUILD/vars.yml" + +# --- 5. Power off ------------------------------------------------------------- +# finish.yml powers the machine off as its last task +log "5. Waiting for the image to power off" +for _ in $(seq 1 200); do vm_running || break; sleep 3; done +stop_vm +vm_running && fail "the VM did not power off" + +if [ $EXPORT -eq 1 ]; then + log " Exporting $BUILD/acpc2026.img" + qemu-img convert -p -O raw "$OVERLAY" "$BUILD/acpc2026.img" +fi + +cat < 0 + block: + - name: Stop the DevDocs service + systemd: + name: container-devdocs.service + state: stopped + + - name: Start a container to change the image + shell: | + podman rm -f devdocs-build 2>/dev/null || true + podman run -d --name devdocs-build {{ image }} + + - name: Remove the documentation sets and set the default list + shell: | + podman exec devdocs-build sh -c ' + for slug in {{ remove_docs | join(" ") }}; do rm -rf "public/docs/$slug"; done + ruby -rjson -e " + f = %q{public/docs/docs.json} + data = JSON.parse(File.read(f)) + data.reject! { |d| %w({{ remove_docs | join(" ") }}).include?(d[%q{slug}]) } + File.write(f, JSON.generate(data))" + sed -i "s|set :default_docs, %w(.*)|set :default_docs, %w({{ contest_docs | join(" ") }})|" lib/app.rb + grep -n "set :default_docs" lib/app.rb + # The compiled assets also hold the list of documentation sets + cd public/assets + ruby -rjson -e " + f = Dir[%q{docs-*.json}].first + data = JSON.parse(File.read(f)) + data.reject! { |d| %w({{ remove_docs | join(" ") }}).include?(d[%q{slug}]) } + File.write(f, JSON.pretty_generate(data)) + File.write(Dir[%q{docs-*.js}].reject { |x| x.end_with?(%q{.gz}) }.first, %q{app.DOCS=} + JSON.generate(data) + %q{;})" + # DevDocs warns when it is not served from devdocs.io (here https://devdocs/) + sed -i "s|production_host:\"devdocs.io\"|production_host:\"devdocs\"|" application-*.js + for f in docs-*.json docs-*.js application-*.js; do + case "$f" in *.gz) ;; *) gzip -kf "$f" ;; esac + done + grep -c kotlin docs-*.js docs-*.json | grep -v ".gz"' + + + - name: Save the change into the image + shell: | + podman stop devdocs-build + podman commit devdocs-build {{ image }} + podman rm -f devdocs-build + podman image prune -f + + - name: Start the DevDocs service again + systemd: + name: container-devdocs.service + state: started + + - name: Check the documentation sets of the image + command: podman run --rm --entrypoint ls {{ image }} public/docs + register: docsets_after + changed_when: false + failed_when: docsets_after.stdout_lines | difference(contest_docs + ['docs.json']) | length > 0 diff --git a/docs/TechNotes.pdf b/docs/TechNotes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e602cc8cc4b98ce68c8ee80ee3efd75b4e406692 GIT binary patch literal 27890 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 :443;" line per allowed domain, written by /firewall.sh + include /run/contest-firewall/proxy_names.conf; + default ""; + } + + log_format sni '$remote_addr "$ssl_preread_server_name" $status $upstream_addr'; + access_log syslog:server=unix:/dev/log,tag=contest_proxy,nohostname sni; + + server { + listen 127.0.0.1:8443; + ssl_preread on; + preread_timeout 10s; + proxy_connect_timeout 10s; + proxy_pass $upstream; + } +} + +http { + access_log off; + server { + listen 127.0.0.1:8080; + return 301 https://$host$request_uri; + } +} diff --git a/files/contest-proxy.service b/files/contest-proxy.service new file mode 100644 index 0000000..0897375 --- /dev/null +++ b/files/contest-proxy.service @@ -0,0 +1,14 @@ +[Unit] +Description=Contest firewall proxy (checks the site name of HTTPS connections) +After=network.target firewall.service +PartOf=firewall.service +# Started and stopped with firewall.service (no [Install] section on purpose) + +[Service] +Type=simple +ExecStartPre=/usr/sbin/nginx -t -q -c /etc/contest-firewall/contest-proxy.conf +ExecStart=/usr/sbin/nginx -c /etc/contest-firewall/contest-proxy.conf -g 'daemon off;' +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=2 + diff --git a/files/desktop/configure_ides.py b/files/desktop/configure_ides.py new file mode 100644 index 0000000..6a6f109 --- /dev/null +++ b/files/desktop/configure_ides.py @@ -0,0 +1,296 @@ +#!/usr/bin/python3 +"""Install contest compiler defaults for the installed IDEs and clean team homes.""" +import configparser +import io +import json +import os +from pathlib import Path +import pwd +import subprocess +import xml.etree.ElementTree as ET + +changed = 0 +JAVA_HOME = '/usr/lib/jvm/java-25-openjdk-amd64' +JAVA_OPTIONS = '-Dfile.encoding=UTF-8 -XX:+UseSerialGC -Xss64m -XX:TieredStopAtLevel=1 -XX:ActiveProcessorCount=2 -Xms2048m -Xmx2048m' + +def write(path, text): + global changed + path = Path(path) + if not path.exists() or path.read_text() != text: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + changed += 1 + +def write_json(path, data): + write(path, json.dumps(data, indent=2) + '\n') + +def read_json(path, default): + return json.loads(path.read_text()) if path.exists() else default + +def geany(language, commands): + path = Path('/usr/share/geany/filedefs/filetypes.' + language) + conf = configparser.ConfigParser(interpolation=None, strict=False) + conf.optionxform = str + conf.read(path) + if not conf.has_section('build-menu'): + conf.add_section('build-menu') + for key, value in commands.items(): + conf.set('build-menu', key, value) + stream = io.StringIO() + conf.write(stream, space_around_delimiters=False) + write(path, stream.getvalue()) + +for lang, cc in [('c', 'acpc-gcc'), ('cpp', 'acpc-g++')]: + geany(lang, {'FT_00_LB': '_Compile', 'FT_00_CM': cc + ' -c "%f" -o "%e.o"', + 'FT_00_WD': '', 'FT_01_LB': '_Build', 'FT_01_CM': cc + ' "%f" -o "%e"', + 'FT_01_WD': '', 'EX_00_LB': '_Execute', 'EX_00_CM': '"./%e"', 'EX_00_WD': ''}) +geany('java', {'FT_00_LB': '_Compile', 'FT_00_CM': 'compilejava "%f"', 'FT_00_WD': '', + 'FT_01_LB': '_Build', 'FT_01_CM': 'compilejava "%f"', 'FT_01_WD': '', + 'EX_00_LB': '_Execute', 'EX_00_CM': 'runjava "%e"', 'EX_00_WD': ''}) +geany('python', {'FT_00_LB': '_Compile', 'FT_00_CM': 'pypy3 -m py_compile "%f"', 'FT_00_WD': '', + 'FT_01_LB': '_Build', 'FT_01_CM': 'pypy3 -m py_compile "%f"', 'FT_01_WD': '', + 'EX_00_LB': '_Execute', 'EX_00_CM': 'pypy3 "%f"', 'EX_00_WD': ''}) + +def child(parent, tag): + node = parent.find(tag) + return node if node is not None else ET.SubElement(parent, tag) + +def string(parent, tag, value): + child(child(parent, tag), 'str').text = value + +def component(parent, name): + for node in parent.findall('component'): + if node.get('name') == name: + return node + return ET.SubElement(parent, 'component', name=name) + +def option(parent, name, value): + node = next((n for n in parent.findall('option') if n.get('name') == name), None) + if node is None: + node = ET.SubElement(parent, 'option', name=name) + node.set('value', value) + +def write_xml(path, root): + ET.indent(root) + write(path, ET.tostring(root, encoding='unicode') + '\n') + +def preferences(path, values): + """Update Java .properties keys, preserving unrelated workspace choices.""" + data = {} + if path.exists(): + for line in path.read_text().splitlines(): + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + data[key] = value + for key, value in {'eclipse.preferences.version': '1', **values}.items(): + data[key] = str(value).replace('\\', '\\\\').replace('\n', r'\n').replace('=', r'\=') + write(path, ''.join(k + '=' + v + '\n' for k, v in sorted(data.items()))) + +# Ask PyDev's own probe for the library paths of the installed PyPy. +probe = next(Path('/opt/eclipse/plugins').glob('org.python.pydev.core_*/pysrc/interpreterInfo.py')) +pyinfo = ET.fromstring(subprocess.check_output(['/usr/local/bin/pypy3', str(probe)], text=True)) +ET.SubElement(pyinfo, 'name').text = 'ACPC PyPy 3.11' +ET.SubElement(pyinfo, 'vmArgs').text = '' +for lib in pyinfo.findall('lib'): + lib.attrib.clear() +pyinfo_text = ET.tostring(pyinfo, encoding='unicode') + '&&&&&' + +for home in (Path('/home/team'), Path('/etc/skel')): + # Code::Blocks uses these settings for both single files and new projects. + cb_path = home / '.config/codeblocks/default.conf' + cb = ET.fromstring(cb_path.read_text()) if cb_path.exists() else ET.Element('CodeBlocksConfig', version='1') + compiler = child(cb, 'compiler') + string(compiler, 'SETTINGS_VERSION', '0.0.3') + string(compiler, 'DEFAULT_COMPILER', 'gcc') + gcc = child(child(compiler, 'sets'), 'gcc') + # Code::Blocks prepends MASTER_PATH/bin even to an absolute compiler name. + for key, value in {'NAME': 'ACPC GCC 15', 'MASTER_PATH': '/usr/local', + 'EXTRA_PATHS': '/usr/bin', + 'C_COMPILER': 'acpc-gcc', 'CPP_COMPILER': 'acpc-g++', + 'LINKER': 'acpc-g++', 'MAKE': 'make', 'LIB_LINKER': 'ar'}.items(): + string(gcc, key, value) + ET.indent(cb) + write(cb_path, '\n' + ET.tostring(cb, encoding='unicode') + '\n') + + code = home / '.config/Code/User' + settings = read_json(code / 'settings.json', {}) + settings.update({ + 'python.defaultInterpreterPath': '/usr/local/bin/pypy3', + 'C_Cpp.default.compilerPath': '/usr/bin/g++-15', + 'C_Cpp.default.cStandard': 'c23', 'C_Cpp.default.cppStandard': 'c++23', + 'java.jdt.ls.java.home': JAVA_HOME, + 'java.configuration.runtimes': [{'name': 'JavaSE-25', 'path': JAVA_HOME, 'default': True}], + 'java.debug.settings.vmArgs': JAVA_OPTIONS, + 'debug.onTaskErrors': 'abort', + }) + write_json(code / 'settings.json', settings) + tasks = read_json(code / 'tasks.json', {'version': '2.0.0', 'tasks': []}) + tasks['tasks'] = [t for t in tasks['tasks'] if not t.get('label', '').startswith('ACPC:')] + for action in ('build', 'run'): + task = {'label': 'ACPC: ' + ('Build' if action == 'build' else 'Build and run'), + 'type': 'process', 'command': '/usr/local/bin/acpc-solution', + 'args': [action, '${file}'], 'options': {'cwd': '${fileDirname}'}, + 'problemMatcher': ['$gcc'], + 'presentation': {'reveal': 'always', 'focus': True, 'panel': 'shared', 'clear': True}} + if action == 'build': task['group'] = {'kind': 'build', 'isDefault': True} + tasks['tasks'].append(task) + write_json(code / 'tasks.json', tasks) + bindings = read_json(code / 'keybindings.json', []) + bindings = [b for b in bindings if not (b.get('key') == 'f6' or str(b.get('args', '')).startswith('ACPC:'))] + bindings.append({'key': 'f6', 'command': 'workbench.action.tasks.runTask', + 'args': 'ACPC: Build and run', 'when': 'editorTextFocus'}) + write_json(code / 'keybindings.json', bindings) + (home / 'contest').mkdir(exist_ok=True) + project_path = home / 'contest/.project' + if not project_path.exists(): + write(project_path, 'contestACPC solutions\n') + preferences(home / 'contest/.settings/org.eclipse.core.resources.prefs', {'encoding/': 'UTF-8'}) + + workspace = home / 'eclipse-workspace' + prefs = workspace / '.metadata/.plugins/org.eclipse.core.runtime/.settings' + preferences(prefs / 'org.python.pydev.prefs', { + 'INTERPRETER_PATH_NEW': pyinfo_text, + # PyDev's first-editor prompt would disable automatic Java/CDT builds + # across this mixed-language workspace. Keep those defaults and skip it. + 'CHECK_PREFERRED_PYDEV_SETTINGS': 'false', + }) + preferences(prefs / 'org.eclipse.jdt.core.prefs', { + 'org.eclipse.jdt.core.compiler.compliance': '25', + 'org.eclipse.jdt.core.compiler.source': '25', + 'org.eclipse.jdt.core.compiler.codegen.targetPlatform': '25', + }) + vm_type = 'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType' + vm_id = 'acpc-java-25' + vms = ET.Element('vmSettings', defaultVM=f'{len(vm_type)},{vm_type}{len(vm_id)},{vm_id}', defaultVMConnector='') + vmtype = ET.SubElement(vms, 'vmType', id=vm_type) + ET.SubElement(vmtype, 'vm', id=vm_id, name='ACPC Java 25', path=JAVA_HOME, vmArgs=JAVA_OPTIONS) + preferences(prefs / 'org.eclipse.jdt.launching.prefs', {'org.eclipse.jdt.launching.PREF_VM_XML': ET.tostring(vms, encoding='unicode')}) + preferences(prefs / 'org.eclipse.ui.prefs', {'showIntro': 'false'}) + preferences(prefs / 'org.eclipse.core.resources.prefs', {'encoding': 'UTF-8'}) + for action in ('build', 'run'): + title = 'ACPC ' + ('Build' if action == 'build' else 'Build and run') + launch = ET.Element('launchConfiguration', type='org.eclipse.ui.externaltools.ProgramLaunchConfigurationType') + for key, value in { + 'org.eclipse.ui.externaltools.ATTR_LOCATION': '/usr/local/bin/acpc-solution', + 'org.eclipse.ui.externaltools.ATTR_TOOL_ARGUMENTS': action + ' "${resource_loc}"', + 'org.eclipse.ui.externaltools.ATTR_WORKING_DIRECTORY': '${container_loc}', + 'org.eclipse.debug.core.ATTR_REFRESH_SCOPE': '${workspace}', + }.items(): + ET.SubElement(launch, 'stringAttribute', key=key, value=value) + for key, value in { + 'org.eclipse.debug.ui.ATTR_LAUNCH_IN_BACKGROUND': 'true', + 'org.eclipse.debug.core.ATTR_CAPTURE_OUTPUT': 'true', + 'org.eclipse.debug.ui.ATTR_CONSOLE_OUTPUT_ON': 'true', + 'org.eclipse.debug.ui.ATTR_CONSOLE_ERROR_ON': 'true', + }.items(): + ET.SubElement(launch, 'booleanAttribute', key=key, value=value) + favorites = ET.SubElement(launch, 'listAttribute', key='org.eclipse.debug.ui.favoriteGroups') + ET.SubElement(favorites, 'listEntry', value='org.eclipse.ui.externaltools.launchGroup') + write_xml(workspace / '.metadata/.plugins/org.eclipse.debug.core/.launches' / (title + '.launch'), launch) + history_path = workspace / '.metadata/.plugins/org.eclipse.debug.ui/launchConfigurationHistory.xml' + history = ET.fromstring(history_path.read_text()) if history_path.exists() else ET.Element('launchHistory') + group_id = 'org.eclipse.ui.externaltools.launchGroup' + group = next((g for g in history if g.get('id') == group_id), None) + if group is None: + group = ET.SubElement(history, 'launchGroup', id=group_id) + child(group, 'mruHistory') + favorites = child(group, 'favorites') + for title in ('ACPC Build and run', 'ACPC Build'): + if not any(title == ET.fromstring(n.get('memento', '')).get('path') for n in favorites): + ET.SubElement(favorites, 'launch', memento=ET.tostring(ET.Element('launchConfiguration', local='true', path=title), encoding='unicode')) + write_xml(history_path, history) + + for ide in ('clion', 'intellij-idea', 'pycharm'): + info = Path('/opt') / ide / 'product-info.json' + if not info.exists(): + continue + options = home / '.config/JetBrains' / json.loads(info.read_text())['dataDirectoryName'] / 'options' + default_file = options / 'project.default.xml' + defaults = ET.fromstring(default_file.read_text()) if default_file.exists() else ET.Element('application') + project = child(component(defaults, 'ProjectManager'), 'defaultProject') + runs = component(project, 'RunManager') + if ide in ('pycharm', 'clion'): + sdk_file = options / 'jdk.table.xml' + sdks = ET.fromstring(sdk_file.read_text()) if sdk_file.exists() else ET.Element('application') + table = component(sdks, 'ProjectJdkTable') + sdk_name = 'PyPy 3.11 (as on Kattis)' + for node in list(table): + if node.find('name') is not None and node.find('name').get('value') == sdk_name: + table.remove(node) + sdk = ET.SubElement(table, 'jdk', version='2') + for key, value in [('name', sdk_name), ('type', 'Python SDK'), + ('version', 'Python 3.11.15'), ('homePath', '/usr/local/bin/pypy3')]: + ET.SubElement(sdk, key, value=value) + roots = ET.SubElement(sdk, 'roots') + cp = ET.SubElement(ET.SubElement(roots, 'classPath'), 'root', type='composite') + for lib in pyinfo.findall('lib'): + ET.SubElement(cp, 'root', url='file://' + lib.text, type='simple') + ET.SubElement(ET.SubElement(roots, 'sourcePath'), 'root', type='composite') + additional = ET.SubElement(sdk, 'additional', SDK_UUID='3f7d6c1e-2a4b-4c8d-9e0f-a1b2c3d4e5f6') + ET.SubElement(additional, 'setting', name='FLAVOR_ID', value='PyPySdkFlavor') + ET.SubElement(additional, 'setting', name='FLAVOR_DATA', value='{}') + write_xml(sdk_file, sdks) + root_manager = component(project, 'ProjectRootManager') + root_manager.attrib.update({'version': '2', 'project-jdk-name': sdk_name, 'project-jdk-type': 'Python SDK'}) + if ide == 'clion': + chain_file = options / 'linux/toolchains.xml' + chains = ET.fromstring(chain_file.read_text()) if chain_file.exists() else ET.Element('application') + cpp = component(chains, 'CPPToolchains') + cpp.set('version', '10') + entries = child(cpp, 'toolchains') + entries.set('detectedVersion', '5') + chain = entries.find('toolchain') + if chain is None: + chain = ET.SubElement(entries, 'toolchain', name='Default', toolSetKind='SYSTEM_UNIX_TOOLSET', debuggerKind='BUNDLED_GDB') + chain.set('customCCompilerPath', '/usr/local/bin/acpc-gcc') + chain.set('customCXXCompilerPath', '/usr/local/bin/acpc-g++') + write_xml(chain_file, chains) + for node in list(runs): + if node.get('default') == 'true' and node.get('type') == 'CppFileRunConfiguration': + runs.remove(node) + cfg = ET.SubElement(runs, 'configuration', default='true', type='CppFileRunConfiguration', factoryName='CppFileRunConfiguration') + option(cfg, 'compilerFile', '/usr/local/bin/acpc-cc') + option(cfg, 'compilerOptions', '') + if ide == 'intellij-idea': + sdk_file = options / 'jdk.table.xml' + sdks = ET.fromstring(sdk_file.read_text()) if sdk_file.exists() else ET.Element('application') + table = component(sdks, 'ProjectJdkTable') + for node in list(table): + if node.find('name') is not None and node.find('name').get('value') == 'ACPC Java 25': + table.remove(node) + sdk = ET.SubElement(table, 'jdk', version='2') + for key, value in [('name', 'ACPC Java 25'), ('type', 'JavaSDK'), + ('version', 'java version "25.0.4.1"'), ('homePath', JAVA_HOME)]: + ET.SubElement(sdk, key, value=value) + roots = ET.SubElement(sdk, 'roots') + cp = ET.SubElement(ET.SubElement(roots, 'classPath'), 'root', type='composite') + for module in sorted(Path(JAVA_HOME, 'jmods').glob('*.jmod')): + ET.SubElement(cp, 'root', url=f'jrt://{JAVA_HOME}!/{module.stem}', type='simple') + ET.SubElement(ET.SubElement(roots, 'sourcePath'), 'root', type='composite') + ET.SubElement(sdk, 'additional') + write_xml(sdk_file, sdks) + root_manager = component(project, 'ProjectRootManager') + root_manager.attrib.update({'version': '2', 'languageLevel': 'JDK_25', + 'project-jdk-name': 'ACPC Java 25', 'project-jdk-type': 'JavaSDK'}) + for kind, factory in [('Application', 'Application'), ('JavaScratch', 'Java Scratch')]: + for node in list(runs): + if node.get('default') == 'true' and node.get('type') == kind: + runs.remove(node) + cfg = ET.SubElement(runs, 'configuration', default='true', type=kind, factoryName=factory) + option(cfg, 'VM_PARAMETERS', JAVA_OPTIONS) + option(cfg, 'ALTERNATIVE_JRE_PATH', JAVA_HOME) + option(cfg, 'ALTERNATIVE_JRE_PATH_ENABLED', 'true') + encoding = component(project, 'Encoding') + for node in list(encoding): + if node.get('url') == 'PROJECT': + encoding.remove(node) + ET.SubElement(encoding, 'file', url='PROJECT', charset='UTF-8') + write_xml(default_file, defaults) + + if home == Path('/home/team'): + account = pwd.getpwnam('team') + for folder in (home / '.config/codeblocks', code, home / 'contest', home / '.config/JetBrains', workspace): + for path in [folder, *folder.rglob('*')]: + os.chown(path, account.pw_uid, account.pw_gid) + +print(f'changed={changed}') diff --git a/files/desktop/jetbrains_first_run.sh b/files/desktop/jetbrains_first_run.sh new file mode 100755 index 0000000..39a2aa3 --- /dev/null +++ b/files/desktop/jetbrains_first_run.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Usage: jetbrains_first_run.sh HOME_DIR +# Settings of the JetBrains IDEs (CLion, IntelliJ IDEA, PyCharm) for the team +# account, so that the teams go straight to work: +# - user agreement accepted, no usage data sharing (first-launch questions) +# - the home directory /home/team trusted (no "Trust project?" question) +# Compiler and interpreter defaults are installed by configure_ides.py. +# The folder name under .java is how Java stores the preference node +# "privacy_policy". +set -eu +dest_home=$1 +prefs="$dest_home/.java/.userPrefs/jetbrains/"$'_!(!!cg"p!(}!}@"j!(k!|w"w!\'8!b!"p!\':!e@==' +consent="$dest_home/.local/share/JetBrains/consentOptions" +mkdir -p "$prefs" "$consent" +cat > "$prefs/prefs.xml" <<'XML' + + + + + +XML +printf 'rsch.send.usage.stat:1.1:0:1791991599000' > "$consent/accepted" + +for ide in pycharm intellij-idea clion; do + [ -f "/opt/$ide/product-info.json" ] || continue + dir=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["dataDirectoryName"])' "/opt/$ide/product-info.json") + options="$dest_home/.config/JetBrains/$dir/options" + mkdir -p "$options" + cat > "$options/trusted-paths.xml" <<'XML' + + + + + +XML + +done + +if [ "$dest_home" != /etc/skel ]; then + owner=$(stat -c %U:%G "$dest_home") + chown -R "$owner" "$dest_home/.java" "$dest_home/.local" "$dest_home/.config/JetBrains" +fi diff --git a/files/disable_firewall.sh b/files/disable_firewall.sh new file mode 100755 index 0000000..e0759ec --- /dev/null +++ b/files/disable_firewall.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Turns the contest firewall off (all traffic allowed) for maintenance. + +systemctl stop firewall.service +systemctl disable firewall.service + +systemctl stop refresh_rules.service +systemctl stop refresh_rules.timer +systemctl disable refresh_rules.service +systemctl disable refresh_rules.timer + +nft flush ruleset +nft delete table inet filter 2>/dev/null + +echo "Firewall disabled. All traffic is now allowed." + +# Reset DNS to its default behaviour (systemd-resolved instead of the filter) +ln -sfn ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf +systemctl restart systemd-resolved diff --git a/files/enable_firewall.sh b/files/enable_firewall.sh new file mode 100755 index 0000000..6292b24 --- /dev/null +++ b/files/enable_firewall.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Turns the contest firewall on and keeps it on across reboots. + +systemctl enable firewall.service +systemctl start firewall.service + +systemctl enable refresh_rules.service +systemctl start refresh_rules.service +systemctl enable refresh_rules.timer +systemctl start refresh_rules.timer + +echo "Firewall enabled." diff --git a/files/firewall.service b/files/firewall.service new file mode 100644 index 0000000..d47af32 --- /dev/null +++ b/files/firewall.service @@ -0,0 +1,13 @@ +[Unit] +Description=Contest firewall +Wants=network-online.target contest-proxy.service contest-dns.service +After=network.target network-online.target +Before=getty.target getty@tty1.service + +[Service] +Type=oneshot +ExecStart=/firewall.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/files/firewall.sh b/files/firewall.sh new file mode 100755 index 0000000..7d80cc5 --- /dev/null +++ b/files/firewall.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Base nftables ruleset: inbound traffic is dropped, outbound traffic is rejected +# except loopback, established connections, the local proxy (web), the local +# DNS filter and the time servers that /refresh_rules.sh adds. +# Web traffic (ports 80 and 443) of every program is redirected to the local +# proxy (contest-proxy.service), which checks the site name. Only the proxy +# may connect to the outside on port 443. + +CONF=/etc/contest-firewall + +# Remove any persisting nftables rules +rm -f /etc/nftables.rules +touch /etc/nftables.rules + +# Configuration of the proxy: the allowed names and the DNS resolver +mkdir -p /run/contest-firewall +for name in $(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$CONF/allowed_domains.txt"); do + echo "$name $name:443;" +done > /run/contest-firewall/proxy_names.conf +# The proxy resolves the names through the DNS filter below +echo "resolver 127.0.0.1 ipv6=off valid=30s;" > /run/contest-firewall/proxy_resolver.conf + +# Local DNS filter (contest-dns.service): the allowed domains and the time +# servers are forwarded to the DNS resolvers, every other name gets no answer. +# Programs on the machine ask it through /etc/resolv.conf, and only the proxy +# account (which also runs the filter) may reach the DNS resolvers. This +# closes DNS as a way to exchange data with a server outside. +{ + echo "listen-address=127.0.0.1" + echo "bind-interfaces" + echo "no-resolv" + echo "no-hosts" + echo "user=contest-proxy" + echo "group=nogroup" + echo "cache-size=1000" + echo "log-queries" + echo "server=/#/" + for name in $(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$CONF/allowed_domains.txt" "$CONF/ntp_servers.txt"); do + for dns in $(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$CONF/dns_servers.txt"); do + echo "server=/$name/$dns" + done + done +} > /run/contest-firewall/dnsmasq.conf +# /run/resolvconf is one of the places the AppArmor profiles (dig, ...) accept, +# and the resolvconf package that would also use it is not installed +mkdir -p /run/resolvconf +echo "nameserver 127.0.0.1" > /run/resolvconf/resolv.conf +ln -sfn /run/resolvconf/resolv.conf /etc/resolv.conf + +# Flush all rules, then create the table, chains and address sets +nft flush ruleset +nft add table inet filter +nft add chain inet filter input '{ type filter hook input priority 0 ; policy drop ; }' +nft add chain inet filter forward '{ type filter hook forward priority 0 ; policy drop ; }' +nft add chain inet filter output '{ type filter hook output priority 0 ; policy accept ; }' +nft add set inet filter ntp_ips '{ type ipv4_addr ; flags interval ; }' + +# Redirect web traffic to the proxy, except the proxy's own connections and +# the local sites of the image (DevDocs) +nft add table inet nat +nft add chain inet nat output '{ type nat hook output priority -100 ; }' +nft add rule inet nat output meta skuid contest-proxy return +nft add rule inet nat output ip daddr 127.0.0.0/8 return +nft add rule inet nat output meta nfproto ipv4 tcp dport 443 redirect to :8443 +nft add rule inet nat output meta nfproto ipv4 tcp dport 80 redirect to :8080 + +# Established/related connections and loopback +nft add rule inet filter input ct state related,established accept +nft add rule inet filter output ct state related,established accept +nft add rule inet filter input iif lo accept +nft add rule inet filter output oif lo accept +# Connections redirected to the proxy (they still show the network interface here) +nft add rule inet filter output ip daddr 127.0.0.1 tcp dport '{ 8080, 8443 }' ct status dnat accept + +# Web: only the proxy connects to the outside, by name, to the allowed sites +# it resolves itself through the DNS filter (no list of addresses to keep up +# to date, content delivery networks change them all the time) +nft add rule inet filter output meta skuid contest-proxy tcp dport 443 accept + +# Time servers, for chrony only (addresses filled by /refresh_rules.sh) +nft add rule inet filter output meta skuid _chrony ip daddr @ntp_ips udp dport 123 accept + +# DNS resolvers, for the proxy and the DNS filter only +for dns in $(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$CONF/dns_servers.txt"); do + nft add rule inet filter output meta skuid contest-proxy ip daddr "$dns" udp dport 53 accept + nft add rule inet filter output meta skuid contest-proxy ip daddr "$dns" tcp dport 53 accept +done + +# Reject any other outgoing connection +nft add rule inet filter output reject diff --git a/files/help/index.html b/files/help/index.html new file mode 100644 index 0000000..b5b0f81 --- /dev/null +++ b/files/help/index.html @@ -0,0 +1,104 @@ + + + + + + +Contest Help (offline) · ACPC 2026 + + + +
ACPC 2026 · Local reference

Contest Help (offline)

Input, judging and language questions for C, C++, Java and Python 3. All articles on this page work without Internet access.

+
+ +
+ +

Input and output

+

Read the problem's input from standard input and write the answer to standard output. Follow the format in the statement. Do not print prompts, progress messages or debugging text as part of the answer.

+

For local tests, save the input in a file and redirect it into your program. Redirection supplies the file contents through standard input, just as typing would. You do not need to open a named input file in the program.

+

Source (online): Kattis input and output

+

Reading until EOF

+

Some problems provide a test count or a terminating value. Others end when no input remains. Follow the rule in the statement.

+

End of file, or EOF, is a condition reported by a read operation. It is not a word to enter in the input. A redirected input file ends automatically. In a terminal, finish your input line with Enter, then press Ctrl+D on an empty line to signal EOF.

+

Source (online): Kattis end of file

+

Valid input

+

The judge's input follows the problem's input specification. Your solution can rely on the stated ranges and structure. Include the boundary values that the statement allows when constructing local tests.

+

Source (online): Kattis input specification

+

Testing locally

+

Use the sample input and expected output from the problem page, then test additional cases. Passing the samples does not prove the solution is correct for every permitted input.

+

For a C++ file named main.cpp and a sample saved as sample.in, the contest machine provides these commands:

+
compilecpp main.cpp
+runcpp < sample.in > actual.out
+diff -u sample.ans actual.out
+

Save the expected output in sample.ans. Read the Technical Notes for the equivalent commands in the other languages and for IDE build actions. Local runs do not enforce the judge's time and memory limits. A text comparison can help locate differences, but the problem's output rules determine whether an answer is valid.

+

Source (online): Kattis local testing. Commands are specific to the ACPC image.

+

How judging works

+

Kattis first compiles the submission or checks it as required by the chosen language. It then runs the program on test inputs and checks the answers against the problem's rules. Each test run starts a new process, so program variables do not carry over between test files.

+

The judge enforces resource limits and may stop checking after a failure. A rejection therefore does not describe every possible problem with the submission. Some problems allow several valid answers or a specified numerical tolerance. Read their output requirements carefully.

+

Source (online): Kattis judging process

+

Judgements

+ + + + + + + + + +
ResultMeaning
AcceptedThe submission passed the required checks.
Compile ErrorThe build or language check failed. Read the compiler output.
Run Time ErrorThe program crashed, raised an uncaught exception or ended with a nonzero exit code.
Time Limit ExceededA run exceeded the time allowed for a test.
Wrong AnswerAn answer did not satisfy the output requirements.
Output Limit ExceededThe program produced too much output, possibly because of a loop or debugging prints.
Memory Limit ExceededThe run needed more memory than allowed. Memory exhaustion can also appear as a run time error.
Judge ErrorThe judging system encountered a problem. Ask contest staff.
+

Source (online): Kattis judgement list

+

Investigating a rejection

+

Read the judgement and any compiler or run feedback available on the submission page. Check the selected language and submitted files, then reproduce the issue locally where possible.

+

Recheck input parsing, output formatting and the limits in the statement. Test small cases that you can solve by hand, boundary values and cases with different input structures. Remove debugging output before submitting again. Hidden test inputs are generally unavailable, so the samples alone are not a complete test suite.

+

Sources (online): Rejected submissionsHidden test data

+

C and C++

+

Use an int main(...) entry point and finish successful execution with a zero exit status. A nonzero exit status is a run time error even if the printed answer looks correct.

+

For C++ programs with substantial input or output, stream synchronization and repeated flushing can be expensive. Before any stream I/O, std::ios::sync_with_stdio(false); and std::cin.tie(nullptr); can reduce that overhead. After disabling synchronization, keep input and output within the C++ stream API. Prefer '\n' to std::endl when a flush is unnecessary.

+

Sources (online): C exit statusC++ exit statusC++ I/O performance

+

Java main class

+

The main class is the class containing public static void main(String[] args). Enter its class name, without .java or .class, when Kattis asks for it. If the class belongs to a package, the main class name includes that package.

+

For the local single-file workflow in the Technical Notes, use a file such as Main.java, a class named Main and no package declaration. Compile with compilejava Main.java and run with runjava Main.

+

Source (online): Kattis Java main class. Local commands are specific to the ACPC image.

+

Java input and formatting

+

Scanner is convenient but may be too slow for large inputs. Buffered reading and writing can reduce I/O overhead. Read the input specification before choosing how to split lines or tokens.

+

Locale affects formatted numbers. Kattis documents the en_US locale for Java, which uses a decimal point. For predictable local formatting, specify java.util.Locale.US where the formatting API accepts a locale, and follow the problem's requested precision.

+

Sources (online): Java I/O performanceJava locale

+

Java exceptions

+

An uncaught exception normally causes a run time error. Memory exhaustion can be reported as a memory limit error. The feedback available from Kattis may be shorter than the stack trace you see locally.

+

When reproducing a failure, inspect the local exception and the line where it arose. Check bounds, parsing, null references and memory use against the problem's valid inputs.

+

Source (online): Kattis Java exceptions

+

Python 3 and modules

+

The ACPC Python 3 runtime is PyPy 7.3.23, implementing Python 3.11.15. Use the configured PyPy interpreter or the compilepython3 and runpython3 commands for local tests. CPython and PyPy can differ in performance and some implementation details.

+

Use the Python standard library available in the contest runtime. Do not assume that a third-party module installed elsewhere will be available on the judge. An import of an unavailable module fails at run time. The local Python 3.11 documentation is available in Language Documentation.

+

Sources (online): Python implementationPython modules. Version numbers are from the ACPC configuration.

+
+
Prepared for ACPC 2026 from the linked Kattis support articles, checked on 15 October 2026. These are concise summaries with local instructions. The contest rules and problem statements take precedence. Source links require Internet access outside the contest restrictions.
+ + diff --git a/files/ide/acpc-cc b/files/ide/acpc-cc new file mode 100644 index 0000000..0b1d559 --- /dev/null +++ b/files/ide/acpc-cc @@ -0,0 +1,44 @@ +#!/usr/bin/python3 +"""GCC 15 driver used by IDE toolchains, with the contest language and link options.""" +import json +import os +from pathlib import Path +import re +import shlex +import sys + +def expand_response_files(arguments, depth=0): + # CLion also puts its language/standard probes in GCC response files. + # Inspect their contents before choosing C vs C++ or replacing flags. + if depth > 16: + raise SystemExit('ACPC: response files are nested too deeply') + expanded = [] + for arg in arguments: + if arg.startswith('@') and Path(arg[1:]).is_file(): + expanded.extend(expand_response_files(shlex.split(Path(arg[1:]).read_text()), depth + 1)) + else: + expanded.append(arg) + return expanded + +args = expand_response_files(sys.argv[1:]) +name = Path(sys.argv[0]).name +sources = [a for a in args if Path(a).suffix in ('.c', '.C', '.cc', '.cpp', '.cxx', '.c++')] +explicit_cxx = any(a.startswith('-xc++') or (a == '-x' and i + 1 < len(args) and args[i + 1].startswith('c++')) + for i, a in enumerate(args)) +cxx = name.endswith(('g++', 'c++')) or explicit_cxx or (name not in ('acpc-gcc', 'gcc', 'cc') and any(not a.endswith('.c') for a in sources)) +compiler = '/usr/bin/g++-15' if cxx else '/usr/bin/gcc-15' +query = any(a in ('--version', '-dumpversion', '-dumpfullversion', '-dumpmachine', '-print-search-dirs') + or a.startswith(('-print-file-name=', '-print-prog-name=')) for a in args) +if not query: + args = [a for a in args if not a.startswith('-std=') and not re.fullmatch(r'-O(?:[0-3sgz]|fast)?', a)] + args = ['-g', '-O2', '-std=gnu++23' if cxx else '-std=gnu23'] + args + if not any(a in args for a in ('-c', '-S', '-E', '-M', '-MM', '-fsyntax-only', '-shared')): + args += ['-static'] + args += ['-lrt', '-Wl,--whole-archive', '-lpthread', '-Wl,--no-whole-archive'] if cxx else ['-lm'] +cmd = [compiler] + args +if not query and sources: + print('ACPC: ' + shlex.join(cmd), file=sys.stderr, flush=True) +if log := os.environ.get('ACPC_COMPILER_LOG'): + with open(log, 'a') as f: + f.write(json.dumps({'cwd': os.getcwd(), 'argv': cmd}) + '\n') +os.execv(compiler, cmd) diff --git a/files/ide/acpc-eclipse b/files/ide/acpc-eclipse new file mode 100644 index 0000000..ae177c5 --- /dev/null +++ b/files/ide/acpc-eclipse @@ -0,0 +1,13 @@ +#!/bin/bash +# Initialize the supplied contest workspace once, also after a team-home reset. +set -eu +export PATH="/usr/local/lib/acpc-toolchain:$PATH" +workspace="$HOME/eclipse-workspace" +mkdir -p "$workspace" +if [ ! -f "$workspace/.acpc-imported" ]; then + /opt/eclipse/eclipse -nosplash -data "$workspace" \ + -application org.eclipse.cdt.managedbuilder.core.headlessbuild \ + -import "$HOME/contest" -no-indexer > "$workspace/import.log" 2>&1 + touch "$workspace/.acpc-imported" +fi +exec /opt/eclipse/eclipse -data "$workspace" "$@" diff --git a/files/ide/acpc-solution b/files/ide/acpc-solution new file mode 100644 index 0000000..67813ba --- /dev/null +++ b/files/ide/acpc-solution @@ -0,0 +1,39 @@ +#!/usr/bin/python3 +"""Build or build-and-run the active contest source from an IDE task.""" +import os +from pathlib import Path +import resource +import shlex +import subprocess +import sys + +if len(sys.argv) != 3 or sys.argv[1] not in ('build', 'run'): + raise SystemExit('Usage: acpc-solution build|run SOURCE') +action, arg = sys.argv[1:] +src = Path(arg).resolve(strict=True) +os.chdir(src.parent) +ext = src.suffix +if ext == '.c': + build = ['/usr/local/bin/acpc-gcc', src.name, '-o', src.stem] + run = [str(src.with_suffix(''))] +elif ext in ('.cpp', '.cc', '.C', '.cxx', '.c++'): + build = ['/usr/local/bin/acpc-g++', src.name, '-o', src.stem] + run = [str(src.with_suffix(''))] +elif ext == '.java': + build = ['/usr/lib/jvm/java-25-openjdk-amd64/bin/javac', '-encoding', 'UTF-8', + '-sourcepath', '.', '-cp', './*', '-d', '.', src.name] + run = ['/usr/local/bin/runjava', src.stem] +elif ext in ('.py', '.py3'): + build = ['/usr/local/bin/pypy3', '-m', 'py_compile', src.name] + run = ['/usr/local/bin/pypy3', src.name] +else: + raise SystemExit('Use a C, C++, Java or Python source file.') +print('Build: ' + shlex.join(build), flush=True) +result = subprocess.run(build) +if result.returncode: + raise SystemExit(result.returncode) +if action == 'run': + print('Run: ' + shlex.join(run), flush=True) + soft, hard = resource.getrlimit(resource.RLIMIT_STACK) + resource.setrlimit(resource.RLIMIT_STACK, (hard, hard)) + os.execv(run[0], run) diff --git a/files/languages/compilec b/files/languages/compilec new file mode 100755 index 0000000..1a2529d --- /dev/null +++ b/files/languages/compilec @@ -0,0 +1,3 @@ +#!/bin/bash +# Compiles a C program as Kattis does for ACPC 2026 (gcc-15 15.2.0). Result: a.out +exec gcc-15 -g -O2 -o a.out -std=gnu23 -static "$@" -lm diff --git a/files/languages/compilecpp b/files/languages/compilecpp new file mode 100755 index 0000000..aa5347e --- /dev/null +++ b/files/languages/compilecpp @@ -0,0 +1,3 @@ +#!/bin/bash +# Compiles a C++ program as Kattis does for ACPC 2026 (g++-15 15.2.0). Result: a.out +exec g++-15 -g -O2 -o a.out -std=gnu++23 -static -lrt -Wl,--whole-archive -lpthread -Wl,--no-whole-archive "$@" diff --git a/files/languages/compilejava b/files/languages/compilejava new file mode 100755 index 0000000..7129477 --- /dev/null +++ b/files/languages/compilejava @@ -0,0 +1,3 @@ +#!/bin/bash +# Compiles a Java program as Kattis does for ACPC 2026 (javac 25.0.4.1) +exec javac -encoding UTF-8 -sourcepath . -cp './*' -d . "$@" diff --git a/files/languages/compilepython3 b/files/languages/compilepython3 new file mode 100755 index 0000000..fb4f5df --- /dev/null +++ b/files/languages/compilepython3 @@ -0,0 +1,3 @@ +#!/bin/bash +# Checks the syntax of a Python 3 program with PyPy (Kattis runs it with pypy3 7.3.23) +exec pypy3 -m py_compile "$@" diff --git a/files/languages/runc b/files/languages/runc new file mode 100755 index 0000000..90ea2ab --- /dev/null +++ b/files/languages/runc @@ -0,0 +1,5 @@ +#!/bin/bash +# Runs the program compiled by compilec (a.out) +# Large stack (2 GB), for deep recursion +ulimit -s 2097152 +exec ./a.out "$@" diff --git a/files/languages/runcpp b/files/languages/runcpp new file mode 100755 index 0000000..9d2efdf --- /dev/null +++ b/files/languages/runcpp @@ -0,0 +1,5 @@ +#!/bin/bash +# Runs the program compiled by compilecpp (a.out) +# Large stack (2 GB), for deep recursion +ulimit -s 2097152 +exec ./a.out "$@" diff --git a/files/languages/runjava b/files/languages/runjava new file mode 100755 index 0000000..ea22ec6 --- /dev/null +++ b/files/languages/runjava @@ -0,0 +1,6 @@ +#!/bin/bash +# Runs a Java program as Kattis does for ACPC 2026 (java 25.0.4.1). +# Usage: runjava MainClass. Kattis sets the memory to the limit of the problem, +# 2048 MB here by default (MEMLIM=1024 runjava MainClass for another limit). +MEMLIM=${MEMLIM:-2048} +exec java -Dfile.encoding=UTF-8 -XX:+UseSerialGC -Xss64m -XX:TieredStopAtLevel=1 -XX:ActiveProcessorCount=2 -Xms${MEMLIM}m -Xmx${MEMLIM}m -cp '.:./*' "$@" diff --git a/files/languages/runpython3 b/files/languages/runpython3 new file mode 100755 index 0000000..e19b15a --- /dev/null +++ b/files/languages/runpython3 @@ -0,0 +1,3 @@ +#!/bin/bash +# Runs a Python 3 program as Kattis does for ACPC 2026 (pypy3 7.3.23, Python 3.11.15) +exec pypy3 "$@" diff --git a/files/lockdown/10-acpc-no-network-changes.rules b/files/lockdown/10-acpc-no-network-changes.rules new file mode 100644 index 0000000..7d6fc45 --- /dev/null +++ b/files/lockdown/10-acpc-no-network-changes.rules @@ -0,0 +1,9 @@ +// ACPC 2026: the teams may not change the network (Wi-Fi, phone hotspot, +// modem, VPN, ...). Only the administrators (sudo group) keep these actions. +polkit.addRule(function(action, subject) { + if ((action.id.indexOf("org.freedesktop.NetworkManager.") == 0 || + action.id.indexOf("org.freedesktop.ModemManager1.") == 0) && + !subject.isInGroup("sudo")) { + return polkit.Result.NO; + } +}); diff --git a/files/lockdown/10-acpc-no-power-actions.rules b/files/lockdown/10-acpc-no-power-actions.rules new file mode 100644 index 0000000..cff8612 --- /dev/null +++ b/files/lockdown/10-acpc-no-power-actions.rules @@ -0,0 +1,14 @@ +// ACPC 2026: the teams may not restart, power off, suspend or hibernate the +// machine. A restart would let USBGuard accept a device plugged in before. +// Only the administrators (sudo group) keep these actions. +polkit.addRule(function(action, subject) { + if ((action.id.indexOf("org.freedesktop.login1.reboot") == 0 || + action.id.indexOf("org.freedesktop.login1.power-off") == 0 || + action.id.indexOf("org.freedesktop.login1.halt") == 0 || + action.id.indexOf("org.freedesktop.login1.suspend") == 0 || + action.id.indexOf("org.freedesktop.login1.hibernate") == 0 || + action.id.indexOf("org.freedesktop.login1.set-reboot") == 0) && + !subject.isInGroup("sudo")) { + return polkit.Result.NO; + } +}); diff --git a/files/lockdown/10-acpc-no-removable-media.rules b/files/lockdown/10-acpc-no-removable-media.rules new file mode 100644 index 0000000..3d279f4 --- /dev/null +++ b/files/lockdown/10-acpc-no-removable-media.rules @@ -0,0 +1,8 @@ +// ACPC 2026: the teams may not mount USB sticks, disks or disk images. +// Only the administrators (sudo group) keep the udisks actions. +polkit.addRule(function(action, subject) { + if (action.id.indexOf("org.freedesktop.udisks2.") == 0 && + !subject.isInGroup("sudo")) { + return polkit.Result.NO; + } +}); diff --git a/files/lockdown/72-acpc-no-phone-access.rules b/files/lockdown/72-acpc-no-phone-access.rules new file mode 100644 index 0000000..d7cdbcd --- /dev/null +++ b/files/lockdown/72-acpc-no-phone-access.rules @@ -0,0 +1,7 @@ +# ACPC 2026: the desktop user gets no direct access to phones and cameras +# (MTP and PTP, used to copy files), which bypasses the mounting of disks. +# The tags are set by 69-libmtp.rules and 60-libgphoto2, and used by +# 73-seat-late.rules, so this file sits between them. +ENV{ID_MTP_DEVICE}=="1", TAG-="uaccess" +ENV{ID_GPHOTO2}=="1", TAG-="uaccess" +ENV{ID_MEDIA_PLAYER}=="1", TAG-="uaccess" diff --git a/files/lockdown/90-acpc-keys.conf b/files/lockdown/90-acpc-keys.conf new file mode 100644 index 0000000..4b6f9fb --- /dev/null +++ b/files/lockdown/90-acpc-keys.conf @@ -0,0 +1,8 @@ +# ACPC 2026: the power, reboot and suspend keys do nothing. A long press on +# the power button still cuts the power (hardware), in view of the proctors. +[Login] +HandlePowerKey=ignore +HandleRebootKey=ignore +HandleSuspendKey=ignore +HandleHibernateKey=ignore +HandleLidSwitch=ignore diff --git a/files/lockdown/90-acpc-sysrq.conf b/files/lockdown/90-acpc-sysrq.conf new file mode 100644 index 0000000..1c93bc7 --- /dev/null +++ b/files/lockdown/90-acpc-sysrq.conf @@ -0,0 +1,2 @@ +# ACPC 2026: no SysRq key combinations (they can restart the machine) +kernel.sysrq = 0 diff --git a/files/lockdown/contest-restrictions b/files/lockdown/contest-restrictions new file mode 100644 index 0000000..9368d67 --- /dev/null +++ b/files/lockdown/contest-restrictions @@ -0,0 +1,133 @@ +#!/bin/bash +# Turns the contest restrictions off and on, for maintenance. Root only, so +# it is used through sudo from the administrator account (icpc). +# +# sudo contest-restrictions status +# sudo contest-restrictions report [SINCE] (refused attempts, default: today) +# sudo contest-restrictions off [all|firewall|usb|network] +# sudo contest-restrictions on [all|firewall|usb|network] +# +# firewall firewall, web proxy, DNS filter (/disable_firewall.sh, /enable_firewall.sh) +# usb USBGuard, USB disks and phones for the team account +# network network settings (Wi-Fi, hotspot, ...) for the team account +# +# "off" stays in effect after a reboot, until "on". +set -u +DISABLED=/etc/contest-restrictions/disabled +USB_FILES="/etc/polkit-1/rules.d/10-acpc-no-removable-media.rules /etc/udev/rules.d/72-acpc-no-phone-access.rules" +NETWORK_FILES="/etc/polkit-1/rules.d/10-acpc-no-network-changes.rules" + +usage() { + sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +[ "$(id -u)" -eq 0 ] || { echo "Run it with sudo, from the administrator account." >&2; exit 1; } +mkdir -p "$DISABLED" +chmod 700 /etc/contest-restrictions + +# Moves rule files out of the way (off) or back (on) +move_rules() { + action=$1 + shift + for f in "$@"; do + if [ "$action" = off ] && [ -f "$f" ]; then + mv "$f" "$DISABLED/" + elif [ "$action" = on ] && [ -f "$DISABLED/$(basename "$f")" ]; then + mv "$DISABLED/$(basename "$f")" "$f" + fi + done +} + +firewall() { + if [ "$1" = off ]; then + /disable_firewall.sh + else + /enable_firewall.sh + /refresh_rules.sh + fi +} + +usb() { + if [ "$1" = off ]; then + systemctl disable --now usbguard.service + # USBGuard leaves the controllers refusing new devices when it stops + for f in /sys/bus/usb/devices/usb*/authorized_default; do + [ -e "$f" ] && echo 1 > "$f" + done + move_rules off $USB_FILES + udevadm control --reload + echo "USB devices allowed (unplug and plug again a device refused before)." + echo "USB disks and phones can be used from the team account." + else + # Removable media mounted while the restriction was off + findmnt -rno TARGET | grep '^/run/media/' | while read -r m; do + umount "$m" && echo "Unmounted $m" + done + move_rules on $USB_FILES + udevadm control --reload + systemctl enable --now usbguard.service + echo "USB restrictions on. The devices plugged in right now stay allowed until they are unplugged." + fi +} + +network() { + if [ "$1" = off ]; then + move_rules off $NETWORK_FILES + echo "The team account can change the network settings." + else + move_rules on $NETWORK_FILES + echo "Network settings restricted to the administrators." + fi +} + +state() { + if [ "$1" = yes ]; then echo "on"; else echo "OFF"; fi +} + +status() { + fw=no; systemctl is-active --quiet firewall.service && fw=yes + us=no; systemctl is-active --quiet usbguard.service && [ -f /etc/polkit-1/rules.d/10-acpc-no-removable-media.rules ] && us=yes + nw=no; [ -f /etc/polkit-1/rules.d/10-acpc-no-network-changes.rules ] && nw=yes + echo "firewall $(state $fw)" + echo "usb $(state $us)" + echo "network $(state $nw)" +} + +# What the machine refused: web sites, DNS names, USB devices +report() { + since=${1:-today} + echo "== Web sites refused by the proxy (since $since)" + journalctl -t contest_proxy --since "$since" --no-pager -o cat 2>/dev/null \ + | awk '$3 != "200" || $4 == "-" {print $2}' | sort | uniq -c | sort -rn | head -40 + echo + echo "== DNS names refused by the filter" + # Names that Firefox, VS Code, the IDEs and Kattis pages look up on their + # own in the background + background='(^|\.)(mozilla\.(com|org|net)|firefox-portal-detection\.com|googleapis\.com|gvt1\.com|microsoft\.com|vscode-cdn\.net|vscode-unpkg\.net|exp-tas\.com|openh264\.org|ipv4only\.arpa|sentry\.io|cloudflare-dns\.com|jetbrains\.com|ubuntu\.com|canonical\.com|snapcraft\.io)$' + names=$(journalctl -u contest-dns --since "$since" --no-pager -o cat 2>/dev/null \ + | sed -n 's/^config \(.*\) is NXDOMAIN$/\1/p') + printf '%s\n' "$names" | grep -v -E "$background" | grep . | sort | uniq -c | sort -rn | head -40 + echo " ($(printf '%s\n' "$names" | grep -c -E "$background") lookups by Firefox, VS Code and the IDEs in the background, not listed)" + echo + echo "== USB devices rejected" + journalctl -u usbguard --since "$since" --no-pager -o short 2>/dev/null \ + | grep "target.new='reject'" \ + | sed -E 's/^([A-Za-z]+ +[0-9]+ [0-9:]+).* name "([^"]*)".*/\1 \2/' | tail -20 +} + +case "${1:-}" in + status) status ;; + report) report "${2:-today}" ;; + on|off) + what=${2:-all} + case "$what" in + all) firewall "$1"; usb "$1"; network "$1" ;; + firewall|usb|network) "$what" "$1" ;; + *) usage ;; + esac + echo + status + ;; + *) usage ;; +esac diff --git a/files/lockdown/contest-restrictions-warning b/files/lockdown/contest-restrictions-warning new file mode 100644 index 0000000..46e4b07 --- /dev/null +++ b/files/lockdown/contest-restrictions-warning @@ -0,0 +1,41 @@ +#!/bin/bash +# Runs as root (contest-restrictions-warning.service). While any contest +# restriction is off (after maintenance with contest-restrictions off), shows +# a red banner across the top of the screen, so that the proctors notice it. +# The banner has no title bar or close button, the team account cannot stop +# this process, and the banner comes back within seconds if its window is +# destroyed. It is redrawn every 15 seconds to stay above the other windows. +export DISPLAY=:0 XAUTHORITY=/var/run/lightdm/root/:0 +off() { + ! systemctl is-active --quiet firewall.service || + ! systemctl is-active --quiet usbguard.service || + [ -n "$(ls -A /etc/contest-restrictions/disabled 2>/dev/null)" ] +} +show() { + width=$(xdpyinfo 2>/dev/null | awk '/dimensions/ {split($2, a, "x"); print a[1]}') + [ -n "$width" ] || return + xmessage -geometry "${width}x70+0+0" -buttons "" \ + -xrm "*overrideRedirect: true" \ + -fg white -bg red3 -fn "-*-helvetica-bold-r-*-*-34-*-*-*-*-*-*-*" \ + "CONTEST RESTRICTIONS ARE OFF ON THIS MACHINE - CALL THE STAFF" >/dev/null 2>&1 & + pid=$! + shown=$(date +%s) +} +pid="" +shown=0 +while true; do + if off && [ -e "$XAUTHORITY" ]; then + if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then + show + elif [ $(( $(date +%s) - shown )) -ge 15 ]; then + old=$pid + show + sleep 1 + kill "$old" 2>/dev/null + fi + elif [ -n "$pid" ]; then + kill "$pid" 2>/dev/null + pid="" + fi + sleep 3 +done diff --git a/files/lockdown/contest-restrictions-warning.service b/files/lockdown/contest-restrictions-warning.service new file mode 100644 index 0000000..3686121 --- /dev/null +++ b/files/lockdown/contest-restrictions-warning.service @@ -0,0 +1,11 @@ +[Unit] +Description=Red banner on the screen while a contest restriction is off +After=display-manager.service + +[Service] +ExecStart=/usr/local/sbin/contest-restrictions-warning +Restart=always +RestartSec=3 + +[Install] +WantedBy=graphical.target diff --git a/files/lockdown/reset_team_home.sh b/files/lockdown/reset_team_home.sh new file mode 100755 index 0000000..67d2da0 --- /dev/null +++ b/files/lockdown/reset_team_home.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Manual staff command for permanently erasing the team account's files. +# Stops the desktop, deletes the home and team-owned files on the root +# filesystem, then recreates the home. No backup or undo. +# Never invoke from boot, login, a timer, cron, a playbook or automated tests. +# Run only after explicit staff authorization and verified backups. +# The --now argument is internal to the transient service after confirmation. +set -u +[ "$(id -u)" -eq 0 ] || { echo "Run this script as root (sudo)." >&2; exit 1; } +if [ "${1:-}" != --now ]; then + echo "The files of the team account will be deleted on this machine." + read -rp "Type RESET to continue: " answer + [ "$answer" = RESET ] || { echo "Cancelled."; exit 1; } + # The reset ends the desktop session, and with it this terminal if the + # script was started from the desktop. It runs as a separate service. + systemd-run --unit=reset-team-home --collect --quiet "$(readlink -f "$0")" --now + echo "Reset started. The screen restarts in a few seconds." + exit 0 +fi + +systemctl stop lightdm +loginctl terminate-user team 2>/dev/null +sleep 2 +pkill -KILL -u team 2>/dev/null +sleep 1 +for m in /home/team/.vscode/extensions /home/team/.vscode/extensions-root; do + umount "$m" 2>/dev/null +done +rm -rf --one-file-system /home/team +find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -user team -print0 2>/dev/null \ + | xargs -0 -r rm -rf --one-file-system +mkdir /home/team +cp -a /etc/skel/. /home/team/ +chown -R team:teams /home/team +chmod 750 /home/team +systemctl restart team-vscode-extensions.service +systemctl start lightdm +echo "The team account is clean." diff --git a/files/lockdown/wait-for-time.service b/files/lockdown/wait-for-time.service new file mode 100644 index 0000000..90e428b --- /dev/null +++ b/files/lockdown/wait-for-time.service @@ -0,0 +1,14 @@ +[Unit] +Description=Wait for the clock before the desktop starts (at most 30 seconds) +After=chrony.service firewall.service contest-dns.service contest-proxy.service +Wants=chrony.service +Before=display-manager.service + +[Service] +Type=oneshot +# Three checks 10 seconds apart, then the desktop starts anyway +ExecStart=-/usr/bin/chronyc waitsync 3 1 +TimeoutStartSec=45 + +[Install] +WantedBy=multi-user.target diff --git a/files/refresh_rules.service b/files/refresh_rules.service new file mode 100644 index 0000000..ecc1378 --- /dev/null +++ b/files/refresh_rules.service @@ -0,0 +1,10 @@ +[Unit] +Description=Refresh the addresses of the allowlisted domains +After=firewall.service + +[Service] +Type=oneshot +ExecStart=/refresh_rules.sh + +[Install] +WantedBy=multi-user.target diff --git a/files/refresh_rules.sh b/files/refresh_rules.sh new file mode 100755 index 0000000..71e5a88 --- /dev/null +++ b/files/refresh_rules.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Keeps the address set of the time servers up to date, in one atomic +# nftables transaction. NTP pools answer with different addresses at each +# query, so the addresses chrony actually picked are added to those resolved +# here. Web traffic does not need addresses: the local proxy connects by name. + +CONF=/etc/contest-firewall +TMP=$(mktemp) +mkdir -p /run/contest-firewall + +# One run at a time (the timer and a manual run can overlap) +exec 9> /run/contest-firewall/lock +flock 9 + +entries() { + sed -e 's/#.*//' -e 's/[[:space:]]//g' "$1" | grep -v '^$' +} + +ntp_addresses() { + { + for name in $(entries "$CONF/ntp_servers.txt"); do + dig +short A "$name" + done + chronyc -n sources 2>/dev/null | awk '/^\^/ {print $2}' + } | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | sort -u +} + +{ + echo "flush set inet filter ntp_ips" + for ip in $(ntp_addresses); do + echo "add element inet filter ntp_ips { $ip }" + done +} > "$TMP" + +# Apply atomically, then keep a copy of the active ruleset +nft -f "$TMP" +rm -f "$TMP" +nft list ruleset > /etc/nftables.rules diff --git a/files/refresh_rules.timer b/files/refresh_rules.timer new file mode 100644 index 0000000..ac64349 --- /dev/null +++ b/files/refresh_rules.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Refresh the addresses of the allowlisted domains every 30 seconds + +[Timer] +OnBootSec=0s +OnUnitActiveSec=30s + +[Install] +WantedBy=timers.target diff --git a/files/security/team-vscode-extensions b/files/security/team-vscode-extensions new file mode 100644 index 0000000..6ba5cea --- /dev/null +++ b/files/security/team-vscode-extensions @@ -0,0 +1,19 @@ +#!/bin/bash +# Prepares the VS Code extensions of the team account at boot, as root, with +# the script of the ICPC image. The image runs that script at login through +# sudo, which every account could then use as root. The paths it mounts and +# changes are checked first: a symbolic link there could point anywhere. +set -eu +USER_NAME=team +home=$(getent passwd "$USER_NAME" | cut -d: -f6) +for path in "$home/.pki" "$home/.pki/nssdb" "$home/.vscode" \ + "$home/.vscode/extensions" "$home/.vscode/extensions-work" \ + "$home/.vscode/extensions-overlay" "$home/.vscode/extensions-root"; do + if [ -L "$path" ]; then + logger -t team-vscode-extensions "removing the symbolic link $path" + rm -f "$path" + fi +done +[ -L "$home" ] && { echo "$home is a symbolic link" >&2; exit 1; } +export SUDO_UID=$(id -u "$USER_NAME") SUDO_GID=$(id -g "$USER_NAME") +exec /usr/local/bin/vscode-extension-install diff --git a/files/security/team-vscode-extensions.service b/files/security/team-vscode-extensions.service new file mode 100644 index 0000000..67ac067 --- /dev/null +++ b/files/security/team-vscode-extensions.service @@ -0,0 +1,12 @@ +[Unit] +Description=VS Code extensions of the team account +After=local-fs.target +Before=display-manager.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/local/sbin/team-vscode-extensions + +[Install] +WantedBy=multi-user.target diff --git a/files/time/time_from_contest.service b/files/time/time_from_contest.service new file mode 100644 index 0000000..72600f6 --- /dev/null +++ b/files/time/time_from_contest.service @@ -0,0 +1,7 @@ +[Unit] +Description=Set the clock from the contest site if NTP does not work +After=firewall.service chrony.service + +[Service] +Type=oneshot +ExecStart=/time_from_contest.sh diff --git a/files/time/time_from_contest.sh b/files/time/time_from_contest.sh new file mode 100644 index 0000000..f24c820 --- /dev/null +++ b/files/time/time_from_contest.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Fallback for the clock. If chrony has no time server after two minutes of +# uptime (for instance when the campus network blocks NTP), the clock is set +# from the Date header of the contest site, which is accurate to the second. +CONF=/etc/contest-firewall +[ "$(cut -d. -f1 /proc/uptime)" -ge 120 ] || exit 0 +if chronyc -n sources 2>/dev/null | grep -qE '^\^\*'; then + exit 0 +fi +site=$(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$CONF/allowed_domains.txt" | grep -m1 .) +# -k: certificates cannot be checked while the clock is far off +date_header=$(curl -skI --max-time 10 "https://$site/" | tr -d '\r' | sed -n 's/^[Dd]ate: //p') +[ -n "$date_header" ] || { logger -t time_from_contest "no answer from $site"; exit 1; } +server=$(date -d "$date_header" +%s) || exit 1 +now=$(date +%s) +offset=$((server - now)) +if [ "${offset#-}" -ge 2 ]; then + # The header has whole seconds: aim for the middle of the second + date -s "@$server.5" >/dev/null + logger -t time_from_contest "chrony has no time server; clock set from $site (was off by $offset s)" +fi diff --git a/files/time/time_from_contest.timer b/files/time/time_from_contest.timer new file mode 100644 index 0000000..de95374 --- /dev/null +++ b/files/time/time_from_contest.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Check every minute that the clock has a time source + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min + +[Install] +WantedBy=timers.target diff --git a/finish.yml b/finish.yml new file mode 100644 index 0000000..7551fe8 --- /dev/null +++ b/finish.yml @@ -0,0 +1,39 @@ +- name: End of the build (SSH settings of the ICPC image, cleanup, power off) + hosts: all + become: yes + tasks: + # build.sh replaced these settings to log in during the build + - name: Restore the SSH settings of the ICPC image + copy: + dest: /etc/ssh/sshd_config.d/icpc.conf + mode: '0644' + content: | + PasswordAuthentication no + DenyGroups judges teams + + - name: Remove the SSH settings of the build + file: + path: /etc/ssh/sshd_config.d/00-build.conf + state: absent + + - name: Empty the package cache + command: apt-get clean + changed_when: false + + # Last task, in one go because Ansible may be using sudo through the team + # account (first build) or the icpc account (next builds): + # - icpc, the administrator account, gets the new password + # - team, the account of the teams, gets its new password if one is given + # - team leaves the sudo group (the ICPC image gives it sudo with the + # public password "contest") + - name: Set the passwords, remove team from sudo and power off + shell: | + set -e + echo "icpc:$ADMIN_PASS" | chpasswd + if [ -n "$TEAM_PASS" ]; then echo "team:$TEAM_PASS" | chpasswd; fi + if id -nG team | grep -qw sudo; then gpasswd -d team sudo; fi + systemd-run --on-active=5 systemctl poweroff + environment: + ADMIN_PASS: "{{ admin_pass }}" + TEAM_PASS: "{{ team_pass | default('') }}" + no_log: true diff --git a/firefox.yml b/firefox.yml new file mode 100644 index 0000000..693b80d --- /dev/null +++ b/firefox.yml @@ -0,0 +1,38 @@ +- name: Firefox (home page and bookmarks of the contest) + hosts: all + become: yes + vars: + contest_url: https://atlantic-canada26.kattis.com + contest_id: atlantic-canada26 + policies: /usr/lib/firefox/distribution/policies.json + # The bookmarks of the ICPC image point to http://contest, which is the + # contest server of the World Finals. They are replaced by the Kattis pages. + bookmarks: + - { Placement: toolbar, Title: "Contest", URL: "{{ contest_url }}/contests/{{ contest_id }}" } + - { Placement: toolbar, Title: "Problems", URL: "{{ contest_url }}/contests/{{ contest_id }}/problems" } + - { Placement: toolbar, Title: "Standings", URL: "{{ contest_url }}/contests/{{ contest_id }}/standings" } + - { Placement: toolbar, Title: "Contest Help (offline)", URL: "file:///usr/share/doc/acpc/help/index.html" } + - { Placement: toolbar, Title: "Language Documentation", URL: "https://devdocs/" } + - { Placement: toolbar, Title: "Technical Notes", URL: "file:///usr/share/doc/icpc/TechNotes.pdf" } + + tasks: + - name: Set the home page and the bookmarks + shell: | + python3 - "{{ policies }}" <<'PY' + import json, sys + path = sys.argv[1] + data = json.load(open(path)) + p = data.setdefault("policies", {}) + before = json.dumps(data, sort_keys=True) + p["Bookmarks"] = json.loads('{{ bookmarks | to_json }}') + p["Homepage"] = {"Locked": True, "StartPage": "homepage-locked", + "URL": "{{ contest_url }}"} + # No extensions can be installed, and about:config is closed + p["ExtensionSettings"] = {"*": {"installation_mode": "blocked"}} + p["BlockAboutConfig"] = True + if json.dumps(data, sort_keys=True) != before: + json.dump(data, open(path, "w"), indent=2) + print("changed") + PY + register: ff + changed_when: "'changed' in ff.stdout" diff --git a/firewall.yml b/firewall.yml new file mode 100644 index 0000000..3f5a3b2 --- /dev/null +++ b/firewall.yml @@ -0,0 +1,222 @@ +- 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 diff --git a/install_firewall.sh b/install_firewall.sh new file mode 100755 index 0000000..2517710 --- /dev/null +++ b/install_firewall.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Installs the contest firewall without Ansible, on the booted contest image +# (Ubuntu 26.04, as in the ICPC image). Run it as root from this directory. +# +# sudo ./install_firewall.sh # install, the firewall starts at the next boot +# sudo ./install_firewall.sh --start # install and start it now +set -euo pipefail +cd "$(dirname "$0")" + +if [ "$(id -u)" -ne 0 ]; then + echo "Run this script as root (sudo)." >&2 + exit 1 +fi +START=no +[ "${1:-}" = "--start" ] && START=yes +TIMEZONE=${TIMEZONE:-America/Moncton} + +entries() { + sed -e 's/#.*//' -e 's/[[:space:]]//g' "$1" | grep -v '^$' +} + +echo "Installing nftables, dig, chrony and the nginx stream module" +apt-get update +apt-get install -y nftables dnsutils chrony nginx libnginx-mod-stream dnsmasq-base +id contest-proxy >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin contest-proxy + +echo "Copying the lists, scripts and systemd units" +install -d -m 0755 /etc/contest-firewall +install -m 0644 allowed_domains.txt dns_servers.txt ntp_servers.txt files/contest-proxy.conf /etc/contest-firewall/ +install -m 0500 files/firewall.sh files/refresh_rules.sh files/enable_firewall.sh files/disable_firewall.sh / +install -m 0755 test_firewall.sh / +install -m 0644 files/firewall.service files/refresh_rules.service files/refresh_rules.timer files/contest-proxy.service files/contest-dns.service /etc/systemd/system/ + +echo "Configuring DNS (systemd-resolved)" +dns_list=$(entries dns_servers.txt | tr '\n' ' ' | sed 's/ $//') +if grep -qE '^#?DNS=' /etc/systemd/resolved.conf; then + sed -i -E "s|^#?DNS=.*|DNS=${dns_list}|" /etc/systemd/resolved.conf +else + echo "DNS=${dns_list}" >> /etc/systemd/resolved.conf +fi +if grep -qE '^#?DNSStubListener=' /etc/systemd/resolved.conf; then + sed -i -E 's|^#?DNSStubListener=.*|DNSStubListener=no|' /etc/systemd/resolved.conf +else + echo "DNSStubListener=no" >> /etc/systemd/resolved.conf +fi + +echo "Configuring NTP (chrony) and the timezone" +sed -i -E '/^# (BEGIN|END) CONTEST FIREWALL NTP SERVERS$/,/^# END CONTEST FIREWALL NTP SERVERS$/d' /etc/chrony/chrony.conf +sed -i -E '/^(pool|server)[[:space:]]/d' /etc/chrony/chrony.conf +{ + echo "# BEGIN CONTEST FIREWALL NTP SERVERS" + for ntp in $(entries ntp_servers.txt); do + echo "pool ${ntp} iburst" + done + echo "# END CONTEST FIREWALL NTP SERVERS" + cat /etc/chrony/chrony.conf +} > /etc/chrony/chrony.conf.new +mv /etc/chrony/chrony.conf.new /etc/chrony/chrony.conf +# 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 +if [ -f /etc/chrony/sources.d/ubuntu-ntp-pools.sources ]; then + mv /etc/chrony/sources.d/ubuntu-ntp-pools.sources /etc/chrony/sources.d/ubuntu-ntp-pools.sources.disabled +fi +# The contest machines may also run Windows, which keeps the hardware clock +# in local time: chrony must not write its time into that clock +sed -i -E '/^rtcsync/d' /etc/chrony/chrony.conf +grep -q '^makestep' /etc/chrony/chrony.conf || echo 'makestep 1 3' >> /etc/chrony/chrony.conf +# Fallback: time from the contest site if NTP does not work +install -m 0500 files/time/time_from_contest.sh / +install -m 0644 files/time/time_from_contest.service files/time/time_from_contest.timer /etc/systemd/system/ +ln -sf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime + +echo "Blocking open.kattis.com" +grep -q 'open\.kattis\.com' /etc/hosts || printf '127.0.0.1\topen.kattis.com\n' >> /etc/hosts + +# Firefox must send the real site name, which encrypted client hello (ECH) hides +POLICIES=/usr/lib/firefox/distribution/policies.json +if [ -f "$POLICIES" ]; then + echo "Turning off encrypted client hello in Firefox" + python3 - "$POLICIES" <<'PY' +import json, sys +path = sys.argv[1] +data = json.load(open(path)) +prefs = data.setdefault("policies", {}).setdefault("Preferences", {}) +for name in ("network.dns.echconfig.enabled", "network.dns.http3_echconfig.enabled"): + prefs[name] = {"Value": False, "Status": "locked"} +json.dump(data, open(path, "w"), indent=2) +PY +fi + +echo "Restarting services" +systemctl restart systemd-resolved +systemctl enable chrony +systemctl restart chrony +timedatectl set-ntp true || true +systemctl daemon-reload +systemctl enable firewall.service refresh_rules.service refresh_rules.timer time_from_contest.timer +systemctl start refresh_rules.timer + +if [ "$START" = yes ]; then + systemctl start firewall.service + /refresh_rules.sh + echo "Firewall installed and active." +else + echo "Firewall installed. It will start at the next boot (or run: sudo /enable_firewall.sh)." +fi diff --git a/languages.yml b/languages.yml new file mode 100644 index 0000000..4e86fb3 --- /dev/null +++ b/languages.yml @@ -0,0 +1,126 @@ +- name: Compilers of the contest (same versions and options as Kattis) + hosts: all + become: yes + vars: + # From atlantic-canada26.kattis.com/contests/atlantic-canada26/languages + # C gcc-15 15.2.0 + # C++ g++-15 15.2.0 + # Java javac / java 25.0.4.1 + # Python 3 pypy3 7.3.23 (Python 3.11.15) + # All these versions are present in the base ICPC image. The download + # block also corrects an existing build with a different Java version. + # Checksums are from the ICPC Ubuntu repository's package metadata. + jdk_version: "25.0.4.1+1-1~26.04.4" + jdk_packages: + - { name: openjdk-25-jre-headless, arch: amd64, sha256: fc2084482511d56ccb4c33746fe693313a5c0fdcc9a0442e8227e6ba948006d1 } + - { name: openjdk-25-jre, arch: amd64, sha256: 743a58cd138ccc58c1fbdfd26938a0c75edf21cebe1b938f09f37f4a2fb94866 } + - { name: openjdk-25-jdk-headless, arch: amd64, sha256: 7561b5bccd2644d8a2da5dcdfb597a40234f481e098bf785d1a8c8f90202e655 } + - { name: openjdk-25-jdk, arch: amd64, sha256: 9259712883b6ee039b86b7b08292cfb7747bd030e4ee73c66c2517f95999a861 } + - { name: openjdk-25-source, arch: all, sha256: d4949263fd30053bcb96b247bc6971b8b3d2a7d8e0cfec4c106095f52c3948a9 } + jdk_cache: /var/cache/acpc-jdk + + tasks: + - name: Make sure GCC 15 is installed + apt: + name: [gcc-15, g++-15] + state: present + + - name: Check the installed Java packages + command: + argv: + - dpkg-query + - -W + - '-f=${Version} ${db:Status-Status}' + - '{{ item.name }}' + loop: '{{ jdk_packages }}' + register: jdk_installed + changed_when: false + failed_when: false + + - name: Install the contest Java version if it differs + when: (jdk_installed.results | map(attribute='stdout') | unique | list) != [jdk_version ~ ' installed'] + block: + - name: Create the download directory + file: + path: "{{ jdk_cache }}" + state: directory + mode: '0755' + + - name: Download the Java 25.0.4.1 packages + get_url: + url: "https://sysopspackages.icpc.global/ubuntu/resolute/pool/main/o/openjdk-25/{{ item.name }}_{{ jdk_version }}_{{ item.arch }}.deb" + dest: "{{ jdk_cache }}/{{ item.name }}_{{ jdk_version }}_{{ item.arch }}.deb" + checksum: "sha256:{{ item.sha256 }}" + mode: '0644' + loop: "{{ jdk_packages }}" + + # Their exact-version dependencies must be updated together. + - name: Install the checked Java packages together + command: >- + dpkg --force-confdef --force-confold --install + {% for package in jdk_packages %} + {{ jdk_cache }}/{{ package.name }}_{{ jdk_version }}_{{ package.arch }}.deb + {% endfor %} + + - name: Remove the downloaded packages + file: + path: "{{ jdk_cache }}" + state: absent + + - name: Keep Java at 25.0.4.1 (no automatic upgrade) + dpkg_selections: + name: "{{ item.name }}" + selection: hold + loop: "{{ jdk_packages }}" + + - name: Install the compile and run commands + copy: + src: "files/languages/{{ item }}" + dest: "/usr/local/bin/{{ item }}" + mode: '0755' + loop: + - compilec + - compilecpp + - compilejava + - compilepython3 + - runc + - runcpp + - runjava + - runpython3 + + # Names used by the ICPC technical notes + - name: Keep compilegcc and compileg++ as other names + file: + src: "/usr/local/bin/{{ item.src }}" + dest: "/usr/local/bin/{{ item.dest }}" + state: link + force: yes + loop: + - { src: compilec, dest: compilegcc } + - { src: compilecpp, dest: compileg++ } + + - name: Read the versions + shell: | + gcc-15 -dumpfullversion + g++-15 -dumpfullversion + java -version 2>&1 | head -1 + javac -version 2>&1 + pypy3 -c 'import sys; print(sys.pypy_version_info[:3], sys.version_info[:3])' + register: versions + changed_when: false + + - name: Check the versions against Kattis + assert: + that: + - versions.stdout_lines[0] == "15.2.0" + - versions.stdout_lines[1] == "15.2.0" + - "'\"25.0.4.1\"' in versions.stdout_lines[2]" + - versions.stdout_lines[3] == "javac 25.0.4.1" + - versions.stdout_lines[4] == "(7, 3, 23) (3, 11, 15)" + fail_msg: "{{ versions.stdout_lines }}" + + - name: Install the technical notes for the teams + copy: + src: docs/TechNotes.pdf + dest: /usr/share/doc/icpc/TechNotes.pdf + mode: '0644' diff --git a/lockdown.yml b/lockdown.yml new file mode 100644 index 0000000..4605935 --- /dev/null +++ b/lockdown.yml @@ -0,0 +1,183 @@ +- name: Lockdown (AI completion, USB devices, network settings, printers, clock at login) + hosts: all + become: yes + tasks: + # IntelliJ IDEA and PyCharm ship local AI models for code completion + # (Full Line), which work without any network. The models and the local + # inference server are removed. The plugin code stays, because other + # parts of PyCharm depend on it. + - name: Find the local AI completion models of the JetBrains IDEs + find: + paths: [/opt/intellij-idea/plugins/fullLine, /opt/pycharm/plugins/fullLine] + patterns: ["full-line-model-*", "full-line-native-server-*"] + register: fullline + + - name: Remove them + file: + path: "{{ item.path }}" + state: absent + loop: "{{ fullline.files }}" + loop_control: + label: "{{ item.path }}" + + - name: Check that no model is left + shell: find /opt -xdev \( -name 'full-line-model-*' -o -name 'full-line-native-server-*' -o -iname '*.gguf' \) | head -5 + register: models_left + changed_when: false + failed_when: models_left.stdout | length > 0 + + # udisks lets the desktop user mount USB sticks without a password + - name: Refuse USB disks and disk images to the teams (polkit) + copy: + src: files/lockdown/10-acpc-no-removable-media.rules + dest: /etc/polkit-1/rules.d/10-acpc-no-removable-media.rules + mode: '0644' + + - name: Refuse direct access to phones and cameras (MTP, PTP) + copy: + src: files/lockdown/72-acpc-no-phone-access.rules + dest: /etc/udev/rules.d/72-acpc-no-phone-access.rules + mode: '0644' + + # USBGuard: the devices present at boot (the stick itself, keyboard, mouse) + # are allowed, any device plugged in later is rejected. This also stops + # a device that types prepared code while posing as a keyboard. + # An administrator allows a replacement keyboard with + # "sudo usbguard list-devices" and "sudo usbguard allow-device ID". + - name: Install USBGuard + apt: + name: usbguard + state: present + + - name: Allow the devices present at boot + lineinfile: + path: /etc/usbguard/usbguard-daemon.conf + regexp: '^PresentDevicePolicy=' + line: PresentDevicePolicy=allow + + - name: Reject the devices plugged in later + lineinfile: + path: /etc/usbguard/usbguard-daemon.conf + regexp: '^ImplicitPolicyTarget=' + line: ImplicitPolicyTarget=reject + + # No rule list: it would describe the devices of the build machine + - name: Empty rule list + copy: + dest: /etc/usbguard/rules.conf + content: "" + mode: '0600' + + - name: Enable USBGuard + systemd: + name: usbguard.service + enabled: yes + + - name: Refuse network changes to the teams (polkit) + copy: + src: files/lockdown/10-acpc-no-network-changes.rules + dest: /etc/polkit-1/rules.d/10-acpc-no-network-changes.rules + mode: '0644' + + # chrony steps the clock a few seconds after boot. Without this, the + # desktop could start with the clock of the machine and show it for a + # while. + - name: Wait for the clock before the desktop starts + copy: + src: files/lockdown/wait-for-time.service + dest: /etc/systemd/system/wait-for-time.service + mode: '0644' + + - name: Enable it + systemd: + name: wait-for-time.service + enabled: yes + daemon_reload: yes + + # A restart would let USBGuard accept a device plugged in before it, so the + # teams may not restart, power off or suspend the machine + - name: Refuse restart, power off and suspend to the teams (polkit) + copy: + src: files/lockdown/10-acpc-no-power-actions.rules + dest: /etc/polkit-1/rules.d/10-acpc-no-power-actions.rules + mode: '0644' + + - name: No restart with Ctrl+Alt+Del + systemd: + name: ctrl-alt-del.target + masked: yes + + - name: No SysRq key combinations + copy: + src: files/lockdown/90-acpc-sysrq.conf + dest: /etc/sysctl.d/90-acpc-sysrq.conf + mode: '0644' + + - name: Power, reboot and suspend keys do nothing + block: + - file: + path: /etc/systemd/logind.conf.d + state: directory + mode: '0755' + - copy: + src: files/lockdown/90-acpc-keys.conf + dest: /etc/systemd/logind.conf.d/90-acpc-keys.conf + mode: '0644' + + # The image must not change during maintenance with the firewall off: + # an upgrade could change the versions of the compilers (Kattis) + - name: No automatic package updates + systemd: + name: "{{ item }}" + enabled: no + masked: yes + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + - unattended-upgrades.service + + # The lpadmin group administers the printers and can write in + # /usr/share/ppd/custom. Printing does not need it. + - name: Remove team from the lpadmin group + command: gpasswd -d team lpadmin + register: lpadmin + changed_when: lpadmin.rc == 0 + failed_when: false + + # Root only: the administrator runs it with sudo from the icpc account + - name: Install the command that turns the restrictions off and on + copy: + src: files/lockdown/contest-restrictions + dest: /usr/local/sbin/contest-restrictions + mode: '0700' + + # Runs as root, so the team account cannot stop it + - name: Show a red banner on the screen while a restriction is off + copy: + src: "files/lockdown/{{ item.src }}" + dest: "{{ item.dest }}" + mode: "{{ item.mode }}" + loop: + - { src: contest-restrictions-warning, dest: /usr/local/sbin/contest-restrictions-warning, mode: '0700' } + - { src: contest-restrictions-warning.service, dest: /etc/systemd/system/contest-restrictions-warning.service, mode: '0644' } + + - name: Enable the banner service + systemd: + name: contest-restrictions-warning.service + enabled: yes + daemon_reload: yes + + - name: Create the folder of the restrictions turned off (root only) + file: + path: "{{ item }}" + state: directory + mode: '0700' + loop: + - /etc/contest-restrictions + - /etc/contest-restrictions/disabled + + - name: Install the reset script of the team account + copy: + src: files/lockdown/reset_team_home.sh + dest: /reset_team_home.sh + mode: '0500' diff --git a/ntp_servers.txt b/ntp_servers.txt new file mode 100644 index 0000000..647c1bb --- /dev/null +++ b/ntp_servers.txt @@ -0,0 +1,5 @@ +# Time servers (UDP port 123). Replace them with your campus servers if needed. +0.pool.ntp.org +1.pool.ntp.org +2.pool.ntp.org +3.pool.ntp.org diff --git a/run_vm.sh b/run_vm.sh new file mode 100755 index 0000000..0585dae --- /dev/null +++ b/run_vm.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Boots the built image in a window, to try it as a team would. Changes made +# in this window are discarded when it closes (snapshot mode). +# +# ./run_vm.sh # build/acpc2026.qcow2 +# ./run_vm.sh path/to/image.img # another image +# DISPLAY_OPT="-display none -vnc 127.0.0.1:7" ./run_vm.sh # without a window +cd "$(dirname "$0")" +IMG=${1:-build/acpc2026.qcow2} +[ -f "$IMG" ] || { echo "Image not found: $IMG (run ./build.sh first)" >&2; exit 1; } +FORMAT=$(qemu-img info --output=json "$IMG" | python3 -I -c 'import json, sys; print(json.load(sys.stdin)["format"])') + +for dir in /usr/share/edk2/x64 /usr/share/OVMF /usr/share/edk2/ovmf; do + for code in OVMF_CODE.4m.fd OVMF_CODE_4M.fd OVMF_CODE.fd; do + [ -f "$dir/$code" ] && { OVMF_CODE=$dir/$code; OVMF_VARS=$dir/${code/CODE/VARS}; break 2; } + done +done +[ -n "${OVMF_CODE:-}" ] || { echo "UEFI firmware (OVMF) not found" >&2; exit 1; } +VARS=$(mktemp) +cp "$OVMF_VARS" "$VARS" +trap 'rm -f "$VARS"' EXIT + +# shellcheck disable=SC2086 +qemu-system-x86_64 -name acpc2026-try -machine q35,accel=kvm -cpu host -m 4096 -smp 4 \ + -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \ + -drive if=pflash,format=raw,file="$VARS" \ + -drive file="$IMG",format="$FORMAT",if=virtio,snapshot=on \ + -netdev user,id=net0 -device virtio-net-pci,netdev=net0 \ + -vga virtio ${DISPLAY_OPT:--display gtk} ${QEMU_EXTRA:-} diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..8dfd233 --- /dev/null +++ b/site.yml @@ -0,0 +1,9 @@ +# Full configuration of the contest image, run by build.sh +- import_playbook: accounts.yml +- import_playbook: languages.yml +- import_playbook: firefox.yml +- import_playbook: desktop.yml +- import_playbook: docs.yml +- import_playbook: firewall.yml +- import_playbook: lockdown.yml +- import_playbook: finish.yml diff --git a/test_firewall.sh b/test_firewall.sh new file mode 100755 index 0000000..0dcace7 --- /dev/null +++ b/test_firewall.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Quick check of the contest firewall, to run on a contest machine once the +# firewall is active: allowed sites must answer, every other site must fail, +# also when a program forces the address of the contest site for another name. +CONTEST_URL=${CONTEST_URL:-https://atlantic-canada26.kattis.com} +CONTEST_HOST=${CONTEST_URL#https://} +CONTEST_HOST=${CONTEST_HOST%%/*} +fail=0 + +report() { + if [ "$2" = "$3" ]; then + echo "OK $1 is $2" + else + echo "PROBLEM $1 is $2 (expected: $3)" + fail=1 + fi +} + +check() { + expected=$1 + url=$2 + if curl -s -o /dev/null --max-time 8 "$url"; then result=reachable; else result=blocked; fi + report "$url" $result "$expected" +} + +# Another site reached through the address of the contest site (Cloudflare) +check_through_contest() { + host=$1 + if curl -s -o /dev/null --max-time 8 --resolve "$host:443:$contest_ip" "https://$host/"; then + result=reachable + else + result=blocked + fi + report "https://$host/ through the address of $CONTEST_HOST" $result blocked +} + +check reachable "$CONTEST_URL" +check reachable https://status.kattis.com +check reachable https://devdocs/ +check blocked https://open.kattis.com +check blocked https://www.google.com +check blocked https://github.com +check blocked https://chatgpt.com + +contest_ip=$(getent ahostsv4 "$CONTEST_HOST" | awk 'NR == 1 {print $1}') +if [ -n "$contest_ip" ]; then + check_through_contest open.kattis.com + check_through_contest pastebin.com + check_through_contest discord.com +else + echo "PROBLEM $CONTEST_HOST does not resolve" + fail=1 +fi + +# DNS: only the allowed names get an answer, and the DNS resolvers outside +# cannot be reached directly (no data exchanged through DNS) +if [ -n "$(dig +short +time=3 +tries=1 TXT example.com 2>/dev/null)" ]; then result=reachable; else result=blocked; fi +report "DNS for other names (TXT example.com)" $result blocked +if dig +time=3 +tries=1 example.com @1.1.1.1 >/dev/null 2>&1; then result=reachable; else result=blocked; fi +report "the DNS resolver 1.1.1.1 directly" $result blocked + +echo +for unit in contest-proxy contest-dns; do + if systemctl is-active --quiet $unit.service; then + echo "OK $unit is running" + else + echo "PROBLEM $unit.service is not running" + fail=1 + fi +done +if chronyc -n sources 2>/dev/null | grep -qE '^\^\*'; then + echo "OK the clock is synchronized (NTP)" +else + echo "PROBLEM the clock is not synchronized yet (check again in a minute)" + fail=1 +fi +exit $fail diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9885a54 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,46 @@ +# Testing a build + +These tools start a throwaway copy of `build/acpc2026.qcow2` in QEMU and drive it from the build machine, over SSH and through the virtual keyboard, so that the image can be checked as a team and an administrator would use it. The built image itself is not modified. They need `qemu-storage-daemon`, `debugfs`, `sshpass` and Python with Pillow on the build machine, and the administrator password in `ADMIN_PASS`. + +``` +export ADMIN_PASS='PASSWORD' +tests/check_vm.sh start --wrong-clock --usb +tests/feature_check.sh +tests/technotes_check.sh +tests/ide_check.sh +tests/check_vm.sh stop +``` + +`feature_check.sh` runs, as root in the virtual machine, one check for each feature of the playbooks. `technotes_check.sh` runs, from the team account, the statements of the technical notes that can be checked on the machine. Both write their results to `test_report/` and end with the number of failed checks, which should be 0. When a playbook or the notes change, add the matching check. + +| Tool | Use | +| --- | --- | +| `check_vm.sh start [--wrong-clock] [--usb]` | boots the copy (`tests/work/`) with SSH open for the tests and waits for the team desktop. `--wrong-clock` starts with the hardware clock 3 hours behind, `--usb` adds a USB controller | +| `check_vm.sh stop` | stops the virtual machine and deletes the copy | +| `vssh.sh team\|icpc 'command'` | runs a command over SSH | +| `gui.sh 'program arguments'` | starts a program on the team desktop | +| `key.sh 'text{{ret}}'` | types on the keyboard of the virtual machine. Special keys use the QEMU names, as in `{{esc}}`, `{{ctrl-s}}` and `{{f5}}`, and `key.sh @file` types a file | +| `shot.sh NAME` | saves a screenshot as `test_report/NAME.png` | +| `add_test_tools.sh` | installs `xdotool` in the copy only, for `click.sh` | +| `click.sh X Y [right\|double]` | clicks on the team desktop at a screen position, read on a screenshot (1280 by 800 pixels) | +| `usb.sh plug\|unplug` | plugs a small USB stick into a copy started with `--usb` | +| `make_report.py` | builds `test_report/index.html` from the screenshots, `test_report/captions.txt` (one `NAME \| caption` line per screenshot), `test_report/intro.txt` and the two checklists | +| `ide_check.sh` | verifies the installed IDE commands, C23/C++23, static linking, Java VM options, PyPy, paths with spaces, compiler probes and failed-build handling | +| `make_ide_report.py` | builds the separate, self-contained `test_report/ide-config/index.html` from reviewed screenshots and `results.json` | + +A full test goes through the following steps, with a screenshot of each. + +1. Boot with the wrong clock. The team desktop appears with the right time. +2. Open Firefox from the Applications menu, then the contest, standings, problems, login and status pages, and a few blocked sites. +3. Write a short program in each language in a different editor, then compile and run it in a terminal with the `compile` and `run` commands. `submit` prints the address of the problems on Kattis. +4. Run `/test_firewall.sh` in the team terminal. +5. Try to get around the restrictions from the team terminal (sudo, reboot, Wi-Fi, reading the administration scripts, a USB stick, a forced address, DNS). +6. Build and run a program in each language of each IDE, with the input typed in the IDE, and check which compiler or interpreter the IDE used. Cover all 19 combinations: Geany and VS Code and Eclipse (C, C++, Java, Python), Code::Blocks (C, C++), CLion (C, C++, Python), IntelliJ IDEA (Java), and PyCharm (Python). Use the actions in the technical notes; the ordinary Java run buttons in VS Code and Eclipse use ECJ, so test their supplied ACPC actions to verify javac. The fixtures in `ide_samples/` assert the language standards and runtime versions, exercise C++23 `std::println`, and check Java's seven VM options. Enter `3000000000 4`; each program must print `SUM = 3000000004` and exit successfully. +7. In the team terminal, run `su - icpc`, then `contest-restrictions status`, `off usb` (red banner), plug a USB stick, `report` and `on usb`. Check that the restrictions are active and the banner has disappeared. +8. Open **Contest Help (offline)** from the Contest menu. Disconnect the VM network with `python3 -I tests/monitor.py tests/work/monitor.sock 'set_link net0 off'`. Reload the page, search for `EOF`, follow an article link and open the local Technical Notes PDF. Use `key.sh` and `shot.sh` while SSH is unavailable. Restore the connection with `python3 -I tests/monitor.py tests/work/monitor.sock 'set_link net0 on'` before continuing other checks. + +Do not run `/reset_team_home.sh` as part of this procedure or any automated test. It permanently erases team files and is reserved for an explicitly authorized manual staff action. The feature checks inspect its installation without executing it. + +Keyboard input goes to the window that has the focus, so close dialogs and other windows before typing. First-launch windows depend on what was used before (PyCharm offers to import the settings of VS Code once VS Code has run), so the IDEs are worth testing on a fresh copy as well. The JetBrains IDEs take up to a minute to open in the virtual machine. + +For the IDE report, record each reviewed result in `test_report/ide-config/results.json`: a `cases` list with `id`, `ide`, `language`, `action`, `status`, `evidence`, and `screenshots` (each with `file` and `caption`), plus `verdict`, `build`, `notes`, and `sources` (each with `title` and `url`). Keep the original report and screenshots. Run the final graphical checks on a fresh Ansible build without manually repairing compiler settings. Test fixtures may be copied into the VM; compile and run them through the actual IDE interface and inspect every selected screenshot. diff --git a/tests/add_test_tools.sh b/tests/add_test_tools.sh new file mode 100755 index 0000000..ae9e0a7 --- /dev/null +++ b/tests/add_test_tools.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Installs xdotool in the throwaway test VM (not in the built image), for +# mouse clicks with tests/click.sh. The firewall is turned off during the +# installation and back on afterwards. ADMIN_PASS=... tests/add_test_tools.sh +cd "$(dirname "$0")/.." +tests/vssh.sh icpc "echo '${ADMIN_PASS:?set ADMIN_PASS}' | sudo -S -p '' bash -c ' + command -v xdotool >/dev/null && exit 0 + contest-restrictions off firewall >/dev/null + apt-get install -y -qq xdotool >/dev/null 2>&1 + contest-restrictions on firewall >/dev/null + /refresh_rules.sh + command -v xdotool'" diff --git a/tests/check_vm.sh b/tests/check_vm.sh new file mode 100755 index 0000000..f491a30 --- /dev/null +++ b/tests/check_vm.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Test virtual machine for the built image (build/acpc2026.qcow2). +# +# tests/check_vm.sh start [--wrong-clock] [--usb] boot a throwaway copy +# tests/check_vm.sh stop power it off, delete the copy +# +# The copy (tests/work/check.qcow2) opens SSH for the tests: password login, +# no DenyGroups, and the firewall accepts tcp 22. The built image itself is +# not modified. --wrong-clock starts with the hardware clock 3 hours behind +# (as on a lab machine running Windows), --usb adds a USB controller so that +# USB devices can be plugged in with tests/usb.sh. +set -eu +cd "$(dirname "$0")/.." +W=tests/work +MON=$W/monitor.sock +BUILT=$(realpath build/acpc2026.qcow2) +running() { pgrep -f "^qemu-system-x86_64 -name acpc2026-check" >/dev/null; } + +case "${1:-}" in +start) + shift + running && { echo "The test VM is already running." >&2; exit 1; } + mkdir -p "$W" + qemu-img create -q -f qcow2 -b "$BUILT" -F qcow2 "$W/check.qcow2" + for d in /usr/share/edk2/x64 /usr/share/OVMF /usr/share/edk2/ovmf; do + for c in OVMF_CODE.4m.fd OVMF_CODE_4M.fd OVMF_CODE.fd; do + [ -f "$d/$c" ] && { CODE=$d/$c; VARS=$d/${c/CODE/VARS}; break 2; } + done + done + cp "$VARS" "$W/vars.fd" + # Open SSH in the copy, offline + touch "$W/fuse.raw" + qemu-storage-daemon --blockdev driver=file,node-name=f,filename="$W/check.qcow2" \ + --blockdev driver=qcow2,node-name=q,file=f \ + --export type=fuse,id=e,node-name=q,mountpoint="$W/fuse.raw",writable=on > "$W/qsd.log" 2>&1 & + for _ in $(seq 1 20); do mountpoint -q "$W/fuse.raw" && break; sleep 0.5; done + start=$(sfdisk -J "$W/fuse.raw" | python3 -I -c 'import json,sys; p=json.load(sys.stdin)["partitiontable"]["partitions"]; print(max(p,key=lambda x:x["size"])["start"])') + D="$W/fuse.raw?offset=$((start * 512))" + printf 'PasswordAuthentication yes\n' > "$W/00-test.conf" + w() { debugfs -w -R "$1" "$D" >/dev/null 2>&1 || true; } + w "rm /etc/ssh/sshd_config.d/icpc.conf" + w "write $W/00-test.conf /etc/ssh/sshd_config.d/00-test.conf" + w "mkdir /etc/systemd/system/firewall.service.d" + w "write tests/test-ssh.conf /etc/systemd/system/firewall.service.d/test-ssh.conf" + for f in /etc/ssh/sshd_config.d/00-test.conf /etc/systemd/system/firewall.service.d/test-ssh.conf; do + for x in "mode 0100644" "uid 0" "gid 0"; do w "set_inode_field $f $x"; done + done + for x in "mode 040755" "uid 0" "gid 0"; do w "set_inode_field /etc/systemd/system/firewall.service.d $x"; done + fusermount3 -u "$W/fuse.raw" + pkill -f "^qemu-storage-daemon .*$W/check.qcow2" || true + sleep 1 + EXTRA=() + for a in "$@"; do + case "$a" in + --wrong-clock) EXTRA+=(-rtc "base=$(date -u -d '-3 hours' +%Y-%m-%dT%H:%M:%S)") ;; + --usb) EXTRA+=(-device qemu-xhci,id=xhci) ;; + esac + done + setsid qemu-system-x86_64 -name acpc2026-check -machine q35,accel=kvm -cpu host -m 6144 -smp 4 \ + -drive if=pflash,format=raw,readonly=on,file="$CODE" \ + -drive if=pflash,format=raw,file="$W/vars.fd" \ + -drive id=disk0,file="$W/check.qcow2",format=qcow2,if=none \ + -device virtio-blk-pci,drive=disk0,bootindex=1 \ + -netdev user,id=net0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=net0 \ + -vga virtio -display none -monitor unix:"$MON",server,nowait \ + -serial file:"$W/serial.log" "${EXTRA[@]}" > "$W/qemu.log" 2>&1 & + for _ in $(seq 1 120); do + tests/vssh.sh team true 2>/dev/null && break + sleep 3 + done + # Wait for the desktop session of the team account + for _ in $(seq 1 60); do + tests/vssh.sh team 'loginctl list-sessions --no-legend' 2>/dev/null | grep -q seat0 && break + sleep 2 + done + echo "Test VM ready (SSH 127.0.0.1:2222, monitor $MON)." + ;; +stop) + if running; then + python3 -I tests/monitor.py "$MON" quit || true + for _ in $(seq 1 30); do running || break; sleep 1; done + fi + rm -rf "$W" + echo "Test VM stopped." + ;; +*) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 2 + ;; +esac diff --git a/tests/click.sh b/tests/click.sh new file mode 100755 index 0000000..3620cb8 --- /dev/null +++ b/tests/click.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Mouse on the team desktop (needs tests/add_test_tools.sh), screen pixels: +# tests/click.sh X Y left click +# tests/click.sh X Y right right click +# tests/click.sh X Y double double click +case "${3:-left}" in + right) action="click 3" ;; + double) action="click --repeat 2 --delay 120 1" ;; + *) action="click 1" ;; +esac +exec "$(dirname "$0")/vssh.sh" team "DISPLAY=:0 XAUTHORITY=\$HOME/.Xauthority xdotool mousemove $1 $2 sleep 0.2 $action" diff --git a/tests/feature_check.sh b/tests/feature_check.sh new file mode 100755 index 0000000..a482bda --- /dev/null +++ b/tests/feature_check.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Runs the checklist of every playbook feature in the test VM, as root: +# ADMIN_PASS=... tests/feature_check.sh -> test_report/feature_check.txt +cd "$(dirname "$0")/.." +mkdir -p test_report +sshpass -p "${ADMIN_PASS:?set ADMIN_PASS}" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -P 2222 tests/feature_check_vm.sh icpc@127.0.0.1:/tmp/feature_check_vm.sh +tests/vssh.sh icpc "echo '$ADMIN_PASS' | sudo -S -p '' bash /tmp/feature_check_vm.sh" | tee test_report/feature_check.txt diff --git a/tests/feature_check_vm.sh b/tests/feature_check_vm.sh new file mode 100755 index 0000000..3b1b308 --- /dev/null +++ b/tests/feature_check_vm.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Runs inside the VM as root. Checks every feature of the Ansible playbooks. +pass=0; fail=0 +c() { # c "description" command... + local d=$1; shift + if eval "$@" >/dev/null 2>&1; then echo "PASS $d"; pass=$((pass+1)); else echo "FAIL $d"; fail=$((fail+1)); fi +} +as_team() { runuser -u team -- bash -c "$1"; } +section() { echo; echo "== $1"; } + +section "accounts.yml / finish.yml" +c "team is logged in automatically on the screen" "loginctl list-sessions --no-legend | grep -q 'team seat0'" +c "team is not in the sudo group" "! id -nG team | grep -qw sudo" +c "team is not in the lpadmin group" "! id -nG team | grep -qw lpadmin" +c "icpc is in the sudo group" "id -nG icpc | grep -qw sudo" +c "root password is locked" "passwd -S root | grep -q ' L '" +c "sudo rule of vscode-extension-install removed" "[ ! -e /etc/sudoers.d/vscode-extension-install ]" +c "vscode-extension-install not started at login" "grep -q '^Hidden=true' /etc/xdg/autostart/vscode-extension-install.desktop" +c "VS Code extensions mounted for team at boot" "systemctl is-active -q team-vscode-extensions && findmnt /home/team/.vscode/extensions" +c "boot menu: superuser and password" "grep -q 'set superusers=\"admin\"' /boot/grub/grub.cfg && grep -q password_pbkdf2 /boot/grub/grub.cfg" +c "boot menu: normal entries unrestricted" "grep -q -- '--unrestricted' /boot/grub/grub.cfg" +c "boot menu: no recovery entries" "! grep -q recovery /boot/grub/grub.cfg" +c "boot menu password file is root only" "[ \$(stat -c %a /etc/grub.d/01_acpc_password) = 700 ]" +c "SSH settings of the build removed" "[ ! -e /etc/ssh/sshd_config.d/00-build.conf ]" + +section "languages.yml" +c "gcc-15 15.2.0" "[ \$(gcc-15 -dumpfullversion) = 15.2.0 ]" +c "g++-15 15.2.0" "[ \$(g++-15 -dumpfullversion) = 15.2.0 ]" +c "java 25.0.4.1" "java -version 2>&1 | grep -q '\"25.0.4.1\"'" +c "javac 25.0.4.1" "[ \"\$(javac -version 2>&1)\" = 'javac 25.0.4.1' ]" +c "PyPy 7.3.23 / Python 3.11.15" "pypy3 -c 'import sys; assert sys.pypy_version_info[:3]==(7,3,23) and sys.version_info[:3]==(3,11,15)'" +c "Java packages held at 25.0.4.1" "apt-mark showhold | grep -q openjdk-25-jdk-headless" +c "compilec uses the Kattis command" "grep -q 'gcc-15 -g -O2 -o a.out -std=gnu23 -static \"\$@\" -lm' /usr/local/bin/compilec" +c "compilecpp uses the Kattis command" "grep -q 'g++-15 -g -O2 -o a.out -std=gnu++23 -static -lrt -Wl,--whole-archive -lpthread -Wl,--no-whole-archive' /usr/local/bin/compilecpp" +c "compilejava uses the Kattis command" "grep -q \"javac -encoding UTF-8 -sourcepath . -cp './\\*' -d .\" /usr/local/bin/compilejava" +c "runjava uses the Kattis options" "grep -q 'UseSerialGC -Xss64m -XX:TieredStopAtLevel=1 -XX:ActiveProcessorCount=2' /usr/local/bin/runjava" +c "runpython3 uses pypy3" "grep -q 'exec pypy3' /usr/local/bin/runpython3" +c "compilegcc and compileg++ are other names" "[ \$(readlink /usr/local/bin/compilegcc) = /usr/local/bin/compilec ] && [ \$(readlink /usr/local/bin/compileg++) = /usr/local/bin/compilecpp ]" +c "technical notes are the ACPC 2026 ones" "pdftotext /usr/share/doc/icpc/TechNotes.pdf - | grep -q 'Atlantic Canadian Programming Competition 2026'" + +section "firefox.yml" +P=/usr/lib/firefox/distribution/policies.json +c "home page is the contest" "python3 -c \"import json;p=json.load(open('$P'))['policies'];assert p['Homepage']['URL']=='https://atlantic-canada26.kattis.com' and p['Homepage']['Locked']\"" +c "contest, offline help and documentation bookmarks" "python3 -c \"import json;p=json.load(open('$P'))['policies'];assert [b['Title'] for b in p['Bookmarks']]==['Contest','Problems','Standings','Contest Help (offline)','Language Documentation','Technical Notes'];assert p['Bookmarks'][3]['URL']=='file:///usr/share/doc/acpc/help/index.html'\"" +c "encrypted client hello off (locked)" "python3 -c \"import json;p=json.load(open('$P'))['policies']['Preferences'];assert p['network.dns.echconfig.enabled']=={'Value':False,'Status':'locked'}\"" +c "extension installs blocked" "python3 -c \"import json;p=json.load(open('$P'))['policies'];assert p['ExtensionSettings']['*']['installation_mode']=='blocked'\"" +c "about:config blocked" "python3 -c \"import json;p=json.load(open('$P'))['policies'];assert p['BlockAboutConfig'] is True\"" + +section "desktop.yml" +c "World Finals menu entries removed" "! ls /usr/share/applications/{ccs,scoreboard,problems,ccsdocs,judgingnotes}.desktop" +c "Contest menu: Contest, Problems, Standings and offline help" "ls /usr/share/applications/acpc-{contest,problems,standings,help}.desktop" +c "Documentation menu entry named Language Documentation" "grep -q '^Name=Language Documentation' /usr/share/applications/devdocs.desktop" +c "submit points to Kattis" "as_team 'submit' | grep -q 'This contest runs on Kattis'" +c "pc2submit points to Kattis" "as_team 'pc2submit' | grep -q 'This contest runs on Kattis'" +c "Kotlin compile/run commands removed" "[ ! -e /usr/local/bin/compilekotlin ] && [ ! -e /usr/local/bin/runkotlin ]" +c "JetBrains agreement answered for team" "grep -rq eua_accepted_version /home/team/.java/.userPrefs/jetbrains/" +c "JetBrains data sharing declined for team" "grep -q 'rsch.send.usage.stat:1.1:0' /home/team/.local/share/JetBrains/consentOptions/accepted" +c "same answers in /etc/skel" "grep -rq eua_accepted_version /etc/skel/.java/.userPrefs/jetbrains/ && [ -f /etc/skel/.local/share/JetBrains/consentOptions/accepted ]" +c "PyCharm: PyPy declared and used by default" "grep -q '/usr/local/bin/pypy3' /home/team/.config/JetBrains/PyCharm*/options/jdk.table.xml && grep -q 'PyPy 3.11' /home/team/.config/JetBrains/PyCharm*/options/project.default.xml" +c "CLion, IntelliJ IDEA, PyCharm trust /home/team" "[ \$(grep -l '/home/team' /home/team/.config/JetBrains/*/options/trusted-paths.xml | wc -l) = 3 ]" +c "same JetBrains settings in /etc/skel" "[ \$(ls /etc/skel/.config/JetBrains/*/options/trusted-paths.xml | wc -l) = 3 ]" +c "Eclipse runs on Java 25.0.4.1" "grep -qx '/usr/lib/jvm/java-25-openjdk-amd64/bin/java' /opt/eclipse/eclipse.ini" +c "Geany C build uses the contest compiler driver" "grep -q '^FT_01_CM=acpc-gcc' /usr/share/geany/filedefs/filetypes.c" +c "Geany C++ build uses the contest compiler driver" "grep -q '^FT_01_CM=acpc-g++' /usr/share/geany/filedefs/filetypes.cpp" +c "Geany runs Python with PyPy" "grep -q '^EX_00_CM=pypy3' /usr/share/geany/filedefs/filetypes.python" +c "VS Code uses PyPy for Python" "grep -q '/usr/local/bin/pypy3' /home/team/.config/Code/User/settings.json" + +c "Geany Java uses contest compilation and VM options" "grep -q '^FT_01_CM=compilejava' /usr/share/geany/filedefs/filetypes.java && grep -q '^EX_00_CM=runjava' /usr/share/geany/filedefs/filetypes.java" +c "CLion C and C++ toolchain drivers" "grep -q 'customCCompilerPath' /home/team/.config/JetBrains/CLion*/options/linux/toolchains.xml && grep -q 'customCXXCompilerPath' /home/team/.config/JetBrains/CLion*/options/linux/toolchains.xml" +c "CLion Python defaults to PyPy" "grep -q /usr/local/bin/pypy3 /home/team/.config/JetBrains/CLion*/options/jdk.table.xml" +c "IntelliJ Java SDK and VM options" "grep -q java-25-openjdk /home/team/.config/JetBrains/IntelliJIdea*/options/jdk.table.xml && grep -q UseSerialGC /home/team/.config/JetBrains/IntelliJIdea*/options/project.default.xml" +c "CodeBlocks C and C++ compiler profile" "grep -q acpc-gcc /home/team/.config/codeblocks/default.conf && grep -q acpc-g++ /home/team/.config/codeblocks/default.conf" +c "VS Code contest build actions and F6 shortcut" "grep -q acpc-solution /home/team/.config/Code/User/tasks.json && grep -q f6 /home/team/.config/Code/User/keybindings.json" +c "Eclipse menu initializes the contest workspace" "grep -q '^Exec=/usr/local/bin/acpc-eclipse' /usr/share/applications/Eclipse.desktop" +c "Eclipse PyDev uses PyPy without changing Java/CDT build preferences" "grep -q /usr/local/bin/pypy3 /home/team/eclipse-workspace/.metadata/.plugins/org.eclipse.core.runtime/.settings/org.python.pydev.prefs && grep -qx CHECK_PREFERRED_PYDEV_SETTINGS=false /home/team/eclipse-workspace/.metadata/.plugins/org.eclipse.core.runtime/.settings/org.python.pydev.prefs" +c "IDE profiles restored by reset from skel" "test -f /etc/skel/.config/codeblocks/default.conf && test -f /etc/skel/.config/Code/User/tasks.json && test -f /etc/skel/eclipse-workspace/.metadata/.plugins/org.eclipse.debug.core/.launches/ACPC\ Build\ and\ run.launch" + +section "docs.yml" +c "offline help is installed, root-owned and readable by team" "[ \"\$(stat -c '%U:%G:%a' /usr/share/doc/acpc/help/index.html)\" = root:root:644 ] && as_team 'test -r /usr/share/doc/acpc/help/index.html'" +c "offline help menu opens the local document" "grep -qx 'Exec=firefox file:///usr/share/doc/acpc/help/index.html' /usr/share/applications/acpc-help.desktop" +c "DevDocs runs" "systemctl is-active -q container-devdocs && curl -sk -o /dev/null https://devdocs/" +c "DevDocs has exactly C, C++, OpenJDK 25, Python 3.11" "[ \"\$(podman exec devdocs ls public/docs | tr '\n' ' ')\" = 'c cpp docs.json openjdk~25 python~3.11 ' ]" +c "Kotlin gone from the compiled list" "! podman exec devdocs sh -c 'grep -q kotlin public/assets/docs-*.js'" +c "no 'must be loaded from devdocs.io' notice" "podman exec devdocs sh -c 'grep -q production_host:\\\"devdocs\\\" public/assets/application-*.js'" + +section "firewall.yml" +c "firewall, proxy and DNS filter running" "systemctl is-active -q firewall contest-proxy contest-dns" +c "test_firewall.sh: 15 OK, no problem (as team)" "[ \$(as_team /test_firewall.sh | grep -c '^OK') = 15 ]" +c "web egress only for the proxy account" "nft list chain inet filter output | grep -q \"skuid \$(id -u contest-proxy) tcp dport 443 accept\"" +c "DNS egress only for the proxy account" "nft list chain inet filter output | grep -q \"skuid \$(id -u contest-proxy) ip daddr 1.1.1.1 udp dport 53\"" +c "NTP egress only for chrony" "nft list chain inet filter output | grep -q \"skuid \$(id -u _chrony) ip daddr @ntp_ips udp dport 123 accept\"" +c "incoming connections dropped (policy drop)" "nft list chain inet filter input | grep -q 'policy drop'" +c "resolv.conf points to the DNS filter" "[ \"\$(cat /etc/resolv.conf)\" = 'nameserver 127.0.0.1' ]" +c "open.kattis.com in /etc/hosts" "grep -q 'open.kattis.com' /etc/hosts" +c "clock synchronized by chrony" "chronyc -n sources | grep -q '^\\^\\*'" +c "chrony does not write the hardware clock" "! grep -q '^rtcsync' /etc/chrony/chrony.conf" +c "Ubuntu NTS sources disabled" "[ ! -e /etc/chrony/sources.d/ubuntu-ntp-pools.sources ]" +c "clock fallback timer enabled" "systemctl is-enabled -q time_from_contest.timer" +c "time zone America/Moncton" "[ \$(readlink -f /etc/localtime) = /usr/share/zoneinfo/America/Moncton ]" + +section "lockdown.yml" +c "no local AI models in the JetBrains IDEs" "[ -z \"\$(find /opt -name 'full-line-model-*' -o -name 'full-line-native-server-*')\" ]" +c "USBGuard running, rejects new devices" "systemctl is-active -q usbguard && grep -q '^ImplicitPolicyTarget=reject' /etc/usbguard/usbguard-daemon.conf && grep -q '^PresentDevicePolicy=allow' /etc/usbguard/usbguard-daemon.conf" +c "polkit: removable media refused to teams" "[ -f /etc/polkit-1/rules.d/10-acpc-no-removable-media.rules ]" +c "polkit: network changes refused to teams" "[ -f /etc/polkit-1/rules.d/10-acpc-no-network-changes.rules ]" +c "polkit: restart/power off refused to teams" "[ -f /etc/polkit-1/rules.d/10-acpc-no-power-actions.rules ]" +c "udev: no direct access to phones" "[ -f /etc/udev/rules.d/72-acpc-no-phone-access.rules ]" +c "team cannot reboot" "! systemd-run --machine=team@ --user --wait --pipe --quiet systemctl reboot" +c "team cannot change the network" "systemd-run --machine=team@ --user --wait --pipe --quiet nmcli general permissions | grep -q 'network-control *no'" +c "Ctrl+Alt+Del masked" "[ \$(systemctl is-enabled ctrl-alt-del.target 2>&1) = masked ]" +c "SysRq off" "[ \$(cat /proc/sys/kernel/sysrq) = 0 ]" +c "power/reboot keys ignored" "grep -q '^HandlePowerKey=ignore' /etc/systemd/logind.conf.d/90-acpc-keys.conf" +c "desktop waits for the clock" "systemctl is-enabled -q wait-for-time" +c "automatic updates masked" "[ \"\$(systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer unattended-upgrades 2>&1 | sort -u)\" = masked ]" +c "contest-restrictions installed, root only" "[ \$(stat -c %a /usr/local/sbin/contest-restrictions) = 700 ]" +c "contest-restrictions status: all on" "[ \$(contest-restrictions status | grep -c ' on\$') = 3 ]" +c "banner service running (root)" "systemctl is-active -q contest-restrictions-warning" +c "/etc/contest-restrictions root only" "[ \$(stat -c %a /etc/contest-restrictions) = 700 ]" +c "admin scripts unreadable by team" "! as_team 'cat /disable_firewall.sh /usr/local/sbin/contest-restrictions /reset_team_home.sh'" +c "reset script installed" "[ -x /reset_team_home.sh ]" + +echo +echo "$pass passed, $fail failed" diff --git a/tests/gui.sh b/tests/gui.sh new file mode 100755 index 0000000..d3331ea --- /dev/null +++ b/tests/gui.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Starts a program on the desktop of the team account: tests/gui.sh 'firefox https://devdocs/' +exec "$(dirname "$0")/vssh.sh" team "export DISPLAY=:0 XAUTHORITY=\$HOME/.Xauthority; (setsid $1 >/dev/null 2>&1 &)" diff --git a/tests/ide_check.sh b/tests/ide_check.sh new file mode 100755 index 0000000..f1c31c0 --- /dev/null +++ b/tests/ide_check.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p test_report/ide-config +tar -C tests -cf - ide_check_vm.py ide_samples | tests/vssh.sh team \ + 'mkdir -p /tmp/acpc-ide-check && tar -xf - -C /tmp/acpc-ide-check' +tests/vssh.sh team 'python3 /tmp/acpc-ide-check/ide_check_vm.py' | tee test_report/ide-config/integration-check.txt diff --git a/tests/ide_check_vm.py b/tests/ide_check_vm.py new file mode 100755 index 0000000..e726b05 --- /dev/null +++ b/tests/ide_check_vm.py @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +"""Integration checks in the built image; graphical checks are recorded separately.""" +import configparser +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET + +passed = failed = 0 + +def check(name, fn): + global passed, failed + try: + fn() + print('PASS ', name, flush=True) + passed += 1 + except Exception as error: + print('FAIL ', name, ':', error, flush=True) + failed += 1 + +def require(condition, detail='assertion failed'): + if not condition: + raise AssertionError(detail) + +def run(args, **kwargs): + result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=45, **kwargs) + require(result.returncode == 0, result.stdout) + return result.stdout + +samples = Path(__file__).parent / 'ide_samples' +with tempfile.TemporaryDirectory(prefix='ACPC IDE check ') as directory: + work = Path(directory) + for filename in ('sum.c', 'sum.cpp', 'Sum.java', 'sum.py'): + case = work / filename.replace('.', '-') + case.mkdir() + source = case / filename + shutil.copy(samples / filename, source) + def exercise(source=source): + output = run(['acpc-solution', 'run', str(source)], input='3000000000 4\n') + require('SUM = 3000000004' in output, output) + if source.suffix in ('.c', '.cpp'): + require('statically linked' in run(['file', str(source.with_suffix(''))])) + if source.suffix == '.java': + require(int.from_bytes(source.with_suffix('.class').read_bytes()[6:8], 'big') == 69) + print(' ' + output.strip().replace('\n', '\n ')) + check('build and execute ' + filename + ' (path contains spaces)', exercise) + + def driver_flags(): + log = work / 'compiler.jsonl' + for lang, driver, old_standard, expected, library in ( + ('c', 'acpc-gcc', 'c99', 'gnu23', '-lm'), + ('cpp', 'acpc-g++', 'c++17', 'gnu++23', '-lpthread'), + ): + source = work / ('sum-' + lang) / ('sum.' + lang) + run([driver, '-O0', '-std=' + old_standard, str(source), '-o', str(work / driver)], + env={**os.environ, 'ACPC_COMPILER_LOG': str(log)}) + args = json.loads(log.read_text().splitlines()[-1])['argv'] + require('-O0' not in args and '-O2' in args and '-std=' + expected in args, str(args)) + require('-static' in args and library in args, str(args)) + require('15.2.0' in run(['acpc-gcc', '-dumpfullversion'])) + require('15.2.0' in run(['acpc-g++', '--version'])) + output = run(['acpc-cc', '-x', 'c++', '-dM', '-E', '-'], input='') + require('#define __cplusplus 202302L' in output, 'C++ compiler probes must use the C++ backend') + response = work / 'compiler probe.args' + response.write_text('-x c++ -std=c++17 -dM -E -') + output = run(['acpc-cc', '@' + str(response)], input='') + require('#define __cplusplus 202302L' in output, + 'CLion response-file probes must see the same C++23 standard as compilation') + check('compiler drivers override generic project flags, link statically and answer IDE probes', driver_flags) + + def failed_build(): + case = work / 'sum-cpp' + source = case / 'sum.cpp' + source.write_text('#error deliberate bad source\n') + result = subprocess.run(['acpc-solution', 'run', str(source)], text=True, input='1 2\n', capture_output=True) + require(result.returncode != 0 and 'SUM =' not in result.stdout and 'Run:' not in result.stdout, + 'a failed build must never execute the previous binary') + check('failed build does not execute a stale binary', failed_build) + + def geany_commands(): + for language, filename in [('c', 'sum.c'), ('cpp', 'sum.cpp'), ('java', 'Sum.java'), ('python', 'sum.py')]: + conf = configparser.ConfigParser(interpolation=None, strict=False) + conf.read('/usr/share/geany/filedefs/filetypes.' + language) + source = work / ('geany ' + language) / filename + source.parent.mkdir() + shutil.copy(samples / filename, source) + for key in ('FT_00_CM', 'FT_01_CM', 'EX_00_CM'): + command = conf['build-menu'][key].replace('%f', source.name).replace('%e', source.stem) + result = run(['bash', '-c', command], cwd=source.parent, input='3000000000 4\n') + if key == 'EX_00_CM': require('SUM = 3000000004' in result, result) + if key == 'FT_00_CM' and source.suffix in ('.c', '.cpp'): + require('relocatable' in run(['file', str(source.with_suffix('.o'))])) + check('all four installed Geany build and execute commands work', geany_commands) + +for home in (Path('/home/team'), Path('/etc/skel')): + def profiles(home=home): + cb = ET.parse(home / '.config/codeblocks/default.conf') + cb_root = Path(cb.findtext('./compiler/sets/gcc/MASTER_PATH/str')) / 'bin' + require((cb_root / cb.findtext('./compiler/sets/gcc/C_COMPILER/str')).resolve() == Path('/usr/local/bin/acpc-cc')) + require((cb_root / cb.findtext('./compiler/sets/gcc/CPP_COMPILER/str')).resolve() == Path('/usr/local/bin/acpc-cc')) + require(not Path(cb.findtext('./compiler/sets/gcc/C_COMPILER/str')).is_absolute(), + 'Code::Blocks prefixes MASTER_PATH/bin, including for an absolute filename') + code = home / '.config/Code/User' + tasks = json.loads((code / 'tasks.json').read_text())['tasks'] + require(any(t['label'] == 'ACPC: Build and run' and t['command'] == '/usr/local/bin/acpc-solution' for t in tasks)) + require(any(b.get('key') == 'f6' and b.get('args') == 'ACPC: Build and run' for b in json.loads((code / 'keybindings.json').read_text()))) + for product in ('CLion', 'PyCharm'): + options = next((home / '.config/JetBrains').glob(product + '*/options')) + require('/usr/local/bin/pypy3' in (options / 'jdk.table.xml').read_text()) + options = next((home / '.config/JetBrains').glob('IntelliJIdea*/options')) + require('ACPC Java 25' in (options / 'jdk.table.xml').read_text()) + require('-XX:+UseSerialGC' in (options / 'project.default.xml').read_text()) + options = next((home / '.config/JetBrains').glob('CLion*/options')) + chain = ET.parse(options / 'linux/toolchains.xml').find('./component/toolchains/toolchain') + require(chain.get('customCCompilerPath') == '/usr/local/bin/acpc-gcc') + require(chain.get('customCXXCompilerPath') == '/usr/local/bin/acpc-g++') + workspace = home / 'eclipse-workspace/.metadata/.plugins' + launch = ET.parse(workspace / 'org.eclipse.debug.core/.launches/ACPC Build and run.launch') + require(any(n.get('value') == '/usr/local/bin/acpc-solution' for n in launch.getroot())) + prefs = workspace / 'org.eclipse.core.runtime/.settings' + require('/usr/local/bin/pypy3' in (prefs / 'org.python.pydev.prefs').read_text()) + require('java-25-openjdk' in (prefs / 'org.eclipse.jdt.launching.prefs').read_text()) + check(str(home) + ': settings for every IDE are present and parseable', profiles) + +print(f'\n{passed} passed, {failed} failed') +raise SystemExit(bool(failed)) diff --git a/tests/ide_samples/Sum.java b/tests/ide_samples/Sum.java new file mode 100644 index 0000000..efa4891 --- /dev/null +++ b/tests/ide_samples/Sum.java @@ -0,0 +1,16 @@ +import java.lang.management.ManagementFactory; +import java.util.Scanner; +public class Sum { + public static void main(String[] args) { + if (!System.getProperty("java.version").equals("25.0.4.1")) + throw new IllegalStateException("Java 25.0.4.1 is required"); + var vmArgs = ManagementFactory.getRuntimeMXBean().getInputArguments(); + for (String flag : new String[]{"-Dfile.encoding=UTF-8", "-XX:+UseSerialGC", "-Xss64m", "-XX:TieredStopAtLevel=1", "-XX:ActiveProcessorCount=2", "-Xms2048m", "-Xmx2048m"}) + if (!vmArgs.contains(flag)) throw new IllegalStateException("Missing " + flag); + System.out.println("Java " + System.getProperty("java.version")); + System.out.println("VM: " + vmArgs); + System.out.print("Input two integers: "); + Scanner in = new Scanner(System.in); + System.out.println("SUM = " + (in.nextLong() + in.nextLong())); + } +} diff --git a/tests/ide_samples/sum.c b/tests/ide_samples/sum.c new file mode 100644 index 0000000..6077d06 --- /dev/null +++ b/tests/ide_samples/sum.c @@ -0,0 +1,15 @@ +#include +#ifdef __cplusplus +#error This C file must be compiled as C, not C++. +#endif +#if __STDC_VERSION__ < 202311L +#error C23 is required. +#endif +int main(void) { + long long a, b; + printf("C23 | GCC %s\nInput two integers: ", __VERSION__); + fflush(stdout); + if (scanf("%lld %lld", &a, &b) != 2) return 1; + printf("SUM = %lld\n", a + b); + return 0; +} diff --git a/tests/ide_samples/sum.cpp b/tests/ide_samples/sum.cpp new file mode 100644 index 0000000..44dbb9a --- /dev/null +++ b/tests/ide_samples/sum.cpp @@ -0,0 +1,12 @@ +#include +#include +#if __cplusplus < 202302L +#error C++23 is required. +#endif +int main() { + std::println("C++23 | GCC {}", __VERSION__); + std::cout << "Input two integers: " << std::flush; + long long a, b; + if (!(std::cin >> a >> b)) return 1; + std::println("SUM = {}", a + b); +} diff --git a/tests/ide_samples/sum.py b/tests/ide_samples/sum.py new file mode 100644 index 0000000..fa90bfd --- /dev/null +++ b/tests/ide_samples/sum.py @@ -0,0 +1,8 @@ +import platform +import sys +assert platform.python_implementation() == 'PyPy' +assert sys.pypy_version_info[:3] == (7, 3, 23) +assert sys.version_info[:3] == (3, 11, 15) +print('PyPy 7.3.23 | Python ' + platform.python_version()) +values = input('Input two integers: ').split() +print('SUM =', sum(map(int, values))) diff --git a/tests/key.sh b/tests/key.sh new file mode 100755 index 0000000..3afe816 --- /dev/null +++ b/tests/key.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Types on the keyboard of the test VM: tests/key.sh 'text{{ret}}' or tests/key.sh @file +cd "$(dirname "$0")/.." +exec python3 -I tests/type.py tests/work/monitor.sock "$1" diff --git a/tests/make_ide_report.py b/tests/make_ide_report.py new file mode 100644 index 0000000..4110f9f --- /dev/null +++ b/tests/make_ide_report.py @@ -0,0 +1,67 @@ +#!/usr/bin/python3 +"""Create a portable HTML report from reviewed screenshots and recorded results.""" +import base64 +from datetime import datetime, timezone +import html +import json +from pathlib import Path + +root = Path(__file__).resolve().parent.parent +report = root / 'test_report/ide-config' +data = json.loads((report / 'results.json').read_text()) +e = html.escape + +def photo(name, caption): + content = base64.b64encode((report / name).read_bytes()).decode() + url = 'data:image/png;base64,' + content + return f'
{e(caption)} · PNG
' + +cases = data['cases'] +total = len(cases) +passed = sum(c['status'] == 'pass' for c in cases) +rows = [] +cards = [] +for case in cases: + ident = case['id'] + rows.append(f'{e(case["ide"])}{e(case["language"])}{e(case["status"].upper())}{e(case["action"])}') + pictures = ''.join(photo(p['file'], p['caption']) for p in case['screenshots']) + cards.append(f'

{e(case["ide"])} · {e(case["language"])} {e(case["status"].upper())}

Action: {e(case["action"])}

{e(case["evidence"])}

{pictures}
') + +logs = [] +for name, title in [('integration-check.txt', 'Compiler and configuration integration checks'), + ('feature-check.txt', 'Image feature checks'), ('technotes-check.txt', 'Technical notes checks'), + ('idempotence.txt', 'Configuration reapplied without changes'), + ('reset-check.txt', 'Team-home reset and restored configuration'), + ('reset-integration-check.txt', 'Compiler integration checks after the team-home reset'), + ('build-summary.txt', 'Final image build and installed file checksums'), + ('gui-artifacts.txt', 'Artifacts created by graphical runs')]: + path = report / name + if path.exists(): + logs.append(f'
{e(title)}
{e(path.read_text())}
') +sources = ''.join(f'
  • {e(s["title"])}
  • ' for s in data['sources']) +fixtures = ''.join(f'
    {e(p.name)} — test source
    {e(p.read_text())}
    ' for p in sorted((root / 'tests/ide_samples').iterdir()) if p.is_file()) +options = ''.join(f'' for ide in dict.fromkeys(c['ide'] for c in cases)) +notes = ''.join(f'
  • {e(note)}
  • ' for note in data['notes']) +build = e(data['build']) +notes_page = report / 'technotes-final-3.png' +instructions = ('

    Instructions supplied to teams

    Page 3 of the updated TechNotes explains the tested action for every IDE and the first-launch dialogs. The PDF installed in the final image was checked against this copy.

    ' + photo(notes_page.name, 'TechNotes · IDE instructions, page 3') + '
    ') if notes_page.exists() else '' +page = f''' + +ACPC 2026 — IDE compiler verification +
    + +
    ACPC 2026 · contest USB image

    IDE compiler verification

    +

    {e(data['verdict'])}

    {passed}/{total}language workflows passed
    7IDEs on the real VM desktop
    3000000004expected result for 3000000000 + 4
    +

    {build}

    Screenshots are embedded in this file. Click any image to inspect it, or download its PNG. No external service is needed to view this report.

    +

    What was tested

    Each recorded workflow was launched through the IDE interface, built or interpreted the supplied source, accepted input typed on the VM keyboard and produced the expected result. The screenshots were opened and visually inspected. Configuration was installed by Ansible; compiler settings were not repaired manually during the final GUI pass.

    C rejects accidental C++ compilation and requires C23. C++ requires C++23 and uses std::println. Java asserts the exact runtime version and the required VM flags. Python asserts PyPy and its version. Supplementary checks inspect static C/C++ executables and Java class versions.

      {notes}
    +

    Results

    {''.join(rows)}
    IDELanguageResultAction tested
    +

    +{''.join(cards)} +{instructions} +

    Supporting checks

    {''.join(logs)}{fixtures}
    +

    Configuration references

    The installed IDE files and their serialized settings were also inspected. These primary references explain the supported configuration mechanisms.

      {sources}

    Report generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}.

    +
    ''' +(report / 'index.html').write_text(page) +print(f'{report / "index.html"}: {passed}/{total} passed; {len(page):,} bytes') diff --git a/tests/make_report.py b/tests/make_report.py new file mode 100755 index 0000000..d36dcbc --- /dev/null +++ b/tests/make_report.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# Builds test_report/index.html from the screenshots and test_report/captions.txt +# (one line per screenshot: NAME | caption), plus test_report/feature_check.txt. +import html, os +os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'test_report')) +captions = {} +if os.path.exists('captions.txt'): + for line in open('captions.txt'): + if '|' in line: + name, text = line.split('|', 1) + captions[name.strip()] = text.strip() +names = sorted(f[:-4] for f in os.listdir('.') if f.endswith('.png')) +order = [n for n in captions if n in names] + [n for n in names if n not in captions] +out = ['ACPC 2026 image, test report', + '', + '

    ACPC 2026 contest image: test report

    '] +if os.path.exists('intro.txt'): + out.append('

    %s

    ' % html.escape(open('intro.txt').read().strip())) +if os.path.exists('technotes_check.txt'): + out.append('

    Technical notes checklist

    %s
    ' % html.escape(open('technotes_check.txt').read())) +if os.path.exists('feature_check.txt'): + out.append('

    Feature checklist

    %s
    ' % html.escape(open('feature_check.txt').read())) +out.append('

    Screens

    ') +for n in order: + out.append('
    %s %s
    ' + % (html.escape(n), html.escape(captions.get(n, '')), n)) +out.append('') +open('index.html', 'w').write('\n'.join(out)) +print('test_report/index.html:', len(order), 'screens') diff --git a/tests/monitor.py b/tests/monitor.py new file mode 100755 index 0000000..7e1ff31 --- /dev/null +++ b/tests/monitor.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# Sends one command to the QEMU monitor: monitor.py SOCKET 'command' +# (screendump FILE, sendkey KEY, device_add ..., quit, ...) +import socket, sys, time +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +time.sleep(0.3) +s.recv(65536) +s.sendall((sys.argv[2] + "\n").encode()) +time.sleep(0.5) diff --git a/tests/shot.sh b/tests/shot.sh new file mode 100755 index 0000000..f37bb90 --- /dev/null +++ b/tests/shot.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Screenshot of the test VM: tests/shot.sh NAME -> test_report/NAME.png +cd "$(dirname "$0")/.." +mkdir -p test_report tests/work +python3 -I tests/monitor.py tests/work/monitor.sock "screendump $PWD/tests/work/shot.ppm" +sleep 1 +python3 -I -c "from PIL import Image; Image.open('tests/work/shot.ppm').save('test_report/$1.png')" +echo "test_report/$1.png" diff --git a/tests/technotes_check.sh b/tests/technotes_check.sh new file mode 100755 index 0000000..3c1b653 --- /dev/null +++ b/tests/technotes_check.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Checks, from the team account in the test VM, the statements of the +# technical notes for the teams (docs/TechNotes.tex) that can be checked on +# the machine: tests/technotes_check.sh -> test_report/technotes_check.txt +cd "$(dirname "$0")/.." +mkdir -p test_report +tests/vssh.sh team 'bash -s' <<'EOF' | tee test_report/technotes_check.txt +set +e +mkdir -p ~/tn && cd ~/tn +pass=0; fail=0 +ok(){ echo "PASS $1"; pass=$((pass+1)); }; ko(){ echo "FAIL $1"; fail=$((fail+1)); } +t(){ d=$1; shift; if eval "$@" >/dev/null 2>&1; then ok "$d"; else ko "$d"; fi; } +echo "2 3" > input.txt +cat > base.cpp <<'X' +#include +int main(){int a,b;scanf("%d %d",&a,&b);printf("%d\n",a+b);} +X +for ext in cpp cc C cxx c++; do cp base.cpp "p.$ext"; rm -f a.out + t "C++ extension .$ext: compilecpp, then runcpp < input.txt" "compilecpp p.$ext && [ \"\$(runcpp < input.txt)\" = 5 ]"; done +cat > p.c <<'X' +#include +int main(void){int a,b;if(scanf("%d %d",&a,&b)!=2)return 1;printf("%d\n",a+b);return 0;} +X +rm -f a.out; t "C: compilec p.c, then runc < input.txt" "compilec p.c && [ \"\$(runc < input.txt)\" = 5 ]" +rm -f a.out; t "compilegcc is another name for compilec" "compilegcc p.c && [ -x a.out ]" +rm -f a.out; t "compileg++ is another name for compilecpp" "compileg++ p.cpp && [ -x a.out ]" +t "programs are linked statically (-static)" "file a.out | grep -q 'statically linked'" +t "compilec runs gcc-15 -g -O2 -o a.out -std=gnu23 -static ... -lm" "grep -q 'gcc-15 -g -O2 -o a.out -std=gnu23 -static' /usr/local/bin/compilec" +t "compilecpp runs the Kattis command" "grep -q 'g++-15 -g -O2 -o a.out -std=gnu++23 -static -lrt -Wl,--whole-archive -lpthread -Wl,--no-whole-archive' /usr/local/bin/compilecpp" +cat > Prog.java <<'X' +import java.util.*; +public class Prog{public static void main(String[] a){Scanner s=new Scanner(System.in);System.out.println(s.nextInt()+s.nextInt());}} +X +t "Java: compilejava Prog.java gives Prog.class, runjava Prog < input.txt" "compilejava Prog.java && [ -f Prog.class ] && [ \"\$(runjava Prog < input.txt)\" = 5 ]" +cat > Mem.java <<'X' +public class Mem{public static void main(String[] a){System.out.println(Runtime.getRuntime().maxMemory()/1048576);}} +X +compilejava Mem.java; m1=$(runjava Mem); m2=$(MEMLIM=1024 runjava Mem) +t "runjava uses 2048 MB (max heap $m1 MB)" "[ $m1 -gt 1900 ] && [ $m1 -le 2048 ]" +t "MEMLIM=1024 runjava uses 1024 MB (max heap $m2 MB)" "[ $m2 -gt 950 ] && [ $m2 -le 1024 ]" +t "java and javac are 25.0.4.1" "java -version 2>&1 | grep -q '\"25.0.4.1\"' && javac -version 2>&1 | grep -q 25.0.4.1" +printf 'a,b=map(int,input().split())\nprint(a+b)\n' > p.py; cp p.py p.py3 +t "Python: compilepython3 p.py, then runpython3 p.py < input.txt" "compilepython3 p.py && [ \"\$(runpython3 p.py < input.txt)\" = 5 ]" +t "Python extension .py3 runs" "[ \"\$(runpython3 p.py3 < input.txt)\" = 5 ]" +t "runpython3 runs PyPy 7.3.23 (Python 3.11.15)" "runpython3 -c 'import sys; assert sys.implementation.name==\"pypy\" and sys.pypy_version_info[:3]==(7,3,23) and sys.version_info[:3]==(3,11,15)'" +printf 'print(1/0)\n' > rt.py; t "compilepython3 only checks the syntax (a run time error passes)" "compilepython3 rt.py" +printf 'print(\n' > bad.py; t "compilepython3 reports a syntax error" "! compilepython3 bad.py" +t "python3 is another interpreter, CPython 3.14" "python3 --version | grep -q 'Python 3.14'" +t "gcc-15 and g++-15 are 15.2.0" "[ \$(gcc-15 -dumpfullversion) = 15.2.0 ] && [ \$(g++-15 -dumpfullversion) = 15.2.0 ]" +cat > deep.cpp <<'X' +#include +int f(int n){return n?1+f(n-1):0;} +int main(){printf("%d\n",f(10000000));} +X +t "runcpp: large stack, recursion depth 10 million" "compilecpp deep.cpp && [ \"\$(runcpp)\" = 10000000 ]" +t "man 3 printf works" "man 3 printf | head -5 | grep -qi printf" +t "pypy3 -m pydoc heapq works" "pypy3 -m pydoc heapq | head -3 | grep -q heapq" +t "logged in as team, home /home/team" "[ \"\$(id -un)\" = team ] && [ \"\$HOME\" = /home/team ] && loginctl list-sessions --no-legend | grep -q 'team seat0'" +for app in clion codeblocks Eclipse intellij-idea pycharm com.microsoft.VSCode gvim emacs geany org.kde.kate org.gnome.gedit; do + t "in the Applications menu: $app" "[ -f /usr/share/applications/$app.desktop ]"; done +t "vim and nano are installed" "command -v vim && command -v nano" +t "DevDocs at https://devdocs/ answers" "curl -sk -o /dev/null https://devdocs/" +for d in c cpp openjdk~25 python~3.11; do + t "DevDocs serves the documentation of $d" "[ \$(curl -sk -o /dev/null -w %{http_code} https://devdocs/docs/$d/index.json) = 200 ]"; done +t "DevDocs has no Kotlin documentation" "[ \$(curl -sk -o /dev/null -w %{http_code} https://devdocs/docs/kotlin~2.4/index.json) != 200 ]" +t "offline contest help is readable from the documented menu" "test -r /usr/share/doc/acpc/help/index.html && grep -qx 'Exec=firefox file:///usr/share/doc/acpc/help/index.html' /usr/share/applications/acpc-help.desktop" +t "offline help covers input, judging and every contest language" "python3 -c \"from pathlib import Path;s=Path('/usr/share/doc/acpc/help/index.html').read_text();assert all(x in s for x in ['Reading until EOF','Judgements','C and C++','Java main class','Python 3 and modules'])\"" +t "contest site reachable" "curl -s -o /dev/null -m 8 https://atlantic-canada26.kattis.com" +t "Kattis status page reachable" "curl -s -o /dev/null -m 8 https://status.kattis.com" +t "other websites blocked (en.cppreference.com)" "! curl -s -o /dev/null -m 8 https://en.cppreference.com" +t "clock synchronized" "chronyc -n sources | grep -q '^\^\*'" +t "time zone Atlantic (ADT/AST)" "date +%Z | grep -qE 'ADT|AST'" +for source in p.c p.cpp Prog.java p.py; do + t "IDE action acpc-solution builds and runs $source" "acpc-solution run $source < input.txt | tail -1 | grep -qx 5" +done +t "VS Code contest folder and task shortcut exist" "test -d ~/contest && grep -q f6 ~/.config/Code/User/keybindings.json" +t "Eclipse named contest build/run action exists" "test -f ~/eclipse-workspace/.metadata/.plugins/org.eclipse.debug.core/.launches/ACPC\ Build\ and\ run.launch" +t "Geany Java uses runjava for the documented VM options" "grep -q '^EX_00_CM=runjava' /usr/share/geany/filedefs/filetypes.java" +t "CLion includes Python with the PyPy SDK" "grep -q /usr/local/bin/pypy3 ~/.config/JetBrains/CLion*/options/jdk.table.xml" +cd ~ && rm -rf ~/tn +echo; echo "$pass passed, $fail failed" +EOF diff --git a/tests/test-ssh.conf b/tests/test-ssh.conf new file mode 100644 index 0000000..360a179 --- /dev/null +++ b/tests/test-ssh.conf @@ -0,0 +1,3 @@ +[Service] +# Test VM only (tests/check_vm.sh). Accepts SSH so the test tools can log in. +ExecStartPost=/usr/sbin/nft insert rule inet filter input tcp dport 22 accept diff --git a/tests/type.py b/tests/type.py new file mode 100755 index 0000000..5aa2ece --- /dev/null +++ b/tests/type.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# Types text into the VM through the QEMU monitor (US keyboard). +# Usage: type.py MONITOR_SOCKET 'text' (newline = Enter; keys: {{ctrl-s}}, {{esc}}, ...) +import re, socket, sys, time + +SHIFTED = {'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', '*': '8', + '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left', + '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe', + '<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent'} +PLAIN = {' ': 'spc', '\n': 'ret', '\t': 'tab', '-': 'minus', '=': 'equal', '[': 'bracket_left', + ']': 'bracket_right', '\\': 'backslash', ';': 'semicolon', "'": 'apostrophe', + ',': 'comma', '.': 'dot', '/': 'slash', '`': 'grave_accent'} + +def keys(text): + for token in re.split(r'(\{\{[a-z0-9_-]+\}\})', text): + if token.startswith('{{') and token.endswith('}}'): + yield token[2:-2] + continue + for c in token: + if c in PLAIN: + yield PLAIN[c] + elif c in SHIFTED: + yield 'shift-' + SHIFTED[c] + elif c.isupper(): + yield 'shift-' + c.lower() + elif c.isalnum(): + yield c + +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +time.sleep(0.2) +text = open(sys.argv[2][1:]).read() if sys.argv[2].startswith('@') else sys.argv[2] +for k in keys(text): + s.sendall(('sendkey %s\n' % k).encode()) + time.sleep(0.06) +time.sleep(0.3) diff --git a/tests/usb.sh b/tests/usb.sh new file mode 100755 index 0000000..62ba86e --- /dev/null +++ b/tests/usb.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Plugs a USB stick (32 MB, label TEAMUSB, one file) into the test VM started +# with --usb: tests/usb.sh plug|unplug +cd "$(dirname "$0")/.." +W=tests/work +case "$1" in +plug) + mkdir -p "$W/usb/content" + echo "prepared code library" > "$W/usb/content/lib.cpp" + truncate -s 32M "$W/usb/usb.img" + mkfs.ext4 -q -F -L TEAMUSB -d "$W/usb/content" "$W/usb/usb.img" + n=$(date +%s) + python3 -I tests/monitor.py "$W/monitor.sock" "drive_add 0 if=none,id=ud$n,file=$PWD/$W/usb/usb.img,format=raw" + python3 -I tests/monitor.py "$W/monitor.sock" "device_add usb-storage,bus=xhci.0,drive=ud$n,id=usbstick" + ;; +unplug) + python3 -I tests/monitor.py "$W/monitor.sock" "device_del usbstick" + ;; +esac diff --git a/tests/vssh.sh b/tests/vssh.sh new file mode 100755 index 0000000..1128956 --- /dev/null +++ b/tests/vssh.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Runs a command in the test VM: tests/vssh.sh team|icpc 'command' +# Passwords: TEAM_PASS (default contest) and ADMIN_PASS (password given to build.sh). +user=$1; shift +case "$user" in + team) pass=${TEAM_PASS:-contest} ;; + icpc) pass=${ADMIN_PASS:?set ADMIN_PASS to the administrator password} ;; + *) echo "user must be team or icpc" >&2; exit 2 ;; +esac +exec sshpass -p "$pass" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=5 -p 2222 "$user@127.0.0.1" "$@" diff --git a/write_sticks.sh b/write_sticks.sh new file mode 100755 index 0000000..5f9dc40 --- /dev/null +++ b/write_sticks.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Writes the contest image to USB sticks, all at the same time. +# ERASES the listed sticks. Only USB disks are accepted. +# +# sudo ./write_sticks.sh build/acpc2026.qcow2 /dev/sdb /dev/sdc ... +# +# The image can be the qcow2 file of build.sh (the ICPC image it is based on +# must stay where it was during the build) or a raw .img file. +set -uo pipefail + +usage() { + echo "Usage: sudo $0 IMAGE DEVICE [DEVICE...]" >&2 + echo >&2 + echo "USB disks currently attached:" >&2 + lsblk -d -o NAME,SIZE,MODEL,TRAN | awk 'NR == 1 || $NF == "usb"' >&2 + exit 2 +} + +[ $# -ge 2 ] || usage +IMG=$1 +shift +[ -f "$IMG" ] || { echo "Image not found: $IMG" >&2; exit 1; } +[ "$(id -u)" -eq 0 ] || { echo "Run this script as root (sudo)." >&2; exit 1; } +command -v qemu-img >/dev/null || { echo "qemu-img is missing." >&2; exit 1; } + +FORMAT=$(qemu-img info --output=json "$IMG" | python3 -I -c 'import json, sys; print(json.load(sys.stdin)["format"])') +SIZE=$(qemu-img info --output=json "$IMG" | python3 -I -c 'import json, sys; print(json.load(sys.stdin)["virtual-size"])') + +echo "Image: $IMG ($FORMAT, $((SIZE / 1000000000)) GB)" +echo +for dev in "$@"; do + [ -b "$dev" ] || { echo "Not a disk: $dev" >&2; exit 1; } + [ "$(lsblk -dn -o TYPE "$dev")" = disk ] || { echo "Not a whole disk: $dev" >&2; exit 1; } + [ "$(lsblk -dn -o TRAN "$dev")" = usb ] || { echo "Not a USB disk, refused: $dev" >&2; exit 1; } + if lsblk -n -o MOUNTPOINTS "$dev" | grep -q '[^[:space:]]'; then + echo "A partition of $dev is mounted, unmount it first." >&2 + exit 1 + fi + devsize=$(blockdev --getsize64 "$dev") + [ "$devsize" -ge "$SIZE" ] || { echo "$dev is too small ($((devsize / 1000000000)) GB)" >&2; exit 1; } + echo " $dev $(lsblk -dn -o SIZE,MODEL "$dev")" +done +echo +echo "ALL DATA ON THESE STICKS WILL BE ERASED." +read -rp "Type ERASE to continue: " answer +[ "$answer" = ERASE ] || { echo "Cancelled."; exit 1; } + +logdir=$(mktemp -d) +write_one() { + local dev=$1 start=$(date +%s) + if qemu-img convert -n -f "$FORMAT" -O raw -t none "$IMG" "$dev" > "$logdir/$(basename "$dev").log" 2>&1; then + sync + echo " done $dev ($(( $(date +%s) - start )) s)" + else + echo " FAILED $dev (see $logdir/$(basename "$dev").log)" + return 1 + fi +} + +echo "Writing..." +fail=0 +pids=() +for dev in "$@"; do + write_one "$dev" & + pids+=($!) +done +for pid in "${pids[@]}"; do wait "$pid" || fail=1; done + +if [ $fail -eq 0 ]; then + echo "All sticks are written." + rm -rf "$logdir" +else + echo "At least one stick failed. Logs: $logdir" +fi +exit $fail