Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a67056611 | ||
|
|
31d69406bf | ||
|
|
8dd0922015 | ||
|
|
0260bba2af | ||
|
|
ea14985d1b | ||
|
|
198adb6151 | ||
|
|
fe11245669 | ||
|
|
cd15275fb3 | ||
|
|
0a01a79e71 |
@@ -4,3 +4,5 @@ __pycache__/
|
||||
*/videos/*
|
||||
userdata/*
|
||||
*server.log
|
||||
*secrets/*
|
||||
*.cci
|
||||
|
||||
@@ -18,8 +18,6 @@ ALLOWED_METHODS = {"GET", "POST", "HEAD", "PUT"}
|
||||
script_directory = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||
webroot_real = os.path.realpath(script_directory + "/webroot")
|
||||
|
||||
requestType = ""
|
||||
|
||||
trying = True
|
||||
while trying:
|
||||
try:
|
||||
@@ -384,6 +382,7 @@ def handle_connection(connectionSocket, addr):
|
||||
response = ("HTTP/1.1 " + responseType + "\n").encode()
|
||||
response += responseHeaders
|
||||
response += b"\n"
|
||||
if requestType is None: requestType = "unknown"
|
||||
if (requestType != "HEAD"):
|
||||
response += responsecontent
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -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"}
|
||||
)
|
||||
@@ -15,5 +15,6 @@ Content:
|
||||
<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>Nintendo Switch friend code:</strong> SW-7690-5791-7118<br>
|
||||
<img src="/Images/mii.gif">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,11 +24,14 @@ Content:
|
||||
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
|
||||
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>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="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="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_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>[THIS PAGE IS UNDER CONSTRUCTION]
|
||||
<br><abbr title="construction."><img src=/Images/underconstruction.gif alt="construction." class="fitinpageplease"></abbr>
|
||||
|
||||