From 141537fa9aa0768dd517cbb804bf46b5eec4c8fe Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 2 Jul 2026 17:23:38 -0700 Subject: [PATCH 1/4] Bind publish as a boolean in create_extn The publish column is boolean (per the schema supersat posted in #2), and asyncpg rejects a str parameter for a boolean column, so inserting the string 't'/'f' fails with a DataError. Pass a real bool instead. --- steakweb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/steakweb.py b/steakweb.py index 6b43db0..d80f0b4 100644 --- a/steakweb.py +++ b/steakweb.py @@ -156,7 +156,7 @@ async def create_extn(request): await init_db_pool() name = data.get('name', '') - publish = 't' if data.get('publish') else 'f' + publish = bool(data.get('publish')) if data['type'] == 'sip': switch = 11 authcode = gen_sip_pw() From 979ddfb019cc97e518f6c8131734c55bee95dea6 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 2 Jul 2026 17:23:52 -0700 Subject: [PATCH 2/4] Read the published checkbox by its actual field name in publish_extn The homepage row form posts the checkbox as 'published', but the non-admin path read 'publish' with a default of '1', so a regular user could never unpublish a line. Use the same key and default as the admin path. --- steakweb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/steakweb.py b/steakweb.py index d80f0b4..6988dbf 100644 --- a/steakweb.py +++ b/steakweb.py @@ -187,7 +187,7 @@ async def publish_extn(request): if check_auth_isadmin(session): n = await dbconn.execute("UPDATE registered_extensions SET publish = $2 WHERE extn = $1", int(data['extn']), data.get('published', '0')== '1') else: - n = await dbconn.execute("UPDATE registered_extensions SET publish = $2 WHERE extn = $1 AND userid = $3", int(data['extn']), data.get('publish', '1') == '1', int(session['uid'])) + n = await dbconn.execute("UPDATE registered_extensions SET publish = $2 WHERE extn = $1 AND userid = $3", int(data['extn']), data.get('published', '0') == '1', int(session['uid'])) if n != 'UPDATE 1': session['error'] = 'Could not change directory name; contact support' From 8c294311adc949bee930dd36cd5bb9b743107e64 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 25 Jun 2026 15:01:12 -0700 Subject: [PATCH 3/4] Add a local dev environment Run steakweb against a throwaway Postgres with no SAML IdP, to test the activation and directory changes locally. dev/ has the docker-compose, a reconstructed schema (none exists in the repo), seed data, a local config, and a README. steakweb.py gets a STEAKWEB_DEV-gated dev login and TCP bind that do nothing in prod. Adds a .gitignore. --- .gitignore | 5 ++++ dev/README.md | 64 ++++++++++++++++++++++++++++++++++++++++ dev/config.dev.json | 28 ++++++++++++++++++ dev/docker-compose.yml | 37 +++++++++++++++++++++++ dev/schema.sql | 61 ++++++++++++++++++++++++++++++++++++++ dev/seed.sql | 35 ++++++++++++++++++++++ steakweb.py | 67 +++++++++++++++++++++++++++++++++++------- 7 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 .gitignore create mode 100644 dev/README.md create mode 100644 dev/config.dev.json create mode 100644 dev/docker-compose.yml create mode 100644 dev/schema.sql create mode 100644 dev/seed.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9a530c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.venv/ +__pycache__/ +*.pyc +/config.json +*.sock diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..2a9e0d6 --- /dev/null +++ b/dev/README.md @@ -0,0 +1,64 @@ +# Local dev setup + +Runs steakweb on your machine against a throwaway Postgres, no prod DB or SAML +IdP needed. Enough to click through the activation form and the directory. + +There's no schema in the repo, so `dev/schema.sql` is reconstructed from the SQL +in `steakweb.py`. It's close enough that everything works, but if the real +schema turns up, drop it in and recreate the DB (`down -v` then `up`). + +Everything dev-only is gated on `STEAKWEB_DEV`, so it's off in prod. + +## Run it + +1. Start Postgres (loads schema + seed on first boot, listens on `127.0.0.1:5433`): + + docker compose -f dev/docker-compose.yml up -d + +2. Python deps: + + python3 -m venv .venv + .venv/bin/pip install -r requirements.txt + + `python3-saml` may need system libs to build (Debian/Ubuntu: + `sudo apt install pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl`). + It's imported but unused in dev. + +3. Config (`config.json` is gitignored): + + cp dev/config.dev.json config.json + +4. Run: + + STEAKWEB_DEV=1 .venv/bin/python steakweb.py + + Serves on `http://127.0.0.1:8080`. If 8080's taken, set `STEAKWEB_DEV_PORT=8099`. + +In prod (`STEAKWEB_DEV` unset) it serves over the unix socket like before. The +TCP port, `/dev/login`, and the non-secure cookie are dev-only. + +## What to check + +Swap `8080` for your port below. + +Directory, no login: + +- `/directory` — the 6 published rows, with the unpublished "Hidden Back Office" + (4040) hidden and the ` & "Bobby"', 1010, '000000000005', 't', NULL, 't'), -- HTML metachars + (6999, 'Edge Of Range', 1010, '000000000006', 't', NULL, 't'), + (8888, 'Legacy Trunk (out of range)', 1010, '000000000007', 't', NULL, 't'); -- out-of-range legacy diff --git a/steakweb.py b/steakweb.py index 6988dbf..0a350c7 100644 --- a/steakweb.py +++ b/steakweb.py @@ -51,7 +51,32 @@ def check_auth_isadmin(session): def gen_sip_pw(): return secrets.token_urlsafe(8) - + +### DEV ONLY ------------------------------------------------------------------ +# Everything guarded by STEAKWEB_DEV is completely inert in production: when the +# env var is unset, DEV_MODE is False, the dev route is never registered, and +# none of this code path runs. It exists only so a developer can exercise the +# authenticated flows (homepage, create_extn, ...) locally without a real +# Authentik/SAML IdP. See dev/README.md. +DEV_MODE = bool(os.environ.get('STEAKWEB_DEV')) + +async def dev_login(request): + # DEV ONLY: forge a logged-in session (no IdP). Mirrors the shape that + # saml_acs() produces: uid, attributes (with a goauthentik username), iat. + # Append ?admin=1 to also join the "Extension Admins" group. + session = await aiohttp_session.new_session(request) + session['uid'] = os.environ.get('STEAKWEB_DEV_UID', '1010') + username = os.environ.get('STEAKWEB_DEV_USER', 'devuser') + attributes = { + 'http://schemas.goauthentik.io/2021/02/saml/username': [username], + } + if 'admin' in request.query: + attributes['http://schemas.xmlsoap.org/claims/Group'] = ['Extension Admins'] + session['attributes'] = attributes + session['iat'] = time.time() + raise web.HTTPFound('/') +### END DEV ONLY -------------------------------------------------------------- + ### get request handlers async def homepage(request): @@ -256,7 +281,10 @@ async def init_saml_settings(): aiohttp_session.setup(app, EncryptedCookieStorage( cookiekey, cookie_name='session', - secure=True, + # DEV ONLY: a Secure cookie is dropped by browsers over plain http, + # which would break /dev/login over http://localhost. In production + # (DEV_MODE False) this stays True, exactly as before. + secure=not DEV_MODE, samesite='strict' )) @@ -277,15 +305,32 @@ async def init_saml_settings(): app.add_routes([web.static('/static', os.path.join(os.getcwd(), 'static'))]) - asyncio.run(init_saml_settings()) + if DEV_MODE: + # DEV ONLY: forge-a-session login so authenticated flows work without an IdP. + app.add_routes([web.get('/dev/login', dev_login)]) - try: - os.unlink(socketpath) - except: - pass - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.bind(socketpath) - os.chmod(socketpath, 0o666) - web.run_app(app, sock=sock) + if DEV_MODE: + # DEV ONLY: the production init hits the remote IdP metadata endpoint + # (identity.shady.tel); skip it since SAML is unused in dev. + print('STEAKWEB_DEV set: skipping SAML IdP metadata fetch') + else: + asyncio.run(init_saml_settings()) + + if DEV_MODE: + # DEV ONLY: bind a TCP port so a browser/curl can reach the app directly + # (production serves over the unix socket in the else branch below). + host = os.environ.get('STEAKWEB_DEV_HOST', '127.0.0.1') + port = int(os.environ.get('STEAKWEB_DEV_PORT', '8080')) + print(f'STEAKWEB_DEV set: serving on http://{host}:{port}') + web.run_app(app, host=host, port=port) + else: + try: + os.unlink(socketpath) + except: + pass + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.bind(socketpath) + os.chmod(socketpath, 0o666) + web.run_app(app, sock=sock) # vim: set ts=4 sw=4 expendtab From ffcc47c37c3e03343ff5d564b816fd471eb36c08 Mon Sep 17 00:00:00 2001 From: Sparky Fen Date: Thu, 2 Jul 2026 17:25:11 -0700 Subject: [PATCH 4/4] Use the production schema from supersat in the dev database Replaces the schema reconstructed from steakweb.py's queries with the definitive one posted in PR #2 (boolean publish/provisioned, the extra extension columns, and the switches table), and reseeds to match. Adds a primary key on extn, which the posted schema omits but create_extn's "already taken" handling depends on. --- dev/README.md | 7 ++-- dev/schema.sql | 110 ++++++++++++++++++++++++------------------------- dev/seed.sql | 19 +++++---- 3 files changed, 69 insertions(+), 67 deletions(-) diff --git a/dev/README.md b/dev/README.md index 2a9e0d6..6eab5a5 100644 --- a/dev/README.md +++ b/dev/README.md @@ -3,9 +3,8 @@ Runs steakweb on your machine against a throwaway Postgres, no prod DB or SAML IdP needed. Enough to click through the activation form and the directory. -There's no schema in the repo, so `dev/schema.sql` is reconstructed from the SQL -in `steakweb.py`. It's close enough that everything works, but if the real -schema turns up, drop it in and recreate the DB (`down -v` then `up`). +`dev/schema.sql` is the production schema (posted by supersat in PR #2), plus a +primary key on `extn` that create_extn's "already taken" handling relies on. Everything dev-only is gated on `STEAKWEB_DEV`, so it's off in prod. @@ -54,7 +53,7 @@ Activation, needs a session: - In the Add a New Extension form: - letters (`ABCD`) → "Extension must be a four-digit number", no 500 - out of range (`1999`, `7000`) → "Extension number must start with 2, 3, 4, 5, or 6" - - a taken number (`2345`) → "That extension is already taken; please choose another" + - a taken number (`2345`) → "Extension 2345 is already taken; please choose another" - a free number 2000–6999 → created, shows up in the list ## Done testing diff --git a/dev/schema.sql b/dev/schema.sql index da83140..f4b9d4e 100644 --- a/dev/schema.sql +++ b/dev/schema.sql @@ -1,61 +1,59 @@ -- ===================================================================== --- dev/schema.sql -- BEST-GUESS RECONSTRUCTION (DEV ONLY) +-- dev/schema.sql -- production schema (DEV ONLY copy) -- ===================================================================== --- There is NO authoritative schema file in this repo. This table was --- reconstructed by reading every SQL statement in steakweb.py and --- inferring column types from how the code reads/binds each column. --- --- Columns observed in steakweb.py and the reasoning behind each type: --- --- extn integer, the unique identifier for a row. create_extn --- relies on asyncpg.UniqueViolationError when re-inserting --- an existing extn, and every handler uses `WHERE extn = $1` --- as the row key -> modeled as INTEGER PRIMARY KEY. --- --- name free text the customer chooses -> TEXT. --- --- userid integer taken from the SAML session (`int(session['uid'])`) --- and compared with `WHERE userid = $1` -> INTEGER. --- --- auth_code a generated string (12 random digits, or a 24-char SIP --- password). Stored/read as a string -> TEXT. --- --- publish The code BINDS and COMPARES this as the Python *strings* --- 't' / 'f' (e.g. create_extn does --- `publish = 't' if data.get('publish') else 'f'` and then --- passes that string as a query parameter; directory() does --- `WHERE publish = 't'`; publish_extn sets `publish = 't'`). --- A real BOOLEAN column would make asyncpg REJECT the string --- parameter 't'/'f' (asyncpg requires a Python bool for a --- bool column), so this must be a 1-char text type that --- literally stores the bytes 't'/'f'. -> CHAR(1). --- --- switch integer, nullable. Code uses `switch IS NULL`, --- `switch IS NOT NULL`, and `switch = 11` -> INTEGER NULL. --- --- provisioned Same 't'/'f' string treatment as publish --- (homepage admin query: `WHERE provisioned = 't'`) -> CHAR(1). --- --- ASSUMPTIONS / NOTES: --- * CHAR(1) (bpchar) is used instead of the internal "char" type because --- asyncpg encodes/decodes bpchar as a Python str cleanly, which is --- exactly what the code binds. ("char" with quotes would also store a --- single byte but asyncpg's handling of it is less obvious; bpchar is --- the safe, well-defined choice that satisfies the 't'/'f' contract.) --- * Defaults below are guesses chosen so the app behaves sensibly in dev; --- production may differ. create_extn never sets `switch` or `provisioned`, --- so those need server-side defaults (switch -> NULL = TDM/unprovisioned, --- provisioned -> 'f'). --- * No foreign keys / extra indexes are reconstructed because the code --- does not depend on any. +-- Definitive schema provided by supersat in PR #2. Loaded into the +-- throwaway dev Postgres on first boot; see dev/README.md. -- ===================================================================== -CREATE TABLE registered_extensions ( - extn integer PRIMARY KEY, - name text NOT NULL DEFAULT '', - userid integer, - auth_code text, - publish char(1) NOT NULL DEFAULT 'f', -- stores 't' / 'f' (NOT boolean) - switch integer, -- nullable; 11 == SIP - provisioned char(1) NOT NULL DEFAULT 'f' -- stores 't' / 'f' (NOT boolean) +CREATE TYPE public.switch_address_type AS ENUM ( + 'AAR', + 'ENP', + 'sipv4', + 'sipv6', + 'other' ); + +CREATE TYPE public.switch_type AS ENUM ( + 'tdm', + 'isr', + 'sip' +); + +CREATE TABLE public.registered_extensions ( + extn integer NOT NULL, + name text, + userid integer, + switch integer, + port text, + auth_code text, + reservation_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + last_updated timestamp without time zone, + expires_at timestamp without time zone, + publish boolean DEFAULT true NOT NULL, + provisioned boolean DEFAULT true NOT NULL +); + +CREATE TABLE public.switches ( + id integer NOT NULL, + type public.switch_type NOT NULL, + address text, + address_type public.switch_address_type NOT NULL, + name text, + description text, + sip_gateway text +); + +CREATE SEQUENCE public.switches_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER TABLE ONLY public.switches ALTER COLUMN id SET DEFAULT nextval('public.switches_id_seq'::regclass); + +-- Not in the schema supersat posted, but create_extn relies on a unique +-- violation on extn to report "already taken", so dev needs the constraint. +ALTER TABLE ONLY public.registered_extensions + ADD CONSTRAINT registered_extensions_pkey PRIMARY KEY (extn); diff --git a/dev/seed.sql b/dev/seed.sql index b2c625a..ccedb58 100644 --- a/dev/seed.sql +++ b/dev/seed.sql @@ -25,11 +25,16 @@ -- >>> exists, so you should get the friendly "already taken" message, NOT a 500. -- ===================================================================== +-- switch 11 is the SIP switch (steakweb.py hardcodes switch = 11 for SIP) +INSERT INTO switches (id, type, address_type, name) VALUES + (11, 'sip', 'sipv4', 'Dev SIP switch'); +SELECT setval('switches_id_seq', 11); + INSERT INTO registered_extensions (extn, name, userid, auth_code, publish, switch, provisioned) VALUES - (2345, 'Dev Test Line', 1010, '000000000001', 't', NULL, 't'), -- duplicate target - (2600, 'Phreak Hotline', 1010, '000000000002', 't', 11, 't'), -- SIP (switch=11) - (3141, 'Pi Information Line', 1010, '000000000003', 't', NULL, 't'), - (4040, 'Hidden Back Office', 1010, '000000000004', 'f', NULL, 't'), -- UNPUBLISHED -> hidden - (5050, ' & "Bobby"', 1010, '000000000005', 't', NULL, 't'), -- HTML metachars - (6999, 'Edge Of Range', 1010, '000000000006', 't', NULL, 't'), - (8888, 'Legacy Trunk (out of range)', 1010, '000000000007', 't', NULL, 't'); -- out-of-range legacy + (2345, 'Dev Test Line', 1010, '000000000001', true, NULL, true), -- duplicate target + (2600, 'Phreak Hotline', 1010, '000000000002', true, 11, true), -- SIP (switch=11) + (3141, 'Pi Information Line', 1010, '000000000003', true, NULL, true), + (4040, 'Hidden Back Office', 1010, '000000000004', false, NULL, true), -- UNPUBLISHED -> hidden + (5050, ' & "Bobby"', 1010, '000000000005', true, NULL, true), -- HTML metachars + (6999, 'Edge Of Range', 1010, '000000000006', true, NULL, true), + (8888, 'Legacy Trunk (out of range)', 1010, '000000000007', true, NULL, true); -- out-of-range legacy