mirror of
https://github.com/Cogential/frametanium.git
synced 2026-10-23 07:00:32 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80a4e62a49 | ||
|
|
6635ac1342 | ||
|
|
a3a2450a4d | ||
|
|
fbd86e26fd |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# Frametanium: notes for Claude sessions
|
||||
|
||||
See README.md for what it does. Service: `manager.py` (stdlib Python, runs on the Frame). Headset app: `app/app.c`.
|
||||
|
||||
## Rules (from the 2026-10-20 review)
|
||||
- **Auth:** every `/api/*` request needs a secret, loopback included. Headset app sends `X-Frametanium-Key` (`app_key`
|
||||
from `~/.config/frametanium/config.json`), the page sends `X-Key` (the `password`). Both are generated by
|
||||
`ensure_secrets()`. Never add a loopback/no-password bypass, never accept secrets in the query string, and keep
|
||||
`host_ok()` (Host allow-list + Origin check). Any auth change must change app.c and page.html together, and old
|
||||
headset apps must still get a readable "#" notice in TSV replies.
|
||||
- Write settings files only with `save_json` (unique temp + fsync; `private=True` for config.json) and do
|
||||
read-modify-writes under `files_lock`.
|
||||
- Test locally: run manager.py with `HOME=<scratch>` and a config.json with another port; build app.c natively
|
||||
(`gcc $(sdl2-config --cflags) app/app.c $(sdl2-config --libs)`) and screenshot with
|
||||
`SDL_VIDEODRIVER=dummy PM_SCREENSHOT=out.bmp PM_SCREEN=list`.
|
||||
- Don't push, tag, release or deploy without the user's OK.
|
||||
|
||||
## Review fixes still to do (report: /data/cogent/reviews/2026-10-20/review-frametanium.md)
|
||||
Done 2026-10-21: settings-file robustness (code-review 10, 11; security LOW 13), auth (security HIGH 1, 2, LOW 10, 12).
|
||||
Left, in this order: security MEDIUM 3 (Discover installs run install.sh without a confirm step), 4 (no integrity
|
||||
check: verify the GitHub asset `digest`; plan was an ed25519 signature for Frametanium's own updates, verified with
|
||||
`openssl pkeyutl -rawin`), 6 (tar symlink traversal, .tar.zst via GNU tar), 5 (workflow: SDL2 hash, pin
|
||||
actions by SHA), 7 (AppImage executed to unpack: prefer `unsquashfs -o <ELF end>`), LOW 8/9/11/14; then code-review
|
||||
bugs 1-9 and 12-16 (2: update Frametanium last in update_all; 4: Frametanium's own row needs a VERSION file).
|
||||
@@ -71,8 +71,10 @@ the bundle (`tools/bundle.sh`).
|
||||
Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library.
|
||||
|
||||
## Notes
|
||||
- The service listens on port 8643 on your network. Set `password` in `~/.config/frametanium/config.json` to require
|
||||
one in the browser (the headset app connects locally and doesn't need it).
|
||||
- The service listens on port 8643 on your network, and every request needs a secret: the browser page asks for the
|
||||
password (made up on first start and shown under the list in the headset app; change `password` in
|
||||
`~/.config/frametanium/config.json` if you like), and the headset app sends `app_key` from the same file (mode 600).
|
||||
Requests addressed to unknown host names or sent from other sites' pages are refused.
|
||||
- Standard-library Python only. The headset app is C + SDL2 with the public-domain
|
||||
[font8x8](https://github.com/dhepper/font8x8).
|
||||
- Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs
|
||||
|
||||
@@ -134,8 +134,12 @@ static void fill(int x, int y, int w, int h, Uint32 rgb) {
|
||||
|
||||
/* ---------------------------------------------------------------- service */
|
||||
|
||||
static void read_port(void) {
|
||||
char path[512], buf[2048];
|
||||
/* The service's port and the key it wants from this app, from the service's config.json (readable only by this
|
||||
* user). It's read again when the service turns the key down, e.g. after the service made it on its first start. */
|
||||
static char app_key[128];
|
||||
|
||||
static void read_config(void) {
|
||||
char path[512], buf[4096];
|
||||
snprintf(path, sizeof path, "%s/.config/frametanium/config.json", getenv("HOME") ? getenv("HOME") : "");
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
@@ -147,10 +151,16 @@ static void read_port(void) {
|
||||
int v = atoi(p + 1);
|
||||
if (v > 0 && v < 65536) service_port = v;
|
||||
}
|
||||
p = strstr(buf, "\"app_key\"");
|
||||
if (p && (p = strchr(p, ':')) && (p = strchr(p, '"'))) {
|
||||
size_t k = strspn(p + 1, "0123456789abcdefABCDEF");
|
||||
if (k < sizeof app_key && p[1 + k] == '"') snprintf(app_key, sizeof app_key, "%.*s", (int)k, p + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. */
|
||||
static char* http(const char* method, const char* path, int timeout_s) {
|
||||
/* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. *status gets the
|
||||
* HTTP status. */
|
||||
static char* http_once(const char* method, const char* path, int timeout_s, int* status) {
|
||||
int s = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (s < 0) return NULL;
|
||||
struct timeval tv = { .tv_sec = timeout_s };
|
||||
@@ -162,7 +172,9 @@ static char* http(const char* method, const char* path, int timeout_s) {
|
||||
return NULL;
|
||||
}
|
||||
char req[2048];
|
||||
int n = snprintf(req, sizeof req, "%s %s HTTP/1.0\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n", method, path);
|
||||
int n = snprintf(req, sizeof req,
|
||||
"%s %s HTTP/1.0\r\nHost: localhost\r\nX-Frametanium-Key: %s\r\nContent-Length: 0\r\n\r\n", method,
|
||||
path, app_key);
|
||||
if (n >= (int)sizeof req || write(s, req, n) != n) {
|
||||
close(s);
|
||||
return NULL;
|
||||
@@ -181,10 +193,23 @@ static char* http(const char* method, const char* path, int timeout_s) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
*status = 0;
|
||||
sscanf(buf, "HTTP/%*s %d", status);
|
||||
memmove(buf, body + 4, strlen(body + 4) + 1);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static char* http(const char* method, const char* path, int timeout_s) {
|
||||
int status = 0;
|
||||
char* body = http_once(method, path, timeout_s, &status);
|
||||
if (body && status == 401) { /* the key changed or was just made: read it again */
|
||||
free(body);
|
||||
read_config();
|
||||
body = http_once(method, path, timeout_s, &status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/* Percent-encodes s for a query string. */
|
||||
static void urlenc(char* out, size_t sz, const char* s) {
|
||||
static const char hex[] = "0123456789ABCDEF";
|
||||
@@ -221,6 +246,9 @@ static void copy(char* dst, size_t sz, const char* src) {
|
||||
/* Lines of a reply, one at a time (strtok_r so callers can nest). */
|
||||
#define EACH_LINE(body, line, save) for (char* line = strtok_r(body, "\n", &save); line; line = strtok_r(NULL, "\n", &save))
|
||||
|
||||
/* How to open the web page from a phone or PC, shown under the list. */
|
||||
static char web_line[300];
|
||||
|
||||
static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
|
||||
char* body = http("GET", "/api/state?fmt=tsv", 5);
|
||||
if (!body) return -1;
|
||||
@@ -236,6 +264,10 @@ static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
|
||||
*notice_busy = !strcmp(f[1], "installing");
|
||||
continue;
|
||||
}
|
||||
if (k >= 3 && !strcmp(f[0], "@")) {
|
||||
snprintf(web_line, sizeof web_line, "From a phone or PC: %s password %s", f[1], f[2]);
|
||||
continue;
|
||||
}
|
||||
if (k < 13 || n >= MAX_APPS) continue;
|
||||
App* p = &apps[n++];
|
||||
copy(p->id, sizeof p->id, f[0]);
|
||||
@@ -488,7 +520,7 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann
|
||||
return;
|
||||
}
|
||||
int row_h = 50 * S;
|
||||
int max_rows = (H - y - 40 * S) / row_h;
|
||||
int max_rows = (H - y - (web_line[0] ? 56 : 40) * S) / row_h;
|
||||
if (max_rows < 1) max_rows = 1;
|
||||
int first = scroll_first(sel, max_rows);
|
||||
for (int i = first; i <= n + 1 && i < first + max_rows; i++) {
|
||||
@@ -529,6 +561,7 @@ static void draw_list(App* apps, int n, int sel, const char* banner, Uint32 bann
|
||||
y += row_h;
|
||||
}
|
||||
legend("A Open Y Add X Discover Select Update all Start Check B Close");
|
||||
if (web_line[0]) text_fit(web_line, m, H - H / 20 - 30 * S, S, W - 2 * m, MUTED);
|
||||
SDL_RenderPresent(ren);
|
||||
}
|
||||
|
||||
@@ -760,7 +793,7 @@ int main(void) {
|
||||
if (!ren) return 1;
|
||||
SDL_ShowCursor(SDL_DISABLE);
|
||||
SDL_StopTextInput();
|
||||
read_port();
|
||||
read_config();
|
||||
|
||||
static App apps[MAX_APPS];
|
||||
static Release rels[MAX_RELEASES];
|
||||
|
||||
@@ -18,6 +18,25 @@
|
||||
"mode": "game",
|
||||
"icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png"
|
||||
},
|
||||
{
|
||||
"id": "soh3d",
|
||||
"name": "Ocarina of Time 3D (Ship of Harkinian, experimental)",
|
||||
"repo": "Cogential/Shipwright-frame",
|
||||
"asset": "soh-steam-frame-3d-arm64.zip",
|
||||
"type": "zip",
|
||||
"gameid": "Ocarina_of_Time_SoH_3D",
|
||||
"launch": "run.sh",
|
||||
"keep": [
|
||||
"*.z64",
|
||||
"*.n64",
|
||||
"*.v64"
|
||||
],
|
||||
"data": "~/.local/share/soh",
|
||||
"rom_hint": "Send your Ocarina of Time ROM with Frame Inbox, or put a .z64 in ~/.local/share/soh.",
|
||||
"prereleases": true,
|
||||
"mode": "game",
|
||||
"icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png"
|
||||
},
|
||||
{
|
||||
"id": "2ship",
|
||||
"name": "Majora's Mask (2 Ship 2 Harkinian)",
|
||||
|
||||
+170
-60
@@ -13,10 +13,14 @@ Control it from the headset app (app/) or from a browser at http://<frame>:8643.
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
@@ -46,7 +50,9 @@ STEAM_USERDATA = HOME / ".local/share/Steam/userdata"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"port": 8643,
|
||||
"password": "", # for the web page; empty means anyone on the network can use it
|
||||
"password": "", # for the web page; made up on first start when empty (the headset app shows it)
|
||||
"app_key": "", # for the headset app, which reads it from this file; made up on first start
|
||||
"allowed_hosts": [], # extra names the page may be opened by (IP addresses, .local and plain names always work)
|
||||
"github_token": "", # needed only for private repos (read-only access to their releases is enough)
|
||||
"compat_tool": "SteamLinuxRuntime_4-arm64",
|
||||
"check_hours": 6,
|
||||
@@ -57,28 +63,78 @@ INSTALLERS = ["install.sh", "contrib/install.sh", "scripts/install.sh", "setup.s
|
||||
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
|
||||
|
||||
# Held around every read-modify-write of the settings files, which the web handlers and install threads share.
|
||||
files_lock = threading.RLock()
|
||||
|
||||
|
||||
def load_json(path, default):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except (OSError, ValueError) as e:
|
||||
# A damaged file (e.g. power lost mid-write) is kept aside rather than overwritten by the next save.
|
||||
if isinstance(e, ValueError):
|
||||
aside = path.with_name(f"{path.name}.damaged-{int(time.time())}")
|
||||
try:
|
||||
os.replace(path, aside)
|
||||
print(f"{path} couldn't be read; kept it as {aside}", file=sys.stderr, flush=True)
|
||||
except OSError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path, data):
|
||||
def save_json(path, data, private=False):
|
||||
"""Writes atomically: a temp file of its own (never readable by others when private), synced, then renamed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if private else 0o644)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
|
||||
|
||||
def config():
|
||||
return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})}
|
||||
|
||||
|
||||
PASSWORD_LETTERS = "abcdefghjkmnpqrstuvwxyz23456789" # nothing that's easy to misread
|
||||
|
||||
|
||||
def ensure_secrets():
|
||||
"""Makes up the headset app's key and the web page's password the first time (also on installs from before
|
||||
they existed, which had no password), and keeps config.json readable only by its owner."""
|
||||
with files_lock:
|
||||
cfg = load_json(CONFIG_FILE, {})
|
||||
changed = not CONFIG_FILE.exists()
|
||||
if not cfg.get("app_key"):
|
||||
cfg["app_key"] = secrets.token_hex(32)
|
||||
changed = True
|
||||
if not cfg.get("password"):
|
||||
cfg["password"] = "-".join("".join(secrets.choice(PASSWORD_LETTERS) for _ in range(4)) for _ in range(3))
|
||||
changed = True
|
||||
if changed:
|
||||
save_json(CONFIG_FILE, {**DEFAULT_CONFIG, **cfg}, private=True)
|
||||
for path, mode in ((CONFIG_DIR, 0o700), (CONFIG_FILE, 0o600)):
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def lan_address():
|
||||
"""This machine's address on the local network (no packets are sent), or its hostname."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("192.0.2.1", 9))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def catalog():
|
||||
"""The built-in ports plus the apps the user added. A user entry with the id of a built-in one changes it, and
|
||||
{"id": ..., "hidden": true} takes a built-in one off the list."""
|
||||
@@ -98,13 +154,14 @@ def catalog():
|
||||
|
||||
def update_user_entry(pid, **fields):
|
||||
"""Changes (or with remove=True deletes) the user's entry for an app, creating one for a built-in app."""
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entry = next((e for e in entries if e.get("id") == pid), None)
|
||||
if entry is None:
|
||||
entry = {"id": pid}
|
||||
entries.append(entry)
|
||||
entry.update(fields)
|
||||
save_json(USER_CATALOG, entries)
|
||||
with files_lock:
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entry = next((e for e in entries if e.get("id") == pid), None)
|
||||
if entry is None:
|
||||
entry = {"id": pid}
|
||||
entries.append(entry)
|
||||
entry.update(fields)
|
||||
save_json(USER_CATALOG, entries)
|
||||
|
||||
|
||||
def suggestions():
|
||||
@@ -119,8 +176,9 @@ def unhide_builtin(repo):
|
||||
port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None)
|
||||
if port is None:
|
||||
return None
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))])
|
||||
with files_lock:
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))])
|
||||
return port["id"]
|
||||
|
||||
|
||||
@@ -334,14 +392,15 @@ def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None):
|
||||
g, n = gameid, 2
|
||||
while g in used:
|
||||
g, n = f"{gameid}_{n}", n + 1
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entries.append({
|
||||
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
|
||||
"mode": mode if mode in ("auto", "game", "installer") else "auto",
|
||||
"prereleases": info["prereleases"] if prereleases is None else bool(prereleases),
|
||||
"gameid": g, "description": info["description"][:300], "added": int(time.time()),
|
||||
})
|
||||
save_json(USER_CATALOG, entries)
|
||||
with files_lock:
|
||||
entries = load_json(USER_CATALOG, [])
|
||||
entries.append({
|
||||
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
|
||||
"mode": mode if mode in ("auto", "game", "installer") else "auto",
|
||||
"prereleases": info["prereleases"] if prereleases is None else bool(prereleases),
|
||||
"gameid": g, "description": info["description"][:300], "added": int(time.time()),
|
||||
})
|
||||
save_json(USER_CATALOG, entries)
|
||||
_releases_cache.clear()
|
||||
return pid
|
||||
|
||||
@@ -506,10 +565,11 @@ def discovered():
|
||||
|
||||
|
||||
def discover_mark(repos, key):
|
||||
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
|
||||
have = {r.lower() for r in prefs[key]}
|
||||
prefs[key] += [r for r in repos if r and r.lower() not in have]
|
||||
save_json(DISCOVER_FILE, prefs)
|
||||
with files_lock:
|
||||
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
|
||||
have = {r.lower() for r in prefs[key]}
|
||||
prefs[key] += [r for r in repos if r and r.lower() not in have]
|
||||
save_json(DISCOVER_FILE, prefs)
|
||||
|
||||
|
||||
def start_discover(force=True):
|
||||
@@ -1002,10 +1062,11 @@ def install(pid, tag=None):
|
||||
raise
|
||||
message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "")
|
||||
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
prev = installed.get(pid, {}).get("tag")
|
||||
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
with files_lock:
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
prev = installed.get(pid, {}).get("tag")
|
||||
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
|
||||
data = Path(os.path.expanduser(port.get("data", "")))
|
||||
if port.get("rom_hint") and port.get("data") and not any(data.glob("*.o2r")) and not any(data.glob("*.z64")):
|
||||
@@ -1041,9 +1102,11 @@ def rollback(pid):
|
||||
if mode == "installer":
|
||||
jobs.set(pid, "installing", 1.0, "Running its installer")
|
||||
run_installer(final, port)
|
||||
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()),
|
||||
"mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
with files_lock:
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()),
|
||||
"mode": mode}
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
jobs.set(pid, "done", 1.0, f"Back to {cur.get('previous') or 'the previous version'}.")
|
||||
except Exception as e:
|
||||
jobs.set(pid, "error", 0.0, str(e))
|
||||
@@ -1070,12 +1133,14 @@ def remove(pid, delete_files=True):
|
||||
note = " Remove its tile from the library too: Manage > Remove non-Steam game."
|
||||
shutil.rmtree(final, ignore_errors=True)
|
||||
shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True)
|
||||
installed.pop(pid, None)
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
|
||||
if port.get("builtin"):
|
||||
entries.append({"id": pid, "hidden": True})
|
||||
save_json(USER_CATALOG, entries)
|
||||
with files_lock:
|
||||
installed = load_json(INSTALLED_FILE, {})
|
||||
installed.pop(pid, None)
|
||||
save_json(INSTALLED_FILE, installed)
|
||||
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
|
||||
if port.get("builtin"):
|
||||
entries.append({"id": pid, "hidden": True})
|
||||
save_json(USER_CATALOG, entries)
|
||||
jobs.state.pop(pid, None)
|
||||
_latest.pop(pid, None)
|
||||
jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}")
|
||||
@@ -1210,29 +1275,77 @@ class Handler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
self.reply({"error": message}, code=code)
|
||||
|
||||
def allowed(self, q):
|
||||
password = config().get("password") or ""
|
||||
# The headset app talks to us over loopback and never needs the password.
|
||||
if not password or self.client_address[0] == "127.0.0.1":
|
||||
return True
|
||||
return (self.headers.get("X-Key") or q.get("key", [""])[0]) == password
|
||||
# Every request needs a secret in a header, from loopback too: the headset app sends the key it reads from
|
||||
# config.json, the page the password. Pages from other sites can't set headers on requests here (and never get
|
||||
# a CORS answer), so they can't use the API even from a browser on the Frame itself.
|
||||
fail_lock = threading.Lock()
|
||||
|
||||
def host_ok(self):
|
||||
"""Refuses requests addressed to a name that isn't this machine's (DNS rebinding) and requests a page
|
||||
from another site sent."""
|
||||
host = (self.headers.get("Host") or "").strip().lower()
|
||||
name = urllib.parse.urlsplit("//" + host).hostname or ""
|
||||
try:
|
||||
ipaddress.ip_address(name)
|
||||
ok = True
|
||||
except ValueError:
|
||||
ok = name == "localhost" or name.endswith(".local") or ("." not in name and bool(name)) \
|
||||
or name == socket.gethostname().lower() \
|
||||
or name in {h.lower() for h in config().get("allowed_hosts") or []}
|
||||
origin = self.headers.get("Origin")
|
||||
if ok and origin is not None and urllib.parse.urlsplit(origin).netloc.lower() != host:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def who(self):
|
||||
""""app" for the headset app, "web" for the page with the right password, otherwise None."""
|
||||
cfg = config()
|
||||
|
||||
def same(given, want):
|
||||
return bool(given and isinstance(want, str) and want) and hmac.compare_digest(given.encode(), want.encode())
|
||||
if same(self.headers.get("X-Frametanium-Key"), cfg.get("app_key")):
|
||||
return "app"
|
||||
if same(self.headers.get("X-Key"), cfg.get("password")):
|
||||
return "web"
|
||||
return None
|
||||
|
||||
def check(self, fmt):
|
||||
"""Whether the request may go on; answers it if not."""
|
||||
if not self.host_ok():
|
||||
self.reply({"error": "Open Frametanium by the Frame's address (its IP or name.local)."}, code=403)
|
||||
return None
|
||||
role = self.who()
|
||||
if role is None:
|
||||
with Handler.fail_lock: # one wrong guess a second
|
||||
time.sleep(1)
|
||||
# Headset apps from before the key show this "#" line as a notice.
|
||||
msg = "Frametanium was updated: close this app and open it again."
|
||||
self.reply(f"#\terror\t{msg}\n!\t{msg}\n" if fmt == "tsv" else {"error": "password"},
|
||||
"text/plain" if fmt == "tsv" else "application/json", code=401)
|
||||
return role
|
||||
|
||||
def do_GET(self):
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = urllib.parse.parse_qs(url.query)
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
if url.path == "/":
|
||||
if not self.host_ok():
|
||||
self.reply("Open Frametanium by the Frame's address (its IP or name.local).", "text/plain", 403)
|
||||
return
|
||||
self.reply((APP_DIR / "page.html").read_bytes(), "text/html")
|
||||
return
|
||||
if not self.allowed(q):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
role = self.check(fmt)
|
||||
if role is None:
|
||||
return
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
arg = lambda k: q.get(k, [""])[0]
|
||||
if url.path == "/api/state":
|
||||
rows = app_state()
|
||||
notice = jobs.state.get("_list", {})
|
||||
if fmt == "tsv":
|
||||
head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else ""
|
||||
if role == "app": # the headset app shows how to open the page in a browser
|
||||
cfg = config()
|
||||
head += f"@\thttp://{lan_address()}:{cfg['port']}\t{cfg['password']}\n"
|
||||
self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
|
||||
"can_rollback", "repo", "mode", "prereleases", "builtin", "job"]),
|
||||
"text/plain")
|
||||
@@ -1293,10 +1406,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
url = urllib.parse.urlparse(self.path)
|
||||
q = urllib.parse.parse_qs(url.query)
|
||||
if not self.allowed(q):
|
||||
self.reply({"error": "password"}, code=401)
|
||||
return
|
||||
fmt = q.get("fmt", ["json"])[0]
|
||||
if self.check(fmt) is None:
|
||||
return
|
||||
arg = lambda k: q.get(k, [""])[0]
|
||||
pid = arg("id")
|
||||
if url.path == "/api/refresh":
|
||||
@@ -1361,10 +1473,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif url.path == "/api/token":
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
token = self.rfile.read(min(length, 4096)).decode().strip()
|
||||
cfg = load_json(CONFIG_FILE, {})
|
||||
cfg["github_token"] = token
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
os.chmod(CONFIG_FILE, 0o600)
|
||||
with files_lock:
|
||||
cfg = load_json(CONFIG_FILE, {})
|
||||
cfg["github_token"] = token
|
||||
save_json(CONFIG_FILE, cfg, private=True)
|
||||
_releases_cache.clear()
|
||||
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
|
||||
self.reply({"ok": True})
|
||||
@@ -1387,10 +1499,8 @@ def checker():
|
||||
|
||||
|
||||
def main():
|
||||
ensure_secrets()
|
||||
cfg = config()
|
||||
if not CONFIG_FILE.exists():
|
||||
save_json(CONFIG_FILE, cfg)
|
||||
os.chmod(CONFIG_FILE, 0o600)
|
||||
threading.Thread(target=checker, daemon=True).start()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
|
||||
server.daemon_threads = True
|
||||
|
||||
@@ -99,19 +99,28 @@ const hdr = () => key ? { "X-Key": key } : {};
|
||||
const releases = {};
|
||||
const open = {}; // app ids whose Edit section is open, kept across redraws
|
||||
|
||||
async function get(path) {
|
||||
const r = await fetch(path, { headers: hdr() });
|
||||
if (r.status === 401) {
|
||||
key = prompt("Frametanium password") || "";
|
||||
try { localStorage.setItem("pmKey", key); } catch (e) {}
|
||||
return get(path);
|
||||
// Every request carries the password (the Frametanium app in the headset shows it under its list).
|
||||
async function call(path, opts = {}) {
|
||||
const used = key;
|
||||
const r = await fetch(path, { ...opts, headers: hdr() });
|
||||
if (r.status === 403) {
|
||||
$("notice").hidden = false;
|
||||
$("notice").textContent = (await r.json()).error;
|
||||
throw new Error("refused");
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
async function post(path, body) {
|
||||
const r = await fetch(path, { method: "POST", headers: hdr(), body });
|
||||
return r.json();
|
||||
if (r.status !== 401) return r.json();
|
||||
if (key === used) { // ask once, not once per request that was already on its way
|
||||
const typed = prompt((key ? "That password didn't work. " : "") +
|
||||
"Frametanium password (it's under the list in the Frametanium app in the headset):");
|
||||
if (typed === null) throw new Error("No password");
|
||||
key = typed.trim();
|
||||
try { localStorage.setItem("pmKey", key); } catch (e) {}
|
||||
}
|
||||
return call(path, opts);
|
||||
}
|
||||
const get = path => call(path);
|
||||
const post = (path, body) => call(path, { method: "POST", body });
|
||||
|
||||
function el(tag, props = {}, ...kids) {
|
||||
const e = Object.assign(document.createElement(tag), props);
|
||||
e.append(...kids);
|
||||
|
||||
Reference in new issue
Block a user