Files
linux-practice/server.py

1303 lines
57 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Linux learning lab HTTP server."""
from __future__ import annotations
import hashlib
import http.server
import json
import os
import re
import secrets
import time
import urllib.parse
from typing import Any
from sandbox import LinuxSandbox
DEFAULT_USERNAME = os.environ.get("LINUX_LAB_USERNAME", "admin")
DEFAULT_PASSWORD = os.environ.get("LINUX_LAB_PASSWORD", "safe_linux_2026")
USERS = {
DEFAULT_USERNAME: hashlib.sha256(DEFAULT_PASSWORD.encode("utf-8")).hexdigest(),
}
TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
LOGIN_FILE = os.path.join(os.path.dirname(__file__), "login.html")
2026-03-10 21:45:11 +08:00
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
SANDBOX = LinuxSandbox()
SESSION_COOKIE = "linux_lab_session"
SESSION_TTL_SECONDS = 8 * 60 * 60
SESSIONS: dict[str, dict[str, Any]] = {}
PUBLIC_GET_PATHS = {
"/",
"/login.html",
"/privacy",
"/privacy.html",
"/api/health",
"/api/session",
}
PUBLIC_POST_PATHS = {
"/api/login",
"/api/logout",
}
def load_course() -> dict[str, Any]:
try:
with open(TASKS_FILE, "r", encoding="utf-8") as file:
return json.load(file)
except Exception as exc:
print(f"Error loading course: {exc}")
return {"meta": {}, "modules": []}
COURSE = load_course()
def secure_cookie_enabled() -> bool:
return os.environ.get("LINUX_LAB_SECURE_COOKIE", "").lower() in {"1", "true", "yes", "on"}
def create_session(username: str) -> str:
session_id = secrets.token_urlsafe(24)
SESSIONS[session_id] = {
"user": username,
"expires_at": time.time() + SESSION_TTL_SECONDS,
}
return session_id
def get_session(session_id: str) -> dict[str, Any] | None:
if not session_id:
return None
session = SESSIONS.get(session_id)
if not session:
return None
if session.get("expires_at", 0) <= time.time():
SESSIONS.pop(session_id, None)
return None
session["expires_at"] = time.time() + SESSION_TTL_SECONDS
return session
def delete_session(session_id: str):
if session_id:
SESSIONS.pop(session_id, None)
def looks_garbled(value: Any) -> bool:
if not isinstance(value, str) or not value.strip():
return False
if "\ufffd" in value:
return True
asciiish = sum(ch.isascii() and (ch.isalnum() or ch in " -_/.,:;`()[]{}'\"") for ch in value)
non_ascii = sum(not ch.isascii() for ch in value)
return non_ascii >= 3 and non_ascii > asciiish
def safe_text(value: Any, fallback: str) -> str:
if isinstance(value, str) and value.strip() and not looks_garbled(value):
return value.strip()
return fallback
def safe_list(values: Any, fallback: list[str]) -> list[str]:
if not isinstance(values, list) or not values:
return fallback
cleaned = [str(item).strip() for item in values if str(item).strip()]
if not cleaned:
return fallback
garbled = sum(1 for item in cleaned if looks_garbled(item))
if garbled >= max(1, len(cleaned) // 2):
return fallback
merged: list[str] = []
for index, item in enumerate(cleaned):
if looks_garbled(item):
if index < len(fallback):
merged.append(fallback[index])
continue
merged.append(item)
return merged or fallback
def split_commands(command: str | None, lesson_id: str | None = None) -> list[str]:
if command:
tokens = [part.strip() for part in re.split(r"\s*/\s*|\s*,\s*", command) if part.strip()]
if tokens:
return tokens
if lesson_id:
tail = lesson_id.split("_")[-1].strip()
if tail:
return [tail]
return []
def command_label(commands: list[str]) -> str:
if not commands:
return "mixed commands"
if len(commands) == 1:
return commands[0]
return " / ".join(commands[:3])
def is_supported_command_token(command: str) -> bool:
normalized = command.strip().lower()
if not normalized:
return False
supported = SANDBOX.supported_commands()
if normalized in supported:
return True
if "|" in normalized:
segments = [segment.strip() for segment in normalized.split("|") if segment.strip()]
return bool(segments) and all(segment in supported for segment in segments)
return False
def module_number(module_id: str | None) -> str | None:
if not module_id:
return None
match = re.search(r"(\d+)", module_id)
return match.group(1) if match else None
def module_title(module: dict[str, Any]) -> str:
raw = module.get("title")
fallback = f"Module {module_number(module.get('id'))}" if module_number(module.get("id")) else "Course module"
return safe_text(raw, fallback)
def module_summary(module: dict[str, Any]) -> str:
fallback = "Learn command purpose, expected output, and how each step fits into a Linux troubleshooting flow."
return safe_text(module.get("summary"), fallback)
def lesson_title(lesson: dict[str, Any]) -> str:
commands = split_commands(lesson.get("command"), lesson.get("id"))
fallback = f"Learn {command_label(commands)}"
return safe_text(lesson.get("title"), fallback)
def default_goal(commands: list[str]) -> str:
return f"Understand what {command_label(commands)} does, how to run it, and how to read the result."
def default_why(commands: list[str]) -> str:
return f"In day-to-day operations work, {command_label(commands)} helps you confirm the current state before taking the next step."
def default_concepts(commands: list[str]) -> list[str]:
label = command_label(commands)
return [
f"What problem {label} solves",
"Typical syntax and common options",
"How to verify the output",
"How it fits into a troubleshooting chain",
]
def default_pitfalls(commands: list[str]) -> list[str]:
label = command_label(commands)
return [
f"Running {label} without checking the context first",
"Reading the output but not validating the target state",
"Ignoring current directory, permissions, or file state",
]
def default_scenarios(commands: list[str]) -> list[str]:
label = command_label(commands)
return [
f"Use {label} to inspect a host before editing files or services",
"Confirm current state before and after a change",
"Connect one command to the next action in a real incident flow",
]
def default_flow(commands: list[str]) -> list[str]:
label = command_label(commands)
return [
"Confirm the goal and current working context",
f"Run {label} and inspect the output carefully",
"Decide the next command based on what the output shows",
]
def default_takeaways(commands: list[str]) -> list[str]:
label = command_label(commands)
return [
f"Explain when to use {label} in a real Linux task",
"Know what output matters most",
"Link the command to the next verification or repair step",
]
def default_after_class(commands: list[str]) -> str:
return f"Repeat this lesson in the sandbox two or three more times and explain every step out loud using {command_label(commands)}."
COMMAND_CATEGORIES = {
"navigation": {"pwd", "ls", "cd", "which", "whereis", "clear"},
"filesystem": {"cat", "echo", "mkdir", "touch", "cp", "mv", "rm", "chmod", "stat", "tar"},
"text": {"grep", "find", "head", "tail", "sort", "uniq", "cut", "awk", "sed"},
"observation": {"ps", "top", "uptime", "free", "df", "du", "lsof", "last", "w", "history", "date", "cal"},
"service": {"systemctl", "journalctl", "nohup", "crontab"},
"network": {"ip", "ping", "ss", "curl", "wget", "netstat", "traceroute", "dig"},
"identity": {"whoami", "id", "env", "export", "alias"},
"package": {"apt", "yum", "rpm", "dpkg"},
}
CATEGORY_ROLES = {
"navigation": "Use this command family to regain orientation before acting.",
"filesystem": "Use this command family to inspect, create, move, or protect files safely.",
"text": "Use this command family to filter noisy output into evidence you can act on.",
"observation": "Use this command family to understand what the host is doing right now.",
"service": "Use this command family to control long-running workloads and read their execution history.",
"network": "Use this command family to test reachability, sockets, HTTP responses, and DNS resolution.",
"identity": "Use this command family to understand who you are, what your shell knows, and what your environment passes downstream.",
"package": "Use this command family to inspect, install, or verify software that powers the host.",
"general": "Use the base form first, then add options only when you know what evidence you need.",
}
CATEGORY_EXTENSION_TIPS = {
"navigation": [
"Repeat the lesson from a second directory and explain why path context changes the next command.",
"Practice saying the current directory out loud before every file-changing action.",
],
"filesystem": [
"Run the read-only form first, then explain what would happen if you switched to a state-changing form.",
"Create a small backup-and-verify routine so file work becomes reversible instead of risky.",
],
"text": [
"Turn one noisy log or command output into a smaller evidence set with filters and field extraction.",
"Compare broad search output with a narrower variant and explain which one is safer under pressure.",
],
"observation": [
"Capture the one output field that matters most before jumping into repair actions.",
"Explain how this observation command changes your next diagnostic step instead of stopping at raw output.",
],
"service": [
"Link service state, listening port, and logs into a three-step incident playbook.",
"Practice one failed-start scenario and one healthy-state verification sequence.",
],
"network": [
"Distinguish host reachability, open port, HTTP response, and DNS lookup as separate layers.",
"Run one command per network layer and explain what each result rules in or rules out.",
],
"identity": [
"Check identity and environment before using sudo, editing config, or launching a process.",
"Practice spotting the difference between shell state and system-wide state.",
],
"package": [
"Trace one tool from package name to installed files to executable path.",
"Explain how package inspection helps before upgrades, rollbacks, or incident recovery.",
],
}
COMMAND_ORIGINS = {
"pwd": "pwd is short for 'print working directory'.",
"ls": "ls is the compact Unix shorthand for 'list'.",
"grep": "grep comes from the old ed command g/re/p, meaning globally search a regular expression and print matches.",
"awk": "awk is named after Aho, Weinberger, and Kernighan, the language's original authors.",
"sed": "sed stands for 'stream editor', highlighting that it edits text as a flowing stream.",
"ps": "ps expands to 'process status'.",
"df": "df expands to 'disk free'.",
"du": "du expands to 'disk usage'.",
"ss": "ss expands to 'socket statistics' and was designed as a faster successor to parts of netstat.",
"lsof": "lsof expands to 'list open files'.",
"nohup": "nohup literally means 'no hangup' because it ignores the hangup signal when a terminal closes.",
"crontab": "crontab means 'cron table', the per-user schedule file for cron jobs.",
"apt": "apt is short for 'Advanced Package Tool'.",
"yum": "yum originally expanded to 'Yellowdog Updater, Modified'.",
"dpkg": "dpkg is the low-level Debian package management tool.",
"rpm": "rpm refers to the RPM package manager format and toolchain.",
"dig": "dig is short for 'Domain Information Groper', a famously odd but memorable DNS tool name.",
"curl": "curl is a client-side URL transfer tool; the name is intentionally short and terminal-friendly.",
"wget": "wget historically comes from the idea of 'web get'.",
"netstat": "netstat expands to 'network statistics'.",
"systemctl": "systemctl is the systemd control command for units such as services, timers, and sockets.",
"journalctl": "journalctl is the control/query tool for the systemd journal.",
"whoami": "whoami reads like a sentence because it answers the most practical shell identity question directly.",
"whereis": "whereis focuses on where a binary, source tree, and manual page live.",
"which": "which answers which executable the shell would run first.",
"ping": "ping was named after sonar ping sounds; the common acronym explanation is popular but not official.",
}
COMMAND_FUN_FACTS = {
"pwd": "Strong operators use pwd almost like a seatbelt: quick, boring, and essential before riskier actions.",
"ls": "Many incidents start with a wrong assumption about what exists, which is why ls remains a first-response habit.",
"grep": "The command name looks cryptic until you learn its editor history, and then you start seeing Unix culture in miniature.",
"awk": "awk is tiny in name but surprisingly powerful; many shell one-liners quietly depend on it for structured text work.",
"sed": "sed shines when you need a quick surgical text transform without opening an editor.",
"ss": "On modern Linux hosts, ss is often the better first stop than netstat for live socket checks.",
"curl": "curl often becomes the bridge between 'the service is up' and 'the application is actually responding correctly'.",
"systemctl": "systemctl teaches a crucial operations lesson: a process existing is not the same as a service being healthy.",
"journalctl": "journalctl turns logs into a timeline, which is exactly what you need when a deployment fails under time pressure.",
"nohup": "nohup is a good reminder that shell sessions and process lifetime are related, but not the same thing.",
"dig": "dig is one of the most useful commands for proving that a problem is DNS, or proving that it is not DNS.",
"history": "history is not only memory; it is a personal operations notebook showing how you solved similar problems before.",
}
COMMAND_VARIANTS = {
"pwd": [
{"syntax": "pwd", "purpose": "Print the current logical working directory.", "example": "pwd"},
{"syntax": "pwd -P", "purpose": "Resolve symbolic links and show the physical path.", "example": "pwd -P"},
],
"ls": [
{"syntax": "ls -l", "purpose": "Show a long listing with permissions, owner, size, and time.", "example": "ls -l /var/log"},
{"syntax": "ls -la", "purpose": "Include hidden entries such as dotfiles.", "example": "ls -la ~"},
{"syntax": "ls -lh", "purpose": "Format file sizes in human-readable units.", "example": "ls -lh build"},
],
"cd": [
{"syntax": "cd /path", "purpose": "Jump to a specific directory.", "example": "cd /var/log"},
{"syntax": "cd -", "purpose": "Return to the previous directory quickly.", "example": "cd -"},
{"syntax": "cd ~", "purpose": "Go back to the current user's home directory.", "example": "cd ~"},
],
"cat": [
{"syntax": "cat file", "purpose": "Print the full contents of a file.", "example": "cat /etc/os-release"},
{"syntax": "cat -n file", "purpose": "Show line numbers while printing.", "example": "cat -n app.log"},
],
"echo": [
{"syntax": "echo text", "purpose": "Print a string or quick note.", "example": "echo hello"},
{"syntax": "echo $PATH", "purpose": "Inspect shell expansion and environment values.", "example": "echo $PATH"},
],
"mkdir": [
{"syntax": "mkdir dir", "purpose": "Create one directory.", "example": "mkdir logs"},
{"syntax": "mkdir -p a/b/c", "purpose": "Create parent directories in one run.", "example": "mkdir -p releases/2026/03"},
],
"touch": [
{"syntax": "touch file", "purpose": "Create an empty file or update its timestamp.", "example": "touch notes.txt"},
],
"cp": [
{"syntax": "cp src dest", "purpose": "Copy one file to another path.", "example": "cp app.conf app.conf.bak"},
{"syntax": "cp -r src dest", "purpose": "Copy a directory tree recursively.", "example": "cp -r site site.bak"},
{"syntax": "cp -a src dest", "purpose": "Preserve attributes while copying.", "example": "cp -a /etc/nginx nginx.backup"},
],
"mv": [
{"syntax": "mv old new", "purpose": "Rename or move a file or directory.", "example": "mv access.log archive.log"},
],
"rm": [
{"syntax": "rm file", "purpose": "Remove one file.", "example": "rm debug.log"},
{"syntax": "rm -r dir", "purpose": "Remove a directory tree recursively.", "example": "rm -r old_build"},
{"syntax": "rm -i file", "purpose": "Prompt before deleting.", "example": "rm -i important.txt"},
],
"chmod": [
{"syntax": "chmod 644 file", "purpose": "Set common read/write file permissions.", "example": "chmod 644 app.conf"},
{"syntax": "chmod +x script.sh", "purpose": "Add execute permission.", "example": "chmod +x deploy.sh"},
],
"stat": [
{"syntax": "stat file", "purpose": "Inspect inode, timestamps, and file metadata.", "example": "stat access.log"},
],
"grep": [
{"syntax": "grep pattern file", "purpose": "Search matching lines.", "example": "grep ERROR app.log"},
{"syntax": "grep -n pattern file", "purpose": "Show line numbers with matches.", "example": "grep -n timeout app.log"},
{"syntax": "grep -i pattern file", "purpose": "Ignore letter case.", "example": "grep -i failed auth.log"},
],
"find": [
{"syntax": "find /path -name pattern", "purpose": "Search by file or directory name.", "example": "find /var/log -name '*.log'"},
{"syntax": "find /path -type f", "purpose": "Restrict the search to files.", "example": "find . -type f"},
{"syntax": "find /path -mtime -1", "purpose": "Filter by recent modification time.", "example": "find /backup -mtime -1"},
],
"head": [
{"syntax": "head file", "purpose": "Show the first 10 lines.", "example": "head app.log"},
{"syntax": "head -n 20 file", "purpose": "Show a custom number of leading lines.", "example": "head -n 20 app.log"},
],
"tail": [
{"syntax": "tail file", "purpose": "Show the last 10 lines.", "example": "tail app.log"},
{"syntax": "tail -n 50 file", "purpose": "Show more trailing lines.", "example": "tail -n 50 app.log"},
{"syntax": "tail -f file", "purpose": "Follow a log while it grows.", "example": "tail -f /var/log/nginx/access.log"},
],
"sort": [
{"syntax": "sort file", "purpose": "Sort text lines.", "example": "sort hosts.txt"},
{"syntax": "sort -u file", "purpose": "Sort and deduplicate in one step.", "example": "sort -u users.txt"},
],
"uniq": [
{"syntax": "uniq file", "purpose": "Collapse adjacent duplicates.", "example": "uniq users.txt"},
{"syntax": "sort file | uniq -c", "purpose": "Count repeated values after sorting.", "example": "sort status.txt | uniq -c"},
],
"cut": [
{"syntax": "cut -d: -f1 file", "purpose": "Pick fields using a delimiter.", "example": "cut -d: -f1 /etc/passwd"},
],
"awk": [
{"syntax": "awk '{print $1}' file", "purpose": "Print one or more whitespace-separated fields.", "example": "awk '{print $1}' access.log"},
{"syntax": "awk -F: '{print $1}' file", "purpose": "Set a custom field separator.", "example": "awk -F: '{print $1}' /etc/passwd"},
],
"sed": [
{"syntax": "sed -n '1,10p' file", "purpose": "Print only selected line ranges.", "example": "sed -n '1,10p' app.log"},
{"syntax": "sed 's/old/new/' file", "purpose": "Replace text in the output stream.", "example": "sed 's/http/https/' app.conf"},
],
"ps": [
{"syntax": "ps -ef", "purpose": "Show a full process list.", "example": "ps -ef"},
{"syntax": "ps -ef | grep name", "purpose": "Filter for one process family.", "example": "ps -ef | grep nginx"},
],
"top": [
{"syntax": "top", "purpose": "Open an interactive live process view.", "example": "top"},
{"syntax": "top -b -n 1", "purpose": "Capture one batch snapshot for logs or pipes.", "example": "top -b -n 1 | head -20"},
],
"ip": [
{"syntax": "ip addr", "purpose": "Show interfaces and assigned IP addresses.", "example": "ip addr"},
{"syntax": "ip route", "purpose": "Inspect the routing table.", "example": "ip route"},
],
"ping": [
{"syntax": "ping host", "purpose": "Test reachability continuously.", "example": "ping 8.8.8.8"},
{"syntax": "ping -c 4 host", "purpose": "Send a fixed number of probes.", "example": "ping -c 4 example.com"},
],
"ss": [
{"syntax": "ss -lntp", "purpose": "Show listening TCP ports and owning processes.", "example": "ss -lntp"},
{"syntax": "ss -s", "purpose": "Print a compact socket summary.", "example": "ss -s"},
],
"curl": [
{"syntax": "curl URL", "purpose": "Fetch a URL quickly from the terminal.", "example": "curl http://localhost:8080/health"},
{"syntax": "curl -I URL", "purpose": "Request response headers only.", "example": "curl -I https://example.com"},
{"syntax": "curl -X POST URL", "purpose": "Send a request with an explicit HTTP method.", "example": "curl -X POST http://localhost:8080/api/login"},
],
"wget": [
{"syntax": "wget URL", "purpose": "Download a file to disk.", "example": "wget https://example.com/file.tar.gz"},
{"syntax": "wget -O name URL", "purpose": "Choose the output filename.", "example": "wget -O app.tar.gz https://example.com/latest.tar.gz"},
],
"systemctl": [
{"syntax": "systemctl status name", "purpose": "Inspect service state and recent status text.", "example": "systemctl status nginx"},
{"syntax": "systemctl restart name", "purpose": "Restart a service after config or code changes.", "example": "systemctl restart nginx"},
{"syntax": "systemctl enable name", "purpose": "Enable a unit at boot time.", "example": "systemctl enable nginx"},
],
"journalctl": [
{"syntax": "journalctl -u name", "purpose": "Read logs for one systemd unit.", "example": "journalctl -u nginx"},
{"syntax": "journalctl -xe", "purpose": "Show recent errors with context.", "example": "journalctl -xe"},
{"syntax": "journalctl -u name -n 50", "purpose": "Read the latest lines for one unit.", "example": "journalctl -u nginx -n 50"},
],
"df": [
{"syntax": "df -h", "purpose": "Show filesystem usage in human units.", "example": "df -h"},
{"syntax": "df -i", "purpose": "Check inode exhaustion, not just bytes.", "example": "df -i"},
],
"du": [
{"syntax": "du -sh path", "purpose": "Summarize directory size.", "example": "du -sh /var/log"},
{"syntax": "du -h path", "purpose": "Break size down recursively.", "example": "du -h /var/log | sort -h | tail"},
],
"whoami": [
{"syntax": "whoami", "purpose": "Print the current account name.", "example": "whoami"},
],
"id": [
{"syntax": "id", "purpose": "Show UID, GID, and groups.", "example": "id"},
{"syntax": "id user", "purpose": "Inspect another account if the environment allows it.", "example": "id nginx"},
],
"env": [
{"syntax": "env", "purpose": "Print environment variables.", "example": "env"},
{"syntax": "env | grep NAME", "purpose": "Filter the environment for one variable.", "example": "env | grep PATH"},
],
"export": [
{"syntax": "export VAR=value", "purpose": "Set an environment variable for child processes.", "example": "export APP_ENV=prod"},
{"syntax": "export PATH=$PATH:/opt/bin", "purpose": "Extend PATH temporarily in the current shell.", "example": "export PATH=$PATH:/opt/bin"},
],
"alias": [
{"syntax": "alias", "purpose": "List current shell aliases.", "example": "alias"},
{"syntax": "alias ll='ls -alF'", "purpose": "Create a shortcut for a repeated command.", "example": "alias ll='ls -alF'"},
],
"which": [
{"syntax": "which cmd", "purpose": "Show the executable the shell would run first.", "example": "which python"},
],
"whereis": [
{"syntax": "whereis cmd", "purpose": "Locate binary, source, and manual page paths.", "example": "whereis nginx"},
],
"apt": [
{"syntax": "apt search name", "purpose": "Search repositories for packages.", "example": "apt search nginx"},
{"syntax": "apt install name", "purpose": "Install a package.", "example": "apt install nginx"},
{"syntax": "apt remove name", "purpose": "Remove a package.", "example": "apt remove nginx"},
],
"yum": [
{"syntax": "yum search name", "purpose": "Search repositories.", "example": "yum search nginx"},
{"syntax": "yum install name", "purpose": "Install a package.", "example": "yum install nginx"},
],
"rpm": [
{"syntax": "rpm -qa", "purpose": "List installed RPM packages.", "example": "rpm -qa | grep nginx"},
{"syntax": "rpm -qi name", "purpose": "Inspect one package in detail.", "example": "rpm -qi nginx"},
],
"dpkg": [
{"syntax": "dpkg -l", "purpose": "List installed Debian packages.", "example": "dpkg -l | grep nginx"},
{"syntax": "dpkg -L name", "purpose": "List files installed by one package.", "example": "dpkg -L nginx"},
],
"tar": [
{"syntax": "tar -tf file.tar", "purpose": "List archive contents without extracting.", "example": "tar -tf backup.tar"},
{"syntax": "tar -czf out.tar.gz dir", "purpose": "Create a gzip-compressed archive.", "example": "tar -czf logs.tar.gz /var/log/myapp"},
{"syntax": "tar -xzf file.tar.gz", "purpose": "Extract a gzip-compressed archive.", "example": "tar -xzf logs.tar.gz"},
],
"nohup": [
{"syntax": "nohup cmd &", "purpose": "Keep a process alive after terminal disconnect.", "example": "nohup python3 server.py &"},
],
"uptime": [
{"syntax": "uptime", "purpose": "Show uptime and load averages.", "example": "uptime"},
],
"free": [
{"syntax": "free -h", "purpose": "Show memory usage in human units.", "example": "free -h"},
],
"lsof": [
{"syntax": "lsof -i :port", "purpose": "Find the process using a port.", "example": "lsof -i :8080"},
{"syntax": "lsof file", "purpose": "Find which process has a file open.", "example": "lsof /var/log/app.log"},
],
"netstat": [
{"syntax": "netstat -lntp", "purpose": "Legacy view of listening TCP ports.", "example": "netstat -lntp"},
],
"traceroute": [
{"syntax": "traceroute host", "purpose": "Trace network hops to a target host.", "example": "traceroute example.com"},
],
"dig": [
{"syntax": "dig name", "purpose": "Query DNS records with full output.", "example": "dig example.com"},
{"syntax": "dig name +short", "purpose": "Print compact answers only.", "example": "dig example.com +short"},
],
"date": [
{"syntax": "date", "purpose": "Print the current system time.", "example": "date"},
{"syntax": "date '+%F %T'", "purpose": "Format the output explicitly.", "example": "date '+%F %T'"},
],
"cal": [
{"syntax": "cal", "purpose": "Show the current month calendar.", "example": "cal"},
{"syntax": "cal 2026", "purpose": "Show a whole year calendar.", "example": "cal 2026"},
],
"crontab": [
{"syntax": "crontab -l", "purpose": "List current cron jobs.", "example": "crontab -l"},
{"syntax": "crontab -e", "purpose": "Edit the cron table.", "example": "crontab -e"},
],
"history": [
{"syntax": "history", "purpose": "Show previous shell commands.", "example": "history"},
{"syntax": "history | grep word", "purpose": "Search for a prior command pattern.", "example": "history | grep tar"},
],
"last": [
{"syntax": "last", "purpose": "Show recent login records.", "example": "last"},
],
"w": [
{"syntax": "w", "purpose": "Show who is logged in and what they are doing.", "example": "w"},
],
"clear": [
{"syntax": "clear", "purpose": "Clear terminal output without changing shell state.", "example": "clear"},
],
}
def command_category(command: str) -> str:
normalized = command.strip().lower()
for category, commands in COMMAND_CATEGORIES.items():
if normalized in commands:
return category
return "general"
def command_origin(command: str) -> str:
normalized = command.strip().lower()
return COMMAND_ORIGINS.get(
normalized,
f"{normalized} follows the classic Unix habit of using short names so repeated terminal work stays fast.",
)
def command_fun_fact(command: str) -> str:
normalized = command.strip().lower()
return COMMAND_FUN_FACTS.get(
normalized,
f"Treat {normalized} as part of a command chain: its real value appears when it informs the next observation or repair step.",
)
def generic_variant(command: str) -> list[dict[str, str]]:
normalized = command.strip().lower()
if normalized in {"cd", "export", "alias"}:
return [
{
"syntax": f"help {normalized}",
"purpose": "Inspect builtin shell help before using unfamiliar forms.",
"example": f"help {normalized}",
}
]
return [
{
"syntax": f"{normalized} --help",
"purpose": "Review the supported options before using the command in a new situation.",
"example": f"{normalized} --help",
}
]
def command_variants(command: str) -> list[dict[str, str]]:
normalized = command.strip().lower()
return COMMAND_VARIANTS.get(normalized, generic_variant(normalized))
def build_command_guides(commands: list[str]) -> list[dict[str, Any]]:
guides: list[dict[str, Any]] = []
seen: set[str] = set()
for command in commands:
normalized = command.strip().lower()
if not normalized or normalized in seen:
continue
seen.add(normalized)
guides.append(
{
"command": normalized,
"role": CATEGORY_ROLES.get(command_category(normalized), CATEGORY_ROLES["general"]),
"origin": command_origin(normalized),
"fun_fact": command_fun_fact(normalized),
"variants": command_variants(normalized),
}
)
return guides
def build_ops_extensions(commands: list[str]) -> list[str]:
items: list[str] = []
seen: set[str] = set()
for command in commands:
category = command_category(command)
for tip in CATEGORY_EXTENSION_TIPS.get(category, []):
if tip not in seen:
items.append(tip)
seen.add(tip)
closing_notes = [
"Run the simplest read-only form first, then explain what stronger or riskier form you would use only if the evidence demands it.",
"Write a tiny playbook note for this lesson: goal -> command -> key output -> next step.",
]
for note in closing_notes:
if note not in seen:
items.append(note)
seen.add(note)
return items[:6]
def build_review_prompts(commands: list[str], lesson_goal: str) -> list[str]:
label = command_label(commands)
return [
f"What system question are you trying to answer with {label} before you touch the system state?",
f"Which output line, field, or signal matters most for this goal: {lesson_goal}",
f"What narrower or safer syntax variant would you try first if the default output feels too broad or too risky?",
f"After {label}, what should the next verification, comparison, or repair step be in a real operations flow?",
]
def flatten_lessons() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for module in COURSE.get("modules", []):
for lesson in module.get("lessons", []):
item = dict(lesson)
item["module_id"] = module.get("id")
item["module_title"] = module.get("title")
item["module_summary"] = module.get("summary")
rows.append(item)
return rows
def flatten_exercises() -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for module in COURSE.get("modules", []):
for lesson in module.get("lessons", []):
for exercise in lesson.get("exercises", []):
item = dict(exercise)
item["module_id"] = module.get("id")
item["module_title"] = module.get("title")
item["lesson_id"] = lesson.get("id")
item["lesson_title"] = lesson.get("title")
item["lesson_command"] = lesson.get("command")
rows.append(item)
return rows
def find_lesson(lesson_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
for module in COURSE.get("modules", []):
for lesson in module.get("lessons", []):
if lesson.get("id") == lesson_id:
return module, lesson
return None, None
def find_exercise(exercise_id: str) -> dict[str, Any] | None:
for exercise in flatten_exercises():
if exercise.get("id") == exercise_id:
return exercise
return None
def build_lesson_preview(module: dict[str, Any], lesson: dict[str, Any]) -> dict[str, Any]:
commands = split_commands(lesson.get("command"), lesson.get("id"))
return {
"id": lesson.get("id"),
"display_title": lesson_title(lesson),
"display_goal": safe_text(lesson.get("goal"), default_goal(commands)),
"command": lesson.get("command") or command_label(commands),
"command_tokens": commands,
"exercise_count": len(lesson.get("exercises", [])),
}
def build_module_preview(module: dict[str, Any]) -> dict[str, Any]:
lessons = [build_lesson_preview(module, lesson) for lesson in module.get("lessons", [])]
2026-03-19 14:58:56 +08:00
exercises = [exercise for lesson in module.get("lessons", []) for exercise in lesson.get("exercises", [])]
operations = [exercise for exercise in exercises if exercise.get("type") == "operation"]
reflections = [exercise for exercise in exercises if exercise.get("type") != "operation"]
commands = sorted(
{
command
for lesson in module.get("lessons", [])
for command in split_commands(lesson.get("command"), lesson.get("id"))
if command
}
)
return {
"id": module.get("id"),
"display_title": module_title(module),
"display_summary": module_summary(module),
"lesson_count": len(module.get("lessons", [])),
2026-03-19 14:58:56 +08:00
"exercise_count": len(exercises),
"operation_count": len(operations),
"reflection_count": len(reflections),
"command_count": len(commands),
"command_tokens": commands,
"lessons": lessons,
}
2026-03-19 14:58:56 +08:00
def extract_solution_commands() -> list[str]:
commands: list[str] = []
for module in COURSE.get("modules", []):
for lesson in module.get("lessons", []):
for exercise in lesson.get("exercises", []):
for solution in exercise.get("solution", []):
for segment in str(solution).split("|"):
head = segment.strip().split(" ")[0].strip()
if head:
commands.append(head)
return commands
def build_diagnostics() -> dict[str, Any]:
lessons = flatten_lessons()
course_commands = sorted(
{
command
for lesson in lessons
for command in split_commands(lesson.get("command"), lesson.get("id"))
if command
}
)
solution_commands = sorted(set(extract_solution_commands()))
required_commands = sorted(set(course_commands) | set(solution_commands))
supported_commands = sorted(SANDBOX.supported_commands())
unsupported_commands = [command for command in required_commands if not is_supported_command_token(command)]
2026-03-19 14:58:56 +08:00
total_exercises = sum(len(lesson.get("exercises", [])) for lesson in lessons)
operation_exercises = sum(
1
for lesson in lessons
for exercise in lesson.get("exercises", [])
if exercise.get("type") == "operation"
)
reflection_exercises = total_exercises - operation_exercises
return {
"supported_command_count": len(supported_commands),
"required_command_count": len(required_commands),
"covered_command_count": len(required_commands) - len(unsupported_commands),
"coverage_rate": 100 if not required_commands else round(((len(required_commands) - len(unsupported_commands)) / len(required_commands)) * 100),
2026-03-19 14:58:56 +08:00
"unsupported_commands": unsupported_commands,
"course_commands": required_commands,
"operation_exercises": operation_exercises,
"reflection_exercises": reflection_exercises,
"module_checks": [build_module_preview(module) for module in COURSE.get("modules", [])],
}
def build_exercise(exercise: dict[str, Any], lesson_stub: dict[str, Any]) -> dict[str, Any]:
commands = split_commands(lesson_stub.get("command"), lesson_stub.get("id"))
label = command_label(commands)
first_solution = ""
if isinstance(exercise.get("solution"), list) and exercise["solution"]:
first_solution = str(exercise["solution"][0]).strip()
payload = {
"id": exercise.get("id"),
"type": exercise.get("type") or "operation",
"hint": safe_text(exercise.get("hint"), f"Try {first_solution or label} first, then inspect the result."),
"success_msg": safe_text(exercise.get("success_msg"), f"Nice work. You completed a {label} practice task."),
"solution": exercise.get("solution", []),
}
if payload["type"] == "operation":
payload["title"] = safe_text(exercise.get("title"), f"Run {first_solution or label}")
else:
payload["question"] = safe_text(
exercise.get("question"),
f"Explain when you would use {label} in a real Linux operations flow.",
)
payload["answer"] = safe_text(
exercise.get("answer"),
f"Focus on purpose, expected output, and the next step after {label}.",
)
return payload
def build_lesson_payload(module: dict[str, Any], lesson: dict[str, Any]) -> dict[str, Any]:
commands = split_commands(lesson.get("command"), lesson.get("id"))
all_lessons = flatten_lessons()
lesson_ids = [item.get("id") for item in all_lessons]
current_id = lesson.get("id")
index = lesson_ids.index(current_id) if current_id in lesson_ids else -1
goal = safe_text(lesson.get("goal"), default_goal(commands))
previous_lesson = None
next_lesson = None
if index > 0:
prev_module, prev_lesson = find_lesson(lesson_ids[index - 1] or "")
if prev_module and prev_lesson:
previous_lesson = build_lesson_preview(prev_module, prev_lesson)
if index >= 0 and index + 1 < len(lesson_ids):
next_module, next_lesson_item = find_lesson(lesson_ids[index + 1] or "")
if next_module and next_lesson_item:
next_lesson = build_lesson_preview(next_module, next_lesson_item)
return {
"id": lesson.get("id"),
"display_title": lesson_title(lesson),
"display_goal": goal,
"display_why": safe_text(lesson.get("why_it_matters"), default_why(commands)),
"display_module_title": module_title(module),
"display_module_summary": module_summary(module),
"command": lesson.get("command") or command_label(commands),
"command_tokens": commands,
"examples": safe_list(lesson.get("examples"), commands or ["pwd"]),
"concepts": safe_list(lesson.get("concepts"), default_concepts(commands)),
"pitfalls": safe_list(lesson.get("pitfalls"), default_pitfalls(commands)),
"scenarios": safe_list(lesson.get("scenarios"), default_scenarios(commands)),
"troubleshooting_flow": safe_list(lesson.get("troubleshooting_flow"), default_flow(commands)),
"takeaways": safe_list(lesson.get("takeaways"), default_takeaways(commands)),
"classic_view": safe_text(
lesson.get("classic_view"),
f"Treat {command_label(commands)} as part of a workflow, not as an isolated command to memorize.",
),
"after_class": safe_text(lesson.get("after_class"), default_after_class(commands)),
"related_commands": safe_list(lesson.get("related_commands"), commands or ["pwd"]),
"command_guides": build_command_guides(commands),
"ops_extensions": build_ops_extensions(commands),
"review_prompts": build_review_prompts(commands, goal),
"exercises": [build_exercise(exercise, lesson) for exercise in lesson.get("exercises", [])],
"exercise_count": len(lesson.get("exercises", [])),
"module_id": module.get("id"),
"previous_lesson": previous_lesson,
"next_lesson": next_lesson,
}
def build_overview() -> dict[str, Any]:
modules = [build_module_preview(module) for module in COURSE.get("modules", [])]
lessons = flatten_lessons()
commands = sorted(
{
command
for lesson in lessons
for command in split_commands(lesson.get("command"), lesson.get("id"))
if command
}
)
exercise_count = sum(len(lesson.get("exercises", [])) for lesson in lessons)
meta = COURSE.get("meta", {})
2026-03-19 14:58:56 +08:00
diagnostics = build_diagnostics()
return {
"meta": {
"version": meta.get("version", "4.0"),
"title": safe_text(meta.get("title"), "Linux Learning Lab"),
"description": safe_text(
meta.get("description"),
"Searchable Linux lessons with command previews, sandbox practice, and guided troubleshooting.",
),
"updated": meta.get("updated", ""),
"module_count": meta.get("module_count", len(modules)),
"lesson_count": meta.get("total_lessons", len(lessons)),
"exercise_count": meta.get("total_exercises", exercise_count),
"command_count": len(commands),
},
"runtime": {
"cwd": SANDBOX.cwd,
"user": SANDBOX.user,
},
2026-03-19 14:58:56 +08:00
"diagnostics": {
"coverage_rate": diagnostics["coverage_rate"],
"unsupported_count": len(diagnostics["unsupported_commands"]),
"operation_exercises": diagnostics["operation_exercises"],
"reflection_exercises": diagnostics["reflection_exercises"],
},
"modules": modules,
"commands": commands[:24],
}
def search_course(query: str) -> list[dict[str, Any]]:
needle = query.strip().lower()
if not needle:
return []
results: list[dict[str, Any]] = []
for module in COURSE.get("modules", []):
module_view = build_module_preview(module)
module_score = 0
module_id = str(module.get("id", "")).lower()
if needle == module_id:
module_score += 120
elif needle in module_id:
module_score += 80
if needle in module_view["display_title"].lower():
module_score += 60
if needle in module_view["display_summary"].lower():
module_score += 40
if module_score and module.get("lessons"):
results.append(
{
"type": "module",
"title": module_view["display_title"],
"subtitle": module_view["display_summary"],
"module_id": module.get("id"),
"lesson_id": module.get("lessons", [{}])[0].get("id"),
"score": module_score,
}
)
for lesson in module.get("lessons", []):
lesson_view = build_lesson_preview(module, lesson)
lesson_score = 0
targets = [
str(lesson.get("id", "")).lower(),
lesson_view["display_title"].lower(),
lesson_view["display_goal"].lower(),
str(lesson_view["command"]).lower(),
" ".join(token.lower() for token in lesson_view["command_tokens"]),
]
for text in targets:
if not text:
continue
if needle == text:
lesson_score += 110
elif needle in text:
lesson_score += 55
if lesson_score:
results.append(
{
"type": "lesson",
"title": lesson_view["display_title"],
"subtitle": f"{module_view['display_title']} | {command_label(lesson_view['command_tokens'])}",
"module_id": module.get("id"),
"lesson_id": lesson.get("id"),
"score": lesson_score,
}
)
for exercise in lesson.get("exercises", []):
exercise_view = build_exercise(exercise, lesson)
exercise_score = 0
for text in [
str(exercise.get("id", "")).lower(),
str(exercise_view.get("title", "")).lower(),
str(exercise_view.get("question", "")).lower(),
str(exercise_view.get("hint", "")).lower(),
]:
if not text:
continue
if needle == text:
exercise_score += 90
elif needle in text:
exercise_score += 40
if exercise_score:
results.append(
{
"type": "exercise",
"title": exercise_view.get("title") or exercise_view.get("question") or exercise.get("id", ""),
"subtitle": f"{lesson_view['display_title']} | {exercise.get('id', '')}",
"module_id": module.get("id"),
"lesson_id": lesson.get("id"),
"exercise_id": exercise.get("id"),
"score": exercise_score,
}
)
ranked = sorted(results, key=lambda item: (-item["score"], item["title"]))
return [{key: value for key, value in item.items() if key != "score"} for item in ranked[:24]]
class LinuxLearningHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def send_json(self, data: Any, status: int = 200, extra_headers: dict[str, str] | None = None):
raw = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
for header, value in (extra_headers or {}).items():
self.send_header(header, value)
self.end_headers()
self.wfile.write(raw)
def send_file(self, path: str, content_type: str):
with open(path, "rb") as file:
body = file.read()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_redirect(self, location: str):
self.send_response(302)
self.send_header("Location", location)
self.end_headers()
def cookie_header(self, session_id: str = "", clear: bool = False) -> str:
base = f"{SESSION_COOKIE}={session_id if not clear else ''}; Path=/; HttpOnly; SameSite=Lax"
if clear:
return base + "; Max-Age=0"
if secure_cookie_enabled():
base += "; Secure"
return base + f"; Max-Age={SESSION_TTL_SECONDS}"
def current_session_id(self) -> str:
cookie = self.headers.get("Cookie", "")
for part in cookie.split(";"):
name, _, value = part.strip().partition("=")
if name == SESSION_COOKIE:
return value.strip()
return ""
def current_session(self) -> dict[str, Any] | None:
return get_session(self.current_session_id())
def current_user(self) -> str | None:
session = self.current_session()
if not session:
return None
return str(session.get("user") or "")
def is_public_path(self, path: str, method: str) -> bool:
if method == "GET":
return path in PUBLIC_GET_PATHS
if method == "POST":
return path in PUBLIC_POST_PATHS
return False
def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method):
return True
if not self.current_session():
self.send_json({"error": "Authentication required"}, 401)
return False
return True
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/":
self.send_redirect("/app" if self.current_session() else "/login.html")
return
if path == "/login.html":
if self.current_session():
self.send_redirect("/app")
return
self.send_file(LOGIN_FILE, "text/html; charset=utf-8")
return
if path in {"/app", "/index.html"}:
if not self.current_session():
self.send_redirect("/login.html")
return
self.send_file(HTML_FILE, "text/html; charset=utf-8")
return
2026-03-10 21:45:11 +08:00
if path in {"/privacy", "/privacy.html"}:
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
return
if not self.require_auth_if_needed(path, "GET"):
return
if path == "/api/health":
overview = build_overview()
self.send_json(
{
"ok": True,
"cwd": SANDBOX.cwd,
"user": SANDBOX.user,
"module_count": overview["meta"]["module_count"],
"lesson_count": overview["meta"]["lesson_count"],
"exercise_count": overview["meta"]["exercise_count"],
2026-03-19 14:58:56 +08:00
"coverage_rate": overview["diagnostics"]["coverage_rate"],
}
)
return
if path == "/api/session":
user = self.current_user()
self.send_json({"authenticated": bool(user), "user": user or ""})
return
if path == "/api/course":
self.send_json(COURSE)
return
if path == "/api/overview":
self.send_json(build_overview())
return
2026-03-19 14:58:56 +08:00
if path == "/api/diagnostics":
self.send_json(build_diagnostics())
return
if path == "/api/lesson":
lesson_id = urllib.parse.parse_qs(parsed.query).get("id", [""])[0]
if not lesson_id:
self.send_json({"error": "id required"}, 400)
return
module, lesson = find_lesson(lesson_id)
if not module or not lesson:
self.send_json({"error": "Lesson not found"}, 404)
return
self.send_json({"lesson": build_lesson_payload(module, lesson)})
return
if path == "/api/course/search":
query = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
self.send_json({"query": query, "results": search_course(query)})
return
if path == "/api/run":
cmd = urllib.parse.parse_qs(parsed.query).get("cmd", [""])[0]
if not cmd:
self.send_json({"error": "No command provided"}, 400)
return
self.send_json(SANDBOX.execute(cmd))
return
if path == "/api/check":
query = urllib.parse.parse_qs(parsed.query)
exercise_id = query.get("exercise_id", [""])[0]
cmd = query.get("last_cmd", [""])[0]
output = query.get("output", [""])[0]
if not exercise_id:
self.send_json({"error": "exercise_id required"}, 400)
return
exercise = find_exercise(exercise_id)
if not exercise:
self.send_json({"error": "Exercise not found"}, 404)
return
state = {
"cmd": cmd,
"output": output,
"cwd": SANDBOX.cwd,
"exists": SANDBOX.exists,
"is_executable": SANDBOX.is_executable,
}
success, reason = self.evaluate_exercise(exercise, state)
lesson_stub = {"command": exercise.get("lesson_command"), "id": exercise.get("lesson_id")}
exercise_view = build_exercise(exercise, lesson_stub)
self.send_json(
{
"exercise_id": exercise_id,
"success": success,
"message": exercise_view["success_msg"] if success else reason,
"hint": exercise_view.get("hint"),
"lesson_title": safe_text(exercise.get("lesson_title"), lesson_title(lesson_stub)),
"module_title": safe_text(exercise.get("module_title"), "Course module"),
"next_suggestion": self.build_next_suggestion(exercise_id) if success else None,
}
)
return
self.send_json({"error": "Not found"}, 404)
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
content_length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(content_length).decode("utf-8") if content_length else "{}"
if path == "/api/login":
try:
data = json.loads(raw)
except Exception:
self.send_json({"success": False, "error": "Invalid JSON"}, 400)
return
username = data.get("username", "")
password = data.get("password", "")
if username in USERS and hashlib.sha256(password.encode("utf-8")).hexdigest() == USERS[username]:
session_id = create_session(username)
self.send_json(
{
"success": True,
"user": username,
"message": "Login succeeded",
"expires_in": SESSION_TTL_SECONDS,
},
extra_headers={"Set-Cookie": self.cookie_header(session_id)},
)
return
self.send_json({"success": False, "error": "Invalid username or password"}, 401)
return
if not self.require_auth_if_needed(path, "POST"):
return
if path == "/api/logout":
delete_session(self.current_session_id())
self.send_json(
{"success": True, "message": "Logged out"},
extra_headers={"Set-Cookie": self.cookie_header(clear=True)},
)
return
if path == "/api/reset":
SANDBOX.reset()
self.send_json({"success": True, "message": "Sandbox reset"})
return
self.send_json({"error": "Not found"}, 404)
def evaluate_exercise(self, exercise: dict[str, Any], state: dict[str, Any]) -> tuple[bool, str]:
exercise_type = exercise.get("type")
if exercise_type in {"understanding", "scenario"}:
return False, "This is a reflection task. Read the reference answer, then explain it in your own words."
cmd = state["cmd"].strip()
for solution in exercise.get("solution", []):
if cmd == str(solution).strip():
return True, ""
success_test = exercise.get("success_test")
if not success_test:
return False, "The command ran, but this task does not have an automated check yet."
try:
ok = bool(eval(success_test, {"__builtins__": {}}, state))
if ok:
return True, ""
return False, "The command ran, but the target state has not been reached yet."
except Exception:
return False, "The checker could not validate this attempt. Compare your command with the example and try again."
def build_next_suggestion(self, current_exercise_id: str) -> str | None:
rows = flatten_exercises()
for index, item in enumerate(rows):
if item.get("id") != current_exercise_id:
continue
if index + 1 >= len(rows):
return None
next_item = rows[index + 1]
lesson_stub = {"command": next_item.get("lesson_command"), "id": next_item.get("lesson_id")}
exercise_view = build_exercise(next_item, lesson_stub)
title = exercise_view.get("title") or exercise_view.get("question") or "the next exercise"
return f"Next up: {lesson_title(lesson_stub)} | {title}"
return None
if __name__ == "__main__":
port = 8084
print(f"Linux Learning Lab is running at http://127.0.0.1:{port}")
print("Remote host: https://linux.xiaoxiaoluohao.indevs.in")
http.server.ThreadingHTTPServer(("127.0.0.1", port), LinuxLearningHandler).serve_forever()