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
16 changes: 12 additions & 4 deletions keel/web/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from typing import TYPE_CHECKING, Any

from keel.web import payload, runtime
from keel.web.security import csrf_token
from keel.web.security import csrf_token, local_deployment

if TYPE_CHECKING: # pragma: no cover - typing only
from keel.web.server import ServeConfig
Expand Down Expand Up @@ -198,13 +198,21 @@ def read_config(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> di
edit gone wrong) must cost the page its badge, not its boot. `payload.config_payload`
records the read-only boundary: nothing in this package writes `auto_trade.mode`.
"""
peers = runtime.live_peers(current_port=cfg.port)
profile = _profile_name(cfg.db_path)
return payload.config_payload(
cfg.build_info,
describe=cfg.build,
mode=_auto_trade_mode(cfg.config_path),
profile=_profile_name(cfg.db_path),
peers=peers,
profile=profile,
# The other consoles in this deployment root (#814), as names and token-free hrefs only;
# `payload.profile_switcher` says why. Read per request like the mode, so a console
# started after this one appears on the next load.
switcher=payload.profile_switcher(
profile,
cfg.port,
runtime.live_peers(exclude_port=cfg.port),
local_deployment(external_hosts=cfg.external_hosts, bound_host=cfg.host),
),
**_session_state(cfg.db_path, now_ts),
db_path=cfg.db_path,
config_path=cfg.config_path,
Expand Down
50 changes: 48 additions & 2 deletions keel/web/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3275,7 +3275,7 @@ def config_payload(
autonomous: bool = False,
db_path: str = "",
config_path: str = "",
peers: Sequence[Mapping[str, Any]] = (),
switcher: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""The running build and the deployment it serves, for the consumers that need either by
name.
Expand Down Expand Up @@ -3309,6 +3309,10 @@ def config_payload(
`keel.commands._common._require_interactive_confirmation` exists for), and the browser can
display that decision and cannot make it. An absent `mode` (`""`) means the config could
not be read -- the first-run state -- and the badge hides rather than guessing.

`switcher` (#814) is the one thing here about OTHER consoles: `profile_switcher`'s answer
for the ones serving beside this one. It names them and carries a token-free href to each;
see that function for why nothing more.
"""
return {
"version": str(getattr(build, "version", "") or ""),
Expand Down Expand Up @@ -3351,7 +3355,49 @@ def config_payload(
on_state=GOOD,
off_state=WARN,
),
"peers": list(peers),
"switcher": dict(switcher) if switcher is not None else profile_switcher("", 0, [], False),
}


def profile_switcher(
profile: str, port: int, peers: Sequence[Mapping[str, Any]], local: bool
) -> dict[str, Any]:
"""The console switcher (#814): the other consoles serving from this deployment root.

`choices` are the consoles to go TO -- never this one, which the session chip names in plain
text. `display` names all of them, this one marked, for the deployment card.

**Names and token-free hrefs, and nothing else.** #815 put each console's
`http://.../?token=...` here, so every page held every console's session token -- the paper
console's page held the live one's -- against `security.py`'s "must never be written into the
page". A `href` is `/switch/<port>` on THIS console; `server._switch` reads the peer's record
when it is followed and hands the token to the browser's navigation, never to the page.

**This console comes from its own arguments**, never from a record: a console started at a
terminal writes none, and #815's list, built from records alone, then had no current entry --
the browser selected the first option, and the header named a deployment that was not the
one on screen. `peers` may or may not include this console; it is dropped either way.

**No mode in a label.** A peer's mode would be one recorded at its start-up and stale after
any config edit; the badge on the console you arrive at reads it live.

Rule 2: `label` and `display` arrive composed, so the client places strings. Rule 3:
`switchable` is stated, never inferred from a list length. Not `local` -- a declared remote
origin, or a bind off loopback -- offers nothing: the peers' loopback addresses cannot be
opened from the viewer's device, and `/switch/` refuses there anyway.
"""
others = sorted(
(int(peer["port"]), str(peer.get("profile") or "") or f"port {int(peer['port'])}")
for peer in peers
if int(peer["port"]) != port
)
if not local or not others:
return {"switchable": False, "choices": [], "display": ""}
here = (port, f"{profile or f'port {port}'} (this console)")
return {
"switchable": True,
"choices": [{"label": label, "href": f"/switch/{number}"} for number, label in others],
"display": ", ".join(label for _number, label in sorted([here, *others])),
}


Expand Down
56 changes: 24 additions & 32 deletions keel/web/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ def record_serving(
token: str,
interactive: bool,
profile: str = "",
mode: str = "",
) -> Path | None:
"""Record how to reach this server, unless a human is watching stdout.

Expand All @@ -119,6 +118,11 @@ def record_serving(
is also printed, and so a test can exercise both sides without touching the process's own
streams.

`profile` names this console in the other consoles' switchers (#814). No mode rides along:
it would be read once here and shown until the process died, and `api._auto_trade_mode`
re-reads the config on every request precisely so a badge never states a mode the config no
longer declares. Each console's own badge reports its mode, live, once you arrive.

Returns the path written, or `None` when the rule above says to write nothing.
"""
if interactive:
Expand All @@ -140,8 +144,6 @@ def record_serving(
}
if profile:
data["profile"] = str(profile)
if mode:
data["mode"] = str(mode)
body = json.dumps(data)
# CREATED at `0600`, not corrected to it (#759 review). `Path.write_text` creates at the
# process umask -- measured `0644` under the usual `022` -- and the `chmod` that followed left
Expand Down Expand Up @@ -305,50 +307,40 @@ def stdout_is_interactive() -> bool:
return False


def live_peers(current_port: int | None = None) -> list[dict[str, Any]]:
"""All active peer console servers discovered in the runtime directory (#814).
def live_peers(*, exclude_port: int) -> list[dict[str, Any]]:
"""The OTHER consoles serving from this deployment root, as `{port, profile}` (#814).

Port and profile and nothing else. A record holds a bearer token, and #815 returned it here
inside a URL that `/api/config` carried into every console's page -- the paper console's page
held the live console's token. What a switcher needs to OFFER a console is its name and its
port; what it needs to ENTER one, `server._switch` reads from the record at click time and
hands only to the browser's navigation, never to the page.

Scans `run_dir()` for all `serve-*.json` records, verifying each against `live_record` (live
process and responding loopback port). Returns structured peer metadata sorted by port, with
wire-safe types (port as string, current as boolean).
Same deployment root only, by construction: `run_dir` is under `state_root()`, so another
deployment's consoles are never read (the rule `RUN_DIR_NAME`'s note states). Each record is
offered only if `live_record` says its process is still there, and `exclude_port` drops this
console's own -- the caller lists itself from its own arguments, because a console started
at a terminal writes no record at all.
"""
directory = run_dir()
if directory is None or not directory.is_dir():
return []

peers: list[dict[str, Any]] = []
try:
entries = sorted(directory.glob("serve-*.json"))
except OSError:
return []

peers: list[dict[str, Any]] = []
for entry in entries:
name = entry.stem
parts = name.split("-")
if len(parts) != 2:
continue
try:
port = int(parts[1])
port = int(entry.stem.removeprefix("serve-"))
except ValueError:
continue

if port == exclude_port:
continue
record = live_record(port)
if record is None:
continue

profile = str(record.get("profile", "") or "")
mode = str(record.get("mode", "") or "")
is_current = bool(current_port is not None and port == int(current_port))

peers.append(
{
"port": str(port),
"profile": profile,
"mode": mode,
"url": url_for(record),
"current": is_current,
}
)

peers.sort(key=lambda p: int(p["port"]))
peers.append({"port": port, "profile": str(record.get("profile") or "")})
peers.sort(key=lambda peer: peer["port"])
return peers
19 changes: 16 additions & 3 deletions keel/web/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,14 +293,27 @@ def gated_action_permitted(
in a quiet edit here. Until then a tunnelled deployment serves the read surface and refuses
every gated action, which is the posture that fails safe.
"""
return local_deployment(external_hosts=external_hosts, bound_host=bound_host) and (
_is_loopback_peer(peer)
)


def local_deployment(*, external_hosts: frozenset[str], bound_host: str) -> bool:
"""No declared remote origin and a loopback bind: the half of `gated_action_permitted` that
holds for the whole process rather than per request.

Split out for the profile switcher (#814), which asks it at two moments: when `/api/config`
decides whether to OFFER a switch (no request peer to ask about -- the answer is the
deployment's posture), and when `/switch/<port>` decides whether to PERFORM one, where the
peer check is added exactly as it is for a release. One definition, so the offer and the
route cannot disagree about what "local" means.
"""
if external_hosts:
return False
# The bind, by the same rule as the peer, so `127.0.0.53` and `::1` are loopback here too and
# a name that is not an address ("example.internal") is refused -- this decides a security
# question, and "I could not tell" is not "yes".
if bound_host not in _LOOPBACK_NAMES and not _is_loopback_peer((bound_host, 0)):
return False
return _is_loopback_peer(peer)
return bound_host in _LOOPBACK_NAMES or _is_loopback_peer((bound_host, 0))


def tokens_match(presented: str | None, expected: str) -> bool:
Expand Down
64 changes: 60 additions & 4 deletions keel/web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ def ensure_schema(db_path: str) -> None:
#: `API_SETUP_PREFIX`.
EVENTS_PATH = "/api/events"

#: The profile switcher's one route (#814): `/switch/<port>` moves the browser to another console
#: serving from this deployment root. A GET that redirects and changes nothing -- navigation, the
#: one thing the session chip's docstring allows it -- and the reason the page never needs a
#: peer's token: see `_switch`. `sw.js` declines these navigations by the same prefix, or it
#: would answer them from its cached shell and the server would never see one.
SWITCH_PREFIX = "/switch/"

#: The write surface, in full, today. A path here maps to one `keel.commands.setup.Action`; there
#: is no other POST this server answers, and `keel/web/__init__.py` is the file to read before
#: adding one.
Expand Down Expand Up @@ -913,6 +920,54 @@ def _read_json_object(self) -> dict[str, str] | None:
values[str(name)] = "" if value is None else str(value)
return values

def _switch(self, target: str) -> None:
"""Send the browser into another console serving from this deployment root (#814).

**The token travels the way `keel open` already sends it, and no other way.** The page
offers `/switch/<port>` and nothing more; this reads the peer's record NOW -- so a peer
restarted since the page loaded, with a new token, is still reached -- and answers with a
`303` to that peer's `?token=` address. The peer exchanges the token for its own cookie
and strips it from the URL (`do_GET`'s hand-off). Only the browser's navigation carries
it: no page script can read a redirect's `Location`, so the page never holds a session
token, its own or anyone's, which #815's `<option value>` did.

**Local only, by the floor `gated_action_permitted` holds for a release.** Entering the
live console from the paper one hands over the live console's session, so this requires
what reading the `0600` record directly would: this machine. A declared remote origin or
an off-loopback bind refuses, and so does a peer that is not loopback -- a tunnel presents
as loopback, which is why the deployment's posture is checked as well.

Behind `_admitted` (the caller's), so without this console's own session nothing about
any other is revealed, not even whether it is running. An unknown or dead port is a 404
and names no console.
"""
if not gated_action_permitted(
self.client_address,
external_hosts=self.cfg.external_hosts,
bound_host=self.cfg.host,
):
self._refuse(
403,
"Refused",
"Switching consoles is restricted to a loopback session on a loopback bind. Use "
"`keel open` at the terminal of the machine running keel.",
)
return
port = int(target) if target.isdigit() else None
if port == self.cfg.port:
self._send(303, "", extra=(("Location", "/"),))
return
record = runtime.live_record(port) if port is not None else None
if record is None:
self._refuse(
404,
"No such console",
"No keel console is running on that port now. Reload this page for the current "
"list.",
)
return
self._send(303, "", extra=(("Location", runtime.url_for(record)),))

def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours
self.body_consumed = False
parsed = urlsplit(self.path)
Expand Down Expand Up @@ -947,6 +1002,10 @@ def do_GET(self) -> None: # noqa: N802 - stdlib's naming, not ours
if not self._admitted():
return

if parsed.path.startswith(SWITCH_PREFIX):
self._switch(parsed.path.removeprefix(SWITCH_PREFIX))
return

if parsed.path == EVENTS_PATH:
# Live updates (#537). Checked BEFORE the `API_PREFIX` branch below, because that
# branch ends in `api.respond`, which would answer this path with the 404 it gives
Expand Down Expand Up @@ -1113,15 +1172,12 @@ def serve(cfg: ServeConfig, *, echo: Callable[[str], None] = print) -> int:
# printing the wrong one would be a false safety assurance about a live credential -- the
# class of thing `payload._session_banner` refuses to do about mode.
interactive = runtime.stdout_is_interactive()
profile = Path(running.db_path).stem if running.db_path else ""
mode = api._auto_trade_mode(running.config_path)
recorded = runtime.record_serving(
host=running.host,
port=running.port,
token=running.token,
interactive=interactive,
profile=profile,
mode=mode,
profile=api._profile_name(running.db_path),
)
if recorded is None:
echo("Stopping keel revokes it: the token is new every run and is never written to disk.")
Expand Down
14 changes: 9 additions & 5 deletions keel/web/static/css/keel.css
Original file line number Diff line number Diff line change
Expand Up @@ -390,20 +390,24 @@ header .sessionpart {
text-overflow: ellipsis;
white-space: nowrap;
}
header .profile-select {
/* THE CONSOLE SWITCHER (#814), beside the chip. As quiet as the chip's text halves -- it is a way
* to another console, not a fact about this one -- and bounded the same way, since its options
* are operators' own filenames. */
header #console-switcher { align-self: center; }
header #console-switcher:empty { display: none; }
header .console-select {
background: var(--bg);
color: var(--muted);
border: 1px solid var(--control-line);
border-radius: 4px;
font-size: 0.85em;
font-family: inherit;
padding: 0.1rem 0.3rem;
max-width: 14ch;
max-width: 20ch;
cursor: pointer;
outline: none;
}
header .profile-select:hover,
header .profile-select:focus-visible {
header .console-select:hover,
header .console-select:focus-visible {
color: var(--fg);
border-color: var(--accent);
}
Expand Down
7 changes: 7 additions & 0 deletions keel/web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@
</details>
<span id="session-equity" class="sessionpart"></span>
</div>
<!--
THE CONSOLE SWITCHER (#814): BESIDE the chip, never inside it. The chip names this console in
plain text and may not be a control (#704); this is the one control, and all it does is
navigate to `/switch/<port>`, which hands the browser to another RUNNING console. Empty in
the markup and hidden while empty -- a deployment with one console has nothing to offer.
-->
<span id="console-switcher"></span>

<!--
THE THEME TOGGLE (#597): two states, and the stylesheet is the state machine.
Expand Down
Loading
Loading