09156230b3
"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>
608 lines
24 KiB
Python
608 lines
24 KiB
Python
"""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 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
|
|
from lembas.services.agent import terminal as terminal_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(),
|
|
# 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 "",
|
|
},
|
|
)
|
|
|
|
|
|
@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 := _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")),
|
|
},
|
|
)
|
|
|
|
|
|
# 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."
|
|
|
|
# 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
|
|
)
|
|
)
|
|
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.get("/api/agents/{profile_id}/browse")
|
|
async def browse_profile(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
profile_id: str,
|
|
path: str = "",
|
|
pick: str = "dir",
|
|
):
|
|
"""One directory on the far side, as a fragment the picker swaps in.
|
|
|
|
Hung off the profile rather than the chat because the commonest caller is
|
|
the *new*-chat composer, where there is no chat yet -- the directory is one
|
|
of the things being chosen. Ownership of the profile is the whole
|
|
authorisation, as everywhere else in this module.
|
|
|
|
This is a person clicking, not a model calling, so it does not go through
|
|
`agent/policy.py`. That is the same argument the terminal panel rests on and
|
|
it holds for the same reason -- somebody who owns the credential could list
|
|
the directory with an ssh client -- but it does mean Manual mode's promise
|
|
that everything is shown to you first now has a second exception. Both are
|
|
written down in CLAUDE.md.
|
|
"""
|
|
profile = _profile(db, user, profile_id)
|
|
entries: list = []
|
|
error = ""
|
|
|
|
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
|
|
# a wall of prose about known_hosts is worse than one that says this.
|
|
error = "This connection's host key has not been confirmed yet. Check it first."
|
|
else:
|
|
try:
|
|
executor = ssh_service.SshExecutor(ssh_service.spec_from(profile), "")
|
|
entries = await executor.scan_dir(path or profile.default_dir or "/")
|
|
except ExecError as exc:
|
|
error = exc.message
|
|
|
|
here = path or profile.default_dir or "/"
|
|
return render(
|
|
request,
|
|
"agents/_browse.html",
|
|
{
|
|
"profile": profile,
|
|
"here": here,
|
|
"parent": _parent_of(here),
|
|
"entries": entries,
|
|
"error": error,
|
|
# Whether a file is a choice or only something to look at. The
|
|
# directory picker wants the folder you are standing in; Canvas
|
|
# wants the file you click. One listing, because a second copy is a
|
|
# second place for the path arithmetic to be got subtly differently.
|
|
"pick": "file" if pick == "file" else "dir",
|
|
},
|
|
)
|
|
|
|
|
|
# --- Background jobs -----------------------------------------------------------
|
|
# A job runs detached on the far side for as long as it takes -- a build, an
|
|
# install, a test suite -- and until now the only way to see one was to ask the
|
|
# model to call `job_list`. Something that outlives the reply that started it
|
|
# needs a surface that outlives the reply too.
|
|
#
|
|
# Read-only listing and stopping sit **outside `agent/policy.py`**, which makes
|
|
# this the fifth exception to "the modes govern the model, not the interface",
|
|
# after the terminal panel, the directory browser, the project listing and
|
|
# Canvas saving a file. The argument is the one those rest on: whoever owns the
|
|
# credential could read the log with `cat` and stop the job with `kill`, and a
|
|
# panel that asked permission to show what is already running would be a panel
|
|
# nobody could use. `job_stop` as a *model* tool keeps its RISK_EXECUTE and its
|
|
# approval card; nothing about what a model may do has changed.
|
|
def _job_chat(db: Db, user: RequiredUser, chat_id: str):
|
|
"""The chat, and the agent context its jobs belong to.
|
|
|
|
404 for a chat that is not this reader's, as everywhere else -- whether an
|
|
id exists is not something to hand out. The agent context is what carries
|
|
the connection, so a chat whose profile has been deleted or disabled has no
|
|
jobs to show rather than an error to render.
|
|
"""
|
|
from lembas.db.models import Chat
|
|
from lembas.services.agent import session as agent_session
|
|
|
|
chat = db.get(Chat, chat_id)
|
|
if chat is None or chat.user_id != user.id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
|
return chat, agent_session.resolve(db, chat, user)
|
|
|
|
|
|
@router.get("/api/agents/{profile_id}/draft")
|
|
async def draft_target(db: Db, user: RequiredUser, profile_id: str, dir: str = ""):
|
|
"""The id the panels should use for a chat that does not exist yet.
|
|
|
|
Hung off the profile rather than the chat for the reason `browse` is: the
|
|
caller is the *new*-chat composer, where the connection and the directory
|
|
are the things being chosen. Ownership of the profile is the whole
|
|
authorisation, as everywhere else in this module.
|
|
|
|
Deterministic, so asking twice for the same target gives the same id and
|
|
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}
|
|
|
|
|
|
@router.get("/api/chats/{chat_id}/jobs")
|
|
async def jobs_chip(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
|
"""How many jobs are running, as the chip in the composer row.
|
|
|
|
Always rendered, even at zero -- the chip is what carries `hx-trigger`, so a
|
|
fragment that collapsed to nothing would stop polling and the first job
|
|
started afterwards would never appear. The template renders an empty span in
|
|
that case, so the row does not reflow as jobs come and go.
|
|
"""
|
|
chat, agent = _job_chat(db, user, chat_id)
|
|
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
|
return render(
|
|
request,
|
|
"chat/_jobs_chip.html",
|
|
{"chat": chat, "jobs": views, "running": sum(1 for view in views if view.running)},
|
|
)
|
|
|
|
|
|
@router.get("/api/chats/{chat_id}/jobs/panel")
|
|
async def jobs_panel(request: Request, db: Db, user: RequiredUser, chat_id: str, job: str = ""):
|
|
"""The list, and one job's output when a row is expanded.
|
|
|
|
The log is fetched only for the named job. Reading every job's tail on every
|
|
poll would be one SSH connection per job per five seconds, for output nobody
|
|
is looking at.
|
|
"""
|
|
chat, agent = _job_chat(db, user, chat_id)
|
|
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
|
|
|
body = ""
|
|
error = ""
|
|
if job and agent is not None:
|
|
if not jobs_service.valid_id(job) or not any(view.id == job for view in views):
|
|
# Namespaced by chat on the far side, and checked here as well: the
|
|
# path is built from the chat id, but the route takes the job id
|
|
# from the URL and must not read one that belongs elsewhere.
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
|
try:
|
|
reading = await jobs_service.read(agent, job)
|
|
body = reading.body
|
|
except ExecError as exc:
|
|
error = exc.message
|
|
|
|
return render(
|
|
request,
|
|
"chat/_jobs_panel.html",
|
|
{"chat": chat, "jobs": views, "open_job": job, "body": body, "error": error},
|
|
)
|
|
|
|
|
|
@router.post("/api/chats/{chat_id}/jobs/{job_id}/stop")
|
|
async def stop_job(request: Request, db: Db, user: RequiredUser, chat_id: str, job_id: str):
|
|
chat, agent = _job_chat(db, user, chat_id)
|
|
views = jobs_service.listing(db, chat_id) if agent is not None else []
|
|
if agent is None or not jobs_service.valid_id(job_id):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
|
if not any(view.id == job_id for view in views):
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "No such job.")
|
|
|
|
error = ""
|
|
try:
|
|
await jobs_service.stop(agent, job_id)
|
|
except ExecError as exc:
|
|
error = exc.message
|
|
|
|
return render(
|
|
request,
|
|
"chat/_jobs_panel.html",
|
|
{
|
|
"chat": chat,
|
|
"jobs": jobs_service.listing(db, chat_id),
|
|
"open_job": "",
|
|
"body": "",
|
|
"error": error,
|
|
},
|
|
)
|
|
|
|
|
|
def _parent_of(path: str) -> str:
|
|
"""The directory above, or "" at the root.
|
|
|
|
Plain string work rather than pathlib: these are POSIX paths on somebody
|
|
else's machine, and running them through a local Path would apply this
|
|
host's rules to them.
|
|
"""
|
|
trimmed = (path or "/").rstrip("/")
|
|
if not trimmed or trimmed == "":
|
|
return ""
|
|
head = trimmed.rsplit("/", 1)[0]
|
|
return head or "/"
|
|
|
|
|
|
@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)
|
|
|
|
# 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
|
|
)
|
|
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 = ""
|
|
# Un-trusting a host has to reach the shell already open on it, or the one
|
|
# connection that matters is the one this does not touch.
|
|
await terminal_service.close_for_profile(profile.id)
|
|
index_service.forget(profile.id)
|
|
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
|
|
await terminal_service.close_for_profile(profile.id)
|
|
index_service.forget(profile.id)
|
|
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)
|
|
|
|
# A shell already open holds its own connection and would not notice any of
|
|
# this. `session.profile_for` re-checks the profile on every reply, so the
|
|
# model stops at once; without the line below, "I disabled that connection"
|
|
# would simply not be true of the terminal on screen.
|
|
if not profile.enabled or not profile.host_key or (profile.host, profile.port) != before:
|
|
await terminal_service.close_for_profile(profile.id)
|
|
index_service.forget(profile.id)
|
|
|
|
db.commit()
|
|
return RedirectResponse(
|
|
f"/agents/{profile.id}?saved=Saved.", status_code=status.HTTP_303_SEE_OTHER
|
|
)
|