Runtime & Internals¶
Architecture¶
Every command that touches Docker runs as user sre (uid 1100): sre-wrapper exports USER_USERNAME and calls sudo sre --user; the CLI then drops from root to sre_uid. How the drop happens depends on the project:
Non-privileged projects (no machine has
privileged=True): privileges are dropped permanently at start viasetuid(sre_uid)/setgid(docker_gid)— the process can never regain root for the rest of its life.Privileged projects (at least one machine has
privileged=True): privileges are dropped temporarily viaseteuid/setegidonly. Kathara requires the deploy/connect/exec paths to run as genuine root (to mount cgroups, attach a TTY to/sbin/init, etc.), so the CLI raises the effective uid back to 0 around those operations and lowers it again afterwards. Host-side subprocesses still drop tosre_uidin the child viapreexec_drop_to_sre(), so user code on the host never runs as root.
Filesystem layout¶
Path |
Description |
|---|---|
|
Installation root |
|
CLI binary |
|
C binary for student use (handles sudo) |
|
GUI launcher script |
|
Lab files |
|
Shared libraries for project files ( |
|
SVG icons and logos |
|
Compiled gettext translations for the CLI |
|
State of |
|
Runtime state root ( |
|
Exam configuration (absent = no exam) |
|
One directory per running lab instance |
|
Default evaluation archive directory |
|
Self-grade cooldown timestamps |
|
Shared directories for projects with |
|
Temporary directories of |
Running lab directory¶
/var/lib/sre/projects/{timestamp}@@@{lab_name}@@@{username}/
├── .private/ mode 0o700
│ ├── data.json serialized Data instance, mode 0o600
│ ├── srelab symlink to the lab's srelab.py
│ ├── eval_in_progress lock file (PID) during active eval
│ └── auto_eval.log one ISO timestamp per student self-evaluation
├── info.json public machine/switch/question metadata (InfoLab; `switches`: the visible networks with their type)
├── operations.log debug projects only: operations of every state / evaluation (GUI Log tab), mode 0o644
├── answers/
│ ├── answers.json student answers (updated by GUI)
│ └── cheat.json instructor-provided answers (if any)
└── shared/ mode 0o777 (only if shared_path=True)
Configuration¶
Constants (src/SRE/params.py)¶
Constant |
Value |
Description |
|---|---|---|
|
|
UID of the |
|
|
GID of the |
|
|
User mode: a caller only sees and drives their own projects and those started by root or the |
|
|
Max parallel Docker API calls during eval |
|
|
Default auto-eval period (s) |
|
|
Default exam duration (min), used if |
|
|
|
|
|
Window before |
|
|
Extract last GECOS sub-field as student email |
|
|
External terminal emulator |
|
|
Default terminal font size (pt) |
|
|
Default terminal colors |
|
|
Name substrings that hide labs from |
|
|
Allowed lab source directories |
|
|
Type of switch of a network that declares no |
|
|
Console commands a student may run on a managed switch; any other is refused in user mode |
|
|
Keys used in |
Environment variables¶
GUI / wrapper → sre CLI¶
Variable |
Used by |
Description |
|---|---|---|
|
sre CLI (with |
Student login name (set by |
|
sre CLI, GUI, tests |
Override |
|
params.py |
Override the sre-wrapper path |
|
sre CLI |
Loads French help strings when set to a French locale |
sre CLI → containers¶
Injected into machines at deploy / exec / state-transition time.
Variable |
Description |
|---|---|
|
Running lab name ( |
|
X authority cookie forwarded into privileged containers so GUI apps can reach the host X server — defined by |
|
Host IP reachable from containers (default |
Locale¶
The CLI uses gettext (locale/fr/LC_MESSAGES/sre.mo); the GUI uses Qt .qm catalogs (translations/sysreseval_fr.qm). Both are regenerated by make translations.
Dynamic module loading¶
Each lab is loaded at runtime via importlib.util.spec_from_file_location('srelab', ...). The module path must sit under params.authorized_src_dir (/opt/sre/lab/ or /home/etudiant/), and /opt/sre/lib/ is prepended to sys.path so labs can from ips import …, from net_config import …, etc.
Data serialization¶
Data0 encodes to JSON/msgpack with a polymorphic envelope:
{
"__type__": "srelab.Data",
"data": {
"secret": "changeme",
"ips": {"router": "10.0.0.1/24"},
"nets": {"lan1": "10.0.0.0/24"},
"ips6": {"router": "2001:db8::1/64"},
"nets6": {"lan1": "2001:db8::/64"},
"macs": {}
}
}
Value |
Encoding |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Nested |
|
The container keys and markers are the params.data_json_* constants. Files written before IPv6 support have no ips6 / nets6 keys: they load as empty containers.
data.json is saved mode 0o600 inside .private/ (mode 0o700).
Test execution¶
Grade.run_tests() per step:
Call
grade()to register tests (no execution).Group commands per machine and encode them into one env var
EXETESTS@@@{timeout}:{cmd}@@@{timeout}:{cmd}@@@….Run
exetests.pyinside each container; outputs are separated by a per-run UUID.Parse outputs into
self._tests[(machine, step)][(command, timeout)]; repeat untilstep > max_step; callgrade()a final time to compute grades.
Up to 16 machines tested concurrently (ThreadPoolExecutor). Exit codes: 0–255 actual, -1 = timeout.
The console commands registered with Grade.test_switch() are stored in the same self._tests, under (network, step) and (command, 0). They are not sent to exetests.py: after the containers of a step, run_tests() runs them one by one on the console of the managed switch (see Switches). Being ordinary entries of tests, they are archived and replayed by sre re-eval like the others.
Switches¶
The type of a network (Network.mode: hub, switch, managed switch — see Hubs, switches and VLANs) is the mode of its Kathara collision domain, a feature of the Kathara fork and of its VDE network plugin.
Deployment.
NetScheme0.get_new_lab_from_scheme()sets the mode of the Kathara link of each network that is not a hub and passes the declared VLANs of a port (vlan=,tagged_vlans=) when a machine is connected to a managed switch. Nothing is said for a hub, so a lab with hubs only makes the same Kathara calls as before and runs with the stock plugin. Kathara refuses a non-hub mode when the plugin does not advertise it (KATHARA_SWITCH_MODESin the plugin settings):sre start/sre restorethen roll back andstart.quit_if_no_switch_modes()reports the networks concerned.Console. A managed switch is a
vde_switchstarted with a management socket, which Kathara reaches from the host (Kathara.exec_link(), one connection per command).src/SRE/switch_console.pyis the only place where SRE uses it:SwitchConsole.run(network, command)returns(output, code)—0, the error number of the switch, or-2— and never raises; a word@machineis replaced by the port found withKathara.get_link_ports()(ports are labelled<machine>:eth<N>by the plugin). It servesNetScheme.switch_cmd()(applied bysre statewith the host operations of the step),Grade.test_switch(),sre execandsre connect.Students.
sre connect <project> <network>in user mode is a prompt of SRE, not a raw access to the socket (which belongs to root and the Docker group): each line is checked againstparams.switch_user_commandsbefore it is sent, andconnect.get_switch()refuses a hub or a plain switch, a network of hidden machines only, and a switch closed byallow_connection(a debug project lifts the last two).End of a session. The shell of a machine dies with its container; nothing would end the prompt of a switch when its project is stopped.
switch_console.interactive()therefore polls the project directory from a thread (everyparams.switch_console_watch_intervalsecond) and, once it is gone, interrupts the prompt of the main thread withSIGINT— as Ctrl-C does, so thatreadlinerestores the terminal — and returns.GUI.
Grade0.save_lab_info()writesswitchesininfo.json:{name, mode, allow_connection}for every network a student sees.view/switches_view.pydraws the Networks tab from it; the tab is always shown, hubs only or not.Operations log. Switch commands of states and evaluations are logged as
stepN - on switch <network> : <command>.
Course text (informations)¶
The course of a lab and its question texts may hold the plain-text markers of SRE.informations (## @@sec:tag@@ Title, @@tag:name@@, @@ref:tag@@ / @@ref:tag:title@@, @@toc@@ / @@toc:depth@@), emitted by info_subtitle(), info_tag(), info_ref(), info_toc() (defined there, re-exported by SRE.lib_sre next to tr() and instructor()) or typed inside a tr() block. The engine is src/SRE/informations.py (imports instructor_text only):
Grade0.save_lab_info()callsresolve_informations()on theTranslatedTextofinformations(language by language; the languages must declare the same sections and tags, in the same order) andresolve_text()on the question titles and descriptions and on the state descriptions with the resultingNumbering, beforestrip_instructor(). The GUI,sre exportand the archives only ever see resolved text;sre checkruns the same resolution and printsoutline().Resolved forms: a header becomes
## <a id="tag"></a>2.1. Title(an untagged section gets the idsec.2.1, which no tag can equal), an anchor<a id="tag"></a>, a reference[2.1](#tag)in the course and the bare2.1in a question (the browser of a question has no anchor), the table of contents a nested markdown bullet list of such links, indented like the line of its marker so that thededent()ofmarkdown_to_html()still finds the common margin. Markers inside fenced code blocks are left untouched.The
@@sec@@,@@tag@@and@@toc@@markers standing inside an instructor fragment are dropped (@@ref@@is resolved), so that a project in instructor mode is numbered like a student’s one. A question hash is computed at registration from the marked title (_tt_hash_str), so a renumbering of the course never detaches stored answers.QTextBrowser knows the
idanchors and scrolls to them on a#taglink without leaving the document. fpdf2 raises on an<a>withouthrefand on a#taglink to a named destination it does not know:pdf.markdown_to_html()removes the anchors and unwraps those links (the numbers stay) beforewrite_html().Errors are
ValueErrorwith a lab-facing message (sre checkprints them asFAIL).
Progress reporting¶
sre start emits JSON lines to stderr so the GUI can render progress:
{"phase": "pull", "status": "start", "image": "sre/base:1.10"}
{"phase": "pull", "status": "progress", "image": "sre/base:1.10", "percent": 42}
{"phase": "pull", "status": "end", "image": "sre/base:1.10"}
{"phase": "deploy", "status": "start", "total": 3}
{"phase": "deploy", "status": "progress", "current": 1, "total": 3}
{"phase": "deploy", "status": "end", "total": 3}
Security model¶
Concern |
Mechanism |
|---|---|
Allowed UIDs |
|
Privilege drop |
Root calls |
Lab path whitelist |
|
Eval lock |
Lock file opened with |
Host commands drop privileges |
|
Shell injection |
|
|
Mode |
Unknown |
|
Single GUI instance |
PID file at |
Student command restrictions |
|
Project ownership |
In user mode |
State access control |
|
Switch console |
In user mode only the commands of |
Archive format¶
Every evaluation writes a zstandard-compressed msgpack file with a .zst extension to params.archive_dirs (default ['/var/lib/sre/archives']; labs may extend the list via a module-level archive_dirs attribute).
Filename: {eval_date}_{running_lab_name}.zst — eval_date is YYYYmmddHHMMSS, running_lab_name is {timestamp}@@@{lab_name}@@@{username}. The leading timestamp sorts chronologically with ls.
archive = {
"running_lab_name": "{timestamp}@@@{lab_name}@@@{username}",
"eval_date": "20260615110042",
"data_json": "<JSON string>", # serialized Data
"tests": {
("router", 1): {("ping -c1 8.8.8.8", 5): ("64 bytes ...\n", 0), ...},
...
},
"errors": ["error message", ...],
"answers": {
"<question_hash>": "<student answer>",
"hostname": "pc-101",
"login": "alice",
"fullname": "Alice Martin", # from GECOS, always present
"email": "alice@example.com", # when email_in_gecos_last_field=True
"group": "INFO1", # web front-end only, when a group source is configured
"language": "fr",
"answers_updated_at": "20260615110000",
"auto_eval_count": 2, # self-evals before this one
"exam_mode": true, # exam-mode only
"exam_started_at": "2026-06-15T09:00", # exam-mode only
"exam_duration": 120, # exam-mode only
"exam_time_remaining": 3600, # exam-mode only
},
"grade_list": [
{"title": "Connectivity", "max_grade": 4, "grade": 4,
"grade_letter": null, "description": ""},
...
],
"total_grade": 14.0,
"total_max": 20.0,
}
Archives are written by sre eval, eval-all, eval-exam, end-exam, re-eval, and consumed by sre cat, sheet, outline, check-eval, watch — see CLI Reference.
Save file format¶
sre save writes, and sre restore reads, a single .sre file (src/SRE/save_archive.py). The layout is sequential so that sre restore - can consume it from stdin:
SRESAVE1\n magic (params.save_file_magic)
{"format_version": 1, …}\n one-line cleartext JSON header (keys = params.save_meta_*)
<payload> tar stream — encrypted when the lab defines save_key
Header fields: format_version, sre_version, lab_name (the lab@path form), running_lab_name, srelab_file, username, saved_at, debug_project, full_images, shared_path, encrypted, kdf_salt, kdf_iterations.
Payload members:
Member |
Content |
|---|---|
|
|
|
The patched Kathara |
|
Student answers and |
|
|
|
Contents of the user public dir ( |
|
|
Not saved: info.json (regenerated), records/, the eval lock, and what was changed on the console of a managed switch after the start (the restored switches get their type and the VLANs the lab declares; the lab’s restore state can re-apply more with switch_cmd()). Kathara’s diff excludes /etc/hosts, /etc/hostname, /etc/resolv.conf, /dev, /proc, /sys, /run, /tmp, /hosthome, /shared, /hostlab; bind-mounted directories are covered by user_public/ and mnt/ instead.
Encryption (save_key): the payload tar stream is cut into params.save_cipher_chunk_size chunks, each sealed with AES-256-GCM (key = PBKDF2-HMAC-SHA256 of save_key with the header’s salt). Frame: [4-byte length][1-byte last flag][12-byte nonce][ciphertext+tag]; the associated data is sha256(header) + chunk index + last flag, so header tampering, reordering and truncation are detected. The cryptography package is imported lazily (only needed for encrypted files).
Restore (src/SRE/command/restore.py): read the header → import the lab module (allow_save_restore required, save_key checked) → extract the payload into /var/lib/sre/tmp with tarfile.data_filter → recreate the project directories with the start.py helpers (ProjectSetup, create_project_directories), copy the saved project files back and give them to the student (answers 0o666, shared/ and volume dirs 0o777) → build the Kathara lab from the restored Data (get_new_lab_from_scheme(): fresh host ports, X11 cookie, volumes, /shared) → Kathara.restore_lab(kathara.tar, lab=…) (images rebuilt from the base image + diff, deployed with the same pull/deploy progress events as start) → refresh exetests.py → finalize_project (lifecycle state restore, data.json, info.json). Any failure triggers rollback_project (undeploy + directory removal). Restored images (kathara_save_<hash>:<device>) are not garbage-collected; docker image prune removes the unused ones.