4 Commits
Author SHA1 Message Date
CogentialandClaude Opus 5.5 80a4e62a49 CLAUDE.md: auth and settings-file rules, and the review fixes still to do
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:08:05 -04:00
CogentialandClaude Opus 5.5 6635ac1342 Require a secret for every API request, loopback included; check Host and Origin
The service makes up a web password and a headset-app key on first start (config.json, mode 600). The headset
app sends the key in X-Frametanium-Key and shows the page's address and password under its list; the page sends
the password in X-Key (no more ?key=). Unknown Host names (DNS rebinding) and cross-site Origins get 403, wrong
secrets wait a second. Headset apps from before this see 'close this app and open it again' after a self-update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:07:54 -04:00
CogentialandClaude Opus 5.5 a3a2450a4d Settings files: unique temp files, fsync, locked read-modify-writes, damaged files kept aside
Two writers no longer share one .tmp file or lose each other's changes, and a truncated catalog.json is
moved aside instead of being replaced by an empty list on the next save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3G4bF9bag9rHEh1Anvngi
2026-10-21 00:05:47 -04:00
CogentandClaude Opus 5.5 fbd86e26fd Catalog: Ocarina of Time 3D (experimental SoH build with a stereoscopic 3D screen)
Same repo as SoH; its releases carry soh-steam-frame-3d-arm64.zip, so
each entry only sees its own builds. Shares SoH's data folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHDaz86aayqMULHsjByEmT
2026-10-11 13:25:23 -04:00
6 changed files with 277 additions and 80 deletions

No files matched your search

+24
View File
@@ -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).
+4 -2
View File
@@ -71,8 +71,10 @@ the bundle (`tools/bundle.sh`).
Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library. Uninstall: `systemctl --user disable --now frametanium.service` and remove Frametanium from the library.
## Notes ## Notes
- The service listens on port 8643 on your network. Set `password` in `~/.config/frametanium/config.json` to require - The service listens on port 8643 on your network, and every request needs a secret: the browser page asks for the
one in the browser (the headset app connects locally and doesn't need it). 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 - Standard-library Python only. The headset app is C + SDL2 with the public-domain
[font8x8](https://github.com/dhepper/font8x8). [font8x8](https://github.com/dhepper/font8x8).
- Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs - Testing on another architecture: `FRAMETANIUM_EXEC_WRAPPER=qemu-aarch64-static` (with `QEMU_LD_PREFIX`) runs
+40 -7
View File
@@ -134,8 +134,12 @@ static void fill(int x, int y, int w, int h, Uint32 rgb) {
/* ---------------------------------------------------------------- service */ /* ---------------------------------------------------------------- service */
static void read_port(void) { /* The service's port and the key it wants from this app, from the service's config.json (readable only by this
char path[512], buf[2048]; * 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") : ""); snprintf(path, sizeof path, "%s/.config/frametanium/config.json", getenv("HOME") ? getenv("HOME") : "");
FILE* f = fopen(path, "r"); FILE* f = fopen(path, "r");
if (!f) return; if (!f) return;
@@ -147,10 +151,16 @@ static void read_port(void) {
int v = atoi(p + 1); int v = atoi(p + 1);
if (v > 0 && v < 65536) service_port = v; 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. */ /* Sends a request to the service; returns the body (caller frees) or NULL if it isn't reachable. *status gets the
static char* http(const char* method, const char* path, int timeout_s) { * 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); int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) return NULL; if (s < 0) return NULL;
struct timeval tv = { .tv_sec = timeout_s }; 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; return NULL;
} }
char req[2048]; 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) { if (n >= (int)sizeof req || write(s, req, n) != n) {
close(s); close(s);
return NULL; return NULL;
@@ -181,10 +193,23 @@ static char* http(const char* method, const char* path, int timeout_s) {
free(buf); free(buf);
return NULL; return NULL;
} }
*status = 0;
sscanf(buf, "HTTP/%*s %d", status);
memmove(buf, body + 4, strlen(body + 4) + 1); memmove(buf, body + 4, strlen(body + 4) + 1);
return buf; 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. */ /* Percent-encodes s for a query string. */
static void urlenc(char* out, size_t sz, const char* s) { static void urlenc(char* out, size_t sz, const char* s) {
static const char hex[] = "0123456789ABCDEF"; 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). */ /* 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)) #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) { static int load_apps(App* apps, char* notice, size_t nsz, int* notice_busy) {
char* body = http("GET", "/api/state?fmt=tsv", 5); char* body = http("GET", "/api/state?fmt=tsv", 5);
if (!body) return -1; 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"); *notice_busy = !strcmp(f[1], "installing");
continue; 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; if (k < 13 || n >= MAX_APPS) continue;
App* p = &apps[n++]; App* p = &apps[n++];
copy(p->id, sizeof p->id, f[0]); 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; return;
} }
int row_h = 50 * S; 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; if (max_rows < 1) max_rows = 1;
int first = scroll_first(sel, max_rows); int first = scroll_first(sel, max_rows);
for (int i = first; i <= n + 1 && i < first + max_rows; i++) { 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; y += row_h;
} }
legend("A Open Y Add X Discover Select Update all Start Check B Close"); 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); SDL_RenderPresent(ren);
} }
@@ -760,7 +793,7 @@ int main(void) {
if (!ren) return 1; if (!ren) return 1;
SDL_ShowCursor(SDL_DISABLE); SDL_ShowCursor(SDL_DISABLE);
SDL_StopTextInput(); SDL_StopTextInput();
read_port(); read_config();
static App apps[MAX_APPS]; static App apps[MAX_APPS];
static Release rels[MAX_RELEASES]; static Release rels[MAX_RELEASES];
+19
View File
@@ -18,6 +18,25 @@
"mode": "game", "mode": "game",
"icon_url": "https://raw.githubusercontent.com/Cogential/Shipwright-frame/steam-frame/soh/macosx/sohIcon.png" "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", "id": "2ship",
"name": "Majora's Mask (2 Ship 2 Harkinian)", "name": "Majora's Mask (2 Ship 2 Harkinian)",
+170 -60
View File
@@ -13,10 +13,14 @@ Control it from the headset app (app/) or from a browser at http://<frame>:8643.
""" """
import fnmatch import fnmatch
import hmac
import ipaddress
import json import json
import os import os
import re import re
import secrets
import shutil import shutil
import socket
import stat import stat
import struct import struct
import subprocess import subprocess
@@ -46,7 +50,9 @@ STEAM_USERDATA = HOME / ".local/share/Steam/userdata"
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"port": 8643, "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) "github_token": "", # needed only for private repos (read-only access to their releases is enough)
"compat_tool": "SteamLinuxRuntime_4-arm64", "compat_tool": "SteamLinuxRuntime_4-arm64",
"check_hours": 6, "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_.-]+$") 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): def load_json(path, default):
try: try:
with open(path) as f: with open(path) as f:
return json.load(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 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) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp") tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
with open(tmp, "w") as f: 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) json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) os.replace(tmp, path)
def config(): def config():
return {**DEFAULT_CONFIG, **load_json(CONFIG_FILE, {})} 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(): 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 """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.""" {"id": ..., "hidden": true} takes a built-in one off the list."""
@@ -98,13 +154,14 @@ def catalog():
def update_user_entry(pid, **fields): 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.""" """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, []) with files_lock:
entry = next((e for e in entries if e.get("id") == pid), None) entries = load_json(USER_CATALOG, [])
if entry is None: entry = next((e for e in entries if e.get("id") == pid), None)
entry = {"id": pid} if entry is None:
entries.append(entry) entry = {"id": pid}
entry.update(fields) entries.append(entry)
save_json(USER_CATALOG, entries) entry.update(fields)
save_json(USER_CATALOG, entries)
def suggestions(): 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) port = next((p for p in load_json(APP_DIR / "catalog.json", []) if p["repo"].lower() == repo.lower()), None)
if port is None: if port is None:
return None return None
entries = load_json(USER_CATALOG, []) with files_lock:
save_json(USER_CATALOG, [e for e in entries if not (e.get("id") == port["id"] and e.get("hidden"))]) 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"] return port["id"]
@@ -334,14 +392,15 @@ def add_app(repo_text, asset=None, name=None, mode="auto", prereleases=None):
g, n = gameid, 2 g, n = gameid, 2
while g in used: while g in used:
g, n = f"{gameid}_{n}", n + 1 g, n = f"{gameid}_{n}", n + 1
entries = load_json(USER_CATALOG, []) with files_lock:
entries.append({ entries = load_json(USER_CATALOG, [])
"id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True, entries.append({
"mode": mode if mode in ("auto", "game", "installer") else "auto", "id": pid, "name": title, "repo": info["repo"], "asset": asset_glob(asset), "auto_asset": True,
"prereleases": info["prereleases"] if prereleases is None else bool(prereleases), "mode": mode if mode in ("auto", "game", "installer") else "auto",
"gameid": g, "description": info["description"][:300], "added": int(time.time()), "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) })
save_json(USER_CATALOG, entries)
_releases_cache.clear() _releases_cache.clear()
return pid return pid
@@ -506,10 +565,11 @@ def discovered():
def discover_mark(repos, key): def discover_mark(repos, key):
prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []}) with files_lock:
have = {r.lower() for r in prefs[key]} prefs = load_json(DISCOVER_FILE, {"seen": [], "hidden": []})
prefs[key] += [r for r in repos if r and r.lower() not in have] have = {r.lower() for r in prefs[key]}
save_json(DISCOVER_FILE, prefs) 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): def start_discover(force=True):
@@ -1002,10 +1062,11 @@ def install(pid, tag=None):
raise raise
message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "") message = f"Installed {rel['tag']}." + (f" {tail}" if tail else "")
installed = load_json(INSTALLED_FILE, {}) with files_lock:
prev = installed.get(pid, {}).get("tag") installed = load_json(INSTALLED_FILE, {})
installed[pid] = {"tag": rel["tag"], "previous": prev, "time": int(time.time()), "mode": mode} prev = installed.get(pid, {}).get("tag")
save_json(INSTALLED_FILE, installed) 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", ""))) 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")): 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": if mode == "installer":
jobs.set(pid, "installing", 1.0, "Running its installer") jobs.set(pid, "installing", 1.0, "Running its installer")
run_installer(final, port) run_installer(final, port)
installed[pid] = {"tag": cur.get("previous"), "previous": cur.get("tag"), "time": int(time.time()), with files_lock:
"mode": mode} installed = load_json(INSTALLED_FILE, {})
save_json(INSTALLED_FILE, installed) 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'}.") jobs.set(pid, "done", 1.0, f"Back to {cur.get('previous') or 'the previous version'}.")
except Exception as e: except Exception as e:
jobs.set(pid, "error", 0.0, str(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." note = " Remove its tile from the library too: Manage > Remove non-Steam game."
shutil.rmtree(final, ignore_errors=True) shutil.rmtree(final, ignore_errors=True)
shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True) shutil.rmtree(final.parent / f".{final.name}.previous", ignore_errors=True)
installed.pop(pid, None) with files_lock:
save_json(INSTALLED_FILE, installed) installed = load_json(INSTALLED_FILE, {})
entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid] installed.pop(pid, None)
if port.get("builtin"): save_json(INSTALLED_FILE, installed)
entries.append({"id": pid, "hidden": True}) entries = [e for e in load_json(USER_CATALOG, []) if e.get("id") != pid]
save_json(USER_CATALOG, entries) if port.get("builtin"):
entries.append({"id": pid, "hidden": True})
save_json(USER_CATALOG, entries)
jobs.state.pop(pid, None) jobs.state.pop(pid, None)
_latest.pop(pid, None) _latest.pop(pid, None)
jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}") jobs.set("_list", "done", 1.0, f"Removed {port['name']}.{note}")
@@ -1210,29 +1275,77 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self.reply({"error": message}, code=code) self.reply({"error": message}, code=code)
def allowed(self, q): # Every request needs a secret in a header, from loopback too: the headset app sends the key it reads from
password = config().get("password") or "" # config.json, the page the password. Pages from other sites can't set headers on requests here (and never get
# The headset app talks to us over loopback and never needs the password. # a CORS answer), so they can't use the API even from a browser on the Frame itself.
if not password or self.client_address[0] == "127.0.0.1": fail_lock = threading.Lock()
return True
return (self.headers.get("X-Key") or q.get("key", [""])[0]) == password 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): def do_GET(self):
url = urllib.parse.urlparse(self.path) url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query) q = urllib.parse.parse_qs(url.query)
fmt = q.get("fmt", ["json"])[0]
if url.path == "/": 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") self.reply((APP_DIR / "page.html").read_bytes(), "text/html")
return return
if not self.allowed(q): role = self.check(fmt)
self.reply({"error": "password"}, code=401) if role is None:
return return
fmt = q.get("fmt", ["json"])[0]
arg = lambda k: q.get(k, [""])[0] arg = lambda k: q.get(k, [""])[0]
if url.path == "/api/state": if url.path == "/api/state":
rows = app_state() rows = app_state()
notice = jobs.state.get("_list", {}) notice = jobs.state.get("_list", {})
if fmt == "tsv": if fmt == "tsv":
head = f"#\t{notice.get('status', '')}\t{notice.get('message', '')}\n" if notice else "" 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", self.reply(head + tsv(rows, ["id", "name", "installed", "latest", "status", "progress", "message",
"can_rollback", "repo", "mode", "prereleases", "builtin", "job"]), "can_rollback", "repo", "mode", "prereleases", "builtin", "job"]),
"text/plain") "text/plain")
@@ -1293,10 +1406,9 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
url = urllib.parse.urlparse(self.path) url = urllib.parse.urlparse(self.path)
q = urllib.parse.parse_qs(url.query) 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] fmt = q.get("fmt", ["json"])[0]
if self.check(fmt) is None:
return
arg = lambda k: q.get(k, [""])[0] arg = lambda k: q.get(k, [""])[0]
pid = arg("id") pid = arg("id")
if url.path == "/api/refresh": if url.path == "/api/refresh":
@@ -1361,10 +1473,10 @@ class Handler(BaseHTTPRequestHandler):
elif url.path == "/api/token": elif url.path == "/api/token":
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
token = self.rfile.read(min(length, 4096)).decode().strip() token = self.rfile.read(min(length, 4096)).decode().strip()
cfg = load_json(CONFIG_FILE, {}) with files_lock:
cfg["github_token"] = token cfg = load_json(CONFIG_FILE, {})
save_json(CONFIG_FILE, cfg) cfg["github_token"] = token
os.chmod(CONFIG_FILE, 0o600) save_json(CONFIG_FILE, cfg, private=True)
_releases_cache.clear() _releases_cache.clear()
threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start() threading.Thread(target=refresh, kwargs={"force": True}, daemon=True).start()
self.reply({"ok": True}) self.reply({"ok": True})
@@ -1387,10 +1499,8 @@ def checker():
def main(): def main():
ensure_secrets()
cfg = config() cfg = config()
if not CONFIG_FILE.exists():
save_json(CONFIG_FILE, cfg)
os.chmod(CONFIG_FILE, 0o600)
threading.Thread(target=checker, daemon=True).start() threading.Thread(target=checker, daemon=True).start()
server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler) server = ThreadingHTTPServer(("0.0.0.0", int(cfg["port"])), Handler)
server.daemon_threads = True server.daemon_threads = True
+20 -11
View File
@@ -99,19 +99,28 @@ const hdr = () => key ? { "X-Key": key } : {};
const releases = {}; const releases = {};
const open = {}; // app ids whose Edit section is open, kept across redraws const open = {}; // app ids whose Edit section is open, kept across redraws
async function get(path) { // Every request carries the password (the Frametanium app in the headset shows it under its list).
const r = await fetch(path, { headers: hdr() }); async function call(path, opts = {}) {
if (r.status === 401) { const used = key;
key = prompt("Frametanium password") || ""; const r = await fetch(path, { ...opts, headers: hdr() });
try { localStorage.setItem("pmKey", key); } catch (e) {} if (r.status === 403) {
return get(path); $("notice").hidden = false;
$("notice").textContent = (await r.json()).error;
throw new Error("refused");
} }
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
async function post(path, body) { const typed = prompt((key ? "That password didn't work. " : "") +
const r = await fetch(path, { method: "POST", headers: hdr(), body }); "Frametanium password (it's under the list in the Frametanium app in the headset):");
return r.json(); 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) { function el(tag, props = {}, ...kids) {
const e = Object.assign(document.createElement(tag), props); const e = Object.assign(document.createElement(tag), props);
e.append(...kids); e.append(...kids);