Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.venv/
__pycache__/
*.pyc
/config.json
*.sock
63 changes: 63 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# 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.

`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.

## 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 `<script>` name (5050) shown as plain text, not run.
- `/api/directory.json` — same rows as JSON, just name + number.

Activation, needs a session:

- Hit `/dev/login` to fake a login (`?admin=1` to also be an admin). That drops
you on the My Extensions page.
- 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`) → "Extension 2345 is already taken; please choose another"
- a free number 2000–6999 → created, shows up in the list

## Done testing

docker compose -f dev/docker-compose.yml down # stop, keep data
docker compose -f dev/docker-compose.yml down -v # stop and wipe (re-seeds next up)
rm -f config.json
28 changes: 28 additions & 0 deletions dev/config.dev.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"saml_settings": {
"strict": false,
"debug": true,
"sp": {
"entityId": "http://localhost:8080/saml/service-provider-metadata",
"assertionConsumerService": {
"url": "http://localhost:8080/saml/acs",
"binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
},
"NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
},
"security": {
"wantMessagesSigned": false,
"wantAssertionsSigned": false
}
},
"saml_req_data": {
"https": "off",
"http_host": "localhost",
"server_port": "8080",
"script_name": "/"
},
"socket_path": "/tmp/steakweb-dev.sock",
"dbconnstr": "postgresql://steak:steak@127.0.0.1:5433/steak",
"cookiekey": "ARatgnovUSdf-o6HBauRk7w1zAhtWDDHJsDC3CwzRyA=",
"idp_metadata": "https://identity.invalid.example/application/saml/steak/metadata/"
}
37 changes: 37 additions & 0 deletions dev/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# dev/docker-compose.yml -- Local Postgres for steakweb dev/testing (DEV ONLY)
#
# Brings up a throwaway Postgres with the reconstructed schema + seed data
# applied automatically on first boot. Exposed on host port 5433 (NOT the
# default 5432) so it won't clash with any Postgres you already run.
#
# Start: docker compose -f dev/docker-compose.yml up -d
# Stop: docker compose -f dev/docker-compose.yml down
# Reset: docker compose -f dev/docker-compose.yml down -v (drops the volume,
# so schema.sql + seed.sql are re-applied on the next up)
#
# The init scripts in /docker-entrypoint-initdb.d are only run when the data
# directory is empty (i.e. first boot / after `down -v`).

services:
db:
image: postgres:16-alpine
container_name: steakweb-dev-db
environment:
POSTGRES_USER: steak
POSTGRES_PASSWORD: steak
POSTGRES_DB: steak
ports:
- "127.0.0.1:5433:5432"
volumes:
- steakweb-dev-pgdata:/var/lib/postgresql/data
# Applied in filename order: 01 schema, then 02 seed.
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./seed.sql:/docker-entrypoint-initdb.d/02-seed.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U steak -d steak"]
interval: 3s
timeout: 3s
retries: 20

volumes:
steakweb-dev-pgdata:
59 changes: 59 additions & 0 deletions dev/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- =====================================================================
-- dev/schema.sql -- production schema (DEV ONLY copy)
-- =====================================================================
-- Definitive schema provided by supersat in PR #2. Loaded into the
-- throwaway dev Postgres on first boot; see dev/README.md.
-- =====================================================================

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);
40 changes: 40 additions & 0 deletions dev/seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- =====================================================================
-- dev/seed.sql -- Sample data for manual testing (DEV ONLY)
-- =====================================================================
-- All rows are owned by the dev user. The /dev/login route (gated behind
-- STEAKWEB_DEV) logs you in as this same userid, so these rows show up on
-- the homepage "My Extensions" list as well as (when published) on the
-- public /directory page.
--
-- DEV USERID = 1010 (matches the /dev/login dev session uid)
--
-- What each row is here to prove:
-- * Several PUBLISHED rows across prefixes 2/3/5/6 -> appear on /directory
-- * One UNPUBLISHED row (4040) -> hidden from /directory
-- * One name with HTML metacharacters (5050) -> proves HTML escaping
-- * One out-of-range "legacy" number (8888) -> lists fine, but the
-- create form rejects
-- new 8xxx numbers
-- * A documented DUPLICATE target (2345) -> re-create it via the
-- Add Extension form to
-- hit the "already taken"
-- path (UniqueViolation)
--
-- >>> To reproduce the "already taken" error: log in via /dev/login, then in
-- >>> the Add Extension form submit extension 2345 (any name). It already
-- >>> 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', 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, '<script>alert(''xss'')</script> & "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
71 changes: 58 additions & 13 deletions steakweb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -156,7 +181,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()
Expand Down Expand Up @@ -187,7 +212,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'
Expand Down Expand Up @@ -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'
))

Expand All @@ -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