SSH connections, kept by the people who own them
An agent chat will act on a machine you choose, so this is the screen where you choose it. User-owned like a note, not admin-owned like a connection: these are somebody's own machines and somebody's own keys, and "anyone in this group may log in to my server" is a different feature with a different blast radius. services/sharing.py is deliberately not involved either -- sharing grants reading, and a host somebody else can read is a host they can log in to. Trust on first use, made explicit rather than assumed. Adding a host does not connect to it. Check looks at its key and shows you the fingerprint; nothing is sent until you accept, because get_server_host_key completes the key exchange and stops -- no username, no credential. Accepting pins it, and a host that later presents a different key is refused with the reason rather than quietly trusted. Moving a profile to another host or port forgets the pin, since a key belongs to the machine it came from. Four asyncssh defaults are actively wrong here and all four are passed explicitly: every LLeMbas user shares one unix account, so `known_hosts` would be a shared trust store, `client_keys` would authenticate one person with another's key, `config` would let a ProxyCommand redirect the connection, and `agent_path` would silently use $SSH_AUTH_SOCK. There is a test for exactly that, and it needs no server. Files go over SFTP rather than through a shell. The SSH exec protocol carries one command *string* that the far side parses, with no argv form at all, so a model-supplied path in a command line is unavoidably a quoting problem. Over SFTP a path is a path. Chat gains its kind, connection, project directory and mode; the first three are fixed once a chat has a message, because a transcript whose earlier turns ran somewhere else is not one conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""SSH connections, kept by the people who own them.
|
||||
|
||||
Not an admin screen. These are somebody's own machines and somebody's own keys,
|
||||
so the pages sit beside the library rather than under `/admin` -- an
|
||||
administrator decides only whether the feature exists at all.
|
||||
|
||||
Trust on first use, made explicit. Adding a host does not connect to it; the
|
||||
**Check** button looks at its key, shows the fingerprint, and waits. Only when
|
||||
that is accepted is the key pinned, and only then will anything authenticate.
|
||||
`asyncssh.get_server_host_key` completes the key exchange and stops, so a host
|
||||
that has not been accepted is never offered a username, let alone a credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||
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 ssh as ssh_service
|
||||
from lembas.services.agent.base import ExecError
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
dependencies=[Depends(require_permission("agent.ssh"))], tags=["agents"]
|
||||
)
|
||||
|
||||
|
||||
def _profile(db: Db, user: RequiredUser, profile_id: str) -> SshProfile:
|
||||
"""One profile belonging to this person.
|
||||
|
||||
Ownership is the whole authorisation. `sharing.py` is deliberately not
|
||||
involved: it grants reading, and a host somebody else can read is a host
|
||||
they can log in to.
|
||||
"""
|
||||
profile = db.get(SshProfile, profile_id)
|
||||
if profile is None or profile.owner_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||
return profile
|
||||
|
||||
|
||||
def _owned(db: Db, user_id: str) -> list[SshProfile]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(SshProfile).where(SshProfile.owner_id == user_id).order_by(SshProfile.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/agents?saved={message}" if message else "/agents"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
def _number(raw, *, default: int, low: int, high: int) -> int:
|
||||
text = str(raw or "").strip()
|
||||
if not text.isdigit():
|
||||
return default
|
||||
return min(max(int(text), low), high)
|
||||
|
||||
|
||||
def _apply(profile: SshProfile, form) -> None:
|
||||
"""Copy a submitted form onto a profile.
|
||||
|
||||
Checkboxes are read by key presence: FastAPI cannot tell `x=` from an absent
|
||||
`x`, and an absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
profile.name = str(form.get("name") or "").strip()[:120]
|
||||
profile.host = str(form.get("host") or "").strip()[:255]
|
||||
profile.username = str(form.get("username") or "").strip()[:120]
|
||||
profile.port = _number(form.get("port"), default=22, low=1, high=65535)
|
||||
profile.connect_timeout = _number(form.get("connect_timeout"), default=15, low=3, high=120)
|
||||
profile.default_dir = str(form.get("default_dir") or "").strip()[:500]
|
||||
|
||||
method = str(form.get("auth") or "").strip()
|
||||
profile.auth = method if method in AUTH_METHODS else profile.auth
|
||||
profile.enabled = "enabled" in form
|
||||
|
||||
|
||||
def _detail(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
profile: SshProfile,
|
||||
*,
|
||||
is_new: bool,
|
||||
error: str = "",
|
||||
saved: str = "",
|
||||
):
|
||||
# The user is passed rather than read off the profile: a draft has never
|
||||
# been attached to a session, so `profile.owner` is None on the one page
|
||||
# that most needs a sidebar.
|
||||
return render(
|
||||
request,
|
||||
"agents/detail.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profile": profile,
|
||||
"is_new": is_new,
|
||||
"error": error,
|
||||
"saved": saved,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
"masked_password": mask(decrypt(profile.password_encrypted))
|
||||
if profile.password_encrypted
|
||||
else "",
|
||||
"has_key": bool(profile.private_key_encrypted),
|
||||
"problem": ssh_service.available(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agents")
|
||||
async def agents_page(request: Request, db: Db, user: RequiredUser, saved: str = ""):
|
||||
return render(
|
||||
request,
|
||||
"agents/index.html",
|
||||
{
|
||||
**sidebar_context(db, user),
|
||||
"profiles": _owned(db, user.id),
|
||||
"saved": saved,
|
||||
"problem": ssh_service.available(),
|
||||
"enabled": bool(settings_store.agents(db).get("enabled")),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Registered before /{profile_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first "new" is captured as an id. This has been
|
||||
# a bug once already, in /admin/models.
|
||||
@router.get("/agents/new")
|
||||
async def new_profile_page(request: Request, db: Db, user: RequiredUser):
|
||||
draft = SshProfile(
|
||||
owner_id=user.id, name="", host="", username="", port=22, connect_timeout=15, enabled=True
|
||||
)
|
||||
return _detail(request, db, user, draft, is_new=True)
|
||||
|
||||
|
||||
@router.post("/api/agents")
|
||||
async def create_profile(request: Request, db: Db, user: RequiredUser) -> Response:
|
||||
form = await request.form()
|
||||
profile = SshProfile(owner_id=user.id)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id):
|
||||
return _detail(request, db, user, profile, is_new=True, error=problem)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(str(form.get("password") or ""), "")
|
||||
profile.private_key_encrypted = keep_or_replace(str(form.get("private_key") or ""), "")
|
||||
profile.key_passphrase_encrypted = keep_or_replace(str(form.get("key_passphrase") or ""), "")
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
log.info("%s added ssh profile %s", user.email, profile.name)
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Added+{profile.name}.+Check+it+to+confirm+its+fingerprint.",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
|
||||
def _problem(db: Db, profile: SshProfile, owner_id: str, *, existing_id: str = "") -> str:
|
||||
if not profile.name:
|
||||
return "A connection needs a name."
|
||||
if not profile.host:
|
||||
return "A connection needs a host."
|
||||
if not profile.username:
|
||||
return "A connection needs a username to log in as."
|
||||
|
||||
clash = db.scalar(
|
||||
select(SshProfile).where(
|
||||
SshProfile.owner_id == owner_id, SshProfile.name == profile.name
|
||||
)
|
||||
)
|
||||
if clash is not None and clash.id != existing_id:
|
||||
return f"You already have a connection called “{profile.name}”."
|
||||
return ""
|
||||
|
||||
|
||||
@router.get("/agents/{profile_id}")
|
||||
async def profile_page(
|
||||
request: Request, db: Db, user: RequiredUser, profile_id: str, saved: str = ""
|
||||
):
|
||||
profile = _profile(db, user, profile_id)
|
||||
return _detail(request, db, user, profile, is_new=False, saved=saved)
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/check")
|
||||
async def check_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Look at the host's key, and connect if it has already been accepted.
|
||||
|
||||
Two steps in one button, because they are one question: *is this the machine
|
||||
I meant, and will it let me in?* An unseen key comes back as a fingerprint
|
||||
to accept; an accepted one is used to log in and run something harmless.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
if not profile.host_key:
|
||||
# First sight. Nothing is pinned until a person says so.
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "offer": {"line": line, "fingerprint": fingerprint}},
|
||||
)
|
||||
|
||||
if line.strip() != profile.host_key.strip():
|
||||
message = (
|
||||
"This host is presenting a different key than the one you accepted. "
|
||||
"Nothing was sent to it. If you rebuilt the machine, forget the key "
|
||||
"below and check again; if you did not, stop and find out why."
|
||||
)
|
||||
profile.last_error = message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{
|
||||
"profile": profile,
|
||||
"error": message,
|
||||
"offer": {"line": line, "fingerprint": fingerprint, "changed": True},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
found = await ssh_service.check(ssh_service.spec_from(profile), profile.default_dir)
|
||||
except ExecError as exc:
|
||||
profile.last_error = exc.message
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return render(
|
||||
request, "agents/_check.html", {"profile": profile, "error": exc.message}
|
||||
)
|
||||
|
||||
profile.last_error = ""
|
||||
profile.last_checked_at = datetime.now(UTC)
|
||||
profile.server_info = {"system": found.get("system", ""), "cwd": found.get("cwd", "")}
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "found": found})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/accept")
|
||||
async def accept_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
"""Pin the fingerprint that was just shown.
|
||||
|
||||
The line is re-fetched rather than taken from the form: a value that made a
|
||||
round trip through a browser is not what should end up as the thing every
|
||||
future connection is checked against.
|
||||
"""
|
||||
profile = _profile(db, user, profile_id)
|
||||
try:
|
||||
line, fingerprint = await ssh_service.capture_host_key(
|
||||
profile.host, profile.port, timeout=profile.connect_timeout
|
||||
)
|
||||
except ExecError as exc:
|
||||
return render(request, "agents/_check.html", {"profile": profile, "error": exc.message})
|
||||
|
||||
profile.host_key = line
|
||||
profile.host_fingerprint = fingerprint
|
||||
profile.last_error = ""
|
||||
db.commit()
|
||||
log.info("%s pinned host key for %s (%s)", user.email, profile.name, fingerprint)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"agents/_check.html",
|
||||
{"profile": profile, "accepted": fingerprint},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/forget")
|
||||
async def forget_host_key(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
db.commit()
|
||||
return render(request, "agents/_check.html", {"profile": profile, "forgotten": True})
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}/delete")
|
||||
async def delete_profile(db: Db, user: RequiredUser, profile_id: str) -> Response:
|
||||
profile = _profile(db, user, profile_id)
|
||||
name = profile.name
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
log.info("%s deleted ssh profile %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/api/agents/{profile_id}")
|
||||
async def update_profile(request: Request, db: Db, user: RequiredUser, profile_id: str):
|
||||
profile = _profile(db, user, profile_id)
|
||||
form = await request.form()
|
||||
|
||||
before = (profile.host, profile.port)
|
||||
_apply(profile, form)
|
||||
|
||||
if problem := _problem(db, profile, user.id, existing_id=profile.id):
|
||||
db.rollback()
|
||||
return _detail(
|
||||
request, db, user, _profile(db, user, profile_id), is_new=False, error=problem
|
||||
)
|
||||
|
||||
profile.password_encrypted = keep_or_replace(
|
||||
str(form.get("password") or ""), profile.password_encrypted
|
||||
)
|
||||
profile.private_key_encrypted = keep_or_replace(
|
||||
str(form.get("private_key") or ""), profile.private_key_encrypted
|
||||
)
|
||||
profile.key_passphrase_encrypted = keep_or_replace(
|
||||
str(form.get("key_passphrase") or ""), profile.key_passphrase_encrypted
|
||||
)
|
||||
if profile.auth == AUTH_PASSWORD:
|
||||
profile.private_key_encrypted = ""
|
||||
profile.key_passphrase_encrypted = ""
|
||||
|
||||
# A pinned key belongs to a host and a port. Moving either means this is a
|
||||
# different machine until proven otherwise, and silently keeping the old
|
||||
# key would be the one mistake this whole mechanism exists to prevent.
|
||||
if (profile.host, profile.port) != before and profile.host_key:
|
||||
profile.host_key = ""
|
||||
profile.host_fingerprint = ""
|
||||
log.info("%s moved ssh profile %s; its host key was forgotten", user.email, profile.name)
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
Reference in New Issue
Block a user