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,445 @@
|
||||
"""SSH connections, and who may do what with them.
|
||||
|
||||
The pages are user-facing rather than admin, because these are somebody's own
|
||||
machines and somebody's own keys. Most of what is worth pinning here is about
|
||||
that ownership, and about the host key -- the one thing standing between "this
|
||||
is my container" and "this is something answering on its address".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import SshProfile, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt
|
||||
|
||||
asyncssh = pytest.importorskip("asyncssh")
|
||||
|
||||
|
||||
def _form(**overrides) -> dict:
|
||||
base = {
|
||||
"name": "Project box",
|
||||
"host": "127.0.0.1",
|
||||
"port": "2222",
|
||||
"username": "root",
|
||||
"default_dir": "/project",
|
||||
"connect_timeout": "15",
|
||||
"auth": "key",
|
||||
"enabled": "true",
|
||||
}
|
||||
base.update(overrides)
|
||||
return {k: v for k, v in base.items() if v is not None}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ssh_host():
|
||||
"""A real SSH server, on a thread and an event loop of its own.
|
||||
|
||||
Its own loop matters: these tests drive the app through the synchronous
|
||||
TestClient, so a server sharing the test's loop could not accept a
|
||||
connection while a `client.post(...)` was blocking it, and the check would
|
||||
time out rather than succeed. Yields the port it is listening on.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
thread = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
async def start():
|
||||
server = await asyncssh.create_server(
|
||||
asyncssh.SSHServer,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[asyncssh.generate_private_key("ssh-ed25519")],
|
||||
)
|
||||
return server, next(iter(server.sockets)).getsockname()[1]
|
||||
|
||||
server, port = asyncio.run_coroutine_threadsafe(start(), loop).result(10)
|
||||
try:
|
||||
yield port
|
||||
finally:
|
||||
|
||||
async def stop():
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
asyncio.run_coroutine_threadsafe(stop(), loop).result(10)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def _grant(db, user_id: str) -> None:
|
||||
"""Give a plain account the two agent permissions."""
|
||||
settings_store.update(
|
||||
db, {"default_permissions": {"agent.ssh": True, "tools.agent": True}}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user(client: TestClient, db, registered):
|
||||
"""Another signed-in account, so ownership can be tested."""
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
_grant(db, user.id)
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "sam@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# --- Guards --------------------------------------------------------------------
|
||||
def test_the_pages_need_the_permission(client: TestClient, db, registered):
|
||||
"""Administrators pass everything, so this needs a plain account."""
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Merry", "email": "m@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "m@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "m@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert client.get("/agents").status_code == 403
|
||||
assert client.post("/api/agents", data=_form()).status_code == 403
|
||||
|
||||
|
||||
def test_new_is_not_parsed_as_a_profile_id(client: TestClient, registered):
|
||||
response = client.get("/agents/new")
|
||||
assert response.status_code == 200
|
||||
assert "New connection" in response.text
|
||||
|
||||
|
||||
# --- Creating and editing ------------------------------------------------------
|
||||
def test_creating_a_connection(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
|
||||
profile = db.scalar(select(SshProfile))
|
||||
assert profile.name == "Project box"
|
||||
assert profile.port == 2222
|
||||
assert profile.default_dir == "/project"
|
||||
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
|
||||
assert profile.verified is False, "nothing is trusted until a key is accepted"
|
||||
|
||||
|
||||
def test_a_duplicate_name_is_refused_per_person(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
response = client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
|
||||
assert "already have a connection" in response.text
|
||||
assert len(list(db.scalars(select(SshProfile)))) == 1
|
||||
|
||||
|
||||
def test_two_people_may_use_the_same_name(client: TestClient, db, registered, second_user):
|
||||
"""The uniqueness is per owner. Two people each calling theirs "box" is not
|
||||
a conflict, and treating it as one would be a surprise."""
|
||||
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
|
||||
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "frodo@shire.test", "password": "speak-friend-and-enter"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post("/api/agents", data=_form(name="box"), follow_redirects=False)
|
||||
|
||||
assert len(list(db.scalars(select(SshProfile)))) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "message"),
|
||||
[("name", "needs a name"), ("host", "needs a host"), ("username", "username")],
|
||||
)
|
||||
def test_the_essentials_are_required(client: TestClient, db, registered, field, message):
|
||||
response = client.post("/api/agents", data=_form(**{field: ""}), follow_redirects=False)
|
||||
assert message in response.text
|
||||
assert db.scalar(select(SshProfile)) is None
|
||||
|
||||
|
||||
def test_a_secret_is_never_rendered_in_full(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="SUPER SECRET KEY"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
page = client.get(f"/agents/{profile.id}").text
|
||||
assert "SUPER SECRET KEY" not in page
|
||||
assert UNCHANGED_SENTINEL in page
|
||||
|
||||
|
||||
def test_leaving_the_dots_alone_keeps_the_key(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}",
|
||||
data=_form(private_key=UNCHANGED_SENTINEL),
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(profile)
|
||||
assert decrypt(profile.private_key_encrypted) == "KEY MATERIAL"
|
||||
|
||||
|
||||
def test_switching_to_a_password_drops_the_key(client: TestClient, db, registered):
|
||||
"""Keeping a key that is no longer used would leave a credential lying in
|
||||
the database with nothing pointing at it."""
|
||||
client.post("/api/agents", data=_form(private_key="KEY MATERIAL"), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}",
|
||||
data=_form(auth="password", password="hunter2", private_key=UNCHANGED_SENTINEL),
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(profile)
|
||||
assert profile.private_key_encrypted == ""
|
||||
assert decrypt(profile.password_encrypted) == "hunter2"
|
||||
|
||||
|
||||
# --- Ownership ------------------------------------------------------------------
|
||||
def test_another_account_cannot_see_or_touch_your_connection(
|
||||
client: TestClient, db, registered, second_user
|
||||
):
|
||||
"""`sharing.py` is deliberately not involved: it grants reading, and a host
|
||||
somebody else can read is a host they can log in to."""
|
||||
mine = SshProfile(
|
||||
owner_id=db.scalar(select(User).where(User.email == "frodo@shire.test")).id,
|
||||
name="Not yours",
|
||||
host="10.0.0.5",
|
||||
username="root",
|
||||
)
|
||||
db.add(mine)
|
||||
db.commit()
|
||||
|
||||
# `second_user` is the one signed in.
|
||||
assert client.get(f"/agents/{mine.id}").status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}", data=_form()).status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}/delete").status_code == 404
|
||||
assert client.post(f"/api/agents/{mine.id}/check").status_code == 404
|
||||
assert client.get("/agents").text.count("Not yours") == 0
|
||||
|
||||
|
||||
def test_deleting_a_connection(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
client.post(f"/api/agents/{profile.id}/delete", follow_redirects=False)
|
||||
assert db.scalar(select(SshProfile)) is None
|
||||
|
||||
|
||||
# --- The host key ----------------------------------------------------------------
|
||||
def test_checking_an_unseen_host_offers_a_fingerprint_and_pins_nothing(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/check")
|
||||
assert "SHA256:" in response.text
|
||||
assert "Accept and pin" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.host_key == "", "looking is not accepting"
|
||||
|
||||
|
||||
def test_accepting_pins_the_key_and_the_fingerprint(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/accept")
|
||||
assert "Pinned" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.verified is True
|
||||
assert profile.host_fingerprint.startswith("SHA256:")
|
||||
assert "ssh-ed25519" in profile.host_key
|
||||
|
||||
|
||||
def test_a_host_whose_key_changed_is_reported_and_not_silently_accepted(
|
||||
client: TestClient, db, registered, ssh_host
|
||||
):
|
||||
client.post("/api/agents", data=_form(port=str(ssh_host)), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
# Pin something else entirely.
|
||||
profile.host_key = f"[127.0.0.1]:{ssh_host} ssh-ed25519 {'A' * 68}\n"
|
||||
profile.host_fingerprint = "SHA256:old"
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/api/agents/{profile.id}/check")
|
||||
assert "different key" in response.text
|
||||
assert "Nothing was sent" in response.text
|
||||
|
||||
db.refresh(profile)
|
||||
assert profile.host_fingerprint == "SHA256:old", "the old pin is left alone"
|
||||
|
||||
|
||||
def test_moving_a_connection_to_another_host_forgets_its_key(client: TestClient, db, registered):
|
||||
"""A pinned key belongs to a host and a port. Keeping it across a move is the
|
||||
one mistake the whole mechanism exists to prevent."""
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
profile.host_fingerprint = "SHA256:whatever"
|
||||
db.commit()
|
||||
|
||||
client.post(f"/api/agents/{profile.id}", data=_form(host="10.0.0.9"), follow_redirects=False)
|
||||
db.refresh(profile)
|
||||
|
||||
assert profile.host_key == ""
|
||||
assert profile.host_fingerprint == ""
|
||||
|
||||
|
||||
def test_editing_something_harmless_keeps_the_key(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
db.commit()
|
||||
|
||||
client.post(
|
||||
f"/api/agents/{profile.id}", data=_form(default_dir="/elsewhere"), follow_redirects=False
|
||||
)
|
||||
db.refresh(profile)
|
||||
|
||||
assert profile.host_key == "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
assert profile.default_dir == "/elsewhere"
|
||||
|
||||
|
||||
def test_forgetting_a_key_clears_it(client: TestClient, db, registered):
|
||||
client.post("/api/agents", data=_form(), follow_redirects=False)
|
||||
profile = db.scalar(select(SshProfile))
|
||||
profile.host_key = "127.0.0.1 ssh-ed25519 AAAA\n"
|
||||
db.commit()
|
||||
|
||||
client.post(f"/api/agents/{profile.id}/forget")
|
||||
db.refresh(profile)
|
||||
assert profile.host_key == ""
|
||||
|
||||
|
||||
# --- The admin half --------------------------------------------------------------
|
||||
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, db, registered):
|
||||
client.post("/auth/logout")
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Pip", "email": "p@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == "p@shire.test"))
|
||||
user.role = "user"
|
||||
user.active = True
|
||||
db.commit()
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": "p@shire.test", "password": "correct horse battery"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert client.get("/admin/agents").status_code == 403
|
||||
|
||||
|
||||
def test_agents_are_off_until_an_administrator_says_otherwise(client: TestClient, db, registered):
|
||||
assert settings_store.agents(db)["enabled"] is False
|
||||
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"enabled": "true",
|
||||
"default_timeout": "30",
|
||||
"max_timeout": "600",
|
||||
"max_output_bytes": "65536",
|
||||
"max_steps": "40",
|
||||
"max_wall_seconds": "900",
|
||||
"max_total_output_bytes": "1048576",
|
||||
"approval_timeout": "900",
|
||||
"allow_default": "file_read\ngit *\n\n",
|
||||
"deny_default": "shutdown *",
|
||||
"ask_free_text": "true",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
values = settings_store.agents(db)
|
||||
assert values["enabled"] is True
|
||||
assert values["default_timeout"] == 30
|
||||
assert values["allow_default"] == ["file_read", "git *"], "blank lines dropped"
|
||||
assert values["deny_default"] == ["shutdown *"]
|
||||
|
||||
|
||||
def test_the_numbers_are_clamped(client: TestClient, db, registered):
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"enabled": "true",
|
||||
"default_timeout": "0",
|
||||
"max_timeout": "99999",
|
||||
"max_output_bytes": "1",
|
||||
"max_steps": "9999",
|
||||
"max_wall_seconds": "1",
|
||||
"max_total_output_bytes": "1",
|
||||
"approval_timeout": "0",
|
||||
"allow_default": "",
|
||||
"deny_default": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
values = settings_store.agents(db)
|
||||
assert values["default_timeout"] == 1
|
||||
assert values["max_timeout"] == 3600
|
||||
assert values["max_steps"] == 200
|
||||
assert values["approval_timeout"] == 60, "a zero would park a task forever"
|
||||
|
||||
|
||||
def test_an_unticked_checkbox_turns_it_off(client: TestClient, db, registered):
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||
client.post(
|
||||
"/admin/agents",
|
||||
data={
|
||||
"default_timeout": "60",
|
||||
"max_timeout": "600",
|
||||
"max_output_bytes": "65536",
|
||||
"max_steps": "40",
|
||||
"max_wall_seconds": "900",
|
||||
"max_total_output_bytes": "1048576",
|
||||
"approval_timeout": "900",
|
||||
"allow_default": "",
|
||||
"deny_default": "",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert settings_store.agents(db)["enabled"] is False
|
||||
|
||||
|
||||
def test_encrypted_credentials_never_appear_in_the_database_in_the_clear(db, user_id):
|
||||
profile = SshProfile(
|
||||
owner_id=user_id,
|
||||
name="box",
|
||||
host="h",
|
||||
username="u",
|
||||
password_encrypted=encrypt("hunter2"),
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
raw = db.execute(
|
||||
select(SshProfile.password_encrypted).where(SshProfile.id == profile.id)
|
||||
).scalar_one()
|
||||
assert "hunter2" not in raw
|
||||
assert decrypt(raw) == "hunter2"
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Acting on a machine over SSH.
|
||||
|
||||
Driven against a real asyncssh server on 127.0.0.1 with a generated host key, so
|
||||
nothing here touches an outside network and the host-key path is exercised for
|
||||
real rather than mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from lembas.services.agent import ssh
|
||||
from lembas.services.agent.base import ExecError, ExecRequest
|
||||
|
||||
asyncssh = pytest.importorskip("asyncssh")
|
||||
|
||||
|
||||
# --- A server to talk to -------------------------------------------------------
|
||||
def _generate_key():
|
||||
return asyncssh.generate_private_key("ssh-ed25519")
|
||||
|
||||
|
||||
class _Server(asyncssh.SSHServer):
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
return False # no authentication wanted; every login succeeds
|
||||
|
||||
|
||||
async def _handler(process):
|
||||
"""A shell that understands just enough to be tested against."""
|
||||
command = process.command or ""
|
||||
if "sleep" in command:
|
||||
await asyncio.sleep(5)
|
||||
process.exit(0)
|
||||
return
|
||||
if "flood" in command:
|
||||
process.stdout.write("x" * 200_000)
|
||||
process.exit(0)
|
||||
return
|
||||
if "fail" in command:
|
||||
process.stderr.write("it went wrong\n")
|
||||
process.exit(3)
|
||||
return
|
||||
if "colour" in command:
|
||||
process.stdout.write("\x1b[31mred\x1b[0m\n")
|
||||
process.exit(0)
|
||||
return
|
||||
process.stdout.write(f"ran: {command}\n")
|
||||
process.exit(0)
|
||||
|
||||
|
||||
async def _start(host_key=None):
|
||||
server = await asyncssh.create_server(
|
||||
_Server,
|
||||
"127.0.0.1",
|
||||
0,
|
||||
server_host_keys=[host_key or _generate_key()],
|
||||
process_factory=_handler,
|
||||
sftp_factory=True,
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
return server, port
|
||||
|
||||
|
||||
def _spec(port: int, host_key: str, **overrides) -> dict:
|
||||
base = {
|
||||
"id": "p1",
|
||||
"label": "test box",
|
||||
"host": "127.0.0.1",
|
||||
"port": port,
|
||||
"username": "tester",
|
||||
"auth": "key",
|
||||
"password": "",
|
||||
"private_key": "",
|
||||
"key_passphrase": "",
|
||||
"host_key": host_key,
|
||||
"connect_timeout": 5,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def project(tmp_path):
|
||||
"""A directory of its own.
|
||||
|
||||
Not `tmp_path` itself: the autouse database fixture points the data
|
||||
directory there, so a listing would find lembas.db and the uploads folder.
|
||||
"""
|
||||
path = tmp_path / "project"
|
||||
path.mkdir()
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def box(project):
|
||||
"""A running server, its pinned host key line, and a project directory."""
|
||||
key = _generate_key()
|
||||
server, port = await _start(key)
|
||||
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
||||
try:
|
||||
yield {
|
||||
"port": port,
|
||||
"host_key": line,
|
||||
"fingerprint": fingerprint,
|
||||
"dir": str(project),
|
||||
"server": server,
|
||||
}
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
# --- The four defaults that must never be left to asyncssh --------------------
|
||||
def test_the_dangerous_defaults_are_all_passed_explicitly():
|
||||
"""Every LLeMbas user shares one unix account, so "whatever the account has
|
||||
lying around" is never the right answer. This is the most important test in
|
||||
the file and it needs no server at all."""
|
||||
kwargs = ssh._connect_kwargs(_spec(22, "127.0.0.1 ssh-ed25519 AAAA\n"))
|
||||
|
||||
# Bytes, never None: None turns host key checking off entirely.
|
||||
assert isinstance(kwargs["known_hosts"], bytes)
|
||||
assert kwargs["known_hosts"] != b""
|
||||
# Not left to load ~/.ssh/id_*, which could be another person's key.
|
||||
assert kwargs["client_keys"] == []
|
||||
# Not left to read ~/.ssh/config, where ProxyCommand could redirect us.
|
||||
assert kwargs["config"] is None
|
||||
# Not left to use $SSH_AUTH_SOCK.
|
||||
assert kwargs["agent_path"] is None
|
||||
|
||||
|
||||
def test_a_profile_with_no_confirmed_host_key_refuses_to_connect():
|
||||
with pytest.raises(ExecError, match="host key has not been confirmed"):
|
||||
ssh._connect_kwargs(_spec(22, ""))
|
||||
|
||||
|
||||
def test_a_password_profile_sends_a_password_and_no_keys():
|
||||
kwargs = ssh._connect_kwargs(
|
||||
_spec(22, "h ssh-ed25519 AAAA\n", auth="password", password="hunter2")
|
||||
)
|
||||
assert kwargs["password"] == "hunter2"
|
||||
assert kwargs["client_keys"] == []
|
||||
|
||||
|
||||
def test_a_key_profile_sends_no_password():
|
||||
kwargs = ssh._connect_kwargs(_spec(22, "h ssh-ed25519 AAAA\n", password="stale"))
|
||||
assert kwargs["password"] is None
|
||||
|
||||
|
||||
# --- Trust on first use --------------------------------------------------------
|
||||
async def test_the_first_look_captures_a_key_and_a_fingerprint(box):
|
||||
assert box["host_key"].startswith(f"[127.0.0.1]:{box['port']} ssh-ed25519 ")
|
||||
assert box["fingerprint"].startswith("SHA256:")
|
||||
|
||||
|
||||
async def test_capturing_a_key_offers_no_credential():
|
||||
"""get_server_host_key completes the key exchange and stops, which is what
|
||||
makes accepting a fingerprint from a button safe: nothing is sent to a host
|
||||
that has not been accepted yet.
|
||||
|
||||
The server here records every authentication attempt, so an empty list is
|
||||
evidence rather than an absence of it.
|
||||
"""
|
||||
attempts: list[str] = []
|
||||
|
||||
class Watchful(asyncssh.SSHServer):
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
attempts.append(username)
|
||||
return False
|
||||
|
||||
server = await asyncssh.create_server(
|
||||
Watchful, "127.0.0.1", 0, server_host_keys=[_generate_key()]
|
||||
)
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
try:
|
||||
line, fingerprint = await ssh.capture_host_key("127.0.0.1", port)
|
||||
assert line and fingerprint.startswith("SHA256:")
|
||||
assert attempts == [], "a host not yet accepted was offered a username"
|
||||
finally:
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
async def test_a_host_that_answers_with_a_different_key_is_refused(box):
|
||||
"""The pinned key is the whole of the protection. A host presenting another
|
||||
one is either rebuilt or is not the host."""
|
||||
other_line, _ = await ssh.capture_host_key("127.0.0.1", box["port"])
|
||||
wrong = other_line.rsplit(" ", 1)[0] + " " + "A" * 68 + "\n"
|
||||
|
||||
executor = ssh.SshExecutor(_spec(box["port"], wrong), box["dir"])
|
||||
with pytest.raises(ExecError):
|
||||
await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
|
||||
|
||||
async def test_an_unreachable_host_says_so():
|
||||
with pytest.raises(ExecError, match="Could not reach"):
|
||||
await ssh.capture_host_key("127.0.0.1", 1, timeout=3)
|
||||
|
||||
|
||||
# --- Running commands ----------------------------------------------------------
|
||||
async def test_a_command_runs_and_comes_back(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
|
||||
assert result.ok
|
||||
assert result.exit_status == 0
|
||||
assert "echo hi" in result.output
|
||||
|
||||
|
||||
async def test_the_working_directory_is_set_and_never_spliced_in(box):
|
||||
"""Every command is a fresh shell, so `cd` cannot carry between calls. The
|
||||
directory is quoted, because `cwd` may come from the model."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/a dir")
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
assert "cd '/tmp/a dir' && echo hi" in result.output
|
||||
|
||||
|
||||
async def test_a_directory_with_a_quote_in_it_cannot_end_the_quoting(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), "/tmp/it's; rm -rf /")
|
||||
result = await executor.run(ExecRequest(command="echo hi", timeout=5))
|
||||
assert "'/tmp/it'\\''s; rm -rf /'" in result.output
|
||||
|
||||
|
||||
async def test_a_failing_command_is_a_result_not_an_error(box):
|
||||
"""A command that ran and failed is something the model should read and
|
||||
react to. Only being unable to act at all is an ExecError."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="fail", timeout=5))
|
||||
|
||||
assert result.exit_status == 3
|
||||
assert result.ok is False
|
||||
assert "it went wrong" in result.output, "stderr is interleaved, not dropped"
|
||||
|
||||
|
||||
async def test_a_slow_command_times_out_and_says_so(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="sleep", timeout=1))
|
||||
|
||||
assert result.timed_out is True
|
||||
assert result.ok is False
|
||||
assert "was stopped" in result.output
|
||||
|
||||
|
||||
async def test_a_flood_of_output_is_capped(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="flood", timeout=10, max_bytes=2000))
|
||||
|
||||
assert result.truncated is True
|
||||
assert len(result.output) < 2200
|
||||
assert result.output.endswith("(truncated)")
|
||||
|
||||
|
||||
async def test_terminal_escapes_are_stripped(box):
|
||||
"""Inert in escaped HTML, but this text re-enters the model's context, where
|
||||
they are a known way to hide instructions -- and a log a person later cats."""
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
result = await executor.run(ExecRequest(command="colour", timeout=5))
|
||||
|
||||
assert "red" in result.output
|
||||
assert "\x1b" not in result.output
|
||||
|
||||
|
||||
# --- Files, over SFTP ----------------------------------------------------------
|
||||
async def test_a_file_is_written_and_read_back(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
|
||||
written = await executor.write_file("hello.txt", "a mallorn tree\n")
|
||||
assert written == len("a mallorn tree\n")
|
||||
assert await executor.read_file("hello.txt") == "a mallorn tree\n"
|
||||
|
||||
|
||||
async def test_a_relative_path_resolves_against_the_project_directory(box, project):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
await executor.write_file("nested.txt", "here")
|
||||
|
||||
assert (project / "nested.txt").read_text() == "here"
|
||||
|
||||
|
||||
async def test_a_missing_file_is_reported_plainly(box):
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
with pytest.raises(ExecError, match="no file at"):
|
||||
await executor.read_file("nope.txt")
|
||||
|
||||
|
||||
async def test_a_directory_lists(box, project):
|
||||
(project / "one.txt").write_text("1")
|
||||
(project / "two.txt").write_text("2")
|
||||
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
assert await executor.list_dir() == ["one.txt", "two.txt"]
|
||||
|
||||
|
||||
async def test_reading_a_file_is_capped(box, project):
|
||||
(project / "big.txt").write_text("y" * 50_000)
|
||||
executor = ssh.SshExecutor(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
|
||||
text = await executor.read_file("big.txt", max_bytes=1000)
|
||||
assert len(text) <= 1000
|
||||
|
||||
|
||||
# --- The check a person presses ------------------------------------------------
|
||||
async def test_check_reports_what_it_found(box):
|
||||
found = await ssh.check(_spec(box["port"], box["host_key"]), box["dir"])
|
||||
assert found["ok"] is True
|
||||
assert "uname" in found["output"]
|
||||
|
||||
|
||||
# --- The snapshot --------------------------------------------------------------
|
||||
def test_spec_from_decrypts_the_credential_and_the_row_does_not_hold_it(db, user_id):
|
||||
from lembas.db.models import SshProfile
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
profile = SshProfile(
|
||||
owner_id=user_id,
|
||||
name="box",
|
||||
host="10.0.0.5",
|
||||
username="root",
|
||||
private_key_encrypted=encrypt("PRIVATE KEY MATERIAL"),
|
||||
host_key="h ssh-ed25519 AAAA\n",
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
|
||||
spec = ssh.spec_from(profile)
|
||||
assert spec["private_key"] == "PRIVATE KEY MATERIAL"
|
||||
assert "PRIVATE KEY MATERIAL" not in profile.private_key_encrypted
|
||||
Reference in New Issue
Block a user