From: Thomas Walker Lynch Date: Mon, 12 Oct 2026 12:22:13 +0000 (+0000) Subject: RT-Style goes online X-Git-Url: https://git.reasoningtechnology.com/%27%20%20%20full_path%20%20%20%27?a=commitdiff_plain;h=6cb3e1177bf3b3be62235f160614b4cb5bc0e132;p=RT-Style RT-Style goes online --- diff --git a/developer/authored/Manuscript.copy/server/style-server b/developer/authored/Manuscript.copy/server/style-server new file mode 100644 index 0000000..83f1959 --- /dev/null +++ b/developer/authored/Manuscript.copy/server/style-server @@ -0,0 +1,258 @@ +#!/usr/bin/env node +// -*- mode: js; js-indent-level: 2; indent-tabs-mode: nil -*- +/* +style-server + +Interim HTTPS server for the RT-Style library at style.reasoningtechnology.com. +Serves static files from one directory. It is meant to sit behind Cloudflare, +which connects to it with Full (strict) TLS using the Cloudflare origin +certificate. It is to be replaced by a server built on the TM library. + +Usage: style-server ... + + help | -h | --help print this message (also printed when given no arguments) + check load the certificate and key, check the root directory, serve nothing + serve [port] serve over HTTPS on [port], 443 when not given + + directory whose files are served; a request for /a/b.js is answered from /a/b.js + PEM certificate file + PEM private key file + +What it does for each request: + only GET and HEAD are answered; other methods get 405 + the path is decoded and resolved inside ; any path, or symbolic link, + that would lead outside gets 404 + a directory is answered with its index.html, after a redirect that adds a + trailing slash to the address + files are sent with a content type chosen by extension, Cache-Control, + Access-Control-Allow-Origin "*", and Last-Modified; a request carrying + If-Modified-Since for an unchanged file gets 304 and no body + +One log line per request goes to standard output: client address, method, +path, status, bytes. Behind Cloudflare the client address is taken from the +CF-Connecting-IP header. +*/ + +const https = require("https"); +const fs = require("fs"); +const fsp = require("fs/promises"); +const path = require("path"); + +const CACHE_CONTROL = "public, max-age=3600"; + +const CONTENT_TYPE = { + ".html": "text/html; charset=utf-8" + ,".js": "text/javascript; charset=utf-8" + ,".mjs": "text/javascript; charset=utf-8" + ,".css": "text/css; charset=utf-8" + ,".json": "application/json; charset=utf-8" + ,".txt": "text/plain; charset=utf-8" + ,".svg": "image/svg+xml" + ,".png": "image/png" + ,".jpg": "image/jpeg" + ,".jpeg": "image/jpeg" + ,".gif": "image/gif" + ,".ico": "image/x-icon" + ,".woff": "font/woff" + ,".woff2": "font/woff2" + ,".ttf": "font/ttf" + ,".otf": "font/otf" + ,".pdf": "application/pdf" +}; + +// ---------------------------------------------------------------- work + +// Resolves a URL path to a real file path inside root_real, or null. +// root_real is the root directory with its own symbolic links resolved. +async function resolve_inside(root_real ,url_path){ + let decoded; + try{ decoded = decodeURIComponent(url_path); } + catch(e){ return null; } // malformed %-encoding + if(decoded.includes("\0")) return null; + + // Resolve lexically first: "." + "/a/../b" stays under root_real. + const candidate = path.resolve(root_real ,"." + path.posix.normalize("/" + decoded)); + if(!is_inside(root_real ,candidate)) return null; + + // Then follow symbolic links, and check again. + let real; + try{ real = await fsp.realpath(candidate); } + catch(e){ return null; } // does not exist + if(!is_inside(root_real ,real)) return null; + return real; +} + +function is_inside(root ,p){ + return p === root || p.startsWith(root + path.sep); +} + +// Read when the request arrives: by the time a response is logged, the +// connection may already be gone, and its address with it. +function client_address(request){ + return request.headers["cf-connecting-ip"] || request.socket.remoteAddress || "-"; +} + +function log_line(request ,status ,bytes){ + const when = new Date().toISOString(); + console.log(`${when} ${request.rt_client} ${request.method} ${request.url} ${status} ${bytes}`); +} + +function send_status(request ,response ,status ,extra_headers = {}){ + const body = status + "\n"; + response.writeHead(status ,{ + "Content-Type": "text/plain; charset=utf-8" + ,"Content-Length": Buffer.byteLength(body) + ,"Cache-Control": "no-store" + ,...extra_headers + }); + response.end(request.method === "HEAD" ? undefined : body); + log_line(request ,status ,0); +} + +async function handle(root_real ,request ,response){ + request.rt_client = client_address(request); + if(request.method !== "GET" && request.method !== "HEAD"){ + send_status(request ,response ,405 ,{"Allow": "GET, HEAD"}); + return; + } + + // Only the path part of the address is used; any ?query is ignored. + const url_path = (request.url || "/").split("?")[0].split("#")[0]; + let file = await resolve_inside(root_real ,url_path); + if(file === null){ send_status(request ,response ,404); return; } + + let stat = await fsp.stat(file); + if(stat.isDirectory()){ + if(!url_path.endsWith("/")){ + send_status(request ,response ,301 ,{"Location": url_path + "/"}); + return; + } + file = await resolve_inside(root_real ,url_path + "index.html"); + if(file === null){ send_status(request ,response ,404); return; } + stat = await fsp.stat(file); + } + if(!stat.isFile()){ send_status(request ,response ,404); return; } + + const headers = { + "Content-Type": CONTENT_TYPE[path.extname(file).toLowerCase()] || "application/octet-stream" + ,"Cache-Control": CACHE_CONTROL + ,"Access-Control-Allow-Origin": "*" + ,"X-Content-Type-Options": "nosniff" + ,"Last-Modified": stat.mtime.toUTCString() + }; + + // HTTP dates have whole-second precision, so compare whole seconds. + const since = Date.parse(request.headers["if-modified-since"] || ""); + if(!Number.isNaN(since) && Math.floor(stat.mtimeMs / 1000) * 1000 <= since){ + response.writeHead(304 ,headers); + response.end(); + log_line(request ,304 ,0); + return; + } + + headers["Content-Length"] = stat.size; + response.writeHead(200 ,headers); + if(request.method === "HEAD"){ + response.end(); + log_line(request ,200 ,0); + return; + } + // Log once, when the response finishes or the connection closes, whichever + // comes first. A client that has received all Content-Length bytes may hang + // up before the response formally ends, so a response counts as complete + // when the whole file was read and passed on, and as aborted otherwise. + const stream = fs.createReadStream(file); + let logged = false; + const log_once = () => { + if(logged) return; + logged = true; + log_line(request ,200 ,stream.bytesRead === stat.size ? stat.size : `aborted@${stream.bytesRead}`); + }; + response.on("finish" ,log_once); + response.on("close" ,log_once); + stream.on("error" ,() => response.destroy()); + stream.pipe(response); +} + +function load_tls(cert_path ,key_path){ + return { + cert: fs.readFileSync(cert_path) + ,key: fs.readFileSync(key_path) + ,minVersion: "TLSv1.2" + }; +} + +async function check(root ,cert_path ,key_path){ + const root_real = await fsp.realpath(root); + if(!(await fsp.stat(root_real)).isDirectory()) throw new Error(`${root} is not a directory`); + const tls = load_tls(cert_path ,key_path); + // Building a secure context fails if the key does not match the certificate. + require("tls").createSecureContext(tls); + return root_real; +} + +async function serve(root ,cert_path ,key_path ,port){ + const root_real = await check(root ,cert_path ,key_path); + const server = https.createServer(load_tls(cert_path ,key_path) ,(request ,response) => { + handle(root_real ,request ,response).catch(e => { + console.error(`error: ${request.url}: ${e.message}`); + if(!response.headersSent) send_status(request ,response ,500); + else response.destroy(); + }); + }); + server.on("tlsClientError" ,() => {}); // scanners and broken clients; not worth a log line + await new Promise((ok ,fail) => { server.once("error" ,fail); server.listen(port ,ok); }); + console.log(`style-server: serving ${root_real} on port ${port}`); + + const stop = () => { + console.log("style-server: stopping"); + server.close(() => process.exit(0)); + setTimeout(() => process.exit(0) ,5000).unref(); + }; + process.on("SIGTERM" ,stop); + process.on("SIGINT" ,stop); +} + +// ---------------------------------------------------------------- command line + +function print_help(){ + const source = fs.readFileSync(__filename ,"utf8"); + const start = source.indexOf("/*\n") + 3; + console.log(source.slice(start ,source.indexOf("*/" ,start)).trim()); + return 0; +} + +async function CLI(argv){ + if(argv.length === 0 || ["help" ,"-h" ,"--help"].includes(argv[0])) return print_help(); + const [command ,...rest] = argv; + + if(command === "check"){ + if(rest.length !== 3){ console.error("Error: check needs ."); return 1; } + const root_real = await check(...rest); + console.log(`style-server: ok: root ${root_real}, certificate and key load and match`); + return 0; + } + + if(command === "serve"){ + if(rest.length !== 3 && rest.length !== 4){ + console.error("Error: serve needs [port]."); + return 1; + } + const port = rest.length === 4 ? Number(rest[3]) : 443; + if(!Number.isInteger(port) || port < 1 || port > 65535){ + console.error(`Error: bad port '${rest[3]}'.`); + return 1; + } + await serve(rest[0] ,rest[1] ,rest[2] ,port); + return null; // keep running + } + + console.error(`Error: unknown command '${command}'.\n`); + print_help(); + return 1; +} + +CLI(process.argv.slice(2)).then( + code => { if(code !== null) process.exit(code); } + ,e => { console.error(`Error: ${e.message}`); process.exit(1); } +); diff --git a/developer/authored/Manuscript.copy/server/style-server.service b/developer/authored/Manuscript.copy/server/style-server.service new file mode 100644 index 0000000..c944792 --- /dev/null +++ b/developer/authored/Manuscript.copy/server/style-server.service @@ -0,0 +1,39 @@ +# /etc/systemd/system/style-server.service +# +# Runs style-server as an unprivileged, throwaway user, with only the one +# privilege it needs: binding to port 443. + +[Unit] +Description=RT-Style interim HTTPS server (style.reasoningtechnology.com) +After=network-online.target +Wants=network-online.target + +[Service] +# systemd copies these into a private directory that only this service can +# read, named by %d below. The key itself stays root-only in /etc/ssl/private. +LoadCredential=cert:/etc/ssl/certs/style.reasoningtechnology.com.pem +LoadCredential=key:/etc/ssl/private/style.reasoningtechnology.com.key + +ExecStart=/usr/local/bin/style-server serve /srv/style %d/cert %d/key 443 +Restart=on-failure +RestartSec=5 + +# A user made up for this service, existing only while it runs. +DynamicUser=yes +# The one privilege it needs: binding to a port below 1024. +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=yes + +# It reads /srv/style and nothing else of note; it writes nothing. +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/developer/tool/promote b/developer/tool/promote index 56e8cea..7292374 100755 --- a/developer/tool/promote +++ b/developer/tool/promote @@ -18,16 +18,22 @@ sys.path.insert(0, os.path.join(SCRIPT_DIR, "build_component")) import sync_engine HELP = """usage: promote {write|clean|ls|diff|help|dry write} - write Writes promoted files from scratchpad into consumer. Only updates newer files. + write Copies what is staged in scratchpad/made into consumer. Only updates newer files. clean Remove all contents of the consumer directory (preserves .gitignore). ls List consumer as an indented tree: PERMS OWNER NAME. - diff List missing, orphaned, or out of sync files between scratchpad and consumer. + diff List missing, orphaned, or out of sync files between scratchpad/made and consumer. help Show this message. dry write Preview what write would do without modifying the filesystem. + +Promoted files are read-only: 400, or 500 when executable. Directories below +consumer/ are 500, so not even the owner can add, replace or remove a file in +them; write and clean open them (700) for the duration and lock them again. +consumer/ itself stays 700, as it holds the .gitignore git manages. """ SETUP_MUST_BE = "developer/tool/setup" DEFAULT_DIR_MODE = 0o700 +LOCKED_DIR_MODE = 0o500 def exit_with_status(msg, code=1): print(f"promote: {msg}", file=sys.stderr) @@ -38,7 +44,7 @@ def assert_setup(): if setup_val != SETUP_MUST_BE: hint = ( "SETUP is not 'developer/tool/setup'.\n" - "Enter the project with: . setup developer\n" + "Enter the developer role with: ./setup developer\n" "That script exports: ROLE=developer; SETUP=$ROLE/tool/setup" ) exit_with_status(f"bad environment: SETUP='{setup_val}'. {hint}") @@ -65,6 +71,18 @@ def ensure_dir(path_str, mode=DEFAULT_DIR_MODE, dry=False): os.makedirs(path_str, exist_ok=True) ensure_mode(path_str, mode) +def consumer_subdirs(root_dp): + """Every directory below root_dp; root_dp itself is not included.""" + found = [] + for TM_root, TM_dirs, TM_files in os.walk(root_dp): + for TM_d in TM_dirs: + found.append(os.path.join(TM_root, TM_d)) + return found + +def set_subdir_modes(root_dp, mode): + for TM_d in consumer_subdirs(root_dp): + ensure_mode(TM_d, mode) + def filemode(m): try: return stat.filemode(m) except Exception: return oct(m & 0o777) @@ -123,20 +141,26 @@ def list_tree(root_dp): def cmd_write(dry=False): dst_root = cpath() ensure_dir(dst_root, DEFAULT_DIR_MODE, dry=dry) - src_root = dpath("scratchpad") - - sync_engine.execute_sync( - src_root, - dst_root, - prefix="promote", - dry=dry, - apply_modes=True, - ignore_list=[".gitignore"] - ) + src_root = dpath("scratchpad", "made") + + # Files are replaced by writing a temporary file beside them and renaming + # it into place, which needs write permission on the directory. + if not dry: set_subdir_modes(dst_root, DEFAULT_DIR_MODE) + try: + sync_engine.execute_sync( + src_root, + dst_root, + prefix="promote", + dry=dry, + apply_modes=True, + ignore_list=[".gitignore"] + ) + finally: + if not dry: set_subdir_modes(dst_root, LOCKED_DIR_MODE) def cmd_diff(): dst_root = cpath() - src_root = dpath("scratchpad") + src_root = dpath("scratchpad", "made") src_files = set() if os.path.isdir(src_root): @@ -166,7 +190,7 @@ def cmd_diff(): print(f"Pending promotion (missing in consumer): {TM_rel}") found_diff = True elif TM_rel not in src_files: - print(f"Orphaned in consumer (missing in scratchpad): {TM_rel}") + print(f"Orphaned in consumer (missing in scratchpad/made): {TM_rel}") found_diff = True else: src_abs = os.path.join(src_root, TM_rel) @@ -177,18 +201,20 @@ def cmd_diff(): dst_mtime = os.stat(dst_abs).st_mtime if src_mtime > dst_mtime: - print(f"Pending update (contents differ, newer in scratchpad): {TM_rel}") + print(f"Pending update (contents differ, newer in scratchpad/made): {TM_rel}") else: print(f"Contents differ (locally modified in consumer): {TM_rel}") found_diff = True if not found_diff: - print("No differences found. Consumer matches developer scratchpad.") + print("No differences found. Consumer matches developer scratchpad/made.") def cmd_clean(): consumer_root_dir = cpath() if not os.path.isdir(consumer_root_dir): return + # Removing an entry needs write permission on the directory holding it. + set_subdir_modes(consumer_root_dir, DEFAULT_DIR_MODE) for TM_name in os.listdir(consumer_root_dir): if TM_name == ".gitignore": continue