"""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 # Stands up something real -- see the `slow` marker in pyproject.toml. pytestmark = pytest.mark.slow 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": "99999", "max_wall_seconds": "1", "max_total_output_bytes": "1", "max_completion_tokens": "0", "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"] == 1000 assert values["approval_timeout"] == 60, "a zero would park a task forever" # Not clamped up to a minimum: zero is how "no ceiling on what a reply may # write" is said, exactly as it is for index_chars. assert values["max_completion_tokens"] == 0 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"