diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
new file mode 100644
index 0000000..8fd52b1
--- /dev/null
+++ b/.clusterfuzzlite/Dockerfile
@@ -0,0 +1,14 @@
+# Build image for ClusterFuzzLite / OSS-Fuzz. Same contract as an upstream OSS-Fuzz project, with
+# the one documented difference: ClusterFuzzLite COPYs the checkout in, where OSS-Fuzz git-clones it.
+FROM gcr.io/oss-fuzz-base/base-builder-python
+
+# The panel's runtime dependencies. They have to be present at BUILD time, not just at run time:
+# PyInstaller bundles what it can import while compiling each fuzzer, so a missing dependency here
+# becomes a fuzzer that dies on startup instead of a build that fails loudly.
+# Copied on its own first so this layer caches across source-only changes.
+COPY requirements.txt $SRC/requirements.txt
+RUN pip3 install --no-cache-dir -r "$SRC/requirements.txt"
+
+COPY . $SRC/linuxgsm-panel
+WORKDIR $SRC/linuxgsm-panel
+COPY .clusterfuzzlite/build.sh $SRC/
diff --git a/.clusterfuzzlite/README.md b/.clusterfuzzlite/README.md
new file mode 100644
index 0000000..9e029de
--- /dev/null
+++ b/.clusterfuzzlite/README.md
@@ -0,0 +1,55 @@
+# OSS-Fuzz / ClusterFuzzLite build integration
+
+These three files are the OSS-Fuzz build contract for this repo:
+
+| file | what it does |
+|---|---|
+| `Dockerfile` | `base-builder-python` + the panel's pinned requirements + the checkout |
+| `build.sh` | compiles every `tests/fuzz/fuzz_*.py` harness into a fuzz target and zips its seed corpus |
+| `project.yaml` | language, engine and sanitizers |
+
+`.github/workflows/cflite_pr.yml` runs them on each PR in **code-change** mode: it fuzzes only what
+the diff touched and files any crash as a SARIF finding in the Security tab, alongside the CodeQL
+and Bandit alerts.
+
+This overlaps with `.github/workflows/fuzz.yml` on purpose. That one is the broad sweep — every
+target, fixed 60s, plain Atheris. This one is targeted, uses the real OSS-Fuzz toolchain, and
+dedupes/reports crashes. Dropping either is a reasonable choice; keeping both is the current one.
+
+## Running it locally
+
+Needs Docker. From the repo root:
+
+```bash
+git clone --depth 1 https://github.com/google/oss-fuzz /tmp/oss-fuzz
+python3 /tmp/oss-fuzz/infra/helper.py build_image --external $PWD
+python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --external $PWD
+python3 /tmp/oss-fuzz/infra/helper.py check_build --external $PWD --language python
+python3 /tmp/oss-fuzz/infra/helper.py run_fuzzer --external $PWD fuzz_console
+```
+
+`check_build` is the one worth running after touching `build.sh`: a PyInstaller bundle can compile
+cleanly and still die on its first import if a dynamically-imported module was not bundled. That is
+why `build.sh` names `paramiko`, `eventlet`, `eventlet.tpool` and `config` as `--hidden-import` —
+the harnesses pull them in via `importlib.import_module("...")`, a string PyInstaller cannot see.
+
+## Not enabled: batch fuzzing, corpus pruning, coverage
+
+Those three modes want somewhere to keep a corpus between runs, which means a **separate storage
+repo** and a personal access token. Without one, every run starts from the seeds in
+`tests/fuzz/corpus/` and learns nothing from the last run. To turn them on: create an empty repo,
+add a PAT with write access to it as a secret, then pass `storage-repo` to both actions and add
+workflows with `mode: batch`, `mode: prune` and `mode: coverage`. See
+.
+
+## Upstream OSS-Fuzz
+
+The same three files, moved to `projects/linuxgsm-panel/` in a PR against
+, are a complete submission — the only change needed is a
+`Dockerfile` that `git clone`s this repo instead of `COPY`ing the checkout, plus a
+`primary_contact` email on a Google account.
+
+Worth knowing before spending the effort: the stated bar is that a project *"must have a
+significant user base and/or be critical to the global IT infrastructure"*. A self-hosted game
+control panel is unlikely to clear it. ClusterFuzzLite exists precisely so that projects below that
+bar get the same engine on their own CI, which is what is wired up here.
diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh
new file mode 100755
index 0000000..564d075
--- /dev/null
+++ b/.clusterfuzzlite/build.sh
@@ -0,0 +1,43 @@
+#!/bin/bash -eu
+# Build every Atheris harness in tests/fuzz/ into an OSS-Fuzz fuzz target.
+#
+# The same script works for ClusterFuzzLite and for an upstream OSS-Fuzz project — compile_python_
+# fuzzer comes from the base-builder-python image in both.
+
+SRC_DIR="$SRC/linuxgsm-panel"
+
+# The panel is an application, not a pip package: there is no setup.py, so `pip3 install .` (what the
+# OSS-Fuzz Python guide shows) does not apply. Putting the repo root on PYTHONPATH is what makes the
+# harnesses' `import ssh_manager` resolve, and it is what lets PyInstaller find those modules to
+# bundle while compiling.
+export PYTHONPATH="$SRC_DIR${PYTHONPATH:+:$PYTHONPATH}"
+
+# PyInstaller only bundles what it can see in an `import` statement, and these packages reach for
+# their real implementations through strings at import time. eventlet.hubs is the one that bit:
+#
+# builtin_hub_modules = tuple(importlib.import_module('eventlet.hubs.' + name)
+# for name in ('epolls', 'kqueue', 'poll', 'selects'))
+#
+# so naming --hidden-import eventlet.tpool built five targets that all died on
+# `No module named 'eventlet.hubs.epolls'`. --collect-submodules takes the whole package and does
+# not need updating when a new submodule shows up. dns arrives transitively (eventlet's greendns);
+# paramiko resolves its kex/cipher backends the same dynamic way.
+COMPILE_ARGS=(
+ --collect-submodules eventlet
+ --collect-submodules paramiko
+ --collect-submodules dns
+ --hidden-import config
+)
+
+for fuzzer in "$SRC_DIR"/tests/fuzz/fuzz_*.py; do
+ name="$(basename -s .py "$fuzzer")"
+ compile_python_fuzzer "$fuzzer" "${COMPILE_ARGS[@]}"
+
+ # Seed corpus. The runner picks up _seed_corpus.zip sitting next to the binary in
+ # $OUT. The corpora are named after the target minus its fuzz_ prefix (tests/fuzz/corpus/console
+ # feeds fuzz_console), matching what .github/workflows/fuzz.yml already passes on the command line.
+ corpus_dir="$SRC_DIR/tests/fuzz/corpus/${name#fuzz_}"
+ if [ -d "$corpus_dir" ]; then
+ zip -j -q -r "$OUT/${name}_seed_corpus.zip" "$corpus_dir"
+ fi
+done
diff --git a/.clusterfuzzlite/project.yaml b/.clusterfuzzlite/project.yaml
new file mode 100644
index 0000000..a08a631
--- /dev/null
+++ b/.clusterfuzzlite/project.yaml
@@ -0,0 +1,10 @@
+# ClusterFuzzLite project configuration. `language` is the only required key; the rest are the
+# fields an upstream OSS-Fuzz submission also wants, kept here so the two stay in sync.
+language: python
+homepage: "https://github.com/FMSMITH91/linuxgsm-panel"
+main_repo: "https://github.com/FMSMITH91/linuxgsm-panel"
+fuzzing_engines:
+ - libfuzzer
+sanitizers:
+ - address
+ - undefined
diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml
new file mode 100644
index 0000000..561f6bb
--- /dev/null
+++ b/.github/workflows/cflite_pr.yml
@@ -0,0 +1,59 @@
+name: ClusterFuzzLite
+
+# ClusterFuzzLite is OSS-Fuzz's engine packaged to run on any repository — same base images, same
+# build contract (.clusterfuzzlite/{Dockerfile,build.sh,project.yaml}), no enrolment needed.
+#
+# It overlaps with fuzz.yml, deliberately and differently: fuzz.yml runs every target for a fixed
+# 60s on each push, while this runs in 'code-change' mode, fuzzing only what the diff actually
+# touched and reporting a crash as a SARIF finding in the Security tab next to the CodeQL and
+# Bandit alerts. Keeping both is a choice — fuzz.yml is the broad sweep, this is the targeted one.
+on:
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: read
+
+jobs:
+ code-change:
+ name: fuzz the diff (${{ matrix.sanitizer }})
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write # upload-sarif writes the crash into code scanning
+ concurrency:
+ group: ${{ github.workflow }}-${{ matrix.sanitizer }}-${{ github.ref }}
+ cancel-in-progress: true
+ strategy:
+ fail-fast: false
+ matrix:
+ sanitizer: [ address ]
+ steps:
+ - name: Build fuzzers (${{ matrix.sanitizer }})
+ id: build
+ uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ language: python
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ sanitizer: ${{ matrix.sanitizer }}
+ # bad-build-check is left ON (the default). These targets are PyInstaller bundles, so the
+ # failure to worry about is a fuzzer that builds fine and then dies on its first import —
+ # which is exactly what that check catches.
+
+ - name: Fuzz the changed code (${{ matrix.sanitizer }})
+ id: run
+ uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
+ with:
+ language: python # run_fuzzers defaults to c++ — it does not inherit the build's setting
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ fuzz-seconds: 180 # per PR, across the targets the diff touches — fuzz.yml does the sweep
+ mode: code-change
+ sanitizer: ${{ matrix.sanitizer }}
+ output-sarif: true
+
+ - name: Report a crash as a code-scanning finding
+ if: always() && steps.build.outcome == 'success'
+ uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
+ with:
+ sarif_file: cifuzz-sarif/results.sarif
+ checkout_path: cifuzz-sarif