Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ it (and the operator map, and the marketplace UI) without installing anything: t
evidence site at **[wikithoughts.github.io/passiveworkers](https://wikithoughts.github.io/passiveworkers/)**,
or the screenshots in [docs/preview/](docs/preview/).
The desk has no auth and only answers loopback names; to reach it from a LAN IP or Tailscale name, set e.g. `PW_SERVE_HOST=0.0.0.0 PW_SERVE_ALLOWED_HOSTS=desk.tailnet.ts.net,192.168.1.5` on a network you trust (a TLS proxy in front must pass the Host and `X-Forwarded-Proto` through, or POSTs get 403).
If that proxy runs on another machine, also set `FORWARDED_ALLOW_IPS=<proxy IP>` (uvicorn only trusts `X-Forwarded-Proto` from loopback by default), or every POST gets 403 "forbidden origin".

## What leaves your machine

Expand Down
11 changes: 10 additions & 1 deletion passiveworkers/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
import pathlib
import re
import sys
import threading
import uuid

Expand All @@ -33,6 +34,7 @@
_sem = threading.BoundedSemaphore(_MAX_JOBS) # cap concurrency so two heavy runs don't thrash Ollama
_JOB_HISTORY = 50 # cap the in-memory job map so a long-running desk doesn't leak
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_warned_hosts: set = set() # ignored PW_SERVE_ALLOWED_HOSTS entries, each warned about once


def _allowed_hosts() -> frozenset:
Expand All @@ -44,9 +46,15 @@ def _allowed_hosts() -> frozenset:
if host and host not in ("0.0.0.0", "::"):
extra.add(host)
for entry in os.environ.get("PW_SERVE_ALLOWED_HOSTS", "").split(","):
parsed = _split_host(entry.strip().lower())
entry = entry.strip().lower()
parsed = _split_host(entry)
if parsed and parsed[0] not in ("0.0.0.0", "::"):
extra.add(parsed[0])
elif entry and entry not in _warned_hosts:
# fails closed (a 403 "forbidden host"), so say why once instead of leaving a silent typo
_warned_hosts.add(entry)
print(f" warning: PW_SERVE_ALLOWED_HOSTS entry {entry!r} ignored: use a plain hostname or "
"IP (IPv6 in [brackets]); wildcards are not allowed", file=sys.stderr, flush=True)
return _LOOPBACK_HOSTS | extra


Expand Down Expand Up @@ -317,6 +325,7 @@ def main() -> None:
import uvicorn
host = os.environ.get("PW_SERVE_HOST", "127.0.0.1") # 0.0.0.0 only inside containers
port = int(os.environ.get("PW_SERVE_PORT", "8770") or 8770)
_allowed_hosts() # warn about bad PW_SERVE_ALLOWED_HOSTS entries at startup, not on the first request
print(f"🔬 Research desk → http://{host}:{port} (Ctrl-C to stop)", flush=True)
uvicorn.run(app, host=host, port=port, log_level="warning")

Expand Down
13 changes: 13 additions & 0 deletions tests/test_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,16 @@ def test_history_items_use_data_name_not_inline_onclick():
html = serve.SERVE_HTML
assert "onclick=\"openReport" not in html
assert 'data-name="' in html and "dataset.name" in html


def test_ignored_allowed_hosts_entries_warn_once_each(client, monkeypatch, capsys):
# a typo in the override fails closed (403 "forbidden host"); the operator gets told why, once
monkeypatch.setattr(serve, "_warned_hosts", set())
monkeypatch.setenv("PW_SERVE_ALLOWED_HOSTS", "desk.ts.net, fd7a::1, *, 0.0.0.0, bad@x,, [fd7a::2]:8770")
for _ in range(3):
assert client.get("/reports", headers={"host": "desk.ts.net"}).status_code == 200
assert client.get("/reports", headers={"host": "[fd7a::1]"}).status_code == 403
err = capsys.readouterr().err
for bad in ("'fd7a::1'", "'*'", "'0.0.0.0'", "'bad@x'"):
assert err.count(f"PW_SERVE_ALLOWED_HOSTS entry {bad} ignored") == 1, bad
assert "desk.ts.net" not in err and "fd7a::2" not in err and err.count("ignored") == 4