Live on Your Network

Goal

Serve ~/projects/first-site/index.html, load it on a second device on your Wi-Fi, prove it with the server's access log, and write a two-line runbook you could follow again in six months. ---

Type: interactive-form Unit: 4 — The third address

This is the last project. It finishes the file you started in Unit 1.

Goal

Serve ~/projects/first-site/index.html, load it on a second device on your Wi-Fi, prove it with the server’s access log, and write a two-line runbook you could follow again in six months.


How this works

Fill starter/submission.txt. Fields sit between === name === headers. Do not rename, reorder, or delete those headers. Paste captures; do not retype them.

FieldWhat goes in it
platformExactly one of macOS, Linux, WSL
lan_ipThe serving machine’s LAN IPv4 — one dotted quad
phone_urlThe exact URL you typed on the second device
curl_i_outputFull output of curl -I http://127.0.0.1:8000/index.html on the serving machine
access_log_linesAt least two log lines: your curl (127.0.0.1) and the second device
second_device_ipThe address at the start of the second device’s log line
runbookThe filled runbook-skeleton.md
obstaclesWhat broke, what it looked like, how you told which cause it was

Your tasks

  1. Start python3 -m http.server 8000 inside ~/projects/first-site.
  2. On the same machine: curl -I http://127.0.0.1:8000/index.html. Paste it.
  3. Write your LAN IP. Type http://<that-ip>:8000 on the phone (or a second computer on the same Wi-Fi).
  4. Paste the access log. The second device must not be 127.0.0.1.
  5. Fill the runbook: start, stop, tear down.
  6. Note what went wrong — or the checks that came back clean.

On WSL

Reaching a WSL server from a phone is not the default, and this course has not verified the full mirrored-mode path end to end. If the phone cannot see it: try a second Windows browser via localhost, or write WSL-PATH-B in second_device_ip and say so in obstacles.

What the scaffolding is for

A log line from your own laptop is not proof a phone connected. phone_url must use the LAN IP, not localhost.

Expected output

curl_i_output starts HTTP/1.0 200 OK. phone_url is http://<lan_ip>:8000. The log has two different client addresses.

Rules

See rubric.md for how this is scored.

Steps

Your files

Download these onto your machine and fill them in. The brief says which, and what “done” looks like.

runbook-skeleton.md Download

                    # Runbook — serving first-site on my own network

Written for: someone who has never used this machine. Written by: me, six months ago.

Platform: <macOS | Linux | WSL>

## Start

<The commands, in order, one per line, with what you should see after each one.>

1. `cd ~/projects/first-site`
   — you should see: nothing. That is what success looks like for `cd`.
2. `python3 -m http.server 8000`
   — you should see: `Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...`
3. <how you find the LAN IP on this machine — the exact command>
   — you should see: <the shape of the answer, and how to tell the wrong line from the right one>
4. On the second device: `http://192.168.x.x:8000`
   — you should see: <the page>, and a new line in the server terminal starting with the second
     device's address.

## Check

<How to tell, from the serving machine alone, that the server is up and serving the right folder.>

- `curl -I http://127.0.0.1:8000/index.html`
  — good: `HTTP/1.0 200 OK` and `Content-type: text/html`
  — bad: <what a 404 means here, and what a charset on that Content-type line means>
- <the command that shows what is listening on 8000 on this platform, and what its output looks like>

## When it fails

<At least two real symptoms, keyed to what you actually see. One row per symptom.>

| What I see | What it means | What I do |
| --- | --- | --- |
| `OSError: [Errno 98] Address already in use` (`[Errno 48]` on macOS) | | |
| `PermissionError: [Errno 13] Permission denied` | | |
| A list of file names instead of my page | | |
| The phone fails instantly | | |
| The phone hangs, then fails | | |
| The phone shows nothing and no new line appears in the log | | |

## Stop

<How to stop the server, and how to confirm it stopped.>

- `Ctrl-C` in the server's terminal — it prints `Keyboard interrupt received, exiting.`
- <the command that confirms nothing is listening on 8000 any more>

## Teardown

<Everything this procedure opened, and how to close it. Be specific: name the rule, the setting, or
the list entry. If nothing was opened, say exactly that.>

- Server stopped: <yes/no, how>
- Firewall change made: <what, exactly — the rule name or setting — and the command or click that
  reverses it. Or: `No firewall change was made.`>
- <WSL only: the Hyper-V rule named `MyWebServer`, and whether `networkingMode=mirrored` was left in
  `%UserProfile%\.wslconfig`. Note that undoing that file needs the subsystem to fully stop and
  restart — about 8 seconds after closing ALL instances of the distribution shell.>

                  

submission.txt Download

                    ;; Project 6 — Live on Your Network — submission
;;
;; Fill in each field between its header line and the next header line.
;; Lines beginning with ;; are template comments. The graders ignore them, so you may
;; leave them in place or delete them — either way they are not part of your answer.
;; Do NOT add, rename, remove, or reorder the === field === headers.
;; Paste your captures. Never retype them.

=== platform ===
;; Exactly one of:  macOS   Linux   WSL
;; Put the word on its own line.


=== lan_ip ===
;; The serving machine's LAN IPv4, as a bare dotted quad.
;;   Linux/WSL:  ip addr        -> the inet line on your Wi-Fi interface, NOT the lo block
;;   macOS:      networksetup -listallhardwareports
;;               ipconfig getifaddr en0
;; It must fall in one of RFC 1918's three private ranges:
;;      10.0.0.0        -   10.255.255.255  (10/8 prefix)
;;      172.16.0.0      -   172.31.255.255  (172.16/12 prefix)
;;      192.168.0.0     -   192.168.255.255 (192.168/16 prefix)
;; No port, no /24, no interface name.


=== phone_url ===
;; Exactly what you typed into the second device's address bar.
;;   http://<the lan_ip above>:8000        — :8000 is not optional


=== curl_i_output ===
;; Paste the WHOLE output of this, run on the serving machine, in a second terminal:
;;   curl -I http://127.0.0.1:8000/index.html


=== access_log_lines ===
;; Paste at least two lines from the terminal the server is running in.
;;   one caused by your own curl above  -> its first field will be 127.0.0.1
;;   one caused by the second device    -> its first field is the second device's address
;; The real shape, from this course's own capture:
;;   127.0.0.1 - - [29/Jul/2026 04:26:16] "GET /nope HTTP/1.1" 404 -
;; Paste them. Do not edit them afterwards.


=== second_device_ip ===
;; The address at the START of the second device's log line, as a bare dotted quad.
;; It must NOT be inside 127.0.0.0/8 and must NOT equal lan_ip.
;; WSL Path B only: the literal text   WSL-PATH-B


=== runbook ===
;; Fill in runbook-skeleton.md and paste the whole thing here.
;; Keep the "## Teardown" heading.


=== obstacles ===
;; What went wrong, what it looked like on screen, and how you worked out which cause it was.
;; If nothing went wrong, say so and list the checks that came back clean.
;;
;; Required, one line, from step 8:
;;   Outside: <what happened when someone on another network opened phone_url, and why>
;;
;; WSL Path B only, each on its own line:
;;   WSL-IP-ADDR: <the address ip addr reported inside WSL>
;;   WSL-IPCONFIG: <the IPv4 address ipconfig.exe reported in the same shell>
;;   <the line containing networkingMode=mirrored as you set it>
;;   <the New-NetFirewallHyperVRule command exactly as you ran it, with -LocalPorts 8000>
;;   STOPPED AT: <the step it stopped at, and what you saw>

                  

On your machine, run the tests in this project’s tests/ folder. Each one prints PASS or a FAIL: line that names what is wrong.

How it is graded
CriterionWhat earns itWeight
The log line proves a second machine

access_log_lines contains at least two lines in the server's real log shape — <client> - - [<timestamp>] "<request>" <status> <size> — of which at least one has 127.0.0.1 (or another 127.0.0.0/8 address) in the client field and at least one has the address submitted as second_device_ip. That address parses as an IPv4 dotted quad, falls in one of RFC 1918's three private ranges, is not inside 127.0.0.0/8, and is not equal to lan_ip.

  • Full credit: both lines present, second_device_ip non-loopback, RFC 1918, distinct from lan_ip, and the same address appears verbatim at the start of one submitted log line.
  • Zero: every submitted line has a loopback client address; or second_device_ip equals lan_ip; or second_device_ip appears nowhere in access_log_lines. A loopback line is a request the serving machine made to itself and carries no evidence about a second machine at all.
  • WSL Path B substitution (only when platform is WSL and second_device_ip is WSL-PATH-B): grade the diagnosis instead, at the same weight. Full credit requires all of — a WSL-IP-ADDR: line and a WSL-IPCONFIG: line giving the two different addresses the same shell reported; a line showing networkingMode=mirrored was set; the Hyper-V rule reproduced as it was run, carrying {40E0AC32-46A5-438A-A0B2-2B479E8F2E90} and -LocalPorts 8000; a STOPPED AT: line naming the step it stopped at and what was observed there; and at least two well-formed log lines from the serving machine's own requests, proving a server really ran. Missing either address reading scores zero on this criterion — the two-command comparison is the diagnosis.
35
The runbook a stranger could follow

The runbook field is judged as an operational document, not as a list of correct commands. Ask: could someone who has never touched this machine follow it in six months, on a bad day?

  • Full credit (21–25): the commands appear in order and are runnable as written; each step says what you should see when it worked, so the reader can tell where they are; the When it fails section names at least two concrete failure symptoms and what to do about each — by their real strings or symptoms, e.g. Address already in use → find the owner with ss -tlnp or lsof -i :8000; a directory listing instead of the page → started in the wrong folder; a hang rather than an instant failure → something is dropping packets, not "nothing is listening"; the Stop section gives Ctrl-C; the Teardown section names the specific thing that was opened and how it was closed, or states plainly that nothing was opened.
  • Partial (11–20): the commands are right and ordered, but the reader is given no way to tell a working step from a broken one, or the failure section is generic ("if it doesn't work, check the firewall") rather than symptom-keyed.
  • Low (1–10): a transcript. Commands with no expected output, no failure branch, no teardown.
  • Zero: missing, or does not include starting and stopping the server.

Do not award craft credit for length. A tight runbook that branches on symptoms beats a long one that does not.

25
Obstacles are specific and honest

obstacles is graded on specificity and truthfulness, not on drama. What is wanted is: what went wrong, what it looked like on screen, what you checked, and how you knew which cause it was.

  • Full credit (17–20): at least one obstacle described with the evidence that identified it — the exact error string, the command that produced it, and the reasoning that ruled out the other causes. "The phone hung rather than failing instantly, which meant something was swallowing the packets rather than nothing listening, so I checked the firewall before re-checking the address" is full credit. A learner who genuinely hit nothing gets full credit for saying so and naming the checks they ran that came back clean — including the Outside: line from step 8.
  • Partial (8–16): an obstacle is named but not diagnosed ("the firewall was blocking it") with no account of how that was established rather than guessed.
  • Low (1–7): vague, or contradicts the other fields.
  • Zero: empty, or a generic obstacle that could have been written without touching a machine.

The Outside: line from step 8 must be present and must correctly attribute the failure to private addressing rather than to a typo, a firewall, or the server being off. Missing it costs 5 of the 20.

20
The evidence is internally consistent and self-produced

The submitted fields agree with each other and bear the fingerprints of this learner's own server.

  • curl_i_output starts HTTP/1.0 200 — not HTTP/1.1, which python3 -m http.server does not answer with by default — and carries a Server: SimpleHTTP/<version> Python/<version> line.
  • Content-type: text/html appears with no charset parameter, which is what a stored file gets; a charset=utf-8 there is the generated directory listing and means the server was started in the wrong directory.
  • phone_url is http:// + the submitted lan_ip + :8000. A phone_url of http://localhost:8000 is the misconception Unit 6 exists to remove and scores zero here.
  • lan_ip is a bare dotted quad in RFC 1918 space.
  • platform matches the commands and paths quoted elsewhere in the submission.

Zero if the captures cannot be the learner's own — an HTTP/1.1 response line, a missing or malformed Server: header, or headers that contradict the platform or the log.

20
Total100
Test cases and grader source
Test Checks Expected Weight
curl_is_your_own_server Adversarialcurl_i_output pasted from a tutorial or another web server: the response must start HTTP/1.0 200, never HTTP/1.1, and must carry a Server: SimpleHTTP/<v> Python/<v> header. Also catches the wrong serving directory via Content-type: text/html; charset=utf-8, which only a generated listing sends. PASS 20
second_device_is_not_loopback Adversarial — a second_device_ip inside 127.0.0.0/8 (a curl from the serving machine dressed up as a phone) and a second_device_ip equal to lan_ip (the learner's own address read back). Also validates lan_ip as RFC 1918, and handles the WSL Path B diagnostic record. PASS 30
access_log_carries_second_device A second_device_ip that appears nowhere in the submitted log, log lines not in the server's real output shape, and a submission with no loopback baseline line. PASS 25
phone_url_matches_lan_ip Adversarialhttp://localhost:8000 submitted as the phone URL, a missing :8000, a file:// URL, and a host that disagrees with lan_ip. PASS 15
runbook_starts_stops_and_closes A runbook with no start command, no stop instruction, or no filled-in ## Teardown section — and on WSL, a teardown that never names the MyWebServer rule it created. PASS 10

tests/test_01_curl_is_your_own_server.py

                        #!/usr/bin/env python3
"""
test_01_curl_is_your_own_server  —  weight 20  —  ADVERSARIAL

Catches: a `curl_i_output` copied from a tutorial, a blog post, or another web server
instead of captured from the learner's own `python3 -m http.server`.

The tells are documented and specific to this server:
  * the response start-line is `HTTP/1.0 200 OK` — `http.server` sets
    `protocol_version = "HTTP/1.0"` and the docs say "For backwards compatibility, the
    setting defaults to 'HTTP/1.0'." Almost every pasted example on the web is HTTP/1.1.
  * the `Server:` header is built as "SimpleHTTP/" + version and "Python/" + version,
    e.g. `Server: SimpleHTTP/0.6 Python/3.14.6`. Nginx, Apache and Caddy do not say that.
  * a stored `.html` file gets `Content-type: text/html` with NO charset parameter. The
    charset only appears on pages the server generates itself, so
    `Content-type: text/html; charset=utf-8` here means a directory listing was captured —
    the server was started in the wrong directory.

Self-contained: standard library only. Usage:
    python3 test_01_curl_is_your_own_server.py path/to/submission.txt
Prints PASS, or a single definite failure line beginning FAIL:.
"""

import os
import re
import sys

HEADER = re.compile(r"^===\s*([a-z_]+)\s*===\s*$")
PLATFORMS = ("macOS", "Linux", "WSL")


def parse(path):
    fields, cur = {}, None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            m = HEADER.match(line)
            if m:
                cur = m.group(1)
                fields[cur] = []
                continue
            if line.startswith(";;"):
                continue
            if cur is not None:
                fields[cur].append(line)
    return {k: "\n".join(v).strip() for k, v in fields.items()}


def fail(msg):
    print("FAIL: " + msg)
    sys.exit(1)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SUBMISSION", "submission.txt")
    try:
        f = parse(path)
    except OSError as exc:
        fail("cannot read the submission file %r (%s)" % (path, exc.strerror))

    platform = f.get("platform", "")
    if platform not in PLATFORMS:
        fail("platform is %r — it must be exactly one of macOS, Linux, WSL" % platform)

    body = f.get("curl_i_output", "")
    if not body:
        fail("curl_i_output is empty — run `curl -I http://127.0.0.1:8000/index.html` on the "
             "serving machine and paste the whole output")

    lines = [ln.rstrip("\r") for ln in body.split("\n") if ln.strip()]

    start = lines[0]
    if start.startswith("HTTP/1.1"):
        fail("the response start-line is %r — python3 -m http.server answers HTTP/1.0 by default, "
             "so this capture did not come from your server" % start)
    if not re.match(r"^HTTP/1\.0 200\b", start):
        if re.match(r"^HTTP/1\.0 (\d{3})", start):
            code = re.match(r"^HTTP/1\.0 (\d{3})", start).group(1)
            fail("the response start-line is %r — a %s is not a served file. A 404 means the server "
                 "is looking in the wrong directory for index.html" % (start, code))
        fail("the first line of curl_i_output is %r — it must be the response start-line, "
             "beginning `HTTP/1.0 200`" % start)

    for ln in lines:
        if ln.startswith("HTTP/1.1"):
            fail("curl_i_output contains the line %r — python3 -m http.server never answers "
                 "HTTP/1.1, so this capture is not from your server" % ln)

    server = [ln for ln in lines if ln.lower().startswith("server:")]
    if not server:
        fail("curl_i_output has no Server: header — your server always sends one, shaped "
             "`Server: SimpleHTTP/<version> Python/<version>`")
    if not re.match(r"^Server:\s*SimpleHTTP/\S+\s+Python/\S+\s*$", server[0]):
        fail("the Server: header is %r — your server sends `SimpleHTTP/<version> "
             "Python/<version>`, so this capture came from a different web server" % server[0])

    ctype = [ln for ln in lines if ln.lower().startswith("content-type:")]
    if not ctype:
        fail("curl_i_output has no Content-type header — a served index.html always carries one")
    value = ctype[0].split(":", 1)[1].strip()
    if "charset" in value.lower():
        fail("Content-type is %r — the charset parameter appears only on pages the server "
             "generates itself, so this is a directory listing, not your index.html. Start the "
             "server from ~/projects/first-site" % value)
    if value != "text/html":
        fail("Content-type is %r — a served .html file gets exactly `text/html`" % value)

    clen = [ln for ln in lines if ln.lower().startswith("content-length:")]
    if not clen or not clen[0].split(":", 1)[1].strip().isdigit():
        fail("curl_i_output has no Content-Length header with a numeric value — without it there "
             "is no evidence a file of any size was served")

    print("PASS")


if __name__ == "__main__":
    main()

                      

tests/test_02_second_device_is_not_loopback.py

                        #!/usr/bin/env python3
"""
test_02_second_device_is_not_loopback  —  weight 30  —  ADVERSARIAL (the main one)

Catches the two shortcuts that would let a learner submit this project without a second
machine ever having touched the server:

  1. Submitting a loopback address as `second_device_ip`. A client address inside
     127.0.0.0/8 is, by RFC 1122, an "Internal host loopback address" whose "Addresses of
     this form MUST NOT appear outside a host" — RFC 6890 lists 127.0.0.0/8 as
     "Forwardable | False", "Global | False". A log line with that client address records
     the serving machine asking itself a question, which is exactly what the `curl -I` in
     task 2 does. It is evidence of a working server and no evidence at all of a network.
     This is by far the most likely shortcut and it fails here.

  2. Submitting `lan_ip` back as `second_device_ip` — reading the serving machine's own
     address off the screen instead of reading the second device's address out of the log.

It also enforces that `lan_ip` itself is a real private address, and handles the WSL
Path B submission (`second_device_ip` = WSL-PATH-B), which is accepted only on WSL and
only with the full diagnostic record in `obstacles`.

Self-contained: standard library only. Usage:
    python3 test_02_second_device_is_not_loopback.py path/to/submission.txt
Prints PASS, or a single definite failure line beginning FAIL:.
"""

import ipaddress
import os
import re
import sys

HEADER = re.compile(r"^===\s*([a-z_]+)\s*===\s*$")
PLATFORMS = ("macOS", "Linux", "WSL")
SENTINEL = "WSL-PATH-B"
WSL_VMCREATORID = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"

# RFC 1918, section 3, verbatim ranges:
#      10.0.0.0        -   10.255.255.255  (10/8 prefix)
#      172.16.0.0      -   172.31.255.255  (172.16/12 prefix)
#      192.168.0.0     -   192.168.255.255 (192.168/16 prefix)
PRIVATE = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
LOOPBACK = ipaddress.ip_network("127.0.0.0/8")


def parse(path):
    fields, cur = {}, None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            m = HEADER.match(line)
            if m:
                cur = m.group(1)
                fields[cur] = []
                continue
            if line.startswith(";;"):
                continue
            if cur is not None:
                fields[cur].append(line)
    return {k: "\n".join(v).strip() for k, v in fields.items()}


def fail(msg):
    print("FAIL: " + msg)
    sys.exit(1)


def as_ipv4(value, field):
    if not value:
        fail("%s is empty" % field)
    if "\n" in value.strip():
        fail("%s contains more than one line — it must be a single dotted quad and nothing "
             "else" % field)
    token = value.strip()
    try:
        addr = ipaddress.ip_address(token)
    except ValueError:
        fail("%s is %r, which is not an IPv4 address. Submit a bare dotted quad — no port, no "
             "prefix length, no scheme, no interface name" % (field, token))
    if addr.version != 4:
        fail("%s is %r, an IPv6 address. This project is graded on the IPv4 address your second "
             "device used" % (field, token))
    return addr


def check_path_b(f):
    if f.get("platform") != "WSL":
        fail("second_device_ip is %r, which is accepted only when platform is WSL. On macOS and "
             "Linux there is no fallback: capture the second device's address from the access "
             "log" % SENTINEL)
    obstacles = f.get("obstacles", "")
    lines = [ln.strip() for ln in obstacles.split("\n")]

    ipaddr_lines = [ln for ln in lines if ln.startswith("WSL-IP-ADDR:")]
    ipconfig_lines = [ln for ln in lines if ln.startswith("WSL-IPCONFIG:")]
    if not ipaddr_lines:
        fail("WSL Path B needs a line in obstacles beginning `WSL-IP-ADDR:` giving the address "
             "`ip addr` reported inside WSL — the two-command comparison is the diagnosis")
    if not ipconfig_lines:
        fail("WSL Path B needs a line in obstacles beginning `WSL-IPCONFIG:` giving the IPv4 "
             "address `ipconfig.exe` reported in the same shell — the two-command comparison is "
             "the diagnosis")

    def first_quad(text):
        m = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", text)
        return m.group(0) if m else None

    a, b = first_quad(ipaddr_lines[0]), first_quad(ipconfig_lines[0])
    if a is None:
        fail("the WSL-IP-ADDR: line contains no IPv4 address — paste the address `ip addr` "
             "reported")
    if b is None:
        fail("the WSL-IPCONFIG: line contains no IPv4 address — paste the address "
             "`ipconfig.exe` reported")

    if not any("networkingMode=mirrored" in ln for ln in lines):
        fail("obstacles has no line containing `networkingMode=mirrored` — Path B requires "
             "evidence that the documented fix was applied before it stopped")

    rule = [ln for ln in lines if WSL_VMCREATORID in ln]
    if not rule:
        fail("obstacles has no line containing the WSL VMCreatorId "
             "%s — paste the Hyper-V firewall rule exactly as you ran it" % WSL_VMCREATORID)
    if not any("-LocalPorts 8000" in ln for ln in rule):
        fail("the Hyper-V rule line does not end `-LocalPorts 8000` — Microsoft's documented "
             "example opens 80, and the one thing you change is the port your server is on")

    stopped = [ln for ln in lines if ln.startswith("STOPPED AT:")]
    if not stopped or len(stopped[0]) <= len("STOPPED AT:") + 10:
        fail("obstacles has no usable `STOPPED AT:` line — name the step it stopped at and what "
             "you saw there. That sentence is what Path B is graded on")

    print("PASS")
    sys.exit(0)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SUBMISSION", "submission.txt")
    try:
        f = parse(path)
    except OSError as exc:
        fail("cannot read the submission file %r (%s)" % (path, exc.strerror))

    platform = f.get("platform", "")
    if platform not in PLATFORMS:
        fail("platform is %r — it must be exactly one of macOS, Linux, WSL" % platform)

    second_raw = f.get("second_device_ip", "").strip()
    if second_raw == SENTINEL:
        check_path_b(f)

    lan = as_ipv4(f.get("lan_ip", ""), "lan_ip")
    if lan in LOOPBACK:
        fail("lan_ip is %s, which is inside 127.0.0.0/8 — that is the loopback line from `ip "
             "addr` (`inet 127.0.0.1/8 scope host lo`), not your Wi-Fi interface" % lan)
    if not any(lan in net for net in PRIVATE):
        fail("lan_ip is %s, which is not in any RFC 1918 private range (10/8, 172.16/12, "
             "192.168/16) — a home network address falls in one of those three" % lan)

    second = as_ipv4(second_raw, "second_device_ip")
    if second in LOOPBACK:
        fail("second_device_ip is %s, inside 127.0.0.0/8. That is a loopback address: the client "
             "was the serving machine talking to itself, which is what your own `curl -I` does. "
             "RFC 1122 is explicit that such addresses \"MUST NOT appear outside a host\", so no "
             "second device can ever appear in the log with one. Find the log line whose first "
             "field is not 127.x" % second)
    if second == lan:
        fail("second_device_ip equals lan_ip (%s) — that is the serving machine's own address "
             "read back. The second device has its own address, and it is the first field of the "
             "log line your phone caused, not the address you typed into it" % second)
    if not any(second in net for net in PRIVATE):
        fail("second_device_ip is %s, which is not in any RFC 1918 private range (10/8, "
             "172.16/12, 192.168/16) — a device on your own Wi-Fi has a private address" % second)

    print("PASS")


if __name__ == "__main__":
    main()

                      

tests/test_03_access_log_carries_second_device.py

                        #!/usr/bin/env python3
"""
test_03_access_log_carries_second_device  —  weight 25

Verifies that the submitted address is actually backed by the server's own output, rather
than typed into the `second_device_ip` field on its own.

Every request `python3 -m http.server` answers writes one line to stderr, shaped exactly
like this real capture:

    127.0.0.1 - - [29/Jul/2026 04:26:16] "GET /nope HTTP/1.1" 404 -

The first field is the address of whoever asked. This test requires at least two lines in
that shape: one whose client field is inside 127.0.0.0/8 (the request the serving machine
made to itself with `curl -I`) and one whose client field is exactly `second_device_ip`.
Both together are the claim — that one machine served, and a different machine fetched.

The size field being `-` is not a defect: this server always writes `-` there.
`HTTP/1.1` inside the quoted request line is not a defect either: that is the client's
version echoed back, while the response itself is HTTP/1.0.

Self-contained: standard library only. Usage:
    python3 test_03_access_log_carries_second_device.py path/to/submission.txt
Prints PASS, or a single definite failure line beginning FAIL:.
"""

import ipaddress
import os
import re
import sys

HEADER = re.compile(r"^===\s*([a-z_]+)\s*===\s*$")
LOGLINE = re.compile(r'^(\S+) - - \[([^\]]+)\] "([^"]*)" (\d{3}) (\S+)\s*$')
PLATFORMS = ("macOS", "Linux", "WSL")
SENTINEL = "WSL-PATH-B"
LOOPBACK = ipaddress.ip_network("127.0.0.0/8")


def parse(path):
    fields, cur = {}, None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            m = HEADER.match(line)
            if m:
                cur = m.group(1)
                fields[cur] = []
                continue
            if line.startswith(";;"):
                continue
            if cur is not None:
                fields[cur].append(line)
    return {k: "\n".join(v).strip() for k, v in fields.items()}


def fail(msg):
    print("FAIL: " + msg)
    sys.exit(1)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SUBMISSION", "submission.txt")
    try:
        f = parse(path)
    except OSError as exc:
        fail("cannot read the submission file %r (%s)" % (path, exc.strerror))

    platform = f.get("platform", "")
    if platform not in PLATFORMS:
        fail("platform is %r — it must be exactly one of macOS, Linux, WSL" % platform)

    body = f.get("access_log_lines", "")
    if not body:
        fail("access_log_lines is empty — the log is the proof this whole project turns on")

    raw_lines = [ln.rstrip("\r") for ln in body.split("\n") if ln.strip()]
    parsed, unparsed = [], []
    for ln in raw_lines:
        m = LOGLINE.match(ln.strip())
        if m:
            parsed.append((m.group(1), m.group(3), m.group(4)))
        else:
            unparsed.append(ln.strip())

    if len(parsed) < 2:
        hint = (" The first line that is not in that shape is %r." % unparsed[0]) if unparsed else ""
        fail("access_log_lines has %d line(s) in the server's log shape "
             "`<client> - - [<timestamp>] \"<request>\" <status> <size>`, and at least 2 are "
             "required.%s Paste the lines from the server's terminal without editing them."
             % (len(parsed), hint))

    clients = []
    for client, _req, _status in parsed:
        try:
            clients.append(ipaddress.ip_address(client))
        except ValueError:
            fail("a log line starts with %r, which is not an IP address — the first field of "
                 "every line this server writes is the client's address" % client)

    loopback_lines = [c for c in clients if c.version == 4 and c in LOOPBACK]
    if not loopback_lines:
        fail("no submitted log line has a client address inside 127.0.0.0/8 — the `curl -I "
             "http://127.0.0.1:8000/index.html` you ran on the serving machine wrote one, and it "
             "is the baseline the second device's line is contrasted against")

    second_raw = f.get("second_device_ip", "").strip()

    if second_raw == SENTINEL:
        if platform != "WSL":
            fail("second_device_ip is %r, which is accepted only when platform is WSL" % SENTINEL)
        if not any(ln.strip().startswith("STOPPED AT:") for ln in f.get("obstacles", "").split("\n")):
            fail("WSL Path B needs a `STOPPED AT:` line in obstacles saying where the documented "
                 "procedure stopped and what you saw")
        non_loopback = [str(c) for c in clients if not (c.version == 4 and c in LOOPBACK)]
        if non_loopback:
            fail("this submission claims WSL Path B (nothing on the network could reach the "
                 "server) but access_log_lines contains a line from %s, which is not the serving "
                 "machine. If a second machine reached it, submit that address as "
                 "second_device_ip instead" % non_loopback[0])
        print("PASS")
        return

    try:
        second = ipaddress.ip_address(second_raw)
    except ValueError:
        fail("second_device_ip is %r, which is not an IP address" % second_raw)

    matching = [c for c in clients if c == second]
    if not matching:
        seen = ", ".join(sorted({str(c) for c in clients}))
        fail("second_device_ip is %s but no submitted log line has that client address. The "
             "addresses actually present are: %s. The number in that field must be copied out of "
             "the log, not typed in from somewhere else" % (second, seen))

    if second.version == 4 and second in LOOPBACK:
        fail("the line matching second_device_ip has client %s, inside 127.0.0.0/8 — that is the "
             "serving machine asking itself, not a second device" % second)

    print("PASS")


if __name__ == "__main__":
    main()

                      

tests/test_04_phone_url_matches_lan_ip.py

                        #!/usr/bin/env python3
"""
test_04_phone_url_matches_lan_ip  —  weight 15  —  ADVERSARIAL

Catches the submission whose `phone_url` is not the address that was submitted as the
serving machine's, in the three shapes that actually happen:

  * `http://localhost:8000` — the misconception this whole unit exists to remove.
    `localhost` names whichever machine is asking, so on the phone it names the phone.
  * `http://<lan_ip>` with the port dropped. The port "is usually omitted if the web
    server uses the standard ports of the HTTP protocol (80 for HTTP and 443 for HTTPS)
    ... Otherwise it is mandatory." This server is on 8000.
  * a host that is not the `lan_ip` submitted alongside it — the two fields have to
    describe the same machine or the submission is not self-consistent.

Applies identically on the WSL Path B route: the URL was still typed into the device.

Self-contained: standard library only. Usage:
    python3 test_04_phone_url_matches_lan_ip.py path/to/submission.txt
Prints PASS, or a single definite failure line beginning FAIL:.
"""

import os
import re
import sys

HEADER = re.compile(r"^===\s*([a-z_]+)\s*===\s*$")
PLATFORMS = ("macOS", "Linux", "WSL")
URL = re.compile(r"^http://([^/:\s]+):(\d+)(/[^\s]*)?$")


def parse(path):
    fields, cur = {}, None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            m = HEADER.match(line)
            if m:
                cur = m.group(1)
                fields[cur] = []
                continue
            if line.startswith(";;"):
                continue
            if cur is not None:
                fields[cur].append(line)
    return {k: "\n".join(v).strip() for k, v in fields.items()}


def fail(msg):
    print("FAIL: " + msg)
    sys.exit(1)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SUBMISSION", "submission.txt")
    try:
        f = parse(path)
    except OSError as exc:
        fail("cannot read the submission file %r (%s)" % (path, exc.strerror))

    if f.get("platform", "") not in PLATFORMS:
        fail("platform is %r — it must be exactly one of macOS, Linux, WSL" % f.get("platform", ""))

    lan = f.get("lan_ip", "").strip()
    url = f.get("phone_url", "").strip()

    if not lan:
        fail("lan_ip is empty")
    if not url:
        fail("phone_url is empty — submit exactly what you typed into the second device")
    if "\n" in url:
        fail("phone_url contains more than one line — submit the single URL you typed")

    if url.startswith("file://"):
        fail("phone_url is a file:// URL — that addresses a file on the machine doing the "
             "opening, so it can only ever work on the machine the file is on. The second device "
             "must be given an http:// URL naming the serving machine's LAN address")

    m = URL.match(url)
    if not m:
        if re.match(r"^https://", url):
            fail("phone_url is %r — your server speaks HTTP, not HTTPS. The scheme must be "
                 "http://" % url)
        if re.match(r"^http://[^/:\s]+(/[^\s]*)?$", url):
            fail("phone_url is %r — the port is missing. A browser only omits the port for the "
                 "standard ones (80 for HTTP, 443 for HTTPS); on 8000 it is mandatory, so the URL "
                 "must be http://%s:8000" % (url, lan))
        fail("phone_url is %r — it must be http://<lan_ip>:8000, optionally followed by / or "
             "/index.html" % url)

    host, port, path_part = m.group(1), m.group(2), m.group(3)

    if host.lower() in ("localhost", "localhost.localdomain", "localhost4", "127.0.0.1"):
        fail("phone_url names %r. On the second device that names the second device: /etc/hosts "
             "maps localhost to 127.0.0.1, and 127.0.0.1 means \"this machine\" on whichever "
             "machine is asking. Nothing was sent to your server. Use http://%s:8000"
             % (host, lan))

    if host != lan:
        fail("phone_url names %r but lan_ip is %r — the two fields must describe the same "
             "machine" % (host, lan))

    if port != "8000":
        fail("phone_url uses port %s but the project serves on 8000 — `python3 -m http.server` "
             "listens on port 8000 by default" % port)

    if path_part not in (None, "", "/", "/index.html"):
        fail("phone_url ends with %r — submit the URL that loaded your page: the bare address, / "
             "or /index.html" % path_part)

    print("PASS")


if __name__ == "__main__":
    main()

                      

tests/test_05_runbook_starts_stops_and_closes.py

                        #!/usr/bin/env python3
"""
test_05_runbook_starts_stops_and_closes  —  weight 10

The mechanical floor under the runbook. It checks only what a machine can check: that the
runbook says how to start the server, how to stop it, and how to close what the project
opened. Whether the runbook is any good — whether a stranger could follow it — is the
craft criterion in rubric.md and is not decided here.

  * a start line containing `python3 -m http.server`
  * a stop line containing `Ctrl-C` (or `Ctrl+C`); the server prints
    `Keyboard interrupt received, exiting.` when it receives it
  * a `## Teardown` section with real content under it, because this project has the
    learner open an inbound port on a machine a beginner owns, and a runbook that never
    closes it has taught half a habit
  * on WSL, that the teardown section names the rule that was created — `MyWebServer`

Self-contained: standard library only. Usage:
    python3 test_05_runbook_starts_stops_and_closes.py path/to/submission.txt
Prints PASS, or a single definite failure line beginning FAIL:.
"""

import os
import re
import sys

HEADER = re.compile(r"^===\s*([a-z_]+)\s*===\s*$")
PLATFORMS = ("macOS", "Linux", "WSL")
NO_CHANGE = "No firewall change was made."


def parse(path):
    fields, cur = {}, None
    with open(path, encoding="utf-8") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            m = HEADER.match(line)
            if m:
                cur = m.group(1)
                fields[cur] = []
                continue
            if line.startswith(";;"):
                continue
            if cur is not None:
                fields[cur].append(line)
    return {k: "\n".join(v).strip() for k, v in fields.items()}


def fail(msg):
    print("FAIL: " + msg)
    sys.exit(1)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("SUBMISSION", "submission.txt")
    try:
        f = parse(path)
    except OSError as exc:
        fail("cannot read the submission file %r (%s)" % (path, exc.strerror))

    platform = f.get("platform", "")
    if platform not in PLATFORMS:
        fail("platform is %r — it must be exactly one of macOS, Linux, WSL" % platform)

    runbook = f.get("runbook", "")
    if not runbook:
        fail("runbook is empty — fill in starter/runbook-skeleton.md and paste it here")

    lines = runbook.split("\n")

    if not any("python3 -m http.server" in ln for ln in lines):
        fail("the runbook has no line containing `python3 -m http.server` — a runbook that does "
             "not say how to start the thing is not a runbook")

    if not any(("Ctrl-C" in ln) or ("Ctrl+C" in ln) for ln in lines):
        fail("the runbook has no line containing `Ctrl-C` — it must say how to stop the server. "
             "A server left running keeps serving your project directory to everyone on that "
             "Wi-Fi")

    heads = [i for i, ln in enumerate(lines) if ln.strip().startswith("## Teardown")]
    if not heads:
        fail("the runbook has no `## Teardown` section — this project has you open an inbound "
             "port, and the runbook has to say how it gets closed again")

    start = heads[0] + 1
    end = len(lines)
    for i in range(start, len(lines)):
        if lines[i].strip().startswith("## "):
            end = i
            break
    section = [ln.strip() for ln in lines[start:end] if ln.strip()]
    content = [ln for ln in section if not ln.startswith("<")]

    if not content:
        fail("the `## Teardown` section is empty or still holds only the skeleton's "
             "angle-bracket prompts — replace them with what you actually did")

    joined = "\n".join(content)

    if platform == "WSL":
        if "MyWebServer" not in joined:
            fail("platform is WSL but the Teardown section does not name `MyWebServer`, the "
                 "Hyper-V firewall rule this project had you create. Name the rule and record "
                 "the exact command you ran to remove it")
    else:
        if NO_CHANGE not in joined and max(len(ln) for ln in content) < 20:
            fail("the Teardown section says too little to act on. Either name the firewall entry "
                 "or setting that was changed and how it was reversed, or state exactly: "
                 "%r" % NO_CHANGE)

    print("PASS")


if __name__ == "__main__":
    main()

                      

Erase saved progress?

This erases all quiz scores, reading progress, project checklists, and your name on the certificate. It cannot be undone, and it affects only this course in this browser.