diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index bb7289c..3a17f3b 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.8.2" +__version__ = "0.8.3" diff --git a/src/lembas/api/admin_agents.py b/src/lembas/api/admin_agents.py index ae9b5be..9d1b683 100644 --- a/src/lembas/api/admin_agents.py +++ b/src/lembas/api/admin_agents.py @@ -19,7 +19,7 @@ from sqlalchemy import func, select from lembas.api.deps import AdminUser, Db from lembas.db.models import SshProfile from lembas.services import settings_store -from lembas.services.agent import policy +from lembas.services.agent import hosts, policy from lembas.services.agent import ssh as ssh_service from lembas.services.agent import terminal as terminal_service from lembas.web.templating import render @@ -48,6 +48,18 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F "profile_count": db.scalar(select(func.count()).select_from(SshProfile)) or 0, "terminal_count": terminal_service.count(), "modes": [(m, policy.MODE_LABELS[m], policy.MODE_HINTS[m]) for m in policy.MODES], + "loopback_modes": [ + (m, hosts.MODE_LABELS[m], hosts.MODE_HINTS[m]) for m in hosts.MODES + ], + # How many of this instance's connections the current position would + # stop. The number is the point of the card: "3 connections" beside + # a switch somebody is about to move is the difference between an + # informed change and a surprise. + "loopback_count": sum( + 1 + for p in db.scalars(select(SshProfile)) + if hosts.is_loopback(p.host) or p.resolves_here + ), "saved": saved, }, ) @@ -58,6 +70,8 @@ async def save_agents( db: Db, user: AdminUser, enabled: bool = Form(False), + loopback: str = Form("off"), + loopback_port: int = Form(0), default_timeout: int = Form(60), max_timeout: int = Form(600), max_output_bytes: int = Form(64 * 1024), @@ -88,6 +102,13 @@ async def save_agents( db, { "enabled": enabled, + # Anything unrecognised means off, here as well as on read: the one + # direction safe to get wrong is refusing a connection somebody has + # to re-allow, and the other is a shell on this host. + "loopback": loopback if loopback in hosts.MODES else hosts.MODE_OFF, + # Zero means "none named", which is what `port` needs in order to + # refuse rather than to allow. 22 is refused wherever it is stored. + "loopback_port": loopback_port if 1 <= loopback_port <= 65535 else 0, # Clamped here as well as on read. A number with no bound is a way # to break the instance from a form, which is the same reasoning # the search settings carry. @@ -124,4 +145,11 @@ async def save_agents( key=settings_store.AGENTS, ) log.info("agent execution %s by %s", "enabled" if enabled else "disabled", user.email) + if loopback != hosts.MODE_OFF: + log.warning( + "ssh connections to this machine allowed (%s%s) by %s", + loopback, + f", port {loopback_port}" if loopback == hosts.MODE_PORT else "", + user.email, + ) return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER) diff --git a/src/lembas/api/agents.py b/src/lembas/api/agents.py index 1f49485..63dc6b0 100644 --- a/src/lembas/api/agents.py +++ b/src/lembas/api/agents.py @@ -25,6 +25,7 @@ from lembas.api.pages import sidebar_context from lembas.db.models import AUTH_METHODS, AUTH_PASSWORD, SshProfile from lembas.services import settings_store from lembas.services.agent import draft as draft_service +from lembas.services.agent import hosts from lembas.services.agent import index as index_service from lembas.services.agent import jobs as jobs_service from lembas.services.agent import ssh as ssh_service @@ -119,6 +120,9 @@ def _detail( else "", "has_key": bool(profile.private_key_encrypted), "problem": ssh_service.available(), + # Empty on the new-connection page, where there is no host yet to + # ask about -- the answer arrives when it is submitted. + "refused": hosts.refusal_for(db, profile) if profile.host else "", }, ) @@ -130,7 +134,11 @@ async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = "agents/index.html", { **sidebar_context(db, user), - "profiles": _owned(db, user.id), + "profiles": (owned := _owned(db, user.id)), + # Keyed by id rather than resolved in the template, because the + # template has no session and this is a question about instance + # settings, not about the row. + "refusals": {p.id: hosts.refusal_for(db, p) for p in owned}, "saved": saved, "problem": ssh_service.available(), "enabled": bool(settings_store.agents(db).get("enabled")), @@ -179,6 +187,18 @@ def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = " if not profile.username: return "A connection needs a username to log in as." + # Saving is one of the two moments a DNS lookup is affordable, so this is + # where a *name* pointing at loopback is settled and written to the row for + # every later request to read for free. See services/agent/hosts.py. + # + # Not the last word -- `session.resolve` refuses one that was saved before an + # administrator moved the switch, and has to, because a row can predate a + # setting. This is here so the refusal arrives while somebody is looking at + # the form that caused it rather than at an agent chat with no tools. + resolved = hosts.restamp(profile) + if refused := hosts.refusal(db, profile.host, profile.port, resolved=resolved): + return refused + clash = db.scalar( select(SshProfile).where( SshProfile.owner_id == owner_id, SshProfile.name == profile.name @@ -224,7 +244,11 @@ async def browse_profile( entries: list = [] error = "" - if hint := ssh_service.available(): + if refused := hosts.refusal_for(db, profile): + # First, because this one opens a connection and the others only explain + # why one would fail. + error = refused + elif hint := ssh_service.available(): error = hint elif not profile.host_key: # connect_kwargs would raise the same thing, but a picker that opens on @@ -300,6 +324,12 @@ async def draft_target(db: Db, user: RequiredUser, profile_id: str, dir: str = " finds the shell already running there rather than opening a second one. """ profile = _profile(db, user, profile_id) + # A draft is what the terminal and the canvas open against before a chat + # exists, so refusing here is refusing the whole new-chat path. `resolve` + # would refuse it anyway once a chat existed; this stops the panel opening + # on a target it will not be allowed to use. + if refused := hosts.refusal_for(db, profile): + raise HTTPException(status.HTTP_403_FORBIDDEN, refused) draft = draft_service.remember(user.id, profile.id, dir or profile.default_dir or "") return {"id": draft.id, "dir": draft.project_dir} @@ -406,6 +436,18 @@ async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id """ profile = _profile(db, user, profile_id) + # Before anything is sent. Check is the one button here that opens a socket, + # so a refused connection must not get one -- and the reason belongs in the + # place somebody just pressed rather than in a log. + # + # The other moment a lookup is affordable, and the one that catches a name + # whose DNS moved after it was saved: this button is how somebody finds out + # a connection has stopped working, so it is the right place to find out why. + hosts.restamp(profile) + db.commit() + if refused := hosts.refusal_for(db, profile): + return render(request, "agents/_check.html", {"profile": profile, "error": refused}) + try: line, fingerprint = await ssh_service.capture_host_key( profile.host, profile.port, timeout=profile.connect_timeout diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 09296ba..d0fccc7 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -206,17 +206,24 @@ def _agent_context(db: DBSession, user: User, chat: Chat | None) -> dict: would lead anywhere. """ from lembas.db.models import SshProfile + from lembas.services.agent import hosts from lembas.services.agent import policy as agent_policy profiles: list[SshProfile] = [] if settings_store.agents(db).get("enabled") and permissions.has(db, user, "tools.agent"): - profiles = list( - db.scalars( + profiles = [ + profile + for profile in db.scalars( select(SshProfile) .where(SshProfile.owner_id == user.id, SshProfile.enabled.is_(True)) .order_by(SshProfile.name) ) - ) + # A connection pointing at this machine that an administrator has not + # allowed is not offered at all. `session.resolve` refuses it too and + # is the control; this is so it never appears in a picker whose only + # outcome is an agent chat with no tools and nothing said about why. + if hosts.usable(db, profile) + ] current = None if chat is not None and chat.ssh_profile_id: diff --git a/src/lembas/db/models/agent.py b/src/lembas/db/models/agent.py index c391f88..718904d 100644 --- a/src/lembas/db/models/agent.py +++ b/src/lembas/db/models/agent.py @@ -53,6 +53,20 @@ class SshProfile(UUIDPrimaryKey, Timestamps, Base): port: Mapped[int] = mapped_column(Integer, default=22, nullable=False) username: Mapped[str] = mapped_column(String(120), nullable=False) + # Whether `host` resolved to loopback the last time anybody looked. Written + # where a network call is already happening -- saving this connection, and + # Check -- and read on every request that asks whether this connection may + # be used at all. A column rather than a lookup because that question is + # asked several times per page render, and `getaddrinfo` on the request path + # makes an agent page wait out a DNS timeout for a host nobody is talking + # to. A literal `127.0.0.1` needs none of this and is decided from the + # string. See services/agent/hosts.py. + # + # False on every row an upgrade brings in, which is correct for the literal + # case (decided from the string anyway) and optimistic for a *name* until it + # is next saved or checked. + resolves_here: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + auth: Mapped[str] = mapped_column(String(16), default=AUTH_KEY, nullable=False) password_encrypted: Mapped[str] = mapped_column(Text, default="") private_key_encrypted: Mapped[str] = mapped_column(Text, default="") diff --git a/src/lembas/services/agent/hosts.py b/src/lembas/services/agent/hosts.py new file mode 100644 index 0000000..28ed51b --- /dev/null +++ b/src/lembas/services/agent/hosts.py @@ -0,0 +1,241 @@ +"""Whether an SSH connection is allowed to point back at this machine. + +The whole design of agent chats rests on one sentence: nothing runs on the host +LLeMbas is installed on. That is why there is no local sandbox, why local MCP +over stdio is absent, and why "the security of an agent chat is the security of +the host behind its profile" is a statement anybody can check. + +An SSH profile pointed at `127.0.0.1` walks straight past it. The commands go +over SSH, through a real login, and every gate in `policy.py` still applies -- +and they land on the machine holding the database, the Fernet key and every +other user's encrypted credentials. Nothing else in the codebase can tell that +apart from a container on the network, because from the SSH layer's point of +view it is not different. + +So it is a decision an administrator makes deliberately, in one of three +positions: + +- **off** (the default, including on an instance upgrading into this) -- no + connection may point at loopback, and one that already does is refused rather + than quietly kept working. +- **port** -- allowed on exactly one port. This is the position that has a real + use: a container that publishes its SSH port on the host's loopback interface + is genuinely somewhere else, and `127.0.0.1:2222` is how you reach it. Port 22 + is refused even here, because that is the host's own sshd. +- **on** -- allowed anywhere. For somebody who has read the paragraph above and + means it. + +## Literal or resolved, and never resolved on the request path + +Both are checked, at two different moments, and the split is not tidiness. + +The literal forms -- `127.0.0.1`, `::1`, `localhost`, anything in +`127.0.0.0/8` -- are decided from the string with no I/O at all. That is the +check `refusal` makes, and it is why `refusal` can be called from a page render, +from `resolve_tools` and from the composer's profile listing. + +A *name* that resolves to loopback needs `getaddrinfo`, which is a blocking +network call, and putting one of those behind a check that runs several times +per request is how a page render comes to wait out a DNS timeout for a host +nobody is even talking to. The first version of this file did exactly that and +the test suite went from two minutes to not finishing. So resolution happens +**only where a network call is already expected and already awaited** -- saving +a connection, and pressing Check -- and the answer is written to +`SshProfile.resolves_here`, which the request path reads for free. + +The consequence, stated rather than discovered: a name whose DNS changes to +point here after it was saved is not noticed until it is saved or checked again. +That is a real gap and it is the right trade. The alternative is a DNS lookup in +front of every agent page load, and a guard that makes the application feel +broken is a guard somebody turns off. + +A refusal is never silent. Every caller that has somewhere to put a sentence +puts this one there, because "this connection cannot be used" with no reason is +indistinguishable from a bug. +""" + +from __future__ import annotations + +import ipaddress +import logging +import socket +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing only + from sqlalchemy.orm import Session as DBSession + + from lembas.db.models import SshProfile + +log = logging.getLogger(__name__) + +MODE_OFF = "off" +MODE_PORT = "port" +MODE_ON = "on" +MODES = (MODE_OFF, MODE_PORT, MODE_ON) + +MODE_LABELS = { + MODE_OFF: "Never", + MODE_PORT: "Only on one port", + MODE_ON: "Anywhere", +} +MODE_HINTS = { + MODE_OFF: ( + "A connection to this machine is refused, and an existing one stops " + "working. This is what keeps “nothing runs on the LLeMbas host” true." + ), + MODE_PORT: ( + "For a container that publishes its SSH port on this machine's loopback " + "interface. Name that port; everything else here is still refused, and " + "port 22 is refused regardless, because that one is this host's own sshd." + ), + MODE_ON: ( + "Any port on this machine. Commands then run beside the database and the " + "encryption key, with whatever the login account can reach." + ), +} + +# The host's own sshd, and never what somebody means by "the container on 2222". +HOST_SSH_PORT = 22 + + +def _literal(host: str) -> bool | None: + """True/False when the host decides itself, None when it needs resolving.""" + text = (host or "").strip().strip("[]").lower() + if not text: + return False + # Not a real hostname anywhere, and the one everybody types. + if text in ("localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"): + return True + try: + return ipaddress.ip_address(text).is_loopback + except ValueError: + return None + + +def is_loopback(host: str) -> bool: + """Whether this host *string* reaches the machine LLeMbas is running on. + + No I/O, ever. A name is answered False here and settled by `resolves_here` + at the two moments a lookup is affordable -- see the module docstring; the + version of this that resolved inline made every agent page wait on DNS. + """ + return bool(_literal(host)) + + +def resolves_here(host: str) -> bool: + """The same question for a name, by resolving it. Blocking; call sparingly. + + Resolution failure is answered **False**: a name that does not resolve is not + a name pointing here, and refusing it would turn every DNS hiccup into "your + connection is on this machine", which is both wrong and confusing. The + connection itself will fail on its own terms a moment later. + """ + decided = _literal(host) + if decided is not None: + return decided + + try: + for entry in socket.getaddrinfo((host or "").strip().lower(), None): + if _literal(str(entry[4][0])): + return True + except OSError: + return False + return False + + +def policy(db: DBSession) -> tuple[str, int]: + """The configured position, and the port that goes with `port`.""" + from lembas.services import settings_store + + values = settings_store.agents(db) + mode = str(values.get("loopback") or MODE_OFF) + if mode not in MODES: + mode = MODE_OFF + try: + port = int(values.get("loopback_port") or 0) + except (TypeError, ValueError): + port = 0 + return mode, port + + +def refusal(db: DBSession, host: str, port: int, *, resolved: bool = False) -> str: + """Why this host and port may not be used, or "" if they may. + + A sentence rather than a boolean, because every caller has somewhere to show + one and a connection that is unavailable for no stated reason reads as a + fault in the application. + + `resolved` is what a stored profile's `resolves_here` column carries in: the + string said nothing, and a lookup made earlier said yes. + """ + if not (resolved or is_loopback(host)): + return "" + + mode, allowed = policy(db) + if mode == MODE_ON: + return "" + if mode == MODE_PORT: + if allowed and port == allowed and port != HOST_SSH_PORT: + return "" + if allowed: + return ( + f"This connection points at this machine, which is only allowed " + f"on port {allowed}. An administrator sets that on the Agents page." + ) + return ( + "This connection points at this machine, which is allowed only on a " + "port an administrator has named — and none has been." + ) + return ( + "This connection points at the machine LLeMbas itself runs on, which an " + "administrator has not allowed. Agent chats are meant to reach a " + "different host; running here would put the commands beside the database " + "and the encryption key." + ) + + +def refusal_for(db: DBSession, profile: SshProfile | None) -> str: + """The same answer for a stored profile, with no lookup. + + `resolves_here` is the verdict recorded the last time somebody saved or + checked this connection. Reading it is what keeps this callable from a page + render. + """ + if profile is None: + return "" + return refusal( + db, profile.host, profile.port, resolved=bool(getattr(profile, "resolves_here", False)) + ) + + +def usable(db: DBSession, profile: SshProfile | None) -> bool: + return not refusal_for(db, profile) + + +def restamp(profile: SshProfile) -> bool: + """Record whether this profile's host resolves to loopback, and return it. + + Called where a network call is already happening -- saving a connection, and + Check. The column is the request path's only way of knowing about a *name*, + so a save that skips this leaves the guard reading a stale answer. + """ + profile.resolves_here = resolves_here(profile.host) + return profile.resolves_here + + +__all__ = [ + "HOST_SSH_PORT", + "MODES", + "MODE_HINTS", + "MODE_LABELS", + "MODE_OFF", + "MODE_ON", + "MODE_PORT", + "is_loopback", + "policy", + "refusal", + "refusal_for", + "resolves_here", + "restamp", + "usable", +] diff --git a/src/lembas/services/agent/session.py b/src/lembas/services/agent/session.py index 44d7529..68ade35 100644 --- a/src/lembas/services/agent/session.py +++ b/src/lembas/services/agent/session.py @@ -21,7 +21,7 @@ from sqlalchemy.orm import Session as DBSession from lembas.db.models import KIND_AGENT, Chat, SshProfile, User from lembas.services import settings_store -from lembas.services.agent import policy +from lembas.services.agent import hosts, policy from lembas.services.agent import ssh as ssh_service from lembas.services.agent.base import Executor from lembas.services.agent.policy import Limits @@ -146,6 +146,12 @@ def profile_for(db: DBSession, chat: Chat, user: User | None) -> SshProfile | No return None if user is not None and profile.owner_id != user.id: return None + # A row can predate a setting, so this is asked here rather than trusted + # from when the profile was saved: an administrator moving the switch to + # `off` has to stop the chats already pointed at loopback, not only the next + # one somebody tries to create. See services/agent/hosts.py. + if not hosts.usable(db, profile): + return None return profile diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index b0f0217..bf7b91a 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -89,6 +89,13 @@ def _agents_defaults() -> dict[str, Any]: # a model reads web pages, files and command output, all of them # untrusted, so a shell is a capability somebody chooses on purpose. "enabled": False, + # Whether a connection may point back at this machine. Off, and off on + # an instance upgrading into this too: a loopback profile walks straight + # past "nothing runs on the LLeMbas host", and that sentence is what the + # absence of a sandbox rests on. See services/agent/hosts.py for the + # three positions and why the middle one exists. + "loopback": "off", + "loopback_port": 0, # Per command. "default_timeout": 60, "max_timeout": 600, @@ -422,6 +429,17 @@ def agents(db: DBSession) -> dict[str, Any]: max(int(values.get("max_completion_tokens") or 0), 0), 5_000_000 ) values["background_max_jobs"] = min(max(int(values.get("background_max_jobs") or 0), 1), 100) + # Anything unrecognised means off. A stored value this version does not know + # must fail closed here: the one direction that is safe to get wrong is + # refusing a connection somebody has to re-allow, and the other direction is + # a shell on this host. + if values.get("loopback") not in ("off", "port", "on"): + values["loopback"] = "off" + try: + port = int(values.get("loopback_port") or 0) + except (TypeError, ValueError): + port = 0 + values["loopback_port"] = port if 1 <= port <= 65535 else 0 return values diff --git a/src/lembas/web/templates/admin/agents.html b/src/lembas/web/templates/admin/agents.html index c9c4600..3fd7d84 100644 --- a/src/lembas/web/templates/admin/agents.html +++ b/src/lembas/web/templates/admin/agents.html @@ -54,6 +54,39 @@

+
+

Connections to this machine

+

+ Agent chats reach a machine over SSH, and the point of that is that it is + not this one — nothing runs on the host holding the database and the + encryption key. A connection pointed at 127.0.0.1 walks past + that, and from the SSH layer's point of view it looks like any other host. + {% if loopback_count %} + {{ loopback_count }} saved connection{{ '' if loopback_count == 1 else 's' }} + point{{ 's' if loopback_count == 1 else '' }} here. + {% endif %} +

+
+ {% for value, label, hint in loopback_modes %} + + {% endfor %} +
+
+ + +

+ Only read when the position above is Only on one port. + Port 22 is refused whatever is typed here — that one is this host's own + sshd, not a container that published its port on the loopback interface. +

+
+
+

The modes

diff --git a/src/lembas/web/templates/agents/detail.html b/src/lembas/web/templates/agents/detail.html index bb35a6f..91b1d7d 100644 --- a/src/lembas/web/templates/agents/detail.html +++ b/src/lembas/web/templates/agents/detail.html @@ -17,6 +17,13 @@ {% if problem %}

{{ icon("warning", "icon--sm") }} {{ problem }}
{% endif %} +{# A connection saved before an administrator moved the switch. It is still here + and still editable -- pointing it at another host is the way out -- but + nothing will use it, and saying so here is the only place somebody looking at + this row would find out. #} +{% if refused %} +
{{ icon("shield", "icon--sm") }} {{ refused }}
+{% endif %} {% if saved %}
{{ icon("check", "icon--sm") }} {{ saved }}
{% endif %} diff --git a/src/lembas/web/templates/agents/index.html b/src/lembas/web/templates/agents/index.html index 4838985..673a1d6 100644 --- a/src/lembas/web/templates/agents/index.html +++ b/src/lembas/web/templates/agents/index.html @@ -62,11 +62,18 @@
{% for profile in profiles %} -
+ {# `refused` is why this connection cannot be used at all, which is a stronger + statement than `disabled` -- that one is the owner's own choice and this one + is not theirs to make. Greyed out with the same class, because "you cannot + use this" is one visual idea however it came about. #} + {% set refused = refusals.get(profile.id, "") %} +
{{ profile.name }} - {% if profile.verified %} + {% if refused %} + not allowed + {% elif profile.verified %} key confirmed {% else %} not checked @@ -77,7 +84,9 @@ {{ profile.address }}{% if profile.default_dir %} · {{ profile.default_dir }}{% endif %} - {% if profile.last_error %} + {% if refused %} +

{{ refused }}

+ {% elif profile.last_error %}

{{ profile.last_error }}

{% elif profile.server_info.system %}

{{ profile.server_info.system }}

diff --git a/tests/conftest.py b/tests/conftest.py index 7570d91..599ba43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,19 @@ def fresh_database(tmp_path: Path) -> Iterator[None]: Base.metadata.create_all(bind=get_engine()) sync_schema(get_engine()) + + # An SSH connection to loopback is refused by default -- see + # services/agent/hosts.py, and `tests/test_agent_hosts.py` for the guard + # itself. Almost every agent test has to point at 127.0.0.1 anyway, because + # the ones that stand up a real asyncssh server can only listen there, and + # the rest were written beside them. So the suite runs with the switch open + # and the tests that care about it close it explicitly. + from lembas.db.session import session_scope + from lembas.services import settings_store + + with session_scope() as db: + settings_store.update(db, {"loopback": "on"}, key=settings_store.AGENTS) + yield reset_engine() diff --git a/tests/test_agent_hosts.py b/tests/test_agent_hosts.py new file mode 100644 index 0000000..bb9b1a6 --- /dev/null +++ b/tests/test_agent_hosts.py @@ -0,0 +1,269 @@ +"""An SSH connection pointed at the machine LLeMbas itself runs on. + +The whole design of agent chats is one sentence -- nothing runs on this host -- +and a profile pointed at 127.0.0.1 walks past it while looking, from the SSH +layer down, exactly like a container on the network. So it is an administrator's +decision in three positions, and what is pinned here is that every one of them is +enforced where it has to be rather than only in the form. + +Note the conftest: the suite runs with the switch **open**, because the tests +that stand up a real asyncssh server can only listen on loopback. Every test in +this file closes it explicitly, which is also why they are together in one file. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import KIND_AGENT, Chat, SshProfile, User +from lembas.services import settings_store +from lembas.services.agent import hosts +from lembas.services.agent import session as agent_session +from lembas.services.crypto import encrypt + + +def _set(db, mode: str, port: int = 0) -> None: + settings_store.update( + db, {"enabled": True, "loopback": mode, "loopback_port": port}, key=settings_store.AGENTS + ) + db.commit() + + +def _profile(db, host: str = "127.0.0.1", port: int = 22) -> SshProfile: + profile = SshProfile( + owner_id=db.scalars(select(User)).first().id, + name=f"box-{host}-{port}", + host=host, + port=port, + username="deploy", + auth="password", + password_encrypted=encrypt("hunter2"), + host_key="ssh-ed25519 AAAA", + host_fingerprint="SHA256:x", + enabled=True, + ) + db.add(profile) + db.commit() + return profile + + +# --- Recognising the machine -------------------------------------------------- +@pytest.mark.parametrize( + "host", + ["127.0.0.1", "127.0.0.53", "127.1.2.3", "::1", "[::1]", "localhost", "LOCALHOST"], +) +def test_the_obvious_spellings_are_recognised(host: str): + """All of them, and from the string alone. `127.0.0.53` is the one worth + listing: the whole of 127.0.0.0/8 is loopback, and a check written against + the single address `127.0.0.1` misses every other way of writing it.""" + assert hosts.is_loopback(host) + + +@pytest.mark.parametrize("host", ["10.0.0.9", "192.168.1.5", "example.test", ""]) +def test_anything_else_is_not(host: str): + """Including a private address. This guard is about *this machine*, not + about the network it is on -- refusing 192.168.x would refuse the container + on the shelf, which is the intended use of the whole feature.""" + assert not hosts.is_loopback(host) + + +def test_recognising_a_host_never_makes_a_network_call(monkeypatch): + """`getaddrinfo` blocks. `is_loopback` is called several times per page + render -- the composer's picker, `resolve_tools`, the canvas -- so a lookup + behind it makes an agent page wait out a DNS timeout for a host nobody is + talking to. The first version of this did exactly that and the suite went + from two minutes to not finishing.""" + import socket + + def explode(*args, **kwargs): # pragma: no cover - the point is it is unused + raise AssertionError("is_loopback resolved a name on the request path") + + monkeypatch.setattr(socket, "getaddrinfo", explode) + + assert hosts.is_loopback("127.0.0.1") + assert not hosts.is_loopback("build.example") + + +def test_a_name_is_settled_by_resolving_it(monkeypatch): + """Which is where a hosts-file entry gets caught. Blocking, and therefore + called only where a network call is already expected.""" + import socket + + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *a, **k: [(2, 1, 6, "", ("127.0.0.1", 22))], + ) + assert hosts.resolves_here("build.example") + + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *a, **k: [(2, 1, 6, "", ("93.184.216.34", 22))], + ) + assert not hosts.resolves_here("build.example") + + +def test_a_name_that_does_not_resolve_is_not_this_machine(monkeypatch): + """Refusing it would turn every DNS hiccup into "your connection is on this + machine", which is both wrong and unactionable. The connection fails on its + own terms a moment later.""" + import socket + + def fail(*args, **kwargs): + raise OSError("no such host") + + monkeypatch.setattr(socket, "getaddrinfo", fail) + assert not hosts.resolves_here("nowhere.invalid") + + +# --- The three positions ------------------------------------------------------ +def test_off_refuses_every_port(db, registered): + _set(db, hosts.MODE_OFF) + assert hosts.refusal(db, "127.0.0.1", 22) + assert hosts.refusal(db, "127.0.0.1", 2222) + + +def test_on_allows_every_port(db, registered): + _set(db, hosts.MODE_ON) + assert hosts.refusal(db, "127.0.0.1", 22) == "" + assert hosts.refusal(db, "127.0.0.1", 2222) == "" + + +def test_one_port_allows_that_one_and_nothing_else(db, registered): + _set(db, hosts.MODE_PORT, 2222) + assert hosts.refusal(db, "127.0.0.1", 2222) == "" + assert hosts.refusal(db, "127.0.0.1", 2200) + # And a host that is not this machine is not this switch's business. + assert hosts.refusal(db, "10.0.0.9", 22) == "" + + +def test_port_22_is_refused_even_when_it_is_the_named_one(db, registered): + """The position exists for a container that published its SSH port on the + loopback interface. Port 22 is not that -- it is this host's own sshd, and + naming it is either a mistake or the thing the switch exists to stop.""" + _set(db, hosts.MODE_PORT, 22) + assert hosts.refusal(db, "127.0.0.1", 22) + + +def test_naming_no_port_refuses_rather_than_allows(db, registered): + """`port` with nothing named is an unfinished setting, and the safe reading + of an unfinished setting is the one that refuses.""" + _set(db, hosts.MODE_PORT, 0) + assert hosts.refusal(db, "127.0.0.1", 2222) + + +def test_an_unrecognised_stored_position_means_off(db, registered): + """Fail closed. The direction that is safe to get wrong is refusing a + connection somebody has to re-allow; the other direction is a shell on the + host holding the database and the encryption key.""" + settings_store.update(db, {"enabled": True, "loopback": "sometimes"}, key=settings_store.AGENTS) + db.commit() + assert hosts.refusal(db, "127.0.0.1", 2222) + + +# --- Where it is enforced ----------------------------------------------------- +def test_a_refused_connection_cannot_be_saved(client: TestClient, db, registered): + _set(db, hosts.MODE_OFF) + + response = client.post( + "/api/agents", + data={ + "name": "Here", + "host": "127.0.0.1", + "port": "22", + "username": "deploy", + "auth": "password", + "password": "hunter2", + "enabled": "on", + }, + follow_redirects=False, + ) + + assert response.status_code == 200 # the form again, with the reason on it + assert "points at the machine LLeMbas itself runs on" in response.text + assert db.scalar(select(SshProfile).where(SshProfile.name == "Here")) is None + + +def test_a_connection_saved_before_the_switch_moved_stops_working(db, registered): + """The one that matters. A row can predate a setting, so refusing only at + save time would leave every existing loopback profile working exactly as it + did -- which is the whole of the flaw, untouched. + + `session.resolve` is the control: it is what every agent tool, the terminal + and the canvas go through. + """ + _set(db, hosts.MODE_ON) + profile = _profile(db) + chat = Chat( + user_id=profile.owner_id, kind=KIND_AGENT, ssh_profile_id=profile.id, title="t" + ) + db.add(chat) + db.commit() + + owner = db.get(User, profile.owner_id) + assert agent_session.resolve(db, chat, owner) is not None + + _set(db, hosts.MODE_OFF) + assert agent_session.resolve(db, chat, owner) is None + + +def test_a_refused_connection_is_not_offered_in_the_composer( + client: TestClient, db, registered +): + """Or the reader picks it, starts an agent chat, and finds a chat with no + tools and nothing said about why.""" + _set(db, hosts.MODE_ON) + profile = _profile(db) + + assert profile.name in client.get("/chat").text + + _set(db, hosts.MODE_OFF) + assert profile.name not in client.get("/chat").text + + +def test_the_listing_says_why_rather_than_hiding_it(client: TestClient, db, registered): + """The connection is still the owner's and still editable -- pointing it at + another host is the way out. A row that silently stopped working would be + indistinguishable from a fault.""" + _set(db, hosts.MODE_OFF) + profile = _profile(db) + + body = client.get("/agents").text + + assert profile.name in body + assert "not allowed" in body + assert "points at the machine LLeMbas itself runs on" in body + + +def test_check_opens_no_socket_for_a_refused_connection( + client: TestClient, db, registered, monkeypatch +): + """Check is the one button here that connects, so it must refuse before it + does rather than after.""" + _set(db, hosts.MODE_OFF) + profile = _profile(db) + + async def explode(*args, **kwargs): # pragma: no cover - the point is it is unused + raise AssertionError("a refused connection was contacted") + + from lembas.services.agent import ssh as ssh_service + + monkeypatch.setattr(ssh_service, "capture_host_key", explode) + + body = client.post(f"/api/agents/{profile.id}/check").text + + assert "points at the machine LLeMbas itself runs on" in body + + +def test_a_draft_cannot_be_opened_against_a_refused_connection( + client: TestClient, db, registered +): + """The draft is what the terminal and the canvas open against before a chat + exists, so this is the new-chat path's own gate.""" + _set(db, hosts.MODE_OFF) + profile = _profile(db) + + assert client.get(f"/api/agents/{profile.id}/draft").status_code == 403