A connection that cannot point at the machine it is running on
"Nothing runs on the LLeMbas host" is the sentence the absent sandbox and the absent local MCP rest on, and an SSH profile aimed at 127.0.0.1 walked straight past it -- through a real login, with every gate in policy.py still applying, onto the machine holding the database and the Fernet key. From the SSH layer down it is indistinguishable from a container on the network, so nothing here could have noticed. One switch, three positions: never, one named port, anywhere. The middle one is the one with a real use -- a container that published its SSH port on the loopback interface is genuinely somewhere else -- and port 22 is refused even there, because that one is this host's own sshd. Enforced in five places, because a row can predate a setting: saving a profile, `session.resolve` (the control every agent tool, the terminal and the canvas go through), the composer's picker, browsing, and the draft the panels open against before a chat exists. Check refuses before it opens its socket rather than after. And the recognition never resolves a name on the request path. `refusal` runs several times per page render; the first version of this looked names up inline and the suite went from two minutes to not finishing. Literal forms are decided from the string, a name is settled where a network call is already expected, and the answer lives on the row. The gap that leaves is written down rather than discovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user