Parcourir la source

Harden process (Game) checking.

gregory il y a 1 semaine
Parent
commit
b733de7a2c
4 fichiers modifiés avec 95 ajouts et 1 suppressions
  1. 20 0
      Readme
  2. 27 0
      src/au/pfip/Harness.java
  3. 44 1
      src/au/pfip/Processes.java
  4. 4 0
      src/au/pfip/settings.java

+ 20 - 0
Readme

@@ -36,6 +36,26 @@ defaults below. It is Java XML properties format.
 | `GamesRefreshMinutes` | `60` | How often the games list is fetched again |
 | `GamesURL`, `PlaytimeURL` | the overlord URLs | Server endpoints |
 | `LogFilePath` | home folder | Where `timekeeper<day>.log` is written |
+| `MatchWholeWords` | `true` | A game name must not run on into more letters or digits in the command line (`technic` no longer matches `technical-report.odt`). `false` = plain "contains" |
+| `IgnoreCommandLines` | *(empty)* | Comma separated command line fragments that exempt a process, e.g. `--kiosk,-contentproc`. Anyone can add such a fragment to a command they start themselves |
+
+## games.json
+
+A JSON array of strings, or an object whose values are the names (keys are ignored).
+Each name is looked for in the full command line (`ps -eo user,args`) of every process
+the student owns, case-sensitively. Check what a game really looks like first:
+
+```bash
+ps -eo user,args | grep -i <name>
+```
+
+- Use a name that appears in the command line, and is specific: `technic` also matched
+  `technical-report.odt` before whole-word matching.
+- Wine/Proton games show up as their `.exe` name (`bf2.exe`). Native Steam games show up
+  as the binary (`hl2_linux`).
+- Browsers listed here are closed like any other game, including a browser used for
+  Timekeeper's own questions. Use `IgnoreCommandLines` or a separate browser for those.
+- It is a deny-list: a browser, launcher or game that isn't listed isn't enforced.
 
 The games list is cached in `games.cache` next to the settings file and used when the
 server can't be reached.

+ 27 - 0
src/au/pfip/Harness.java

@@ -54,6 +54,33 @@ public class Harness {
         check("stubborn process found", Processes.matching(Processes.snapshot(me), stubName).size() == 1);
         check("stubborn (ignores SIGTERM) is force-killed", Processes.terminate(me, stubName) && !stubborn.isAlive());
 
+        // --- whole-word matching
+        ProcessHandle dummy = ProcessHandle.current();
+        java.util.function.BiPredicate<String, String> hit = (cmd, game) ->
+                !Processes.matching(List.of(new Processes.Proc(dummy, cmd)), game).isEmpty();
+        check("technic matches ~/.technic path", hit.test("java -jar /home/x/.technic/launcher.jar", "technic"));
+        check("technic does NOT match technical-report", !hit.test("libreoffice /home/x/technical-report.odt", "technic"));
+        check("opera matches /usr/lib/opera/opera", hit.test("/usr/lib/opera/opera --type=renderer", "opera"));
+        check("opera does NOT match operate/cooperative", !hit.test("tool --operate cooperative.txt", "opera"));
+        check("minecraft matches minecraft-launcher", hit.test("/opt/minecraft-launcher/minecraft-launcher", "minecraft"));
+        check("minecraft matches -Dminecraft.launcher", hit.test("java -Dminecraft.launcher.brand=x -jar y", "minecraft"));
+        check("wine exe matches", hit.test("wine Z:\\games\\bf2.exe", "bf2.exe"));
+        check("underscore names match", hit.test("/x/hl2_linux -game hl2", "hl2_linux"));
+        check("firefox-esr matches firefox", hit.test("/usr/lib/firefox-esr/firefox-esr", "firefox"));
+        settings.addSetting("MatchWholeWords", "false");
+        check("MatchWholeWords=false is plain contains", hit.test("libreoffice /home/x/technical-report.odt", "technic"));
+        settings.addSetting("MatchWholeWords", "true");
+
+        // --- ignore list
+        Process kiosk = new ProcessBuilder("bash", "-c", "exec -a 'kgame-" + (n + 11) + " --kiosk' sleep 600").start();
+        Thread.sleep(500);
+        String kName = "kgame-" + (n + 11);
+        check("process found without ignore list", Processes.matching(Processes.snapshot(me), kName).size() == 1);
+        settings.addSetting("IgnoreCommandLines", "--kiosk, -contentproc");
+        check("process ignored with IgnoreCommandLines=--kiosk", Processes.matching(Processes.snapshot(me), kName).isEmpty());
+        settings.addSetting("IgnoreCommandLines", "");
+        kiosk.destroyForcibly();
+
         // --- communications against a local fake server
         HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
         final String[] body = {"12"}; final int[] status = {200};

+ 44 - 1
src/au/pfip/Processes.java

@@ -4,8 +4,11 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 
 /**
  * Finds and stops a student's game processes without a shell.
@@ -34,6 +37,7 @@ public class Processes {
     public static List<Proc> snapshot(String user) {
         List<Proc> result = new ArrayList<>();
         long self = ProcessHandle.current().pid();
+        List<String> ignoredCommandLines = ignoredCommandLines();
 
         ProcessHandle.allProcesses().forEach(handle -> {
             try {
@@ -48,6 +52,11 @@ public class Processes {
                 if (commandLine.isEmpty() || commandLine.contains("notify-send")) {
                     return;
                 }
+                for (String ignored : ignoredCommandLines) {
+                    if (commandLine.contains(ignored)) {
+                        return;
+                    }
+                }
                 result.add(new Proc(handle, commandLine));
             } catch (RuntimeException e) {
                 // The process ended while we were looking at it.
@@ -58,17 +67,51 @@ public class Processes {
 
     /**
      * The processes in the list whose command line contains the game name.
+     *
+     * By default the name must not run on into more letters or digits:
+     * "technic" matches "/home/x/.technic/launcher" but not
+     * "technical-report.odt", and "opera" does not match "operate" or
+     * "cooperative". Anything else after the name (. - _ / space \ end of
+     * line) is fine. Nothing is required before the name, so "-Dminecraft"
+     * (a Java property) still matches. Set MatchWholeWords to false for the
+     * old plain "contains" matching.
      */
     public static List<Proc> matching(List<Proc> processes, String game) {
+        boolean wholeWords = !"false".equalsIgnoreCase(settings.getSetting("MatchWholeWords"));
+        Pattern pattern = wholeWords ? wholeWordPattern(game) : null;
+
         List<Proc> result = new ArrayList<>();
         for (Proc proc : processes) {
-            if (proc.commandLine.contains(game)) {
+            boolean found = (pattern == null) ? proc.commandLine.contains(game) : pattern.matcher(proc.commandLine).find();
+            if (found) {
                 result.add(proc);
             }
         }
         return result;
     }
 
+    private static final Map<String, Pattern> patterns = new HashMap<>();
+
+    private static Pattern wholeWordPattern(String game) {
+        return patterns.computeIfAbsent(game, g -> Pattern.compile(Pattern.quote(g) + "(?![A-Za-z0-9])"));
+    }
+
+    /**
+     * Command line fragments (IgnoreCommandLines, comma separated) that take a
+     * process out of consideration, e.g. "--kiosk". Note that anyone can add
+     * such a fragment to a command they start themselves.
+     */
+    private static List<String> ignoredCommandLines() {
+        List<String> ignored = new ArrayList<>();
+        for (String part : settings.getSetting("IgnoreCommandLines").split(",")) {
+            part = part.trim();
+            if (!part.isEmpty()) {
+                ignored.add(part);
+            }
+        }
+        return ignored;
+    }
+
     /**
      * Stop the user's processes for the game: politely first, then by force.
      *

+ 4 - 0
src/au/pfip/settings.java

@@ -38,6 +38,10 @@ public class settings {
         // How many checks in a row may fail to reach the server before the game is closed (1 = close at once).
         defaults.put("OfflineChecksBeforeKill", "5");
         defaults.put("GamesRefreshMinutes", "60");
+        // A game name must not run on into more letters/digits ("technic" no longer matches "technical").
+        defaults.put("MatchWholeWords", "true");
+        // Command line fragments that exempt a process, comma separated, e.g. "--kiosk,-contentproc". Empty = none.
+        defaults.put("IgnoreCommandLines", "");
     }
 
     public static boolean initialiseSettings()