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=True or allow_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 state and sre connect (which normally refuse to touch hidden machines or apply user_allowed=False states) are bypassed.

  • Grading: when you sre eval a debug project, marks are always shown and every grade scope is surfaced — no_mark_on_self_grade, hide_potential_penalty_grades_in_self_grade and the periodic-eval gating are all ignored.

  • Operations log: every state application (initial at start, sre state, the save/restore lifecycle states) and every evaluation appends what it executed to <project dir>/operations.log: one header per run, then stepN - 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 in info.json, as for a project in instructor mode, and the GUI shows its Instructor mode button on the project to display or hide them.

  • -p is implicit: --debug-project always treats the lab argument as a filesystem path, so you can point it directly at the .py you’re editing (e.g. ~/labs/draft.py) without copying it into /opt/sre/lab/ first.

  • --debug-web-project is 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 of sre list --include-exam-only accepted), 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 -p are mutually exclusive; --instructor-mode combines 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

info.json

Public metadata read by the GUI: lab title, informations, machine list, question list. Written at sre start and refreshed on sre eval.

answers/answers.json

Student answers to question_text / question_form blocks.

answers/cheat.json

Optional instructor-provided cheat answers keyed by state name (used by cheat_answers=).

shared/

Bind-mounted into every container as /home/sre/{running_lab_name}/ when the module sets shared_path = True. The host-side path for file exchange.

.private/

Mode 0o700. Holds everything the runtime needs but students shouldn’t touch.

.private/data.json

The serialized Data instance, reloaded on every later operation.

.private/srelab

Symlink to the lab’s .py file (whatever sre start was pointed at).

.private/files/

Target of self.cp_from_host() / self.cp_to_host() — also where cp_from_host resolves relative source paths.

.private/records/

Asciinema recordings of terminal sessions, if record_sessions is enabled.

.private/eval_in_progress

PID lock created during sre eval to prevent concurrent evaluations.

.private/debug_project

Marker dropped by sre start --debug-project (see above).

.private/instructor_mode

Marker of the instructor mode (sre start --instructor-mode, sre set-instructor-mode; see Instructor-only texts).

.private/auto_eval.log

One line per student-triggered self-eval; drives the self.auto_eval_count counter exposed to Grade.grade().

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 @dataclass that holds every per-instance parameter of the lab (IP addresses, secrets, ports, randomized values). Data.generate() is called once at sre start; the resulting instance is serialized to .private/data.json and reloaded on every later operation.

  • Flavor (optional) — a @dataclass that lets a single srelab.py produce different variants of the lab at start time (e.g. random vs. fixed addresses, easy vs. hard). The GUI renders flavor_form as a form; the chosen Flavor is then passed to Data.generate(flavor).

  • NetScheme — declares the network topology (_machine_specs, _network_specs, _topology) and the imperative configuration of each state. The initial state runs at sre start; additional @sre_state methods can be applied later with sre state <lab> <name>.

  • Grade — registers what to evaluate and how to score it. It does not run anything itself: SRE drives a multi-step run_tests() loop that calls Grade.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.ips holds IPv4Interface values (address + prefix length); data.nets holds IPv4Network values; data.ips6 holds IPv6Interface values; data.nets6 holds IPv6Network values; data.macs holds netaddr.EUI (MAC address) values.

  • All five containers are injected automatically into every Data0 subclass 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.ips raises TypeError; always wrap with the correct type before assigning. The families are kept apart: data.ips refuses an IPv6Interface and data.ips6 an IPv4Interface.

  • data.ips requires IPv4Interface (not bare IPv4Address) — always assign with a prefix, e.g. IPv4Interface('10.0.0.1/24'); likewise data.ips6 requires IPv6Interface, e.g. IPv6Interface('2001:db8::1/64').

  • Data.generate(flavor) is called once at sre start; the result is saved to data.json.

  • Serialization: the containers are stored as strings; standalone fields of type IPv4Address / IPv6Address, IPv4Interface / IPv6Interface, IPv4Network / IPv6Network, EUI and nested Data0 subclasses are encoded with markers and restored with their exact type. Any other field must be JSON-serialisable (sre check verifies 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

compute_pre_generate

@classmethod def compute_pre_generate(cls, flavor=None)

class

Set class-level attributes derived from flavor (machine lists, topology sizes, role tables, etc.) before any Data instance exists.

compute_post_generate

def compute_post_generate(self)

instance

Set instance-level attributes derived from the dataclass fields after generation or reload.

When they run:

  1. At sre start: Data.compute_pre_generate(flavor) → Data.generate(flavor) → data.compute_post_generate().

  2. At sre eval, sre state, sre connect, etc.: after data.json is reloaded into a fresh Data instance via from_json / from_dict / unpack, both hooks run again — pre-generate first (with the persisted flavor), then post-generate.

  3. sre check exercises 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

to_dict() / from_dict(d)

Serialize/deserialize field values

from_form_dict(d)

Build a Flavor from form field strings, coercing to declared types (int, bool, float, str). Extra keys are set as plain attributes.

allowed_by_user()

Returns (bool, message). Override to restrict what students can choose.

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

_machine_specs

Class-level dict: {name: {Machine kwargs}}

_network_specs

Class-level dict: {net_name: {Network kwargs}} — display hints and type of switch, see Network parameters

_topology

Class-level dict: {net_name: [machine,...]} or {net_name: {machine: iface_index}}

self.data

The Data instance

self.informations

Markdown text (or TranslatedText) for the Informations tab

self.get_machines()

Iterator over all Machine objects

self.get_machine_names()

Iterator over machine name strings

self.get_networks()

Iterator over all Network objects

Machine constructor parameters

Parameter

Default

Description

image

params.default_docker_image

Docker image

bridged

False

Enable host-network bridging (required for ports)

x11_host

False

Forward X11 to the host: inject SRE_HOST_IP (and the host’s X magic cookie as SRE_XAUTH_COOKIE) so GUI apps launched in the container display on the host’s X server (DISPLAY is set by the .bashrc of the images; needs bridged=True to reach the host). On the web site the apps show in the Display tab, a virtual desktop of the project (SRE_DISPLAY)

hidden

False

Hidden from the Machines tab

allow_connection

True

Show Connect button in the GUI

shell

None

Shell launched by sre connect (interactive terminal session)

kathara_shell

None

Shell Kathara uses to execute startup exec_commands inside the container

privileged

None

Run the container in privileged mode. Refused at startup unless params.allow_privileged_machines = True

ipv6

None

Enable IPv6 in the container. None: the module-level ipv6 option decides (Kathara’s own setting, off, when that one is absent too); True / False: per-machine override. See IPv6 labs

color

None

Node color in the SVG diagram

exec_commands

[]

Commands run at container start

sysctls

{}

Kernel parameters, as an iterable of "net.<key>=<value>" strings (a dict is iterated over its keys, so {"net.ipv6.conf.all.accept_ra=2": True} works; a {name: value} dict is rejected by Kathara). Merged after Kathara’s defaults, so they win

envs

{}

Environment variables

ports

[]

Port mappings, e.g. ["80XX:80/tcp"] (X = wildcard digit)

ulimits

{}

ulimit settings

volumes

{}

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

color

None

Node color in the schema

shape

None

Node shape in the schema

mode

'hub'

Type of switch the machines are plugged into: 'hub' (every frame reaches every machine), 'switch' (the switch learns the MAC addresses: a machine only receives its own traffic and the broadcasts) or 'managed' (a switch with VLANs and a management console). See Switches and VLANs

allow_connection

True

Managed switch only: students may open its console (Connect button of the Networks tab, sre connect)

vlans

{}

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 vlans does not name stays in the default VLAN of the switch (VLAN 0, untagged) with every other undeclared port; a trunk port without vlan keeps 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, vlans on a network that is not managed, a machine that is not on the network or an invalid VLAN ID raise ValueError when the NetScheme is built, so sre check reports 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 of params.switch_user_commands reach the switch, and 'allow_connection': False closes the console to students.

  • A state runs console commands with self.switch_cmd(network, command, ...), where @machine stands for the port of a machine (see Switch operations); grade() reads the switch with self.test_switch(network, command) and get_switch_ports() (see Switch helpers in the Grading Library Reference).

  • A save file and sre export keep the type of each switch and the declared VLANs, not what was changed on a console afterwards. A mode other than hub needs the VDE network plugin of the Kathara fork, installed by make 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>, or sre 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_color are 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() from NetScheme.__init__ (after super().__init__()) or from Grade.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.informations and 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 + or TranslatedText.format() to combine texts, as with tr(): % 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 _TRANSLATIONS entries of the tr() strings that only those calls used are deleted, in every language;

  • instructor is removed from its from ... import when 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_state method is removed with its decorator, unless it is initial, a lifecycle state (save, restore: sre save / sre restore apply them) or user-allowed (@sre_state(user_allowed=True, ...)); the _TRANSLATIONS entries that only those methods used go with them, and sre_state leaves 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() in restore) 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("...") then instructor(note), the definition of note stays in the file. The script warns about such variables; write note = instructor(tr("...")) to have the text removed;

  • with --full, the helper methods and the variables that only the removed states (or their cheat_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 --full a warning names the directory of a removed state standing next to a srelab.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

info_subtitle(title, level=1, tag="")

\n\n## @@sec@@ Title\n\n (### for level=2…), ## @@sec:tag@@ Title with a tag

## <a id="tag"></a>2. Title (an untagged section gets the id sec.2)

info_tag(tag)

@@tag:tag@@

<a id="tag"></a>; a reference to it gives the number of the section it stands in and scrolls to the spot

info_ref(tag) / info_ref(tag, title=True)

@@ref:tag@@ / @@ref:tag:title@@

[2.1](#tag) / [2.1. Title](#tag); in a question text: 2.1 / 2.1. Title (its browser has no anchor)

info_toc(depth=None)

\n\n@@toc@@\n\n, @@toc:1@@ with a depth

a nested bullet list of [2.1. Title](#tag) links, every level or the first depth levels

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

user_allowed

If False, students cannot apply this state themselves (only root/instructor can).

description

Human-readable label shown in the GUI (supports TranslatedText from make_tr).

multi_pass

If True, the state method is re-run before each step so that self.cmd() / self.host_cmd() return real results (see Reading command results below). Default False: the method is called once, as before.

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

self.cmd(machine, command, step=1, timeout=120, default_value='', default_code=0, allow_error=False)

Run a shell command inside machine (under sh, via exetests.py). Returns (output, exit_code) — a placeholder until the command has run, see Reading command results. Non-zero exit codes are reported on stderr unless allow_error=True.

self.file(machine, path, content, permissions=0o644, owner="root:root", mtime=None, step=1)

Create or overwrite a file inside machine. content may be str or bytes.

self.append_to_file(machine, path, content, permissions=None, owner=None, mtime=None, step=1)

Append to a file (creates it if missing).

self.idempotent_append_to_file(machine, path, content, ..., step=1)

Same as append_to_file but only appends if the file does not already end with content — safe to call repeatedly.

File transfer between host and container

Method

Description

self.cp_from_host(src, machine, dest, owner="root:root", permissions=None, mtime=None, step=1)

Copy a file from the host into machine. Relative src is resolved against params.files_dir(running_lab_name).

self.cp_to_host(machine, path, dest, step=1)

Copy a file from machine back to the host, inside params.files_dir(running_lab_name). dest is restricted to that directory.

Host-side operations

Method

Description

self.host_cmd(command, step=1, timeout=120, default_value='', default_code=0, allow_error=False)

Run a shell command on the host (not inside any container). Returns (stdout, exit_code) with the same contract as cmd(). Refused if params.execute_commands_on_host is False.

self.host_callback(callable, step=1)

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

self.switch_cmd(network, command, step=1, default_value='', default_code=0, allow_error=False)

Run one command on the management console of the managed switch network (a network with 'mode': 'managed'; ValueError otherwise). A word @machine in command is replaced by the number of the port the machine is plugged into, e.g. port/setvlan @pc1 10. Returns (output, code) with the same contract as host_cmd(): code is 0, the error number given by the switch (17: the VLAN already exists) or -2 when the command could not be run. See Switches and VLANs.

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(), another cmd()) must be registered by a call that runs after the command, i.e. at step N+1 or later; a plain self.data assignment 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 late self.test(step=N) extends grade().

  • 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 in self.once(key, factory), which calls factory() the first time key is seen and returns the cached value afterwards (pcap_gen.generate_pcap_tcp_example does this for its ports and result dict).

  • self.step is the number of steps already applied when the method runs (0 on the first call) and self.max_step the 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

set_unbound_server(net_scheme, machine)

Drop a permissive Unbound DNS config into /etc/unbound/unbound.conf and start the service. (Thin alias for set_basic_unbound_server.)

set_basic_unbound_server(net_scheme, machine)

Same as above; explicit name when you want to make the “basic / permissive” intent obvious.

change_password(net_scheme, machine, username, password)

Set a user’s password via chpasswd. The password is written to a temporary file (never passed on the command line).

create_user(net_scheme, machine, username, password, uid=None, gid=None)

Create a user with useradd (if not already present) and set its password. uid/gid are passed as integers. The password is written to a temporary file; the username is passed via an environment variable to prevent shell injection.

hosts_file_content(net_scheme, domain_extension, included=None, ips=None, separator="\t\t", ipv6=False, ips6=None)

Return /etc/hosts lines for a set of machines. Each machine gets one line per network it is connected to (two lines if multi-homed, with machine_net naming). Addresses are read from net_scheme.data.ips.* by convention (machine for single-homed, machine_net for multi-homed), or from the ips dict if provided. included defaults to all visible machines. ipv6=True adds, after each IPv4 line, the IPv6 line of the machines that have an address in net_scheme.data.ips6.* (or in the ips6 dict).

create_hosts_file(net_scheme, domain_extension, machine_list=None, included=None, ips=None, separator="\t\t", ipv6=False, ips6=None)

Write /etc/hosts to each machine in machine_list (defaults to all visible machines). Each file begins with standard loopback entries (127.0.0.1 localhost, 127.0.1.1 <machine>, plus ::1 / ff02::1 / ff02::2 with ipv6=True) followed by the lines from hosts_file_content().

setup_simple_tcp_server(net_scheme, machine, port, answer, ip=None, ipv6=False)

Start an idempotent daemon that answers answer to every TCP connection on port, bound to ip (IPv4 or IPv6, prefix stripped), to 0.0.0.0, or to :: (dual stack) with ipv6=True.

set_radvd(net_scheme, machine, prefixes, step=1, *, rdnss=None, dnssl=None, min_rtr_adv_interval=3, max_rtr_adv_interval=10, adv_autonomous=True, adv_on_link=True, adv_router_addr=False, enable_forwarding=True)

Write /etc/radvd.conf (prefixes = {interface: [IPv6Network, ...]}, interfaces as 'eth1' or 1, /64 prefixes for SLAAC) and start radvd, after enabling IPv6 forwarding. Returns the configuration text.

set_slaac_client(net_scheme, machine, interfaces=None, step=1)

Make the machine configure itself from router advertisements: accept_ra=2 and autoconf=1 on interfaces (default: every interface of the topology). Needed because Kathara starts the machines with forwarding on, which makes the kernel ignore RAs.

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:

  1. self.step starts at 0, self.max_step at 1. load_answers() reads the student’s answers.json.

  2. reset_before_grade() clears questions, grade rubrics, and section counters.

  3. grade() is called.

    • self.test(machine, cmd, step=N) registers cmd under (machine, N) and returns (default_value, default_code) (i.e. placeholders) the first time it is seen.

    • If a call uses step=K larger than self.max_step, max_step is bumped to K, extending the loop.

    • self.add_grade_element / self.set_grade / self.question_* calls all register entries; they don’t read any container state directly.

  4. self.step is incremented to N. All commands registered at step N are bundled per machine into one EXETESTS@@@cmd1@@@cmd2@@@… env var and run inside each container in parallel (16-worker ThreadPoolExecutor, via /usr/local/sbin/exetests.py). The test_switch() commands for step N are then sent to the consoles of the managed switches, and the host-side test_host() commands run in parallel on the host.

  5. Results are stored back into self._tests[(machine, N)]. The loop returns to step 2. On this pass, the registration calls for step N find the entry already populated and return the real (stdout, exit_code); the code paths gated on those results now execute.

  6. When self.step > self.max_step, the loop exits. The archive (zstd-compressed msgpack with all tests, answers, errors, and grade list) is written to params.archives_dir and to every directory in the module-level archive_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 through self.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 its add_grade_element calls are wiped by reset_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

SELF_EVAL_SCOPE (1)

Student self-eval only — sre eval --auto-eval (GUI Start evaluation button)

EXO_EVAL_SCOPE (2)

Instructor view only — sre eval (non-auto), sre outline, sre sheet, periodic exam-mode evals

BOTH_EVAL_SCOPE (3, default)

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

self.question_text(title, description='', default_answer='', ...)

the description (markdown) above one free-text box

the text typed, or default_answer ('') before the first answer

self.question_form(title, description='', ...)

the description with one input in place of every @@{field:spec}@@ marker

{field: value} with every field of the form, or {} before the first answer — read the fields with .get()

self.question_dummy(title, description='')

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

@@{name:regex}@@

single-line text, meant to match the regex entirely ([0-9]+, .+, [0-9.]+/[0-9]+…); an empty spec, @@{name:}@@, puts no constraint

str, '' for an untouched field

@@{name:+}@@ / @@{name:+N}@@

multi-line text of N visible lines (params.default_form_field_lines = 4 without N)

str, newlines kept

@@{name:>a|b|c}@@

dropdown of the options, the first one selected by default

str, the option chosen

@@{name:Label A>>>a|Label B>>>b}@@

dropdown showing the labels

str, the value behind the label chosen

@@{name:?}@@ / @@{name:?true}@@

checkbox, unchecked at first with ? alone or ?false, checked with ?true

bool

@@{name::Label}@@

a button submitting the form — Flavor forms only (see Flavor class); in a question form :Label is a regex

bool, true for the button pressed

  • 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 (the pattern attribute, 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 a question_text answer.

  • 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_eval with the constant, and cap at _maximum_mark so 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 with max_grade=0 (so they only subtract) don’t enlarge the denominator either — but if you want them to not subtract below zero, clamp total_grade before 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

self.test(machine, command, step=1, timeout=20, allow_error=False, default_value='', default_code=0)

Register a command in a container; returns (stdout, exit_code) (placeholder on first pass, real result on subsequent passes).

self.test_host(command, step=1, timeout=20, allow_error=False, default_value='', default_code=0)

Same as self.test but the command runs on the host. Refused if params.execute_commands_on_host is False.

self.test_switch(network, command, step=1, default_value='', default_code=0, allow_error=False)

Same as self.test for one command of the management console of the managed switch network (vlan/print, port/print @pc1: @machine is the port number of the machine). The exit code is 0, the error number given by the switch or -2. The result is archived with the tests of the machines, under the name of the network. See Switches and VLANs.

self.question_text(title, section='', description='', hash=None, order=None, default_answer='', cheat_answers=None)

Register a free-text question; returns the student’s answer (or default_answer). See Questions.

self.question_form(title, section='', description='', hash=None, order=None, cheat_answers=None)

Register a form question: one input per @@{field:spec}@@ marker of the description — single-line text with a regex, multi-line text, dropdown, checkbox; the grammar is in Questions. Returns the student’s answers as {field: value}, {} before the first answer.

self.question_dummy(title, section='', description='', hash=None, order=None)

Display-only block (no input shown to the student). See Questions.

self.add_grade_part(title, description='')

Register a named group of grade elements and return the resulting GradePart. Pass it to add_grade_element(..., grade_part=...) to associate elements with this group; parts render in registration order with a subtotal row per part in the GUI Evaluations view and in sre outline PDFs.

self.add_grade_element(title, max_grade, description='', grade=0, scope=params.BOTH_EVAL_SCOPE, grade_part=None, bonus=False)

Add a graded rubric item. scope is a bitmask: SELF_EVAL_SCOPE (1) restricts the element to student self-eval, EXO_EVAL_SCOPE (2) restricts it to instructor/auto eval / sre outline / sre sheet, BOTH_EVAL_SCOPE (3, default) shows it everywhere. grade_part is a GradePart returned by add_grade_part(). bonus=True makes a bonus element: its grade counts, its max_grade is shown (with “Bonus”) but not counted, and the mark is capped at the maximum (see Bonus elements).

self.set_grade(title, grade)

Set the score of a previously registered element.

self.section(level=0, fmt=None, show=None, pad=None)

Increment the section counter at level and return its formatted label (e.g. "I.", "I.1.") — for grouping questions under numbered headings.

self.current_section(level=0, fmt=None, show=None, pad=None)

Same formatting as section() but without incrementing — read the current label for the same level.

self.add_error(error, category=ErrorCategory.ERROR, step=1) / self.add_warning(warning, step=1)

Record an error/warning in the archive. Only registered when self.step == step so the same call doesn’t fire on every pass. add_warning is a thin alias for add_error(..., category=ErrorCategory.WARNING).

self.data, self.step, self.max_step, self.net_scheme

Instance attributes available inside grade().

self.auto_eval_count

Number of self-evaluations the student has run on this project so far, the current run excluded when it is one (the lines of .private/auto_eval.log, appended after the run by sre --user eval --auto-eval only): 0 for the first self-evaluation; an instructor’s or a periodic background evaluation sees the student’s count too and adds nothing to it; a re-evaluation (sre re-eval / sre check-eval) takes the value the archive holds, 0 for a legacy archive without it. Lets grade() apply a penalty per retry, gate hints, etc.

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

DhcpSubnet

One subnet block: subnet, range_start, range_end, optional routers, dns_servers, domain_name, default_lease_time, max_lease_time, fixed_addresses

DhcpParameters

Full server config: interfaces_v4, interfaces_v6, subnets, authoritative, default_lease_time, max_lease_time, ddns_update_style

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 a DigResult: status, flags (has_flag('ad')), answer / authority / additional lists of RR(name, ttl, rclass, rtype, rdata), rdata('A'), soa_serial(), ttl(), error ('timeout', 'transfer failed', 'connection refused'), xfr_records for 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 same nsupdate_cmd() text serves in states (one printf | nsupdate -y line: exetests needs single-line commands).

  • DNSSEC. ecdsa_p256_keypair() gives (private_b64, public_b64) to keep in Data; render_bind_key_files(owner, priv, pub) writes them as dnssec-keygen would (named adopts them with dnssec-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) and render_bind_trust_anchors() (delv -a) write the anchor.

  • Configurations. get_named_conf(grade, m) parses named-checkconf -p (keys, options, zones, views); get_unbound_conf(grade, m) the concatenated unbound.conf.d/*.conf (unbound_values(conf, 'server', 'access-control'), unbound_clauses(conf, 'rpz')); get_stubby_conf, get_firefox_doh (policies.json or network.trr.* prefs), get_zone_status(…, view=) / get_dnssec_status (rndc), get_named_journal / get_unbound_log (events of journalctl: transfers, notifies, updates, RPZ hits), get_unbound_list(…, 'forwards'|'stubs'). unbound_default_local_zone(net) names the built-in local zone a resolver must declare nodefault before 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 MASTER and 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_vmac needs a switch: the backup keeps its vrrp.<vrid> macvlan up, so on a hub it receives the frames sent to the virtual MAC too and forwards them a second time. Set arp_announce=2 on the routers, or the clients learn the real MAC address back;

  • vde_switch keeps a MAC address 3 s on its old port (Min persistence): a virtual MAC is unreachable 3 to 4 s after a clean handover unless hash/setminper 0 was run on a managed switch. switch_cmd() cannot do it in initial for a lab with privileged machines (the console is out of reach once the privileges are dropped at the start): run it with test_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 401 again; an AOR left at max_contacts = 0 (the default) refuses every REGISTER with 403; an endpoint without allow refuses every call with 488;

  • 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 = sdes refuses a plain RTP/AVP offer with 488, and an endpoint without it refuses RTP/SAVP;

  • baresip finds ~/.baresip through $LOGNAME or $USER, which a docker exec shell 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

get_bgp_summary(grade, machine, step=1, allow_error=False)

dict

show bgp ipv4 unicast summary: router_id, as, peers: {ip: {remote_as, state, established, policy_blocked, pfx_rcd, pfx_snt, description, uptime}}. policy_blocked is True for an eBGP peer without inbound/outbound policy (RFC 8212, (Policy) in the text output)

get_bgp_neighbor(grade, machine, peer, step=1, allow_error=False)

dict

show bgp neighbors <peer>: remote_as, local_as, state, established, link (external/internal), negotiated hold_time/keepalive (seconds), local_host/foreign_host, update_source, route_reflector_client, next_hop_self, default_originate, route_map_in/route_map_out, prefix_list_in/prefix_list_out, accepted_prefixes, sent_prefixes; {} for an unknown neighbour

get_bgp_routes(grade, machine, step=1, allow_error=False)

dict[str, list[dict]]

show bgp ipv4 unicast: {prefix: [path, ...]}; each path has valid, best, reason (FRR selectionReason), path_from (external/internal), nexthop, peer, aspath ('65100 65000'), as_list ([65100, 65000]), locprf, med, weight, origin, local

bgp_best_path(routes, prefix)

dict

The best path of prefix in a get_bgp_routes() result ({} when none)

bgp_paths(routes, prefix, nexthop=None, peer=None, first_as=None, ends_with=None, path_from=None, valid=None)

list[dict]

Paths of prefix matching every given criterion (ends_with=[65001, 65300] compares the end of as_list)

get_bgp_prefix(grade, machine, prefix, step=1, allow_error=False)

dict

show bgp ipv4 unicast <prefix>: {'prefix', 'paths': [...]} with the fields above plus accessible (next-hop reachability), peer_router_id, originator_id, cluster_list (route reflection) and communities (['65001:300'])

get_bgp_advertised_routes(grade, machine, peer, step=1, allow_error=False)

dict

show bgp ipv4 unicast neighbors <peer> advertised-routes: {prefix: {nexthop, aspath, as_list, locprf, med, weight, origin}}, i.e. what is really sent to that peer after the outbound policy

is_bgp_protocol(protocol)

bool

True when a kernel route protocol (see get_kernel_routes) denotes a route installed by bgpd; kernel_route_gateways(routes, prefix, protocols=BGP_ROUTE_PROTOCOLS) lists the gateways of a BGP kernel route

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

get_rip_routes(grade, machine, step=1, allow_error=False)

dict[str, list[dict]]

show ip rip: {prefix: [path, ...]}, one path per row (an ECMP route has several). Each path has code (R, C, S, K), sub_code (n learned from a neighbour, i interface, r redistributed, d default, s static RIP route), nexthop (None for a local route), metric, source (the neighbour, None for the router’s own routes), external, tag, time. {} when RIP is not configured

rip_route_metric(routes, prefix, learned=None)

int | None

Smallest metric of prefix; learned=True only looks at the paths learned from a neighbour, learned=False at the router’s own ones

rip_route_sources(routes, prefix)

list[str]

Neighbours prefix was learned from (a route in garbage collection, metric 16, is left out)

get_rip_status(grade, machine, step=1, allow_error=False)

dict

show ip rip status: update / timeout / garbage (seconds), default_metric, redistribute, send_version / receive_version, interfaces ({name: {send, receive, key_chain}}), networks, passive_interfaces, sources ({gateway: {bad_packets, bad_routes, distance, last_update}}), distance. {} when RIP is not configured

is_rip_protocol(protocol) / is_ripng_protocol(protocol)

bool

Kernel route protocol of a route installed by ripd / ripngd; kernel_route_gateways(routes, prefix, protocols=RIP_ROUTE_PROTOCOLS) lists the gateways of a RIP kernel route (its default only accepts OSPF routes). The kernel metric of an FRR route is always 20: read the hop count in get_rip_routes()

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

install_rip_probe(net_scheme, machine, step=1)

Copy lib/rip_probe.py into the machine

rip_listen_cmd(interfaces, keys=None)

Command (re)starting the listener daemon on the interface names; keys = {key id: secret} of the MD5 keys of the lab. Start it before FRR (one step earlier) so that the first update is heard

rip_announce_cmd(source, routes, interval=2)

Command (re)starting a rogue RIP speaker (a second hidden machine, with an address): a RIPv2 response from port 520 of source to 224.0.0.9 every interval seconds. ripd believes any host of a connected subnet of an interface RIP runs on, a passive one included

get_rip_probe(grade, machine, step=1)

Run the static dump command and return a RipProbe (ran, uptime, heartbeat_age, sources: {(interface, address): RipSource} with age, version, auth_type, key_id, auth_valid, routes)

rip_window(update=None)

Seconds within which a router must have been heard: its own update timer (get_rip_status()['update'], clamped to 5–30, 30 when RIP is not running) plus a quarter (the jitter of ripd) and a margin

probe_ready(probe, window)

The listener is alive and has listened for a whole window: required by every check of something that must not be heard

heard(probe, interface, address, window)

The router sent an update on that interface during the window (false for a passive interface)

announced(probe, interface, address, window) / announced_metric(..., prefix, window)

{prefix: metric} announced during the window; a metric of 16 is a withdrawal (route poisoning, poisoned reverse), an offset-list out shows as a larger metric

md5_authenticated(probe, interface, address, window, key_id=None)

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, where routes is 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

random_password(length)

Returns a random alphanumeric password of the given length

random_sentence(length)

Returns a random sequence of lowercase words, space-separated, approximately length characters long

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

get_ip_addresses(grade, machine, step=1, ipv6=False, link_local=False)

dict[str, list[tuple[str,int]]]

Run ip a; return {iface: [(addr, prefixlen), ...]} (sorted by prefix desc, addr asc). ipv6=True adds the IPv6 addresses after the IPv4 ones (SLAAC addresses included, temporary ones excluded; link-local ones only with link_local=True)

get_routes(grade, machine, step=1, ipv6=False)

dict[tuple[str,int], tuple[str,str,int]]

Run ip route (and ip -6 route with ipv6=True); return {(net, mask): (via, dev, metric)} — default maps to ('0.0.0.0', 0) (('::', 0) in the IPv6 table)

get_sysctl_conf(grade, machine, step=1)

dict[str, str]

Read /etc/sysctl.conf and /etc/sysctl.d/*.conf; return {key: value}

get_ip_forward(grade, machine, step=1)

bool

Read /proc/sys/net/ipv4/ip_forward; return True if 1

get_ipv6_forward(grade, machine, step=1)

bool

Read /proc/sys/net/ipv6/conf/all/forwarding; return True if 1

get_sys_parameter(grade, machine, param, step=1)

str | None

Read an arbitrary /proc/sys/... file (e.g. 'net.ipv4.ip_forward'); return string value or None

get_sys_parameter_bool(grade, machine, param, step=1)

bool | None

Same as get_sys_parameter but cast to bool ('1' → True, '0' → False, else None)

get_net_config_entry(grade, machine, step=1, ipv6=False)

NetConfigEntry

Run ip a + ip route; reconstruct a NetConfigEntry from live state. ipv6=True adds the IPv6 addresses and routes to the same entries (a SLAAC address counts as dynamic: an interface with only SLAAC addresses is 'dhcp'; the RA default route has a fe80:: next hop)

get_persistent_net_config_entry(grade, machine, step=1, ipv6=False)

tuple[NetConfigEntry, int]

Parse /etc/network/interfaces; return (entry, n_errors). ipv6=True also parses the inet6 static stanzas (address, netmask <prefixlen>, gateway, post-up ip [-6] route add / ip addr add; `inet6 auto

net_config_entry_family(entry, version)

NetConfigEntry

Keep only the addresses and routes of IP version (4 or 6) in each tuple, to grade the two families of a dual-stack configuration apart

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

ips

Number of IP addresses that match

ips_expected

Number of IP addresses in expected

default_route

1 if the default route matches, 0 otherwise

default_route_expected

1 if expected has a default route

other_routes

Number of matching non-default static routes

other_routes_expected

Number of non-default routes in expected

wrong_routes

Non-default routes present in current but not in expected

dhcp_interfaces

Number of positions where both expected and current are 'dhcp'

dhcp_interfaces_expected

Count of 'dhcp' entries in expected

none_interfaces_expected

Count of None entries in expected

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); src is resolved by reverse-lookup in net_config

  • "machine_name" — machine name looked up in net_config; dest → first IPv4 address of the first interface, or first IPv6 address with ipv6=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

add_ssh_monitor_agent(net_scheme, machine, step=1)

Deploy and start an SSH monitor daemon on machine. It tails /var/log/auth.log and writes one line per forwarded-agent key to /var/log/.ssh_monitor.log when a user logs in via SSH. Used with eval_ssh_connection_with_ssh_agent().

create_ssh_key_on_host(net_scheme, filename, bits=4096, key_type='rsa', password=None, step=1)

Generate an SSH key pair on the host (ssh-keygen). Returns the filename.

remove_ssh_password_authentication_on_sshd(net_scheme, machine, restart_ssh=False, step=1)

Set PasswordAuthentication no in /etc/ssh/sshd_config. Optionally restart sshd.

set_forward_ssh_agent_in_ssh_config(net_scheme, machine_name, step=1)

Write /etc/ssh/ssh_config.d/forward_agent.conf with ForwardAgent yes for all hosts.

copy_ssh_pub_key_on_machine(net_scheme, machine, pub_key, username, step=1)

Copy a public key file (host path) into ~username/.ssh/authorized_keys on machine.

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

check_ssh_key(grade, machine, private_key, key_type='rsa', bits=4096, password=None, step=1)

bool

Verify that a key pair on machine has the expected type, bit length, and passphrase. Public key expected at private_key + '.pub'.

eval_ssh_public_key_in_authorized_keys(grade, machine, username, public_key_file=None, public_key=None, step=1)

bool

Check that a public key appears in ~username/.ssh/authorized_keys with correct ownership and restricted permissions. Provide either public_key_file (host path) or public_key (content string).

eval_ssh_agent_exists(grade, machine_name, username, step=1)

bool

Return True if an ssh-agent process is running as username on machine_name.

eval_ssh_agent_with_loaded_key(grade, machine_name, username, key_on_host, password=None, step=1)

bool

Check that a specific private key (on the host) is loaded in a running agent belonging to username on machine_name.

eval_ssh_possible_with_password_authentification(grade, src_machine, dest_machine, username='nobody', step=1)

bool

Probe dest_machine’s sshd from src_machine; return True if password authentication is offered.

eval_ssh_connection_with_password(grade, machine_name, username, step=1)

bool

Read /var/log/auth.log on machine_name; return True if a successful password login for username is present.

eval_ssh_connection_with_key(grade, machine_name, username, step=1)

bool

Read /var/log/auth.log; return True if a successful public-key login for username is present.

eval_ssh_connection_with_ssh_agent(grade, machine_name, username, key_on_host, password=None, step=1)

bool

Read /var/log/.ssh_monitor.log (written by add_ssh_monitor_agent); return True if username logged in using an agent carrying key_on_host.

eval_synchronized_file(grade, machine_list, filename, step=1)

tuple[bool, str]

Return (True, content) if filename has identical content and mtime on every machine in machine_list; (False, '') otherwise.

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

server_port, client_port

TCP port numbers used

packet_src_to_dst

1-based frame number (src → dst direction)

packet_src_to_dst_tcp_window

TCP window field of that frame

packet_src_to_dst_absolute_seq_number

Absolute sequence number

packet_src_to_dst_absolute_ack_number

Absolute acknowledgment number

packet_dst_to_src

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

title

str | TranslatedText

filename without .py

Lab title shown in the GUI tab header and in exported reports. Pass a TranslatedText (via tr() / make_tr()) for multilingual titles. The same title is also surfaced in the GUI’s Open project picker and on the Open page of the web front-end once an admin has run sre make-titles to materialise per-directory titles.json sidecars, whose "@sort" option also chooses the order of the labs of a directory, by file name, by title or by hand (see sre make-titles in the CLI reference)

default_language

str

'en'

Language code used to resolve TranslatedText values when the GUI’s locale is unavailable and when make_tr() is called without an explicit default_language=

Student self-evaluation

Attribute

Type

Default

Description

allow_self_grade

bool

False

If True, students may trigger their own evaluation from the GUI

delay_between_self_grade

int

0

Cooldown in seconds between two student self-evaluations (only enforced for sre --user eval --auto-eval, i.e. when the student presses Start evaluation in the GUI; periodic background evals do not count). The remaining delay is stored under params.self_grade_timestamp_dir (/var/lib/sre/last_self_grades/{lab_name})

no_mark_on_self_grade

bool

False

During a student-triggered evaluation, hide numeric grades and show only the OK / MEH / FAIL letters

hide_potential_penalty_grades_in_self_grade

bool

False

During a student-triggered evaluation, drop grade-list entries whose grade and max_grade are both 0 (i.e. penalty rubrics that have not yet fired). Has no effect on instructor-triggered evals

Automatic evaluation

Attribute

Type

Default

Description

eval_interval_without_exam_mode

int

0

Auto-eval interval in seconds when no exam is configured. 0 disables periodic evaluation

eval_before_exit

bool

False

If True, run a final evaluation when the student closes the project

use_numerical_marks

bool

params.use_numerical_marks_by_default (True)

If False, the lab reports OK / MEH / FAIL letters instead of numeric scores

display_marks_in_auto_evaluations

bool

params.display_marks_in_auto_evaluations_by_default (False)

If False, the numeric mark is hidden from students during automatic (periodic) evaluations even when use_numerical_marks=True

maximum_mark

int | float

params.default_maximum_mark (20)

Upper bound used when normalizing the lab’s total grade

Archives and recordings

Attribute

Type

Default

Description

archive_dirs

list[str]

[]

Extra directories to write evaluation archives (.zst) to, in addition to params.archive_dirs

files_to_save_in_archives

list[str]

[]

Container paths whose contents are copied into each evaluation archive (under the files key). Useful for capturing config files, logs, or pcap traces produced by the student

record_sessions

bool | None

None

Override whether terminal sessions opened from the GUI are recorded (asciinema). None defers to the global / exam setting

save_records_dir

list[str]

[]

Extra directories to write session-record archives (.tar.gz) to during an exam, in addition to params.save_records_dir (/var/lib/sre/archives). Read by sre save-records and by the automatic save loop during eval-exam

save_record_interval_during_exams

int

params.default_save_record_interval_during_exams (60)

Minimum delay in seconds between two automatic session-record saves for the same project during an exam. Set to 0 to disable automatic saving for this lab

State machine and flavors

Attribute

Type

Default

Description

allow_user_states

bool

False

If True, students can apply states decorated with @sre_state(user_allowed=True) from the GUI. States decorated with user_allowed=False remain instructor-only

flavor_form_at_startup

bool

False

If True and a Flavor subclass is defined, the GUI shows the flavor form before calling sre start, letting the student parameterize the lab

Networking

Attribute

Type

Default

Description

ipv6

bool

params.default_ipv6 (None: Kathara’s own setting, off)

If True, IPv6 is enabled in every machine (disable_ipv6=0, forwarding and accept_ra=0 set by Kathara); Machine(ipv6=...) overrides it per machine. The effective value is written to the lab.conf of sre export. See IPv6 labs

Filesystem and export

Attribute

Type

Default

Description

shared_path

bool

False

Create /home/sre/{running_lab_name}/ (mode 0o777) and bind-mount it into each container for file exchange between host and containers

export_kathara_project

bool

True

If False, disable File → Export Kathara Project in the GUI and refuse sre export for this lab

export_schema_pdf

bool

True

If False, disable File → Export Schema to PDF in the GUI: the drawing of the Schema tab (the visible machines only, with the tab’s lines / spacing / order / icons settings) written to a PDF by graphviz. See Exporting a project

allow_save_restore

bool

False

If True, students may save a running project to a file (File → Save Project, sre save) and restore it later as a new instance (File → Restore Project, sre restore). The save file holds data.json and .private/files — in clear unless save_key is set — so enable it only for labs whose Data holds nothing the student must not read, or set save_key

save_key

str

(unset)

Non-empty secret string. When set, the payload of every save file is encrypted (AES-256-GCM, key derived from save_key); only a lab with the same key can restore it, and students cannot restore cleartext files. Keep the lab file unreadable by students

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

show_nat_network

bool

params.default_show_nat_network (True)

If False, hide the host-network vertex even when machines are marked bridged=True

host_network_name

str

params.default_host_network_name ("Internet")

Label shown on the host-network vertex

host_network_color

str

params.default_host_network_color ("deepskyblue")

Fill color of the host-network vertex (graphviz color name or #rrggbb)

host_network_exploded

bool

params.default_host_network_exploded (False)

If True, draw one separate host-network vertex per bridged=True machine instead of a single shared one

host_network_edge_relative_length

float

params.default_host_network_edge_relative_length (1.0)

Relative length (graphviz len) applied to edges between bridged=True machines and the host vertex. < 1 shortens them, > 1 lengthens them

schema_splines

str

params.graphviz_default_splines ("curved")

Graphviz splines attribute (e.g. "line", "ortho", "curved", "polyline")

schema_overlap

str

params.graphviz_default_overlap ("prism")

Graphviz overlap attribute controlling how the layout resolves node overlaps

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 .py file 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

"name"

By file name, byte-wise (capitals first). The default.

"title"

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.

"manual"

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).