Compare commits

..
9 Commits
17 changed files with 238 additions and 5 deletions
+2
View File
@@ -4,3 +4,5 @@ __pycache__/
*/videos/* */videos/*
userdata/* userdata/*
*server.log *server.log
*secrets/*
*.cci
+1 -2
View File
@@ -18,8 +18,6 @@ ALLOWED_METHODS = {"GET", "POST", "HEAD", "PUT"}
script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))
webroot_real = os.path.realpath(script_directory + "/webroot") webroot_real = os.path.realpath(script_directory + "/webroot")
requestType = ""
trying = True trying = True
while trying: while trying:
try: try:
@@ -384,6 +382,7 @@ def handle_connection(connectionSocket, addr):
response = ("HTTP/1.1 " + responseType + "\n").encode() response = ("HTTP/1.1 " + responseType + "\n").encode()
response += responseHeaders response += responseHeaders
response += b"\n" response += b"\n"
if requestType is None: requestType = "unknown"
if (requestType != "HEAD"): if (requestType != "HEAD"):
response += responsecontent response += responsecontent
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+228
View File
@@ -0,0 +1,228 @@
# webroot/api/cluster/index.py
#
# Fixed-whitelist cluster management API. Executed by server.py's generic
# ".py" exec mechanism, so it inherits the request-scoped local variables
# server.py already built for us: requestType, argumentsDict,
# requestheaders, requestBody, Cookies, sourceIP, sourceIPvisual,
# script_directory, etc. See server.py's handle_connection() for the
# full list if you need something not used here.
#
# Reachable at: https://<host>/api/cluster/
# GET -> discovery: which hosts/actions this token is allowed to use
# POST -> perform an action. JSON body:
# {"host": "...", "action": "...", "params": {...}, "reason": "..."}
#
# Auth: Authorization: Bearer <token> header (NOT a query param — query
# strings get printed to server.py's stdout log on every request).
#
# Config lives OUTSIDE webroot/ and userdata/, per the site's existing
# convention, so it can never be served even by a routing mistake:
# [script_directory]/secrets/api_tokens.json (hashes only, chmod 600)
# [script_directory]/secrets/cluster_hosts.json
# [script_directory]/secrets/cluster_actions.json
# [script_directory]/secrets/cluster_api_audit.log (append-only)
#
# Security model, read this before editing:
# - Every param value is checked against a fixed enum whitelist defined
# in cluster_actions.json BEFORE it touches a command line. Nothing
# from the request body is ever string-substituted freely — only
# values that already exist in that whitelist. Don't change params
# to accept free-form strings without re-thinking this.
# - The SSH command is built as an argv list (never shell=True), and a
# `--` separates the destination from the remote command so a
# whitelisted value can't be mistaken for an ssh option.
# - Every attempt (denied or not) is written to the audit log.
# - The remote side should ALSO be locked down (see README): a
# dedicated SSH key used only for this, and sudoers entries scoped to
# the exact commands in cluster_actions.json — not blanket sudo. This
# script whitelisting actions is one layer, not the only layer.
import json
import hmac
import hashlib
import subprocess
import time
CONFIG_DIR = script_directory + "/secrets"
TOKENS_FILE = CONFIG_DIR + "/api_tokens.json"
HOSTS_FILE = CONFIG_DIR + "/cluster_hosts.json"
ACTIONS_FILE = CONFIG_DIR + "/cluster_actions.json"
AUDIT_LOG = CONFIG_DIR + "/cluster_api_audit.log"
contentType = "application/json"
def make_json(status, body, _json=json):
"""Returns (responseType, responsecontent_bytes). Never mutates
outer scope directly -- exec()'d top-level code + helper functions
don't share a normal global/nonlocal relationship, so we pass
values back explicitly instead of relying on `global` inside here.
_json defaults to the module-level `json` imported above. This looks
redundant but isn't: server.py runs this whole file via
exec(code, globals(), local_vars) with globals() != local_vars, so
`import json` at the top only binds `json` in local_vars. A nested
function body like this one resolves free names via LOAD_GLOBAL
against the *real* globals dict, which never got `json` -- so a
plain `json.dumps(...)` here raises NameError. Default arguments are
evaluated at def-time (top-level, local_vars is visible there), so
capturing it this way sidesteps the issue. Same trick below for
`time`.
"""
return status, _json.dumps(body).encode()
def load_json(path, default, _json=json):
try:
with open(path) as f:
return _json.load(f)
except FileNotFoundError:
return default
def audit(entry: dict, _json=json, _time=time):
entry["ts"] = _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime())
try:
with open(AUDIT_LOG, "a") as f:
f.write(_json.dumps(entry) + "\n")
except Exception:
pass # logging must never be why a request 500s
# --- auth ---------------------------------------------------------------
supplied_token = None
for h in requestheaders:
if h.lower().startswith("authorization: bearer "):
supplied_token = h.split(" ", 2)[2].strip()
break
token_label = None
if supplied_token:
tokens = load_json(TOKENS_FILE, {"tokens": []}).get("tokens", [])
supplied_hash = hashlib.sha256(supplied_token.encode()).hexdigest()
for t in tokens:
if hmac.compare_digest(t.get("token_sha256", ""), supplied_hash):
token_label = t.get("label", "unknown")
break
if token_label is None:
audit({"event": "auth_failed", "source_ip": sourceIPvisual})
responseType, responsecontent = make_json(
"401 Unauthorized", {"error": "missing or invalid Authorization: Bearer <token> header"}
)
elif requestType not in ("GET", "POST"):
responseType, responsecontent = make_json(
"405 Method Not Allowed", {"error": "use GET (discovery) or POST (act)"}
)
else:
actions = load_json(ACTIONS_FILE, {})
hosts = load_json(HOSTS_FILE, {})
if requestType == "GET":
# Read-only discovery. No secrets (keys/addresses) go in this response.
responseType, responsecontent = make_json("200 OK", {
"hosts": {
name: {"allowed_actions": cfg.get("allowed_actions", [])}
for name, cfg in hosts.items()
},
"actions": {
name: {"description": cfg.get("description", ""), "params": cfg.get("params", {})}
for name, cfg in actions.items()
},
})
else: # POST
try:
payload = json.loads(requestBody.decode())
except Exception:
payload = None
if not isinstance(payload, dict):
responseType, responsecontent = make_json(
"400 Bad Request", {"error": "expected a JSON object body"}
)
else:
host_name = payload.get("host")
action_name = payload.get("action")
params = payload.get("params", {}) or {}
reason = payload.get("reason", "")
host_cfg = hosts.get(host_name)
action_cfg = actions.get(action_name)
if host_cfg is None:
responseType, responsecontent = make_json(
"400 Bad Request", {"error": "unknown host", "known_hosts": list(hosts.keys())}
)
elif action_cfg is None:
responseType, responsecontent = make_json(
"400 Bad Request", {"error": "unknown action", "known_actions": list(actions.keys())}
)
elif action_name not in host_cfg.get("allowed_actions", []):
audit({
"event": "action_denied", "token": token_label, "host": host_name,
"action": action_name, "reason": reason, "source_ip": sourceIPvisual,
})
responseType, responsecontent = make_json(
"403 Forbidden", {"error": "action not allowed on this host"}
)
else:
# Every param must match an entry in that action's enum
# whitelist. Nothing free-form ever reaches the command line.
param_specs = action_cfg.get("params", {})
bad_params = []
resolved = {}
for pname, spec in param_specs.items():
val = params.get(pname)
if val not in spec.get("values", []):
bad_params.append(pname)
else:
resolved[pname] = val
if bad_params:
responseType, responsecontent = make_json(
"400 Bad Request", {"error": "invalid or missing params", "bad_params": bad_params}
)
else:
remote_cmd = [part.format(**resolved) for part in action_cfg["remote_cmd_template"]]
ssh_cmd = [
"ssh", "-i", host_cfg["ssh_key"],
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=5",
host_cfg["ssh_user"] + "@" + host_cfg["address"],
"--",
] + remote_cmd
audit({
"event": "action_start", "token": token_label, "host": host_name,
"action": action_name, "params": resolved, "reason": reason,
"source_ip": sourceIPvisual,
})
try:
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=30)
ok = result.returncode == 0
audit({
"event": "action_done", "token": token_label, "host": host_name,
"action": action_name, "returncode": result.returncode,
})
responseType, responsecontent = make_json(
"200 OK" if ok else "502 Bad Gateway",
{
"ok": ok,
"returncode": result.returncode,
"stdout": result.stdout[-4000:],
"stderr": result.stderr[-4000:],
},
)
except subprocess.TimeoutExpired:
audit({
"event": "action_timeout", "token": token_label,
"host": host_name, "action": action_name,
})
responseType, responsecontent = make_json(
"504 Gateway Timeout", {"error": "ssh command timed out after 30s"}
)
+1
View File
@@ -15,5 +15,6 @@ Content:
<strong>Discord:</strong> <a href="https://discordapp.com/users/763802866921242646">superbee_hashtag_3637</a><br> <strong>Discord:</strong> <a href="https://discordapp.com/users/763802866921242646">superbee_hashtag_3637</a><br>
<strong>Matrix:</strong> <a href="https://matrix.to/#/%40superbeelavaman%3Asuperbeelavaman.duckdns.org">@superbeelavaman:superbeelavaman.duckdns.org</a><br> <strong>Matrix:</strong> <a href="https://matrix.to/#/%40superbeelavaman%3Asuperbeelavaman.duckdns.org">@superbeelavaman:superbeelavaman.duckdns.org</a><br>
<strong>Nintendo Switch friend code:</strong> SW-7690-5791-7118<br> <strong>Nintendo Switch friend code:</strong> SW-7690-5791-7118<br>
<img src="/Images/mii.gif">
</p> </p>
</div> </div>
+6 -3
View File
@@ -24,11 +24,14 @@ Content:
I have an RVL-CPU-40 wii that has gone through a lot. I have an RVL-CPU-40 wii that has gone through a lot.
<br>At one point, it was running with no shell and no heatsink, in a freezer. During that time, it was nicknamed <i>Friizer</i>, and continues to <br>At one point, it was running with no shell and no heatsink, in a freezer. During that time, it was nicknamed <i>Friizer</i>, and continues to
retain that name. retain that name.
<br><abbr title="Friizer in the freezer."><a href="/Images/projects/wiitop/friizerfreezer.jpg"><img src="/Images/projects/wiitop/friizerfreezer.jpg" alt="Friizer in the freezer" class="fitinpageplease"></a></abbr> <br><abbr title="Friizer in the freezer."><a href="/Images/projects/wiitop/friizerfreezer_large.jpg"><img src="/Images/projects/wiitop/friizerfreezer.jpg" alt="Friizer in the freezer" class="fitinpageplease"></a></abbr>
<br> <br>
<br>Recently, I delidded the CPU and SoC of Friizer. I then decided to prepare for trimming and do research for what I would need to do to make the Wiitop. <br>Recently, I delidded the CPU and SoC of Friizer. I then decided to prepare for trimming and do research for what I would need to do to make the Wiitop.
<br><abbr title="Friizer has been delidded."><a href="/Images/projects/wiitop/friizerdelid.jpg"><img src="/Images/projects/wiitop/friizerdelid.jpg" alt="Friizer has been delidded." class="fitinpageplease""></a></abbr> <br><abbr title="Friizer has been delidded."><a href="/Images/projects/wiitop/friizerdelid_large.jpg"><img src="/Images/projects/wiitop/friizerdelid.jpg" alt="Friizer has been delidded." class="fitinpageplease"></a></abbr>
<br><abbr title="Marked for the OMGWTF trim"><a href="/Images/projects/wiitop/friizermarked.jpg"><img src="/Images/projects/wiitop/friizermarked.jpg" alt="Marked for the OMGWTF trim" class="fitinpageplease"></a></abbr> <br><abbr title="Marked for the OMGWTF trim"><a href="/Images/projects/wiitop/friizermarked_large.jpg"><img src="/Images/projects/wiitop/friizermarked.jpg" alt="Marked for the OMGWTF trim" class="fitinpageplease"></a></abbr>
<br>
<p>a few months later I haven't done anything new to this page, also I found a typo that was completely fucking up formatting, idk how browser engines ranging from "ancient af" to latest modern browser were handling it fine<br>
I'll work on this more when I have a job to afford buying parts for this.</p>
<br> <br>
<br>[THIS PAGE IS UNDER CONSTRUCTION] <br>[THIS PAGE IS UNDER CONSTRUCTION]
<br><abbr title="construction."><img src=/Images/underconstruction.gif alt="construction." class="fitinpageplease"></abbr> <br><abbr title="construction."><img src=/Images/underconstruction.gif alt="construction." class="fitinpageplease"></abbr>