Lab Authoring Guide¶
A lab is a Python module that defines three classes: Data, NetScheme, and Grade, each inheriting from the corresponding *0 base class in lib_sre.py. An optional Flavor class can be defined to parameterize the lab at start time.
A lab is usually a single .py file.
Directory labs
SRE also supports directory labs — a subdirectory containing a srelab.py
plus per-state file trees (initial/, state1/, …) that are copied into the containers at the matching state
transition. This is the Kathara layout, but in practice it’s rarely needed: most setup is better expressed in code, since Data-derived values
(random IPs, secrets, etc.) can then be injected into the generated files.
The directory form is mainly useful when you need to ship payload that can’t reasonably be generated at runtime — a pre-built apt package,
a large data file, or anything else you don’t want to download from the Internet on every lab start.
/opt/sre/lab/s4/tp_ssh/
├── srelab.py ← the module
├── initial/ ← files injected at startup
│ ├── router/ ← files for the machine named "router"
│ │ └── etc/network/interfaces
│ └── all/ ← files copied to every machine
│ └── etc/motd
└── state1/ ← files injected when "sre state <lab> state1" is called
└── router/
└── etc/...
The authoring workflow on the CLI¶
While iterating on a srelab.py, you typically alternate between editing the module and exercising it with a handful of sre subcommands. Full reference lives in CLI reference; this section just names the commands you’ll reach for as an author and explains the one option that exists for authors only.
sre check [-p] <lab> [<state>]¶
Static validation, no containers deployed. Imports the module, runs Data.compute_pre_generate() → Data.generate() → data.compute_post_generate(), checks that the Data instance survives the data.json round trip (to_json() / from_json(), pack() / unpack(), as sre start and sre eval do), instantiates NetScheme, applies initial() (op registration only), then calls Grade.grade() once. Catches import errors, missing fields, bad topology, and exceptions raised at registration time. With a trailing <state> argument, the named @sre_state method is also exercised. Run this first after every non-trivial edit — it’s much faster than starting a real lab. The numbered sections of informations are resolved and printed as an outline, and the @@ref@@ references of the question texts are checked (see Numbered sections).
sre start --debug-project [-p] <lab> [--xauth-file <path>]¶
The author counterpart to sre start. Privileged only and incompatible with --user. Deploys the lab exactly like a normal start, then drops a .private/debug_project marker that the rest of the runtime keys on. Effect:
Topology: every machine is shown in the GUI’s Machines tab — even those declared
hidden=Trueorallow_connection=False. Connect buttons are exposed for all of them so you can step inside infrastructure machines (DNS, monitors, helpers) to inspect their state. A Full Schema tab, right after Schema, draws the topology with the hidden machines too: they, their links and the networks only they use are in red (the Schema tab stays the student’s view).Restrictions lifted: user-mode guards on
sre stateandsre connect(which normally refuse to touch hidden machines or applyuser_allowed=Falsestates) are bypassed.Grading: when you
sre evala debug project, marks are always shown and every grade scope is surfaced —no_mark_on_self_grade,hide_potential_penalty_grades_in_self_gradeand the periodic-eval gating are all ignored.Operations log: every state application (
initialat start,sre state, thesave/restorelifecycle states) and every evaluation appends what it executed to<project dir>/operations.log: one header per run, thenstepN - on <machine|host> : <operation>lines with the command output and exit code. File writes, appends and copies show the path, mode, owner and size, followed by the content written (text only, capped like command outputs). Each evaluation ends with its grade elements (grade - [<part>] <title> : <grade> / <max>, with(self-eval only)/(exo-eval only)when the scope is restricted) and the totals and marks of both scopes. The GUI shows it live in the Log tab. Never written for a normal project.Instructor texts: the texts wrapped in
instructor()are kept ininfo.json, as for a project in instructor mode, and the GUI shows its Instructor mode button on the project to display or hide them.-pis implicit:--debug-projectalways treats the lab argument as a filesystem path, so you can point it directly at the.pyyou’re editing (e.g.~/labs/draft.py) without copying it into/opt/sre/lab/first.--debug-web-projectis the name form: the same debug project with the lab given by its name as for a normal start (sre start --debug-web-project sre/dhcp.py; the exam-only and draft labs ofsre list --include-exam-onlyaccepted), so that the project keeps the lab name of the catalog (sre@dhcp.py, not@opt@sre@lab@sre@dhcp.py) and is grouped with the lab’s other projects by the reports and the web site. It is what the Debug mode of the web site’s Open page runs. The two options and-pare mutually exclusive;--instructor-modecombines with either.
The marker survives container restarts and is cleared when the project is sre stopped or sre wiped.
--xauth-file <path> is the other authoring-time option on sre start. By default, machines marked x11_host=True pick up the host’s X magic cookie from the SRE_XAUTH_COOKIE environment variable (the sre-wrapper sets this for the GUI flow). When you start a project by hand from a shell where that variable isn’t set — typical for --debug-project — pass --xauth-file ~/.Xauthority to source the cookie from a file instead. Privileged only.
sre connect <running_lab> <device> and sre exec <running_lab> <device> <command…>¶
sre connect opens an interactive shell inside <device> (using the machine’s configured shell), or the management console when <device> is a managed switch (a network with 'mode': 'managed', see Switches and VLANs). sre exec runs a one-shot command and returns its stdout/stderr/exit code — useful for scripting checks against a running lab without holding a TTY. On a managed switch sre exec <running_lab> <network> <command…> runs one console command (vlan/print, port/setvlan @pc1 10). sre exec is privileged only; both honor the debug-project marker, so they can target hidden machines when the project was started with --debug-project.
sre connect --exec <argument…> is a hybrid: launches the machine’s shell, runs the command, exits. Unlike sre exec it goes through the shell launcher and is available to students.
sre state <running_lab> <state_name>¶
Applies a non-initial @sre_state method against the live containers. The main use case during authoring is exercising the instructor’s “fully-configured” state (often called final) so you can run sre eval against a known-good configuration and verify the grading code reports the expected full marks. In a non-debug project, students can only apply states declared with @sre_state(user_allowed=True) and only when the module sets allow_user_states = True — --debug-project lifts both guards.
sre eval <running_lab>¶
Runs an evaluation against the live containers — equivalent to pressing the Start evaluation button in the GUI. The evaluation also refreshes the project’s view of the lab module, so any edits to informations, questions, or grade rubric take effect on the next display without needing to restart the lab.
sre wipe¶
Privileged only (the user-mode form is sre stop-all, the caller’s own projects): stops every running Kathara lab and removes everything under /var/lib/sre/projects/. The reset button between iterations: when a lab is misbehaving and sre stop <running_lab> isn’t enough (orphaned bridges, broken Kathara state, leftover marker files), sre wipe brings the host back to a clean slate. It does not touch exam.json or archive directories.
A typical edit-test cycle:
sre check -p ~/labs/draft.py final # validate module + the 'final' state
sre start --debug-project -p ~/labs/draft.py # deploy, with debug visibility
sre connect <running_lab> router # poke around
sre state <running_lab> final # apply the instructor's known-good state
sre eval <running_lab> # confirm grade == max
sre wipe # reset before the next edit
Identifying a running lab and where its files live¶
Every running project lives in its own subdirectory of /var/lib/sre/projects/. The directory name encodes the launch timestamp, the lab name, and the student username:
/var/lib/sre/projects/{YYYYmmddHHMMSS}@@@{lab_name}@@@{username}/
e.g. 20260606143215@@@s4@ssh@@@alice. List the directory to see what’s currently running:
ls /var/lib/sre/projects/
Partial names are accepted. Every subcommand that takes a <running_lab> argument resolves it by substring match against the directory listing —
you only need to type enough of the name to uniquely identify one project. If your substring matches several running labs, sre prints the candidates and
exits so you can disambiguate; if it matches none, you get no running lab matches '...'. In practice the lab name itself (or even a fragment of it) is usually enough:
sre connect ssh router # works if only one ssh project is running
sre connect 20260606 router # disambiguate by timestamp prefix
sre connect alice router # disambiguate by username
Inside the project directory you’ll find:
Path (relative to the project dir) |
Contents |
|---|---|
|
Public metadata read by the GUI: lab title, |
|
Student answers to |
|
Optional instructor-provided cheat answers keyed by state name (used by |
|
Bind-mounted into every container as |
|
Mode |
|
The serialized |
|
Symlink to the lab’s |
|
Target of |
|
Asciinema recordings of terminal sessions, if |
|
PID lock created during |
|
Marker dropped by |
|
Marker of the instructor mode ( |
|
One line per student-triggered self-eval; drives the |
Evaluation archives are not stored under the project directory — they go to /var/lib/sre/archives/ (plus any extra archive_dirs declared at module level), named {YYYYmmddHHMMSS}_{running_lab_name}.zst.
The four classes at a glance¶
A lab module defines up to four classes, each with a narrow role:
Data— a@dataclassthat holds every per-instance parameter of the lab (IP addresses, secrets, ports, randomized values).Data.generate()is called once atsre start; the resulting instance is serialized to.private/data.jsonand reloaded on every later operation.Flavor(optional) — a@dataclassthat lets a singlesrelab.pyproduce different variants of the lab at start time (e.g. random vs. fixed addresses, easy vs. hard). The GUI rendersflavor_formas a form; the chosenFlavoris then passed toData.generate(flavor).NetScheme— declares the network topology (_machine_specs,_network_specs,_topology) and the imperative configuration of each state. Theinitialstate runs atsre start; additional@sre_statemethods can be applied later withsre state <lab> <name>.Grade— registers what to evaluate and how to score it. It does not run anything itself: SRE drives a multi-steprun_tests()loop that callsGrade.grade()repeatedly, executing the commands registered by each call between passes. See The grading lifecycle.
The flow at sre start is: Data.generate(flavor) → NetScheme(data).initial() (which lays out interfaces and writes files). The flow at sre eval is: reload data.json into a fresh Data, rebuild NetScheme, then run Grade.run_tests() against the live containers.
Data class¶
Data is a dataclass that holds all lab-specific parameters (IP addresses, random secrets, etc.). It inherits from Data0.
from dataclasses import dataclass
from ipaddress import IPv4Interface
from SRE.lib_sre import Data0
from ips import random_ipv4networks, random_ipv4s
@dataclass(slots=True)
class Data(Data0):
secret: str = ''
vlan_id: int = 0
@classmethod
def generate(cls, flavor=None):
data = cls(secret="changeme", vlan_id=42)
# data.nets and data.ips are injected automatically by Data0.__post_init__
data.nets.lan, data.nets.mgmt = random_ipv4networks([24, 24], from_private_network=True)
data.ips.router = random_ipv4s(data.nets.lan, 1)[0] # an IPv4Interface, prefix included
data.ips.printer = IPv4Interface('192.0.2.7/24') # explicit values work too
return data
Key points:
data.ipsholdsIPv4Interfacevalues (address + prefix length);data.netsholdsIPv4Networkvalues;data.ips6holdsIPv6Interfacevalues;data.nets6holdsIPv6Networkvalues;data.macsholdsnetaddr.EUI(MAC address) values.All five containers are injected automatically into every
Data0subclass instance by__post_init__— no declaration needed (and do not declare fields with these names).The containers enforce their types: assigning a plain string to
data.ipsraisesTypeError; always wrap with the correct type before assigning. The families are kept apart:data.ipsrefuses anIPv6Interfaceanddata.ips6anIPv4Interface.data.ipsrequiresIPv4Interface(not bareIPv4Address) — always assign with a prefix, e.g.IPv4Interface('10.0.0.1/24'); likewisedata.ips6requiresIPv6Interface, e.g.IPv6Interface('2001:db8::1/64').Data.generate(flavor)is called once atsre start; the result is saved todata.json.Serialization: the containers are stored as strings; standalone fields of type
IPv4Address/IPv6Address,IPv4Interface/IPv6Interface,IPv4Network/IPv6Network,EUIand nestedData0subclasses are encoded with markers and restored with their exact type. Any other field must be JSON-serialisable (sre checkverifies the round trip).
IP helper functions (from /opt/sre/lib/ips.py)¶
from ipaddress import IPv6Network
from ips import (random_ipv4networks, random_ipv4s, random_ipv6networks, random_ipv6s,
random_ips_from_topology)
# n disjoint IPv4 networks with the given prefix lengths (RFC 1918 ranges with from_private_network=True):
nets = random_ipv4networks([24, 28, 24], from_private_network=True)
# n distinct random IPv4Interface values (prefix included) in a network;
# the network and broadcast addresses are never returned:
data.ips.host, data.ips.gw = random_ipv4s(nets[0], 2, exclude_nets=[nets[1]])
# IPv6 twins — search space: global unicast 2000::/3 by default, unique local fd00::/8 with
# from_private_network=True, or anything with from_network=IPv6Network('2001:db8::/32'):
data.nets6.lan, data.nets6.wan = random_ipv6networks([64, 64], from_private_network=True)
# n distinct IPv6Interface values; offset 0 (the subnet-router anycast address) is never returned:
data.ips6.host, data.ips6.gw = random_ipv6s(data.nets6.lan, 2)
# Fill data.ips (and data.ips6 with ipv6=True) for every machine of a topology from
# data.nets / data.nets6 — data.ips.m for single-homed machines, data.ips.m_net otherwise:
random_ips_from_topology(data, NetScheme._topology, ipv6=True) # ipv4=False, ipv6=True: IPv6-only lab
random_ipv4s_with_range / random_ipv6s_with_range draw ordered address ranges (for DHCP pools), random_mac_address random unicast MAC addresses.
IPv6 labs¶
Kathara starts every container with IPv6 disabled (net.ipv6.conf.all.disable_ipv6=1). A lab turns it on with the module-level option:
ipv6 = True # IPv6 enabled in every machine of the lab
Machine(ipv6=False) (an 'ipv6': False entry in _machine_specs) opts one machine out, Machine(ipv6=True) opts one in when the module option is absent. With IPv6 enabled Kathara sets disable_ipv6=0, net.ipv6.conf.all.forwarding=1 and accept_ra=0 in the container (every interface gets its link-local address), so, as for IPv4 (set_ip_forward), the initial state turns forwarding off on the hosts with set_ipv6_forward(self, m, False). sre export writes the effective flag as m[ipv6]="true" in lab.conf.
A dual-stack lab keeps both families in the same net_config entry, IPv4 first:
from ipaddress import IPv4Network, IPv6Network
from net_config import get_net_config_from_topology, set_net_config_entry, set_ip_forward, set_ipv6_forward
# by hand:
self.net_config = {
'pc': [([d.ips.pc, d.ips6.pc], [(IPv4Network('0.0.0.0/0'), d.ips.gw), (IPv6Network('::/0'), d.ips6.gw)])],
}
# or from the topology (data.ips6 / data.nets6 follow the data.ips / data.nets naming; a machine
# without an entry in data.ips6 — a SLAAC or IPv4-only host — simply gets no IPv6 part):
self.net_config = get_net_config_from_topology(self, gateway='gw', ipv6=True) # default_route6=... for a ::/0 on gw;
# default_route (172.17.0.1, the Docker bridge, for a bridged=True gateway) / default_route=None: no 0.0.0.0/0 on gw
# in initial(): set_net_config_entry() emits `ip addr add` / `ip route add` for both families
set_net_config_entry(self, 'pc', self.net_config['pc'])
set_ipv6_forward(self, 'pc', False)
Readers take ipv6=True to also return the IPv6 side (link-local and temporary addresses are left out): get_ip_addresses, get_routes (adds ip -6 route, default → ('::', 0)), get_net_config_entry, get_persistent_net_config_entry (parses iface ethN inet6 static stanzas); set_persistent_net_config_entry writes one inet and one inet6 stanza per interface; eval_net_config compares both families (net_config_entry_family(entry, 6) grades one family apart); eval_ping(self, 'pc', 'srv', ipv6=True) pings the IPv6 address of a machine named in net_config (an IPv6 literal needs no flag); create_hosts_file(..., ipv6=True) adds the IPv6 lines; setup_simple_tcp_server(..., ipv6=True) listens on ::.
Router advertisements / SLAAC: on the router set_radvd(self, 'gw', {2: [d.nets6.lan]}) writes /etc/radvd.conf and starts radvd; on the hosts set_slaac_client(self, 'pc') sets accept_ra=2 and autoconf=1 (a forwarding host ignores RAs otherwise). Grade with get_ip_addresses(self, 'pc', ipv6=True) (the SLAAC address is in d.nets6.lan) and get_routes(self, 'pc', ipv6=True)[('::', 0)] (next hop fe80::…). The Docker bridge has no IPv6 unless the daemon is configured for it: no IPv6 reaches the outside through bridged=True machines, and set_nat_gateway stays IPv4. See lab/sre/_DRAFT_dummy/ipv6_example.py for a complete dual-stack lab.
compute_pre_generate and compute_post_generate¶
Two optional lifecycle hooks let a Data subclass derive auxiliary values without storing them in data.json. Override either as needed; the defaults are no-ops.
Hook |
Signature |
Receiver |
Purpose |
|---|---|---|---|
|
|
class |
Set class-level attributes derived from |
|
|
instance |
Set instance-level attributes derived from the dataclass fields after generation or reload. |
When they run:
At
sre start:Data.compute_pre_generate(flavor)→Data.generate(flavor)→data.compute_post_generate().At
sre eval,sre state,sre connect, etc.: afterdata.jsonis reloaded into a freshDatainstance viafrom_json/from_dict/unpack, both hooks run again — pre-generate first (with the persistedflavor), then post-generate.sre checkexercises the same sequence as a sanity check.
Because the hooks run on every reload, they should be deterministic and side-effect-free: no random number generation, no IP allocation, no file I/O. Anything that must be randomized once and then persisted belongs in generate().
Typical use case — derive the machine list and per-machine spec from a Flavor:
@dataclass(slots=True)
class Data(Data0):
secret: str = ''
@classmethod
def compute_pre_generate(cls, flavor=None):
if flavor is None:
flavor = Flavor()
match flavor.network_size:
case "small":
r_max, m_max = 2, 2
case "medium":
r_max, m_max = 3, 4
case _:
r_max, m_max = 4, 7
cls.routers = [f"r{i}" for i in range(1, r_max + 1)]
cls.non_routers = [f"m{i}" for i in range(0, m_max + 1)]
cls.machine_specs = {'gw': {'bridged': True}}
@classmethod
def generate(cls, flavor=None):
data = cls(secret=random_password(16))
# cls.routers / cls.non_routers are already populated by compute_pre_generate
data.nets.lan = random_ipv4networks([24], from_private_network=True)[0]
return data
NetScheme.build() and Grade.grade() can then read self.data.routers / self.data.non_routers directly, even on reload, without those fields needing to live in data.json.
Flavor class¶
A Flavor is an optional dataclass that parameterizes a lab at start time. If defined, the GUI presents a form
to the student before starting.
A typical use case — see lab/sre/static_routing.py — is to switch a lab between two modes: when students work on it autonomously or during an exam, randomized IP addresses are preferable so each student has their own topology;
in a supervised classroom, the instructor may want every student to share the same values, so a single explanation at the board applies to everyone. A Flavor lets the lab expose both modes from the same srelab.py and pick one at start time.
from dataclasses import dataclass
from typing import Tuple
from SRE.lib_sre import Flavor0
@dataclass(slots=True)
class Flavor(Flavor0):
nb: int = 0
flavor_form = """
Number of clients: @@{nb:[0-9]+}@@
Mode: @@{mode:>easy|hard}@@
"""
def allowed_by_user(self) -> Tuple[bool, str]:
"""Return (True, '') if the student is allowed this flavor."""
if self.nb <= 5:
return True, ""
return False, "Maximum 5 clients allowed."
# Named presets (accessible as Flavor.easy, Flavor.hard, etc.)
Flavor.easy = Flavor(nb=1)
Flavor.hard = Flavor(nb=5)
The form takes the same fields as question_form() (single-line text with a regex, multi-line
text, dropdown, checkbox: the @@{field:spec}@@ grammar is described in Questions),
plus @@{name::Label}@@, a button that submits the form: the lab gets the pressed button as a
true attribute of the Flavor and the other buttons as false. The dialog adds a Submit button
when the form declares none, and refuses to submit while a text field does not match its regex.
Module-level control:
flavor_form_at_startup = True # show the flavor form when the student opens the lab
Flavor API:
Method |
Description |
|---|---|
|
Serialize/deserialize field values |
|
Build a |
|
Returns |
In Data.generate(flavor), check if flavor is not None before using flavor fields.
NetScheme class¶
NetScheme declares the lab’s network topology and exposes the imperative API used by every state method. Topology is declared at class level via three dicts; per-state actions live in methods decorated with @sre_state (covered in the next section).
from SRE.lib_sre import NetScheme0, sre_state, make_tr
tr = make_tr('en')
class NetScheme(NetScheme0):
# Declare machines: keys are machine names, values are Machine kwargs.
# A name follows Kathara's device-name rule: 1 to 30 characters among a-z, 0-9 and _
# (lowercase only; `mX` or `pc-1` are refused by `Machine()`, so `sre check` reports them)
_machine_specs = {
'router': {'color': 'green'},
'client': {},
'hidden': {'hidden': True, 'allow_connection': False},
}
# Declare network options: display hints, type of switch (hub by default)
_network_specs = {
'lan': {'color': 'yellow'},
'mgmt': {'color': 'gray'},
}
# Declare topology: net_name → list of machines (or dict with explicit interface numbers)
_topology = {
'lan': ['router', 'client'], # auto interface numbering
'mgmt': {'router': 0, 'hidden': 1}, # explicit interface numbers
}
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
# Markdown text for the Informations tab (supports make_tr for i18n); see
# "Numbered sections, references and table of contents" for the numbering helpers
self.informations = tr(
"## Lab description\nConfigure routing.",
fr="## Description\nConfigurez le routage.",
)
Declarations and accessors¶
Attribute / Method |
Description |
|---|---|
|
Class-level dict: |
|
Class-level dict: |
|
Class-level dict: |
|
The |
|
Markdown text (or |
|
Iterator over all |
|
Iterator over machine name strings |
|
Iterator over all |
Machine constructor parameters¶
Parameter |
Default |
Description |
|---|---|---|
|
|
Docker image |
|
|
Enable host-network bridging (required for |
|
|
Forward X11 to the host: inject |
|
|
Hidden from the Machines tab |
|
|
Show Connect button in the GUI |
|
|
Shell launched by |
|
|
Shell Kathara uses to execute startup |
|
|
Run the container in privileged mode. Refused at startup unless |
|
|
Enable IPv6 in the container. |
|
|
Node color in the SVG diagram |
|
|
Commands run at container start |
|
|
Kernel parameters, as an iterable of |
|
|
Environment variables |
|
|
Port mappings, e.g. |
|
|
ulimit settings |
|
|
Volume mounts |
Port wildcards: "80XX:80/tcp" allocates the first free port in 8000–8099.
Network parameters¶
Each _network_specs entry gives the keyword arguments of one network; a network that only appears in _topology takes the defaults.
Parameter |
Default |
Description |
|---|---|---|
|
|
Node color in the schema |
|
|
Node shape in the schema |
|
|
Type of switch the machines are plugged into: |
|
|
Managed switch only: students may open its console (Connect button of the Networks tab, |
|
|
Managed switch only: VLANs of the ports, by machine |
Switches and VLANs¶
By default the machines of a network share a hub, as in every lab written before the switch types existed. 'mode': 'switch' and 'mode': 'managed' change that per network — the whole feature (the Networks tab, the console and its commands, states, grading, limits) is described in Hubs, switches and VLANs:
_network_specs = {
'lan': {'mode': 'managed', 'allow_connection': False,
'vlans': {'pc1': 10, # access port: untagged frames in VLAN 10
'pc2': 20,
'r1': [10, 20], # trunk port: VLANs 10 and 20, tagged
'r2': {'vlan': 1, 'trunk': [30]}}}, # native VLAN 1 + VLAN 30 tagged
'dmz': {'mode': 'switch'},
}
_topology = {'lan': ['pc1', 'pc2', 'r1', 'r2'], 'dmz': ['r1', 'srv'], 'old': ['srv', 'pc2']} # 'old' is a hub
VLAN IDs go from 1 to 4094. A machine that
vlansdoes not name stays in the default VLAN of the switch (VLAN 0, untagged) with every other undeclared port; a trunk port withoutvlankeeps that default VLAN for its untagged frames.On a trunk port the machine handles the tags itself, e.g.
ip link add link eth0 name eth0.10 type vlan id 10.A switch that is not a hub drops the 802.1Q frames of a VLAN the port is not a member of: a trunk between two machines needs a hub or a managed switch with trunk ports, not a plain
'switch'.An unknown
mode,vlanson a network that is not managed, a machine that is not on the network or an invalid VLAN ID raiseValueErrorwhen theNetSchemeis built, sosre checkreports them.A managed switch has a management console:
sre connect <running_lab> <network>, the Connect button of the Networks tab, a terminal in the Terminals tab. In user mode only the commands ofparams.switch_user_commandsreach the switch, and'allow_connection': Falsecloses the console to students.A state runs console commands with
self.switch_cmd(network, command, ...), where@machinestands for the port of a machine (see Switch operations);grade()reads the switch withself.test_switch(network, command)andget_switch_ports()(see Switch helpers in the Grading Library Reference).A save file and
sre exportkeep the type of each switch and the declared VLANs, not what was changed on a console afterwards. A mode other thanhubneeds the VDE network plugin of the Kathara fork, installed bymake network-plugin(see Installation).
See lab/sre/_DRAFT_dummy/switch_example.py for a complete lab: the three types of network, access and trunk ports with a router on a stick, an open and a closed console, two states built on switch_cmd() and a grade reading the switch.
Instructor-only texts — instructor()¶
instructor(text, color=None, background=None) marks a text as meant for the instructor: notes, hints, the solution. It takes a plain string or a tr() text and combines with + like any other text:
from SRE.lib_sre import NetScheme0, Grade0, instructor, make_tr
class NetScheme(NetScheme0):
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
self.informations = instructor(tr("""
### Solution
- add the default route on `client`;
- the `final` state does it.
""", fr="""
### Corrigé
- ajouter la route par défaut sur `client` ;
- l'état `final` le fait.
""")) + tr("Configure the routing.", fr="Configurez le routage.")
class Grade(Grade0):
def grade(self):
super().grade()
self.question_text(
title=tr("Gateway") + instructor(" (10.0.0.1)"),
description=tr("Which gateway does the client use?")
+ instructor(tr("\n\nAny notation is accepted."), color="#1a4fa0", background="#e3edff"))
In a normal project the function ignores its argument: it returns an empty text, and nothing of it is written to
info.json(which students can read).In a project in instructor mode (
sre start --instructor-mode <lab>,sre restore --instructor-mode <file>, orsre set-instructor-mode <running_lab>on a running project — privileged only) the text is kept, and the GUI displays it in red on a light red background while its Instructor mode button is on.color/background('#rrggbb'or a colour name) change the colours of one text;params.instructor_text_color/params.instructor_background_colorare the defaults.A debug project (
sre start --debug-project) keeps the text as well: the same button displays or hides it, so the hints can be checked while the lab is being written.A question hash does not depend on the instructor texts: the answers stay attached when the mode is switched on a running project.
Rules:
call
instructor()fromNetScheme.__init__(aftersuper().__init__()) or fromGrade.grade(). The mode of the project is not known while the module is imported, so a module-level call (title,@sre_state(description=...),Flavor.flavor_form) always gives an empty text;use it for
self.informationsand for the titles and descriptions of the questions. The other texts (grade elements, state descriptions, files pushed to the machines) are plain text and would show the raw marks in instructor mode;a fragment is either inline text or one or several whole markdown blocks (paragraphs, lists, code blocks, tables). It is dedented on its own, so an indented triple-quoted block works whatever the indentation of the public text; it cannot be nested inside a list item of the public text;
no
@@{field:regex}@@form field inside a fragment (a student would not get the field);use
+orTranslatedText.format()to combine texts, as withtr():%and f-strings do not work on translated texts.
A lab file without the instructor texts — sbin/strip-instructor¶
The running system keeps the instructor texts away from students, but the lab file itself still holds them. sbin/strip-instructor writes the file without its instructor() calls, and with --full without its solution states either, for instance before handing a lab over:
sbin/strip-instructor lab/s4/mylab.py /tmp/mylab_public.py # output file
sbin/strip-instructor lab/s4/mylab.py /srv/public_labs/ # existing directory → /srv/public_labs/mylab.py
sbin/strip-instructor -i /srv/public_labs/mylab.py # replace the file itself
sbin/strip-instructor --full lab/s4/mylab.py /srv/public_labs/ # the solution states removed as well
an
instructor(...)operand of a+disappears with its operator (instructor(tr("note")) + tr("public")→tr("public")); a call standing alone becomes'';the
_TRANSLATIONSentries of thetr()strings that only those calls used are deleted, in every language;instructoris removed from itsfrom ... importwhen nothing uses it any more;formatting and comments are kept; the result is checked (it must parse and hold no
instructor()call), otherwise nothing is written.
With --full (no short form) the file loses the states students cannot apply as well, so that it holds no solution any more:
every
@sre_statemethod is removed with its decorator, unless it isinitial, a lifecycle state (save,restore:sre save/sre restoreapply them) or user-allowed (@sre_state(user_allowed=True, ...)); the_TRANSLATIONSentries that only those methods used go with them, andsre_stateleaves its import when no state is left;the
cheat_answers={...}of the questions lose the entries keyed by a removed state (the whole keyword when no entry is left);a removed state that a kept method still uses (
self.final()inrestore) makes the file refused: move the shared code to a helper method, or make the state user-allowed.
The stripped lab behaves like the original outside the instructor mode. Limits:
a text kept in a variable is not followed: with
note = tr("...")theninstructor(note), the definition ofnotestays in the file. The script warns about such variables; writenote = instructor(tr("..."))to have the text removed;with
--full, the helper methods and the variables that only the removed states (or theircheat_answers) used stay in the file: a warning names them (_solution_dhcpd_conf,q1_answers…), to be removed by hand;comments are not examined, and only the lab file is processed (not the state directories of a directory lab: with
--fulla warning names the directory of a removed state standing next to asrelab.py);a question keeps its hash as long as the removed text has no language that the text it was added to lacks (the usual case). Otherwise the archives of the stripped lab do not match the original lab in
sre re-eval.
In instructor mode every state can be applied from the GUI, as in a debug project: the Apply Configuration tab also lists the states that are not user_allowed (in red), so the instructor can apply a final state in front of the class without making it available to students. The project also gets a Log tab listing what every applied state executed (self.cmd(), self.file()…, with outputs and exit codes), again only while the button is on.
Numbered sections, references and table of contents — info_subtitle(), info_toc()¶
The headers of a course, its table of contents and the mentions of a section (see section 7) are computed, so that inserting a section renumbers nothing by hand. Four functions imported from SRE.lib_sre, like tr() and instructor(), emit plain-text markers; the numbers, the links and the table of contents are resolved when info.json is written, in every language, so there is nothing else to call:
from SRE.lib_sre import NetScheme0, Grade0, make_tr, no_tr, info_subtitle, info_tag, info_ref, info_toc
tr = make_tr('fr')
class NetScheme(NetScheme0):
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
self.informations = (
no_tr("## ") + title + no_tr("\n")
+ tr("**Sommaire**\n", en="**Contents**\n") + info_toc()
+ info_subtitle(tr("À quoi sert DHCP", en="What DHCP is for"), tag="why")
+ tr("""
Un serveur prête une adresse pour la durée d'un **bail** (section @@ref:lease@@).
### @@sec@@ Les acteurs
Trois rôles : @@tag:roles@@ client, serveur, relais.
""", en="""
A server lends an address for the duration of a **lease** (section @@ref:lease@@).
### @@sec@@ The actors
Three roles: @@tag:roles@@ client, server, relay.
""")
+ info_subtitle(tr("La vie d'un bail", en="The life of a lease"), tag="lease")
+ tr("""...""")
)
class Grade(Grade0):
def grade(self):
super().grade()
self.question_text(
title=tr("Le bail", en="The lease"),
description=tr("Lisez la section @@ref:lease@@ des Informations (les rôles : @@ref:roles:title@@).",
en="Read section @@ref:lease@@ of the Informations tab (the roles: @@ref:roles:title@@)."))
The student sees a Sommaire listing 1. À quoi sert DHCP, 1.1. Les acteurs and 2. La vie d’un bail as links, the headers ## 1. À quoi sert DHCP, ### 1.1. Les acteurs, ## 2. La vie d'un bail, the sentence (section 2) where the number is a link that scrolls the tab to the section, and in the question Lisez la section 2 des Informations (les rôles : 1. À quoi sert DHCP).
function |
marker it returns |
resolved form in the course |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
a nested bullet list of |
The functions return plain strings (a tr() title keeps its languages) that combine with + like any text. The markers may just as well be typed inside a tr() block — the natural form for a sub-section or a reference in the middle of a long text, and the only one inside a block holding literal braces, which TranslatedText.format() would refuse: ### @@sec@@ Sub-title is numbered by its number of # (##: level 1, ###: level 2…), (section @@ref:lease@@) becomes a link.
Rules:
a header without a marker is not numbered, and a
#header (the lab title) never is;a tag is made of letters, digits,
_and-, in English like the identifiers of the lab, and is unique; a level cannot be skipped (no####right after a##);a question text may hold
@@ref@@markers only; they give bare numbers;the headers read
1.,2.1.,2.1.3., a reference drops the final dot; a marker inside a fenced code block is left as it is;do not number a header inside
instructor(): the numbers must be the same in and out of the instructor mode, so a@@sec@@,@@tag@@or@@toc@@standing inside an instructor fragment is dropped (a@@ref@@there is resolved);the languages of a
tr()course must declare the same sections and tags in the same order;a question hash is computed from the marked title, so renumbering the course never detaches the stored answers.
sre check resolves the course as sre start does, prints its outline (number, title, tag of every section) and reports a bad marker, a level jump, a tag defined twice or unknown — in the course or in a question — and languages whose sections differ, as a FAIL. The sre export PDF keeps the numbers and the table of contents without the links.
State methods¶
A state is a method of NetScheme decorated with @sre_state. The body of the method does not execute commands directly — instead, it registers operations (run a shell command, write a file, copy a file from the host, …) which SRE then applies in order against the running containers.
The initial state is always applied at sre start and is where the lab configures interfaces, launches services, and writes per-machine files derived from Data. Additional states are applied on demand with sre state <running_lab> <state_name>. Their main use case is the instructor’s workflow: a state that brings the project into a fully-configured, all-questions-answered shape, so the grading code can be exercised end-to-end without going through the student workflow. Students see and can apply a state only if the module-level attribute allow_user_states = True and that state was decorated with @sre_state(user_allowed=True).
@sre_state(user_allowed=False)
def initial(self):
"""Always applied at sre start."""
d = self.data
self.cmd('router', f'ip addr add {d.ips.router} dev eth0')
self.cmd('router', 'sysctl -w net.ipv4.ip_forward=1')
self.file('client', '/etc/resolv.conf', f'nameserver {d.ips.router.ip}\n')
@sre_state(user_allowed=True, description=tr("Final config", fr="Configuration finale"))
def final(self):
"""Optional additional state; students can apply it via 'sre state'."""
self.cmd('router', 'iptables -A FORWARD -j ACCEPT')
Lifecycle states: save / restore¶
NetScheme0 defines three lifecycle states that SRE applies itself: initial (at sre start), save (by sre save, just before the container filesystems are captured) and restore (by sre restore, on the new instance right after the containers have been redeployed from the saved filesystems). save and restore are empty by default and only matter for labs that set allow_save_restore = True.
A save captures files only: a restored container starts from its saved filesystem but nothing that initial() launched with cmd() is running any more. Relaunch those daemons in restore():
@sre_state()
def restore(self):
self.cmd('server', 'service ssh start')
self.cmd('server', 'service isc-dhcp-server start', allow_error=True)
Lifecycle states are never user-allowed, never listed in the GUI, and sre check runs save() / restore() when the lab allows save/restore. <srelab_dir>/save/ and <srelab_dir>/restore/ are therefore reserved state-file directories (see State file transfer).
@sre_state decorator¶
Parameter |
Description |
|---|---|
|
If |
|
Human-readable label shown in the GUI (supports |
|
If |
Per-machine operations¶
These all register an op against a single machine and accept a step= parameter (see Multi-step state setup below).
Method |
Description |
|---|---|
|
Run a shell command inside |
|
Create or overwrite a file inside |
|
Append to a file (creates it if missing). |
|
Same as |
File transfer between host and container¶
Method |
Description |
|---|---|
|
Copy a file from the host into |
|
Copy a file from |
Host-side operations¶
Method |
Description |
|---|---|
|
Run a shell command on the host (not inside any container). Returns |
|
Invoke a Python callable on the host at this step, with no arguments. Useful when the next steps need values computed in Python. |
Switch operations¶
Method |
Description |
|---|---|
|
Run one command on the management console of the managed switch |
Multi-step state setup — the step parameter¶
Every state-method operation accepts step=N (default 1). When SRE applies a state it groups all registered ops by step and runs them in ascending order: every step-1 op finishes before any step-2 op starts. This matters when later ops depend on earlier ones being in place — for example writing a config file at step 1 and only restarting the service at step 2:
@sre_state(user_allowed=False)
def initial(self):
self.file('dns', '/etc/unbound/unbound.conf', conf, step=1)
self.cmd('dns', 'systemctl restart unbound', step=2)
Inside a single step the per-machine op order is preserved, and ops on different machines run in parallel. host_cmd / host_callback / switch_cmd for step N run, in registration order, before the container ops of step N (so a key generated on the host at step 1 can be copied into a machine at step 1).
Container commands registered with self.cmd() run under sh through /usr/local/sbin/exetests.py, the runner Grade.test() also uses: consecutive cmd() ops of one machine at one step are sent as a single batch, shell syntax such as |, >> or && works, and each command is killed after timeout seconds (default params.default_state_cmd_timeout, 120 s; 0 disables the timeout; the exit code is then -1). A non-zero exit code is reported on stderr (hidden in --user mode) unless the call passes allow_error=True.
Reading command results — multi_pass=True¶
self.cmd(), self.host_cmd() and self.switch_cmd() return (output, exit_code) with the same register-then-resolve contract as Grade.test(): the value is the placeholder (default_value, default_code) until the command has actually run. By default a state method is called once, before anything runs, so it only ever sees placeholders.
Decorate the state with @sre_state(multi_pass=True) to have SRE call the method again before each step: the call that registers the ops of step N+1 sees the real results of steps 1..N. The method runs max_step + 1 times, like grade() during an evaluation: a final call after the last step sees the results of every command and is the natural place to copy them into self.data. Each call only contributes the ops of the step about to be applied; re-registering the ops of an earlier step is expected and harmless.
@sre_state(multi_pass=True)
def initial(self):
hostname, code = self.cmd('client', 'cat /etc/hostname', step=1) # ('', 0) on the first call
self.file('server', '/etc/motd', f'welcome {hostname.strip()}\n', step=2)
self.data.client_hostname = hostname.strip() # real on the final call; data.json is saved after initial()
Rules for a multi_pass state:
Consume a result at a later step than the command. An op that uses a result (
file(), anothercmd()) must be registered by a call that runs after the command, i.e. at stepN+1or later; a plainself.dataassignment can simply rely on the final call. An op registered during the final call for a new, higher step extends the loop — another pass follows, exactly as a lateself.test(step=N)extendsgrade().The method must be idempotent. It runs several times:
print()shows up once per pass, and anything random or filled in later must give the same answer on every pass. Wrap such values inself.once(key, factory), which callsfactory()the first timekeyis seen and returns the cached value afterwards (pcap_gen.generate_pcap_tcp_exampledoes this for its ports and result dict).self.stepis the number of steps already applied when the method runs (0 on the first call) andself.max_stepthe highest step registered so far.
sre check and sre export always call the method once and only see placeholders.
Network config helpers (from /opt/sre/lib/net_config.py)¶
from net_config import set_net_config_entry, set_sysctl, NetConfigEntry, SysctlConfig
# In initial():
set_net_config_entry(net_scheme=self, machine_name='router', nc_entry=[
([data.ips.router_lan], [(IPv4Network('0.0.0.0/0'), data.ips.gw)]),
])
set_sysctl(net_scheme=self, machine_name='router', sysctl_config={'ipv4.ip_forward': 1})
NetConfig is a list of interface entries; each entry is ([addresses], [(dest_network, gateway), ...]) or 'dhcp' or None. An entry may hold both families ([d.ips.pc, d.ips6.pc], routes to IPv4Network('0.0.0.0/0') and IPv6Network('::/0')): set_net_config_entry dispatches on the address family, and get_net_config_from_topology(..., ipv6=True) builds such entries — see IPv6 labs.
set_ip_forward(net_scheme, machine_name, ip_forward, step=1) and set_ipv6_forward(net_scheme, machine_name, ipv6_forward, step=1) set net.ipv4.ip_forward / net.ipv6.conf.all.forwarding (Kathara starts every machine with forwarding on in both families).
Persistent equivalents — these write to /etc/network/interfaces (one inet static and one inet6 static stanza per interface as needed) and /etc/sysctl.d/99-sre.conf so the config survives a container restart:
set_persistent_net_config_entry(net_scheme, machine_name, nc_entry)set_persistent_sysctl(net_scheme, machine_name, sysctl_config)
State helpers (from /opt/sre/lib/state_helpers.py)¶
Convenience functions called inside @sre_state methods:
from state_helpers import (set_unbound_server, set_basic_unbound_server,
change_password, create_user,
hosts_file_content, create_hosts_file,
setup_simple_tcp_server, set_radvd, set_slaac_client)
Function |
Description |
|---|---|
|
Drop a permissive Unbound DNS config into |
|
Same as above; explicit name when you want to make the “basic / permissive” intent obvious. |
|
Set a user’s password via |
|
Create a user with |
|
Return |
|
Write |
|
Start an idempotent daemon that answers |
|
Write |
|
Make the machine configure itself from router advertisements: |
Grade class¶
Grade declares what the evaluator checks and how results are scored. It does not execute anything itself: every self.test(...) call merely registers a command. SRE then drives a multi-step loop (run_tests()) that calls Grade.grade() repeatedly, executing registered commands between passes and feeding their real results back into the next call.
from SRE.lib_sre import Grade0
class Grade(Grade0):
def grade(self):
"""Register tests, questions, and grade elements.
Called multiple times per evaluation — must be side-effect-free."""
super().grade()
# Register a test: run a shell command in a container
result, code = self.test('router', 'ping -c1 -W1 8.8.8.8', timeout=5)
# Register a question (student's text answer)
answer = self.question_text(
title='Default gateway',
description='What is the default gateway for the client?',
default_answer='',
)
# Register a grade element and set its score
self.add_grade_element('Connectivity', max_grade=4)
if code == 0:
self.set_grade('Connectivity', 4)
elif '64 bytes' in result:
self.set_grade('Connectivity', 2)
self.add_grade_element('Gateway answer', max_grade=2)
if str(self.data.ips.router.ip) in answer:
self.set_grade('Gateway answer', 2)
The grading lifecycle¶
Grade.grade() is not called once. It is called repeatedly by run_tests(), with the registered commands actually executed between calls. The loop is:
self.stepstarts at 0,self.max_stepat 1.load_answers()reads the student’sanswers.json.reset_before_grade()clears questions, grade rubrics, and section counters.grade()is called.self.test(machine, cmd, step=N)registerscmdunder(machine, N)and returns(default_value, default_code)(i.e. placeholders) the first time it is seen.If a call uses
step=Klarger thanself.max_step,max_stepis bumped toK, extending the loop.self.add_grade_element/self.set_grade/self.question_*calls all register entries; they don’t read any container state directly.
self.stepis incremented toN. All commands registered at stepNare bundled per machine into oneEXETESTS@@@cmd1@@@cmd2@@@…env var and run inside each container in parallel (16-workerThreadPoolExecutor, via/usr/local/sbin/exetests.py). Thetest_switch()commands for stepNare then sent to the consoles of the managed switches, and the host-sidetest_host()commands run in parallel on the host.Results are stored back into
self._tests[(machine, N)]. The loop returns to step 2. On this pass, the registration calls for stepNfind the entry already populated and return the real(stdout, exit_code); the code paths gated on those results now execute.When
self.step > self.max_step, the loop exits. The archive (zstd-compressed msgpack with all tests, answers, errors, and grade list) is written toparams.archives_dirand to every directory in the module-levelarchive_dirs.
This has two consequences for grade():
It must be side-effect-free. It is run several times — at least twice (one registration pass, one result pass), more if you use multi-step tests. Never call
subprocess, write to disk, or mutate global state inside it. All work goes throughself.test/self.question_*.self.test()returns placeholders the first time it is reached. Code that branches on the return value (e.g.if code == 0:) executes both with the placeholder (no-op) and with the real result. Make sure the placeholder branch is harmless — it will execute, but itsadd_grade_elementcalls are wiped byreset_before_grade()before the next pass, so only the final pass’s calls end up in the archive.
A worked two-step example: configure step 1 to perform a setup action, then read its effect at step 2.
def grade(self):
super().grade()
self.test('router', 'systemctl restart bird', step=1)
out, code = self.test('router', 'birdc show route', step=2)
self.add_grade_element('OSPF routes', max_grade=5)
if 'OSPF' in out:
self.set_grade('OSPF routes', 5)
On the first call, both self.test() invocations register; both return placeholders. SRE executes the step-1 command (systemctl restart bird). On the second call, the step-1 test returns its real result; the step-2 test registers and returns its placeholder. SRE executes the step-2 command. On the third call, both tests return real results; out contains the routing table, the rubric is populated, and the loop exits.
Grade parts¶
A grade part is a named group that bundles related rubric items together. Parts are purely presentational: they affect how grades are displayed in the GUI’s Evaluations tab and in the PDFs produced by sre outline — each part is rendered as a labeled block with a subtotal row summing its elements. The overall total, the archive contents, and the marks returned by mark_exo_eval() / mark_self_eval() are unchanged.
Register a part with self.add_grade_part(title, description='') and pass the returned object as grade_part= to every element that belongs to it:
def grade(self):
super().grade()
part1 = self.add_grade_part("part1", tr("Client DNS dig"))
self.add_grade_element(title="dig_host1_a", max_grade=1, grade=int(host1_ok),
grade_part=part1, description=tr("dig — A de host1"))
self.add_grade_element(title="dig_host2_a", max_grade=1, grade=int(host2_ok),
grade_part=part1, description=tr("dig — A de host2"))
part2 = self.add_grade_part("part2", tr("Serveur DNS cache Unbound"))
self.add_grade_element(title="unbound_running", max_grade=2, grade=int(unbound_ok),
grade_part=part2, description=tr("unbound actif"))
# ...
Elements registered without grade_part= are shown ungrouped (above or between the parts, in registration order). Parts are rendered in the order they were registered with add_grade_part(). See lab/sre/dns1.py for a real-world example with three parts.
Self-eval vs instructor-eval scope¶
Every add_grade_element call takes a scope= bitmask that decides which audiences see the element. Three values are defined in params:
Value |
Visible in |
|---|---|
|
Student self-eval only — |
|
Instructor view only — |
|
Both audiences |
The total mark reflects only the elements in the active scope: mark_self_eval() sums elements with scope & SELF_EVAL_SCOPE, mark_exo_eval() sums elements with scope & EXO_EVAL_SCOPE. The same grade() body can therefore produce a different total for a student self-eval than for the instructor’s archive.
A common pattern is to register a detailed rubric for the instructor and a coarse summary for the student — enough feedback to know something is broken in a given part, without disclosing which individual check failed:
from SRE import params
def grade(self):
super().grade()
part1 = self.add_grade_part("part1", tr("DNS resolution"))
host1_ok = ... # bool computed from self.test(...) results
host2_ok = ...
ptr_ok = ...
# Detailed rubric — only the instructor (and sre outline / sre sheet) sees these
self.add_grade_element('dig host1 A', max_grade=1, grade=int(host1_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
self.add_grade_element('dig host2 A', max_grade=1, grade=int(host2_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
self.add_grade_element('dig host1 PTR', max_grade=1, grade=int(ptr_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
# Summary — what the student sees during self-eval
all_ok = host1_ok and host2_ok and ptr_ok
self.add_grade_element('part1_global', max_grade=1, grade=int(all_ok),
scope=params.SELF_EVAL_SCOPE,
description='Part 1 (combined)')
During the student’s self-eval, only the SELF_EVAL_SCOPE element appears: one row per part with a pass/fail score (no need for grade parts there).
During the instructor’s evaluation, only the three EXO_EVAL_SCOPE rows appear. scope and grade_part compose freely — elements in the same part can have different scopes and will be subtotalled accordingly for each audience.
Questions¶
Three kinds of questions, registered from grade() like the tests and shown in the Questions tab of the GUI and of the web front-end. The answers live in answers/answers.json under the hash of the question and come back as the return value of the registration call, on every pass of grade().
Method |
What the student sees |
Returns |
|---|---|---|
|
the description (markdown) above one free-text box |
the text typed, or |
|
the description with one input in place of every |
|
|
the description alone |
nothing: a heading, an instruction block |
The three take section= (a prefix of the title, usually self.section(level), see Grade methods), order= (an integer sorting the questions in the tab; without it the questions get 100, 200, 300… in registration order, so order=150 slips one between the first two) and hash= (the identity of the question in the answers file and in the archives: by default the SHA-256 of the title for a question_text, of the title and the description for the two others, so that a change of a form’s description, a field spec included, detaches the stored answers unless hash= pins it; the instructor fragments and the numbering markers never count).
The fields of a form. A form is a markdown description holding @@{name:spec}@@ markers; the text between the markers is rendered like the description of a question_text, a checkbox taking the row of the text before it. name is the key of the field in the returned dict (any characters but : and }, unique within the form); spec gives the kind of input:
Marker |
Input |
Value returned |
|---|---|---|
|
single-line text, meant to match the regex entirely ( |
|
|
multi-line text of N visible lines ( |
|
|
dropdown of the options, the first one selected by default |
|
|
dropdown showing the labels |
|
|
checkbox, unchecked at first with |
|
|
a button submitting the form — Flavor forms only (see |
|
The regex is a Python one, matched with
re.fullmatch(). The GUI installs it as the validator of the input (a keystroke that can lead to no match is refused), the web front-end marks the input invalid (thepatternattribute, so the regex must be a valid JavaScript one too); in both cases the answer is saved as typed, a field left halfway or empty included, so the lab checks the value itself, as it does for aquestion_textanswer.The options of a dropdown may hold spaces and accents;
|separates them and>>>a label from its value, so neither can appear inside an option. The labelled form lets the student read a sentence while the lab compares a code.The markers must be the same in every language of a
tr()description: the field list is read from one of its values.
q = self.question_form(
tr("Routing table of r1"),
section=self.section(1),
description=tr("""
Default gateway of `r1`: @@{gw:[0-9.]+}@@
Protocol that installed it: @@{proto:>static|ospf|bgp}@@
The route is in the kernel table: @@{installed:?}@@
Commands used: @@{commands:+4}@@
"""),
cheat_answers={'final': {'gw': str(self.data.ips.gw.ip), 'proto': 'static',
'installed': True, 'commands': 'ip route'}},
)
self.add_grade_element(tr("Default gateway"), max_grade=2,
grade=2 if q.get('gw') == str(self.data.ips.gw.ip) else 0)
self.add_grade_element(tr("Protocol"), max_grade=1,
grade=1 if q.get('proto') == 'static' and q.get('installed') else 0)
Cheat answers¶
cheat_answers on question_text / question_form maps a state name (e.g. 'final') to an answer: a string for a question_text, a {field: value} dict for a question_form (the values typed as the form returns them: str, bool for a checkbox, the value of a dropdown). When the student has applied that state, the cheat answer is used instead of the student’s actual input. Useful when the instructor’s final state should produce a fully-passing evaluation.
Letter grades vs numeric marks¶
By default, marks are numeric, scaled to params.default_maximum_mark, and rounded to one decimal. Set self._use_numerical_marks = False (per instance, inside grade() or earlier) to switch to letter grades on the total: A+ ≥ 18/20, A ≥ 16, B ≥ 14, C ≥ 12, D ≥ 10, else F.
Per-element letter conversion (OK / MEH / FAIL) is also available via GradeElement.to_grade_letter() — full marks → OK, partial → MEH, zero → FAIL.
Bonus elements¶
self.add_grade_element(..., bonus=True) registers a bonus element: its grade counts in the totals, but its max_grade does not (neither in the grand total nor in the subtotal of its GradePart), so the mark is computed against the non-bonus maximum only. As soon as a scope holds a bonus element, its mark is capped at _maximum_mark: bonus points make up for lost points but never push the mark above the maximum (a lab without bonus elements keeps its usual, uncapped mark). The reports still show the max_grade of a bonus element, with the word “Bonus”: 2 (Bonus) in the Max column of the GUI Evaluations tab, of the sre outline PDFs, of the sre sheet Questions sheet and of the sre watch screens; the --bonus-in-prefix flag of sre outline / sre sheet / sre watch writes Bonus: <label> before the label instead and leaves the Max column numeric.
part = self.add_grade_part('ebgp', tr("Part 2: eBGP sessions"))
self.add_grade_element('ebgp_md5', max_grade=1, grade=int(md5_ok), grade_part=part, bonus=True,
description=tr("bonus: the r3-ispb session is MD5-authenticated on both sides"))
Overriding mark_exo_eval() to adjust the overall mark¶
The overall mark stored in the archive (and surfaced by sre outline, sre sheet, and sre cat) is produced by Grade.mark_exo_eval(). The default implementation is:
def mark_exo_eval(self):
return self._compute_mark(self._total_grade_exo_eval, self._total_max_exo_eval)
i.e. ceil(10 · maximum_mark · total_grade / total_max) / 10 in numerical mode, or the A+/A/B/C/D/F letter in letter mode. _total_grade_exo_eval / _total_max_exo_eval are the sums over every add_grade_element that contributes to the instructor’s view (i.e. excluding self-eval-only rubrics). mark_self_eval() is the counterpart used during student self-evaluations.
Override mark_exo_eval() (and/or mark_self_eval()) on the Grade subclass when you want a non-default scale — typically because the maximum of the rubric does not match the intended denominator. Common reasons:
Fixed denominator. The exam was designed against an absolute target (e.g. 39 points). The rubric may sum to more, but you want the mark expressed against that fixed value. Replace
_total_max_exo_evalwith the constant, and cap at_maximum_markso bonuses don’t push above the cap:import math class Grade(Grade0): def mark_exo_eval(self): if not self._grade_list or self._total_max_exo_eval == 0: return None return min(self._maximum_mark, math.ceil(10 * self._maximum_mark * self._total_grade_exo_eval / 39) / 10)
Bonus / penalty handling. Bonus questions are better registered with
bonus=True(see Bonus elements): no override needed. Penalty rubrics registered withmax_grade=0(so they only subtract) don’t enlarge the denominator either — but if you want them to not subtract below zero, clamptotal_gradebefore scaling.
mark_exo_eval() is called once at the end of run_tests(), after every pass of grade() and after compute_total(). By that time self._grade_list, self._total_grade_exo_eval / _total_max_exo_eval, self._maximum_mark, and self._use_numerical_marks are all final, so the override is free to read them. Return None to signal “no mark” (shown as blank), a float for a numeric mark, or a string for a letter grade.
Grade methods¶
Method |
Description |
|---|---|
|
Register a command in a container; returns |
|
Same as |
|
Same as |
|
Register a free-text question; returns the student’s answer (or |
|
Register a form question: one input per |
|
Display-only block (no input shown to the student). See Questions. |
|
Register a named group of grade elements and return the resulting |
|
Add a graded rubric item. |
|
Set the score of a previously registered element. |
|
Increment the section counter at |
|
Same formatting as |
|
Record an error/warning in the archive. Only registered when |
|
Instance attributes available inside |
|
Number of self-evaluations the student has run on this project so far, the current run excluded when it is one (the lines of |
Grading Library Reference¶
DHCP helpers (from /opt/sre/lib/dhcp.py)¶
from dhcp import DhcpParameters, DhcpSubnet, set_dhcp_server, get_dhcp_server, check_running_dhcp_server
Data classes:
Class |
Purpose |
|---|---|
|
One |
|
Full server config: |
Functions used in NetScheme (state setup):
set_dhcp_server(net_scheme, machine, dhcp_params, step=1) — writes /etc/default/isc-dhcp-server and /etc/dhcp/dhcpd.conf from a DhcpParameters instance, then enables and restarts isc-dhcp-server.
Functions used in Grade (evaluation):
get_dhcp_server(grade, machine, step=1) → (DhcpParameters | None, int) — reads and parses the DHCP server configuration from the running container. Returns the parsed parameters and the number of parse errors. Returns (None, 1) if /etc/default/isc-dhcp-server is absent.
check_running_dhcp_server(grade, machine) → (bool, list[str]) — checks whether isc-dhcp-server is currently active. Returns (running, interfaces) where interfaces is the list of interface names dhcpd is bound to (e.g. ["eth0"]), or ["*"] if it listens on all interfaces. Interfaces are read from the live process command line, not from the config file.
Mail helpers (from /opt/sre/lib/smtp.py)¶
from smtp import (install_smtp_probe, smtp_probe, smtp_query, imap_query, get_postconf, get_master_services,
get_mailq, deferred_to, get_maildir_messages, find_messages, received_hops, parse_dsn,
parse_received_spf, parse_authentication_results, parse_dkim_signature, get_doveconf,
render_unbound_records, spf_record_ok, dkim_txt_record, generate_dkim_keypair)
Helpers of the SMTP labs (Postfix, Dovecot, OpenDKIM, SPF). The evaluation is behavioural: a
hidden machine runs an active probe (lib/smtp_probe.py, standard library only) that
dialogues with the student’s servers, and what must arrive is read in the Maildirs.
Functions used in NetScheme (state setup):
install_smtp_probe(net_scheme, machine, step=1) — copies the probe script into machine.
render_unbound_records([(name, type, value), ...]) — local-data: lines for an unbound
configuration (a TXT value is quoted and split into 255-character strings, (address, 'PTR', name)
becomes local-data-ptr:); dkim_txt_record(public_key_b64) and generate_dkim_keypair(bits)
(the private key as opendkim-genkey writes it) let Data.generate() carry the solution key.
Functions used in Grade (evaluation):
smtp_probe(grade, machine, spec, step=1, timeout=45) → {query_id: ProbeResult} — runs the probe
with spec = {'timeout': 5, 'queries': [...]} built from smtp_query(id, host, port=25, helo=, mail_from=, rcpt_to=[...], starttls=False, auth=(user, password), token=, subject=, body=) and
imap_query(id, host, user, password, token=, poll=). A ProbeResult records every reply as
[code, text] (banner, ehlo, starttls, auth, mail, data, rcpt[address]), the EHLO
extensions before (extensions) and after STARTTLS (extensions_tls), the queue_id, and for IMAP
login, count and messages; use code(reply), accepted(address), sent, logged_in. The
spec must depend on lab data only (same command on every grade pass); the messages sent carry
X-SRE-Probe: token and X-SRE-Probe-Query: id.
get_postconf(grade, machine, params=POSTCONF_PARAMS, step=1) → {name: value} (postconf -x),
mynetworks_covers(value, network), relayhost_target(value) → (host, port),
get_master_services(grade, machine) → {'submission/inet': {..., 'options': {...}}} (postconf -M),
get_mailq(grade, machine) → [MailqEntry] (postqueue -j) and deferred_to(entries, domain).
get_maildir_messages(grade, machine, paths, step=, token=, expected=, max_wait=) → [MaildirMessage]
— every file of new/ and cur/ of the Maildirs paths in one base64 dump, after waiting at most
max_wait seconds for expected files containing token (the deliveries of what the probe has
just sent). Then find_messages(messages, token, query=), body_contains(msg, sentence),
received_hops(msg) (most recent first: from, from_ip, by, with, id, for),
is_bounce(msg) / parse_dsn(msg), parse_received_spf, parse_authentication_results,
parse_dkim_signature; maildir_cleanup_command(paths, token) removes the probe’s messages at a
later step, queue_cleanup_command(sender) its queued ones.
get_doveconf(grade, machine) (doveconf -n as nested dicts), doveconf_listener(conf, path),
doveconf_value(conf, key); parse_txt_strings(dig_output), parse_spf, spf_record_ok(record, mx_ip=, mx_name=), parse_dkim_txt; tls.get_tls_server_certificate(..., starttls='smtp') reads
the certificate of a STARTTLS server.
Facts verified on the mail image: OpenDKIM resolves with its own libunbound starting from the
root, so a closed lab needs Nameservers <dns> in opendkim.conf; policyd-spf’s TestOnly = 1
still rejects on a -all fail (Mail_From_reject = False and HELO_reject = False give a header-only
check); nobody is a valid local recipient of Postfix.
TLS helpers (from /opt/sre/lib/tls.py)¶
from tls import eval_rsa_private_key, set_rsa_private_key, eval_self_signed_certificate, eval_certificate
Functions used in NetScheme (state setup):
set_rsa_private_key(net_scheme, machine_name, key_file, password, bits=4096, cipher='AES-256-CBC') — generates an encrypted RSA private key inside the container using openssl genrsa. File paths and the password are shell-quoted automatically.
Functions used in Grade (evaluation):
eval_rsa_private_key(grade, machine_name, key_file, password=None, bits=4096, cipher='AES-256-CBC', step=1) → bool — verifies that key_file is an RSA private key with the expected size and PEM cipher. Returns True if all checks pass.
eval_self_signed_certificate(grade, machine_name, key_file, cert_file, password, cn=None, bits=4096, cipher='AES-256-CBC', step=1) → dict — checks that cert_file is a valid self-signed certificate whose public key matches key_file. Returns a dict with keys subject, issuer, common_name, not_before, not_after, serial, fingerprint.
eval_certificate(grade, machine_name, key_file, cert_file, ca_file, step=1) → dict — checks that cert_file is a valid certificate signed by ca_file and whose public key matches key_file. Returns the same dict as eval_self_signed_certificate.
DNS helpers (from /opt/sre/lib/dns.py)¶
Used by lab/sre/_DRAFT_misc/dns2.py (private root, resolver, delegation, TSIG transfers,
dynamic updates, DNSSEC, DoT/DoH, views, RPZ). Pure functions first, then (grade, machine, …)
wrappers; fixtures in tests/mock_data/dns/.
Queries.
dig_query(grade, m, server, "www.example.tp A", step=1, dnssec=True, cd=True, norecurse=True, tcp=True, key=(name, secret))returns aDigResult:status,flags(has_flag('ad')),answer/authority/additionallists ofRR(name, ttl, rclass, rtype, rdata),rdata('A'),soa_serial(),ttl(),error('timeout','transfer failed','connection refused'),xfr_recordsfor AXFR listings;referral(result)gives the NS names and the glue of a referral.kdig_query(grade, m, server, name, tls=True|https=True, ca_file=, hostname=)→KdigResult(session 'TLS'|'HTTPS', http_status, …).nsupdate_run(grade, m, server, zone, ["update add host.example.tp. 300 A 10.0.0.9"], key=)→(ok, rcode); the samensupdate_cmd()text serves in states (oneprintf | nsupdate -yline: exetests needs single-line commands).DNSSEC.
ecdsa_p256_keypair()gives(private_b64, public_b64)to keep inData;render_bind_key_files(owner, priv, pub)writes them asdnssec-keygenwould (named adopts them withdnssec-policy);ds_rdata(owner, dnskey_rdata),keytag_of(),ds_covers_keys(ds_rdatas, dnskey_rdatas, owner)check a published DS against the keys a server serves;trust_anchor_line()(unbound) andrender_bind_trust_anchors()(delv -a) write the anchor.Configurations.
get_named_conf(grade, m)parsesnamed-checkconf -p(keys, options, zones, views);get_unbound_conf(grade, m)the concatenatedunbound.conf.d/*.conf(unbound_values(conf, 'server', 'access-control'),unbound_clauses(conf, 'rpz'));get_stubby_conf,get_firefox_doh(policies.json ornetwork.trr.*prefs),get_zone_status(…, view=)/get_dnssec_status(rndc),get_named_journal/get_unbound_log(events ofjournalctl: transfers, notifies, updates, RPZ hits),get_unbound_list(…, 'forwards'|'stubs').unbound_default_local_zone(net)names the built-in local zone a resolver must declarenodefaultbefore it can resolve a private reverse zone.Renderers for states and instructor texts:
render_zone_file,render_tsig_key,render_root_hints,render_rpz_zone,render_stubby_yml,reverse_zone_name/reverse_label,spki_pin,random_tsig_secret;tls.generate_ca_pem(cn)for a lab-provided CA.
HTTP proxy helpers (from /opt/sre/lib/http_proxy.py)¶
from http_proxy import install_backend, backend_config, http_request, http_burst, distribution, forwarded_for
Helpers of the reverse proxy / load balancer lab (lab/sre/_DRAFT_misc/reverse-proxy.py: nginx, Varnish, HAProxy, keepalived; image sysreseval/proxy). The grading is behavioural: requests are sent from a hidden client and the page of the application server tells what the proxy did to them.
The application server (lib/http_backend.py, standard library only): install_backend(net_scheme, machine, backend_config(name, {8080: 'shop', 8081: 'api'}, ...), sick=False, step=1) copies it into a machine and starts it. Every page answers KEY=value lines: SERVER= (who answered), APP=, CLIENT_IP= (the proxy), PATH=, HOST=, HTTP_VERSION=, CONN_REQUESTS= (keep-alive reuse), REQUEST_ID= (the same id twice: the answer came from a cache) and every request header as HEADER_<NAME>=. Special pages: /health (200 / 503), /catalog/… (cacheable, sent with a Set-Cookie), /cart (private), /login + /account (a session kept in the memory of one backend), /slow?ms=, /big, /_log, /_admin/health?state=up|down, /_admin/down?seconds=.
Requests (grade side; an empty reply, code is None, until the tests have run):
http_request(grade, src, url, connect_ip=None, connect_port=None, step=1, max_time=6, **options) → HttpReply — one curl whose connection is forced to an address (--connect-to: no name resolution, the Host header and the SNI stay those of the URL). Options: method, headers, cookies, data, user=(login, password), cacert, insecure, http2, source (local address), compressed, jar, no_body. HttpReply has code, headers (lower-case), header_values(name), set_cookies(), body, field(key), server, http_version, time_total, ok, redirect.
http_burst(grade, src, url, count, connect_ip=None, connect_port=None, step=1, **options) → list[str] — count requests in one shell loop, the list of the bodies; servers(bodies) and distribution(bodies) tell which backends answered. sequence_cmd([request_cmd(..., shell_url=True), ...], prelude='u=/page-$(date +%s%N)') + parse_sequence() run several requests on a URL that is new at each run (cache tests); status_burst_cmd() + parse_status_burst() send requests as fast as possible and keep the status codes (rate limiting).
What behaviour cannot show (pure parsers): parse_haproxy_stat() + haproxy_servers() + haproxy_pool() (show stat on the admin socket, haproxy_show_cmd(): state and last check of each server), parse_varnish_backends() (varnishadm backend.list), parse_keepalived_conf() + keepalived_tracks(conf, 'haproxy'), parse_tcpdump_vrrp() (vrrp_capture_cmd()), parse_nginx_upstreams() (nginx -T: weights, backup), parse_backend_log() + health_checks(entries, now, client) (the health checks a backend received from a proxy).
Facts worth knowing (verified in the running lab): each nginx worker process has its own round-robin state, so a 2:1 weight is not observed exactly over a burst; a read timeout marks the server as failed for fail_timeout (10 s), so one slow page can take every server of an upstream out (never grade a timeout on a shared upstream); HAProxy’s option forwardfor adds a second X-Forwarded-For line instead of extending the client’s; a plain option httpchk sends OPTIONS /.
VRRP / keepalived helpers (from /opt/sre/lib/vrrp.py)¶
from vrrp import (parse_keepalived_conf, find_instance, instance_group, tracked_scripts, parse_tcpdump_vrrp,
adverts, virtual_mac, master_down_interval, read_archive, apply_archive_cmd, wait_cmd)
Helpers of the VRRP lab (lab/sre/_DRAFT_misc/vrrp.py: two keepalived routers, a LAN and a WAN virtual address in a sync group, a health check, authentication and unicast peers, load sharing, a virtual MAC, VRRPv3 / IPv6). The images ship keepalived from version 1.33 on.
Configuration (pure): parse_keepalived_conf(text) → {'global_defs', 'scripts', 'groups', 'instances', 'errors'} with typed fields per instance (vrid, priority, interface, state, advert_int, version, auth_type / auth_pass, vips / excluded_vips as {'address', 'prefixlen', 'dev', 'family'}, unicast_src_ip / unicast_peers, track_scripts / track_interfaces as {'name', 'weight'}, use_vmac, nopreempt, notify). Queries: find_instance(conf, vrid=, interface=, vip=), instance_group(conf, instance), tracked_scripts(conf, instance) and tracked_interfaces(conf, instance) (the instance’s own entries and those of its sync group), notify_paths(conf, instance), instance_addresses(instance, excluded=False).
Advertisements: vrrp_capture_cmd(interface, seconds) is a tcpdump -e -n -v of a few seconds; parse_tcpdump_vrrp(output) reads VRRPv2 and VRRPv3 lines, IPv4 and IPv6, with or without -e / -v: src, dst, family, version, vrid, priority, auth_type, password (a capture shows the auth_type PASS password in clear), interval in seconds, addresses, ttl, src_mac, dst_mac. adverts(frames, vrid=, src=, version=), advert_interval(frames), is_multicast_advert(frame) (false for unicast peers).
Arithmetic: virtual_mac(vrid, ipv6=False) (00:00:5e:00:01:<vrid>), skew_time(priority), master_down_interval(advert_int, priority) (3.61 s for priority 100 and one advertisement per second). parse_notify_log(text) + notify_states(entries, name) read the lines a notify script writes from its four arguments.
Hidden mirror (the technique of lib/hidden_mirror.py): a failover cannot be graded on the students’ routers without breaking them, so their files are replayed on hidden clones. config_archive_cmd() prints /etc/keepalived and /etc/radvd.conf as one base64 tar; read_archive(encoded) checks it before anything is unpacked (plain files under the two paths only, 64 KiB at most; None otherwise) and returns {path: {'content', 'mode'}} (archive_text(), archive_mode()); apply_archive_cmd(encoded) unpacks it on a clone and restarts keepalived and radvd; stop_cmd() stops them and removes what a killed keepalived leaves. Register the steps of the scenario only when the archive read at step 1 is valid (decide on its content, never on the pass number: sre re-eval calls grade() once).
Failures are plain commands on the clones (ip link set eth0 down, drop_echo_from_cmd(address) / fault_clear_cmd() to make a machine ignore the pings of a router). Measurements come after wait_cmd(condition, seconds), a bounded wait on holds_address(address) or echo_server_is(host, name), registered as a command of its own so that retuning it never changes the key of a measurement.
Forged advertisements: install_vrrp_probe(net_scheme, machine) copies lib/vrrp_probe.py (standard library, raw socket) into a hidden machine; forged_adverts(grade, machine, vrid, vips, step=, seconds=8, priority=255, password=None) makes it announce the virtual router for a few seconds and returns {'sent', 'error', 'heard'}. A master protected by a password keeps its address; an unprotected one releases it for the duration.
Facts worth knowing (verified in the running lab on 2026-11-11, keepalived 2.2.7):
with
state MASTERand a priority below 255 an instance still starts as a backup (3.4 to 3.7 s before it takes over); a stopped master is replaced within 0.1 s (priority 0 advertisement), a link put down after the master-down interval;in a sync group the weights of tracked scripts are ignored: track them unweighted, a failure puts the whole group in the FAULT state (4 to 6 s with
interval 2/fall 2);use_vmacneeds a switch: the backup keeps itsvrrp.<vrid>macvlan up, so on a hub it receives the frames sent to the virtual MAC too and forwards them a second time. Setarp_announce=2on the routers, or the clients learn the real MAC address back;vde_switchkeeps a MAC address 3 s on its old port (Min persistence): a virtual MAC is unreachable 3 to 4 s after a clean handover unlesshash/setminper 0was run on a managed switch.switch_cmd()cannot do it ininitialfor a lab with privileged machines (the console is out of reach once the privileges are dropped at the start): run it withtest_switch()at the first step of the evaluation;radvd with
AdvRASrcAddress { fe80::1; };advertises from the virtual link-local address on the master and stays silent on the backup.
VoIP helpers (from /opt/sre/lib/voip.py)¶
from voip import (daemon_spec, daemon_account, daemon_calls, install_sip_probe, sip_daemon_cmd, sip_kick,
run_spec, register_scenario, call_scenario, sip_probe, get_phone_calls, find_calls,
get_contacts, get_aor, get_dialplan, get_voicemail_messages, render_baresip_config)
Helpers of the VoIP lab (lab/sre/_DRAFT_misc/voip.py: Asterisk 22 with chan_pjsip, baresip softphones, a trunk to a second PBX, phones behind a NAT, SIP over TLS and SRTP; image sysreseval/voip, which compiles Asterisk from its sources since Debian 12 and 13 do not package it). The grading is behavioural: SIP phones of the probe call through the PBX and report what they heard.
The phones of the probe (lib/sip_probe.py, standard library only: SIP over UDP and TLS with digest authentication, SDP, RTP in G.711 A-law / µ-law, RFC 4733 digits). State side, install_sip_probe(net_scheme, machine) then net_scheme.cmd(machine, sip_daemon_cmd(spec)) starts persistent phones described by daemon_spec(local_ip, [daemon_account(user, password, server, sip_port, mode='answer'|'busy'|'none', tone=Hz, refresh=, calls=daemon_calls(number, every=25))]): each keeps its account registered, answers its calls with its own tone or with 486 Busy Here, can call a number periodically, and logs every call it receives. Grade side: sip_kick(grade, machine, spec) makes them register at once (an evaluation never waits for the retry period of a phone whose account was just created) and returns their status (registered(user)); sip_probe(grade, machine, run_spec(local_ip, [[scenario, ...], ...], deadline=40)) runs scenarios — the tracks in parallel, the scenarios of a track in turn — register_scenario(id, server, user, password, contact=False) (without a contact the REGISTER only queries: it never evicts a phone) and call_scenario(id, server, user, password, to, tone=, duration=, display=, codecs=, dtmf=[{'at': 1.0, 'digits': '1'}], voicemail=, srtp=, transport='tls', ca_file=, server_name=), and returns {id: ScenarioResult} with status, answered, refused, payloads, media_port, heard / hears(Hz), source_hosts(tail=True), tos_values, reinvites, tls_verified. get_phone_calls(grade, machine, step=) + find_calls(calls, user=, action='answered', heard=, from_user=, since=) read what the phones received.
Tones: every phone and every test call sends its own frequency among TONES (80 Hz apart) and reports those recognised in what it receives: a call tells who answered and that the sound flows in both directions, without any sound device.
The Asterisk console (pure parsers and thin wrappers around asterisk -rx): get_endpoint() / get_aor() (pjsip show endpoint|aor: the parameter table, flag(), codec_list()), get_contacts() (pjsip show contacts), get_transports(), get_identifies(), get_rtp_settings(), get_dialplan(grade, machine, context) (dialplan show, dialplan_applications()), get_voicemail_messages(grade, machine, mailbox) (the information files of the messages); asterisk_start_cmd() starts a PBX that has no systemd. baresip: render_baresip_config(tone=, recordings=) (no sound card: a sine as microphone, what is received recorded as wav files), render_baresip_account(), baresip_start_cmd().
Facts worth knowing (verified in the running lab on 2026-11-11 and 12, Asterisk 22.12.0, baresip 1.0.0):
a REGISTER for an unknown account or with a wrong password gets
401again; an AOR left atmax_contacts = 0(the default) refuses every REGISTER with403; an endpoint withoutallowrefuses every call with488;with
direct_media = yes(the default) both parties get a re-INVITE right after the answer and the RTP flows phone to phone; with a phone behind a NAT this sends the other party to its private address (and the later BYE is lost);a new TLS transport is only opened when Asterisk restarts, not by
pjsip reload;media_encryption = sdesrefuses a plainRTP/AVPoffer with488, and an endpoint without it refusesRTP/SAVP;baresip finds
~/.baresipthrough$LOGNAMEor$USER, which adocker execshell does not set: the image puts a wrapper in/usr/local/bin.
SNMP helpers (from /opt/sre/lib/snmp.py)¶
from snmp import (set_snmp_agent, set_snmptrapd_service, snmp_get, snmp_walk, snmp_set, get_snmpd_conf,
get_interface_rate, parse_snmp_output, parse_trap_log, OID)
Helpers of the SNMP lab (lab/sre/_DRAFT_misc/snmp.py) and of every lab that supervises a router over SNMP (Nagios, Prometheus); image sysreseval/monitoring. State side: set_snmp_agent(net_scheme, machine, community, location, contact, sources=, extends=, systemd=False) writes a snmpd.conf (agentaddress udp:161: Debian’s default 127.0.0.1,[::1] makes snmpd exit when IPv6 is off) and starts the agent, directly or through systemd; set_snmptrapd_service(net_scheme, machine) adds the drop-in that makes Debian’s snmptrapd log the traps it receives (its unit’s -LOw logs none) to /var/log/snmptrapd.log. Grade side: snmp_get/snmp_walk/snmp_set(grade, machine, host, oids, community= | user=, auth=, priv=, ...) → SnmpResult (ran, ok, timed_out, refused, value(oid), int(oid)); the grader’s own commands load no MIB (-m '' -On) and ignore the students’ client files (SNMPCONFPATH=/nonexistent). get_interface_rate() measures a bit rate from two readings of a 64-bit counter, parse_trap_log() + is_authentication_failure() read the traps. Facts: a source refused by the community gives a timeout, a view gives No Such Object; sysLocation written in the file is notWritable; a v3 user created with createUser in /var/lib/snmp/snmpd.conf is replaced by a usmUser line at the first start.
Nagios helpers (from /opt/sre/lib/nagios.py)¶
from nagios import (submit_external_commands, get_objects, get_status, checked_state, check_nrpe, plugin_case,
get_notifications, get_sms, render_lab_templates, render_check_queue, render_nrpe_conf)
Helpers of the Nagios lab (lab/sre/_DRAFT_misc/nagios.py: Nagios 4.4, monitoring plugins 2.3, NRPE 4.1). The evaluation gives orders to the students’ Nagios through its command pipe — submit_external_commands(grade, machine, [external_command('SCHEDULE_FORCED_SVC_CHECK', host, service, '$n'), ...]) → (t0, written), the epoch of the write printed by the container and whether Nagios was there to read — then reads objects.cache (get_objects() → NagiosObjects: templates resolved, group members, effective_command()) and, a dozen seconds later, status.dat (get_status(); checked_state(block, since=t0) grants a state only when it was checked after the orders: a status file left by a stopped Nagios gives nothing). check_nrpe(grade, src, host, command=None) and plugin_case(...) (a student’s plugin run on temporary files, as nagios) return PluginResult (state read from the first word, rc, perfdata). get_notifications() / get_sms() read the log and the outbox of the lab’s SMS gateway. render_lab_templates() writes two fast templates (30 s checks): Debian’s checks every 5 minutes. Facts: Apache needs a2enmod cgi for the web interface; a forced check reaches status.dat within 10 s with status_update_interval=5; a passive result is check_type=1.
Prometheus helpers (from /opt/sre/lib/prometheus.py)¶
from prometheus import (get_targets, get_rules, get_flags, compare_queries, matches_reference, mentions,
promtool_tests, promtool_test_doc, blackbox_probe, snmp_exporter_probe, get_am_config,
amtool_routes_test, get_am_alerts, webhook_probe, install_metrics_app,
install_webhook_sink, install_prom_probe, render_prometheus_yml, to_yaml)
Helpers of the Prometheus lab (lab/sre/_DRAFT_misc/prometheus.py: Prometheus 2.42, Alertmanager 0.25, node / blackbox / SNMP exporters). Typed PromQL: compare_queries(grade, machine, [(id, typed, reference)]) evaluates both expressions at one instant inside the container (lib/prom_probe.py), and matches_reference(expr, ref) wants the same series (the labels of one included in the other’s) and values within 5 %, with a non-empty reference; mentions(typed, 'metric', 'function') refuses a typed constant. Rules: promtool_tests(grade, machine, {tag: promtool_test_doc(rule_file, series, tests)}) runs the students’ rule file on invented series (for: and thresholds checked without waiting; ALERTS is queryable). Alerting end to end: webhook_probe(grade, 'chat', spec, am_url) runs lib/webhook_sink.py probe on the receiver’s machine: four alerts posted with a nonce in their names (a new group each time), their notifications awaited, their inhibition read back. Demo application: install_metrics_app() (lib/metrics_app.py, counters growing at constant rates, so the expected values are known: expected_rates(), expected_quantile()). Facts: the classic interface is under /classic/; Debian’s blackbox.yml has no final newline (cat >> breaks it); a reload with a broken file exits 0 and keeps the old configuration; Alertmanager alone needs --cluster.listen-address=.
Central logs helpers (from /opt/sre/lib/rsyslog.py)¶
from rsyslog import (logger_cmd, append_marker_cmd, http_marker_cmd, syslog_message, syslog_probe, collect_lines,
fresh, where, get_established, parse_logrotate_conf, run_logrotate_sandbox, get_rotated,
render_collector_conf, render_forward_conf, render_imfile_conf)
Helpers of the central logs lab (lab/sre/_DRAFT_misc/rsyslog.py: rsyslog 8.2302, logrotate). Every message the evaluation sends carries the marker sreprobe.<token>.<id>.<epoch> (the sender’s clock): collect_lines() + fresh(lines, t0) + where(lines, id) tell in which files each message of this evaluation landed. Senders: logger_cmd(), append_marker_cmd() (a line in an application file read by imfile), http_marker_cmd() (a request into an access log) and raw RFC 3164 messages over UDP, TCP or TLS from a hidden machine (lib/syslog_probe.py: syslog_probe(grade, machine, [syslog_message(id, host, token, proto=, hostname=, tls={'ca', 'cert', 'key', 'server_name'})])). run_logrotate_sandbox() forces the rotation of the students’ first logrotate block rewritten for one directory of the probe (never the real files), get_rotated() reads where the next message went. Facts: one imtcp serves plain 514 and gtls 6514 with per-input parameters; x509/name accepts a client certificate whose extended key usage is serverAuth only; imfile reads a file from its beginning at its first start; without a HUP rsyslog keeps writing into the renamed file.
NetFlow helpers (from /opt/sre/lib/netflow.py)¶
from netflow import (get_softflowd, get_softflowctl, get_nfcapd, get_udp_listeners, get_nfexpire, get_flow_files,
get_flows, get_export_header, render_softflowd_conf, render_nfcapd_conf, wait_for_flow_cmd)
Helpers of the flow metrology lab (lab/sre/_DRAFT_misc/netflow.py: softflowd 1.1, nfdump 1.7.1). The running configuration comes from the command lines of the daemons (get_softflowd(), get_nfcapd()) and from softflowctl (get_softflowctl() → timeouts, statistics); get_flows(grade, machine, filter) reads flows with a fixed nfdump output format (FlowRow: times, protocol, endpoints, packets, bytes, exporter) next to the collector’s own clock (===NOW); get_export_header() decodes one export datagram captured with tcpdump -x (version, count, FlowSet ids). Facts: a UDP burst of N datagrams of P bytes is one flow of N × (P + 28) bytes; softflowd’s IPv4 data FlowSet is id 1024; nfcapd writes a file every interval even without flows; a freshly restarted exporter sends nothing before its first flows expire (inactive timeout + expiry interval).
Traffic generator (from /opt/sre/lib/traffic.py)¶
from traffic import install_traffic_gen, start_traffic, traffic_spec, listener, tcp_stream_job, scan_job, beacon_job
lib/traffic_gen.py (standard library only) runs listeners (listener(port, 'tcp'|'udp', ip, mode='discard'|'reply'|'http')) and jobs on chosen source addresses: a constant UDP rate (cbr_job), one long TCP stream (tcp_stream_job), a TCP port scan (scan_job), a periodic UDP beacon with a new source port per burst (beacon_job), background web and DNS requests (web_job, dns_job). install_traffic_gen(net_scheme, machine) then start_traffic(net_scheme, machine, traffic_spec(listen=, jobs=, seed=)) (restarting replaces the previous generator). Used by the SNMP lab (a known bit rate through a router) and the NetFlow lab (traffic with recognisable shapes).
TCP port helpers (from /opt/sre/lib/grade_helpers.py)¶
from grade_helpers import eval_tcp_server, test_dig
eval_tcp_server(grade, machine_name, server_name, step=1) → list[int] | None — returns the TCP ports in LISTEN state of the processes matching server_name (pgrep -f, ss -tlnp), whatever the local address form (0.0.0.0:80, *:80, [::]:80, [fd00::1]:8080, 127.0.0.53%lo:53), or None when no such process runs.
test_dig(grade, machine_name, server_ip, *, proto='udp', port=53, request, timeout=2, step=1) → (stdout, exit_code) — runs dig +short against server_ip (a string, an IPv4Address / IPv6Address or an IPv4Interface / IPv6Interface, prefix stripped).
OSPF helpers (from /opt/sre/lib/frr.py)¶
from frr import get_ospf_interfaces
get_ospf_interfaces(grade, machine_name, step=1) → dict — runs vtysh -c "show ip ospf interface" inside the container and parses the output. Returns a dict keyed by interface name, each value containing: area, cost, state (DR/BDR/DROther/…), dr, bdr, neighbor_count, adj_neighbor_count.
BGP helpers (from /opt/sre/lib/frr.py)¶
from frr import (get_bgp_summary, get_bgp_neighbor, get_bgp_routes, get_bgp_prefix,
get_bgp_advertised_routes, bgp_best_path, bgp_paths, is_bgp_protocol)
These helpers parse the json output of vtysh (FRR 9). Like the OSPF ones, each get_* registers one command through grade.test() (two-pass contract) and returns its empty shape ({}) on the registration pass, when BGP is not running or when the output cannot be decoded. Pass allow_error=True while BGP may not be configured yet.
Function |
Returns |
Description |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
The best path of |
|
|
Paths of |
|
|
|
|
|
|
|
|
|
parse_frr_config / get_frr_running_config also return the BGP configuration: router_bgp (as, router_id, ebgp_requires_policy, network_import_check, bestpath, neighbors: {peer: {remote_as, description, update_source, timers, shutdown, password, next_hop_self, route_reflector_client, default_originate, route_map_in/out, prefix_list_in/out, ...}}, networks, aggregate_addresses, redistribute), prefix_lists, route_maps ({name: [{seq, action, match: [...], set: [...]}]}), as_path_lists, community_lists; interface blocks expose their ip address lines in addresses.
Verified FRR 9 defaults worth knowing when writing a BGP lab: bgp ebgp-requires-policy is on (an eBGP session without route-map/prefix-list exchanges no prefix), bgp network import-check is on (a network statement needs a matching route, e.g. ip route X/22 blackhole), and interface lo / ip address A/32 in frr.conf configures the loopback address through zebra.
RIP helpers (from /opt/sre/lib/frr.py and /opt/sre/lib/rip.py)¶
from frr import (get_rip_routes, get_rip_status, rip_route_metric, rip_route_sources,
is_rip_protocol, is_ripng_protocol, RIP_ROUTE_PROTOCOLS)
from rip import (install_rip_probe, rip_listen_cmd, rip_announce_cmd, get_rip_probe, rip_window,
probe_ready, heard, announced, announced_metric, md5_authenticated)
The tables of a router (lib/frr.py). ripd has no json output: these helpers parse the text of vtysh, with the two-pass contract and the allow_error flag of the OSPF ones.
Function |
Returns |
Description |
|---|---|---|
|
|
|
|
|
Smallest metric of |
|
|
Neighbours |
|
|
|
|
|
Kernel route |
What a router announces (lib/rip.py, lib/rip_probe.py). A hidden machine with an interface, without any address, on every network to watch runs a listener that never sends a frame: it is neither a RIP peer nor an ARP or ping target, and it still reads authenticated updates. Grading what is really announced on a link does not depend on the way the router was configured.
Function |
Description |
|---|---|
|
Copy |
|
Command (re)starting the listener daemon on the interface names; |
|
Command (re)starting a rogue RIP speaker (a second hidden machine, with an address): a RIPv2 response from port 520 of |
|
Run the static |
|
Seconds within which a router must have been heard: its own update timer ( |
|
The listener is alive and has listened for a whole window: required by every check of something that must not be heard |
|
The router sent an update on that interface during the window (false for a passive interface) |
|
|
|
The last update carries an MD5 trailer whose digest matches a key of the lab (and the given key id) |
Only the responses sent to a group address are recorded: the request a passive interface still emits and the unicast answers to a request are counted apart. An evaluation sees a configuration change once the router has sent an update with it, at most one update period later; the student texts must say so. A silence alone does not prove a passive interface: an interface RIP has just been enabled on sends a request, then its first update up to a period later, so pair not heard() with get_rip_status() (the interface is in passive_interfaces, or absent from interfaces). A reference state that must remove a route learned under long timers runs clear ip rip on every router at the same step: a router cleared alone learns the route again from its neighbours. Since the export of a project replays initial for the hidden machines too, a lab with a rogue speaker sets export_kathara_project = False.
Standard machine wiring (from /opt/sre/lib/std.py)¶
machine_config(net_scheme, machine_name, config) — concise helper for wiring a machine inside NetScheme.build() (or initial()). config is a (interfaces, sysctls) tuple:
interfaces: list of(network_name, address, routes)entries, whereroutesis a list of(dest_network, gateway)pairs.sysctls: dict of kernel parameter names to values.
Compared to calling set_net_config and set_sysctl directly, machine_config resolves network names through the NetScheme topology automatically.
Miscellaneous generators (from /opt/sre/lib/utils.py)¶
from utils import random_password, random_sentence
Function |
Description |
|---|---|
|
Returns a random alphanumeric password of the given length |
|
Returns a random sequence of lowercase words, space-separated, approximately |
Network inspection helpers (from /opt/sre/lib/net_config.py)¶
These functions are used inside Grade.grade() to read live container state.
from net_config import (get_ip_addresses, get_routes, get_sysctl_conf,
get_net_config_entry, get_persistent_net_config_entry,
eval_net_config, net_config_entry_family,
get_ip_forward, get_ipv6_forward, get_sys_parameter_bool,
get_sys_parameter)
Function |
Returns |
Description |
|---|---|---|
|
|
Run |
|
|
Run |
|
|
Read |
|
|
Read |
|
|
Read |
|
|
Read an arbitrary |
|
|
Same as |
|
|
Run |
|
|
Parse |
|
|
Keep only the addresses and routes of IP |
eval_net_config(grade, expected, machine_name=None, current=None, step=1) — compare a live or provided NetConfigEntry against an expected one (0.0.0.0/0 and ::/0 are both default routes). Returns an attribute-accessible dict with the following keys:
Key |
Description |
|---|---|
|
Number of IP addresses that match |
|
Number of IP addresses in expected |
|
|
|
|
|
Number of matching non-default static routes |
|
Number of non-default routes in expected |
|
Non-default routes present in current but not in expected |
|
Number of positions where both expected and current are |
|
Count of |
|
Count of |
If current is None, get_net_config_entry(grade, machine_name, step) is called automatically.
Switch helpers (from /opt/sre/lib/switch.py)¶
For the managed switches (see Switches and VLANs), on top of Grade.test_switch():
from switch import get_switch_ports, get_switch_vlans
ports = get_switch_ports(self, 'lan')
# {'pc1': {'port': 2, 'interface': 'eth0', 'vlan': 10, 'tagged_vlans': []},
# 'r1': {'port': 1, 'interface': 'eth0', 'vlan': 0, 'tagged_vlans': [10, 20]}}
same_vlan = bool(ports) and ports['pc1']['vlan'] == ports['pc2']['vlan']
vlans = get_switch_vlans(self, 'lan') # {10: {'untagged': [2], 'tagged': [1]}, ...} (port numbers)
vlan is the VLAN of the untagged frames of the port (0: the default VLAN of the switch), tagged_vlans the VLANs of a trunk port. Both functions return {} on the registration pass and when the console failed; they take step= and allow_error= like test_switch(). parse_switch_ports(port_allprint, vlan_allprint) and parse_switch_vlans(vlan_allprint) are the underlying pure parsers.
Ping helper (from /opt/sre/lib/ping.py)¶
from ping import eval_ping
eval_ping(grade, src, dest, step=1, net_config=None, count=1, deadline=1, allow_error=False, ipv6=False) → bool — run ping -c count -w deadline dest from src machine; return True if "bytes from" appears in the output. Raise count / deadline for multi-hop paths; allow_error=True keeps an expected failure out of the archive’s errors.
src and dest can each be:
An
IPv4Address/IPv6Address(or Interface) object, or a valid IPv4 / IPv6 literal — used directly (the literal decides the family);srcis resolved by reverse-lookup innet_config"machine_name"— machine name looked up innet_config;dest→ first IPv4 address of the first interface, or first IPv6 address withipv6=True"machine_name:N"or"machine_name:ethN"— resolves to interface index N of that machine (same family rule)
If net_config is not provided, grade.net_scheme.net_config is used. Raises ValueError on resolution failure.
SSH helpers (from /opt/sre/lib/ssh.py)¶
Used in @sre_state methods (state setup):
from ssh import (add_ssh_monitor_agent, create_ssh_key_on_host,
remove_ssh_password_authentication_on_sshd,
set_forward_ssh_agent_in_ssh_config, copy_ssh_pub_key_on_machine)
Function |
Description |
|---|---|
|
Deploy and start an SSH monitor daemon on |
|
Generate an SSH key pair on the host ( |
|
Set |
|
Write |
|
Copy a public key file (host path) into |
Used in Grade.grade() (evaluation):
from ssh import (check_ssh_key, eval_ssh_public_key_in_authorized_keys,
eval_ssh_agent_exists, eval_ssh_agent_with_loaded_key,
eval_ssh_possible_with_password_authentification,
eval_ssh_connection_with_password, eval_ssh_connection_with_key,
eval_ssh_connection_with_ssh_agent, eval_synchronized_file)
Function |
Returns |
Description |
|---|---|---|
|
|
Verify that a key pair on |
|
|
Check that a public key appears in |
|
|
Return |
|
|
Check that a specific private key (on the host) is loaded in a running agent belonging to |
|
|
Probe |
|
|
Read |
|
|
Read |
|
|
Read |
|
|
Return |
Packet capture helpers (from /opt/sre/lib/pcap_gen.py)¶
Used in @sre_state methods (state setup):
from pcap_gen import generate_pcap_tcp_example, setup_tcp_client_server
generate_pcap_tcp_example(net_scheme, src_machine, dst_machine, dst_ip, dst_interface, output_file, dst_port_min=2000, dst_port_max=2999, payload_size=10, step=1) → dict
Generate a synthetic TCP traffic capture for pcap analysis exercises. Uses 3 consecutive steps starting at step. The pcap file is written to output_file inside dst_machine (must be under /shared/ for host-side analysis). Returns a dict with the captured port numbers and one TCP frame chosen from each direction:
Key |
Description |
|---|---|
|
TCP port numbers used |
|
1-based frame number (src → dst direction) |
|
TCP window field of that frame |
|
Absolute sequence number |
|
Absolute acknowledgment number |
|
Same fields for the dst → src direction |
The dict is populated at step+2 via a host callback after the capture finishes.
setup_tcp_client_server(net_scheme, src_machine, dst_machine, src_ip, dst_ip, secret, dst_port_min=3000, dst_port_max=3999, interval=3, step=1) → dict
Deploy a persistent background TCP client/server pair for generating live traffic throughout the lab. Uses 2 steps. The server runs on dst_machine, the client on src_machine; both survive for the lifetime of the lab. Returns {'server_port': int, 'client_port': int}.
Used in Grade.grade() (evaluation):
from pcap_gen import check_zero_window_probe, get_frame_info
check_zero_window_probe(grade, file, max_length, packet_number) → bool
Return True if the packet at 1-based packet_number in the pcap file (relative to the project shared directory) is a valid TCP Zero Window Probe. Checks: file within max_length KiB; target packet has ≤ 1 byte payload; a prior packet in the reverse direction advertised window=0; target SEQ equals SND.UNA or SND.UNA-1.
get_frame_info(grade, filename, max_length, frame_number) → dict | None
Return all parsed fields for a single frame from a pcap file (relative to the project shared directory). Returns None if the file is missing, too large, not a valid pcap, or frame_number does not exist. The returned dict always contains frame_number, frame_link_type, captured_length, original_length, timestamp_sec, timestamp_usec; plus protocol-specific fields for Ethernet, Linux cooked capture, ARP, IPv4, IPv6, ICMP, TCP, and UDP headers.
Module-level Attributes¶
Declare these at the top level of srelab.py to customize behavior. They are read with getattr(module, name, default), so any attribute may simply be omitted to keep the default.
Lab identity and presentation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
filename without |
Lab title shown in the GUI tab header and in exported reports. Pass a |
|
|
|
Language code used to resolve |
Student self-evaluation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
Cooldown in seconds between two student self-evaluations (only enforced for |
|
|
|
During a student-triggered evaluation, hide numeric grades and show only the |
|
|
|
During a student-triggered evaluation, drop grade-list entries whose |
Automatic evaluation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Auto-eval interval in seconds when no exam is configured. |
|
|
|
If |
|
|
|
If |
|
|
|
If |
|
|
|
Upper bound used when normalizing the lab’s total grade |
Archives and recordings¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Extra directories to write evaluation archives ( |
|
|
|
Container paths whose contents are copied into each evaluation archive (under the |
|
|
|
Override whether terminal sessions opened from the GUI are recorded (asciinema). |
|
|
|
Extra directories to write session-record archives ( |
|
|
|
Minimum delay in seconds between two automatic session-record saves for the same project during an exam. Set to |
State machine and flavors¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
If |
Networking¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
Filesystem and export¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Create |
|
|
|
If |
|
|
|
If |
|
|
|
If |
|
|
(unset) |
Non-empty secret string. When set, the payload of every save file is encrypted (AES-256-GCM, key derived from |
Schema rendering¶
These attributes tweak the graphviz topology diagram drawn for the GUI’s Schema tab and embedded in the PDF produced by sre export.
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
Label shown on the host-network vertex |
|
|
|
Fill color of the host-network vertex (graphviz color name or |
|
|
|
If |
|
|
|
Relative length (graphviz |
|
|
|
Graphviz |
|
|
|
Graphviz |
Complete Minimal Example¶
# /opt/sre/lab/example/srelab.py
from dataclasses import dataclass
from ipaddress import IPv4Interface, IPv4Network
from typing import Dict
from SRE.lib_sre import Data0, NetScheme0, Grade0, sre_state, make_tr
from ips import random_ipv4networks, random_ipv4s
from net_config import set_net_config_entry, NetConfigEntry, get_net_config_from_topology, set_ip_forward
from state_helpers import set_nat_gateway
tr = make_tr('en')
title = tr("Example: Router", fr="Exemple : Routeur")
allow_self_grade = True
delay_between_self_grade = 60
eval_interval_without_exam_mode = 120
@dataclass(slots=True)
class Data(Data0):
@classmethod
def generate(cls, flavor=None):
d = cls()
d.nets.lan1, d.nets.lan2 = random_ipv4networks([24, 24], from_private_network=True)
d.ips.router_lan1, d.ips.client1 = random_ipv4s(d.nets.lan1, 2) # IPv4Interface values
d.ips.router_lan2, d.ips.client2 = random_ipv4s(d.nets.lan2, 2)
return d
class NetScheme(NetScheme0):
_machine_specs = {
'router': {'color': 'green'},
'client1': {},
'client2': {},
}
_network_specs = {
'lan1': {'color': 'yellow'},
'lan2': {'color': 'cyan'},
}
_topology = {
'lan1': ['router', 'client1'],
'lan2': ['router', 'client2'],
}
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
self.informations = tr(
"## Router Lab\nConfigure routing between lan1 and lan2.",
fr="## TP Routage\nConfigurez le routage entre lan1 et lan2.",
)
d = data
self.net_config = get_net_config_from_topology(net_scheme=self, gateway="router")
# self.net_config: Dict[str, NetConfigEntry] = {
# 'router': [([d.ips.router_lan1], [(d.nets.lan2, d.ips.router_lan2)]),
# ([d.ips.router_lan2], [])],
# 'client1': [([d.ips.client1], [(IPv4Network('0.0.0.0/0'), d.ips.router_lan1)])],
# 'client2': [([d.ips.client2], [(IPv4Network('0.0.0.0/0'), d.ips.router_lan2)])],
# }
@sre_state(user_allowed=False)
def initial(self):
for machine, config in self.net_config.items():
set_net_config_entry(net_scheme=self, machine_name=machine, nc_entry=config)
self.cmd('router', 'sysctl -w net.ipv4.ip_forward=1')
for m in self.get_visible_machine_names():
set_ip_forward(net_scheme=self, machine_name=m, ip_forward=(m == "router"))
set_nat_gateway(net_scheme=self, machine="router")
class Grade(Grade0):
def grade(self):
super().grade()
d = self.data
# Test cross-network reachability
result, code = self.test('client1', f'ping -c1 -W2 {d.ips.router_lan2.ip}', timeout=5)
self.add_grade_element('Cross-network ping', max_grade=10)
if code == 0:
self.set_grade('Cross-network ping', 10)
# Ask a question
answer = self.question_text(
title=tr('Routing protocol', fr='Protocole de routage'),
description=tr('Which mechanism routes packets between the two LANs?',
fr='Quel mécanisme route les paquets entre les deux LANs ?'),
)
self.add_grade_element('Routing answer', max_grade=5)
if 'ip_forward' in answer or 'forward' in answer.lower():
self.set_grade('Routing answer', 5)
The list of labs¶
Everything a student can open comes from one tree, /opt/sre/lab/ (params.lab_dir), walked by sre list and shown as a tree by the Open project dialog of the GUI (Opening a project) and by the Open page of the web front-end. This section gathers what decides which labs are listed, under which name, and in which order.
What is a lab, and its name¶
A file lab is any
.pyfile of the tree (s2/lab.py,sre/dhcp.py).A directory lab is a directory holding a
srelab.py, with its state directories next to it (s4/tp_ssh/); nothing below it is scanned.Any other directory only groups labs, as deep as you like (
class1/week3/tp_arp). A directory without a lab below it is not listed.
The name of a lab is its path relative to the tree, with the .py of a file lab: it is what sre start, sre set-exam --labs and the web API take, and what the running lab name carries. The shipped labs live in lab/sre/ (a symbolic link to /usr/share/sysreseval/lab on a Debian install: links to directories are followed); sre update replaces that directory and leaves the others alone.
Exam-only labs¶
A lab whose name, or the name of any directory above it, contains _EXAM_, _OLD_, _DRAFT_ or _TESTS_ (params.exam_only_affix) is left out of sre list and of both pickers, and the directory is not even walked. It stays usable by the privileged commands (sre start s4/ssh_EXAM_final.py, sre check) and by sre set-exam --labs, which is how an exam lab stays invisible until the exam starts, and how a draft stays out of the students’ sight: see Exam-only labs.
Titles: sre make-titles and titles.json¶
The pickers show a lab by its title attribute (a string or a tr() text) instead of its file name once the titles are materialised: each directory of the tree may hold a titles.json mapping the local names of its entries ("dhcp.py", "tp_ssh") to a title, a plain string or a {lang: text} dict resolved in the language of the user. sre make-titles -r /opt/sre/lab (as root) imports every lab, exam-only ones included, and writes one file per directory; run it again after adding or renaming labs: a rerun keeps the options and the order of an existing file, replaces the titles of the labs found, drops the entries that name nothing on disk any more and appends the new labs by name. A lab without title is shown by its file name without .py. The title of a running project (the tab, the reports) comes from the lab itself through info.json, not from titles.json.
The title of a directory¶
A directory of the tree is shown by its name unless the titles.json of its parent gives it a title, under the name of the directory: a plain string, or a {lang: text} dict resolved in the language of the user like the title of a lab. The title of /opt/sre/lab/s4 is thus the "s4" entry of /opt/sre/lab/titles.json:
{
"s4": {"en": "Semester 4", "fr": "Semestre 4"},
"sre": {"en": "Shipped labs", "fr": "TP fournis"}
}
A directory has no title of its own to read, so these titles are written by hand. sre make-titles never touches them (a rerun keeps the entry of a sub-directory as it is), and its option -d (--directories) prepares the files: every sub-directory of the tree is listed in the titles.json of its parent with null (shown by its name), and with -r the levels holding directories only, such as the lab root, get their file too. sre make-titles -r -d /opt/sre/lab then reports the sub-directories still without a title, file by file; replace a null by a title and the pickers show it. Without -d the command writes the labs only.
The order of a directory: "@sort"¶
Written by hand in the titles.json of a directory, the reserved key "@sort" chooses the order of the entries of that directory, the same in the GUI and on the web (every key starting with @ is an option, never an entry):
Value |
Order of the entries |
|---|---|
|
By file name, byte-wise (capitals first). The default. |
|
By title in the language of the user (the language priority of the GUI, the language of the page on the web): case and accents ignored, numbers compared as numbers so that “TP 2” comes before “TP 10”; an entry without title sorts by its name. |
|
The entries of the file in their order, then the unlisted ones by name. |
In every mode the sub-directories come first, then the labs. A directory without the option takes the one of the nearest ancestor: /opt/sre/lab/titles.json sets the whole tree, a sub-directory may override it. A key may also name a sub-directory, with a title (shown instead of the directory name, see above) or null (listed for its place in the manual order only); this is the way to order a level that holds directories only, such as the root. An unknown value is ignored; sre make-titles warns about it and keeps the file as it is.
{
"@sort": "manual",
"sre": {"en": "Shipped labs", "fr": "TP fournis"},
"s4": null,
"static_routing.py": {"en": "Static routing", "fr": "Routage statique"},
"tp_ssh": null
}
What the pickers receive is sre list --with-titles: the tree flattened in display order, a directory entry before its content, each entry with its title, its kind (dir / lab) and the sort mode it is placed by; the front-ends keep that order and only sort the "title" mode themselves (see sre list and sre make-titles in the CLI reference).