Files
linux-practice/server.py
2026-03-18 18:12:04 +08:00

688 lines
26 KiB
Python
Executable File

#!/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 urllib.parse
from typing import Any
from sandbox import LinuxSandbox
USERS = {
"admin": hashlib.sha256(b"safe_linux_2026").hexdigest(),
}
TASKS_FILE = os.path.join(os.path.dirname(__file__), "COURSE_TASKS.json")
HTML_FILE = os.path.join(os.path.dirname(__file__), "index.html")
PRIVACY_FILE = os.path.join(os.path.dirname(__file__), "privacy.html")
SANDBOX = LinuxSandbox()
PUBLIC_GET_PATHS = {
"/",
"/privacy",
"/privacy.html",
"/api/course",
"/api/course/search",
"/api/health",
"/api/lesson",
"/api/overview",
}
PUBLIC_POST_PATHS = {
"/api/login",
}
SAFE_REMOTE_HOST = "xiaoxiaoluohao.indevs.in"
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 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 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)}."
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", [])]
return {
"id": module.get("id"),
"display_title": module_title(module),
"display_summary": module_summary(module),
"lesson_count": len(module.get("lessons", [])),
"exercise_count": sum(len(lesson.get("exercises", [])) for lesson in module.get("lessons", [])),
"lessons": lessons,
}
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
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": safe_text(lesson.get("goal"), default_goal(commands)),
"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"]),
"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", {})
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,
},
"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):
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)))
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 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 check_auth(self, auth_header: str, token: str) -> bool:
if self.client_address[0] == "127.0.0.1":
return True
if token == "safe_linux_2026":
return True
if auth_header.startswith("Bearer ") and auth_header[7:] == "safe_linux_2026":
return True
return False
def require_auth_if_needed(self, path: str, method: str) -> bool:
if self.is_public_path(path, method):
return True
host = self.headers.get("Host", "")
auth_header = self.headers.get("Authorization", "")
token = self.headers.get("X-Token", "")
if SAFE_REMOTE_HOST in host and not self.check_auth(auth_header, token):
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 not self.require_auth_if_needed(path, "GET"):
return
if path == "/":
self.send_file(HTML_FILE, "text/html; charset=utf-8")
return
if path in {"/privacy", "/privacy.html"}:
self.send_file(PRIVACY_FILE, "text/html; charset=utf-8")
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"],
}
)
return
if path == "/api/course":
self.send_json(COURSE)
return
if path == "/api/overview":
self.send_json(build_overview())
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
if not self.require_auth_if_needed(path, "POST"):
return
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]:
self.send_json({"success": True, "token": "safe_linux_2026", "message": "Login succeeded"})
return
self.send_json({"success": False, "error": "Invalid username or password"}, 401)
return
if path == "/api/logout":
self.send_json({"success": True, "message": "Logged out"})
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()