mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 20:46:25 +00:00
71a7550f48
Git servers (forge_hosts) become full credential owners: - an encrypted forge token (Fernet, HANDLER_SECRET_KEY) stored per server and never returned by the API (has_token flag only); used automatically by every project on that host and addressable as db:host:<hostname> — the reserved db: credential scheme is now live - a per-server ed25519 SSH deploy key: generated server-side, public half shown in the dashboard to paste into the forge, private half encrypted at rest and materialized 0600 only in the control container (GIT_SSH_COMMAND / core.sshCommand) Project registration gets a git-server mode: pick a registered server, type owner/name, and the API derives the remote (ssh when the server has a deploy key, https otherwise), computes root_dir under PROJECTS_ROOT, and enqueues a new 'sync' command the worker executes (clone, or ff-only pull). Spawn always pulls first, so runs start from the remote's latest state; POST /projects/:p/sync and 'handler sync' re-pull on demand. Schedules: recurring agent spawns (prefix, prompt, interval, role). The worker fires due schedules as ordinary queued spawn commands with timestamped agent names, so runs are fresh stateless agents and appear in the Activity audit trail; missed intervals collapse into one catch-up run. Dashboard: Git Servers pane shows the SSH public key (copy button) and takes a write-only token; Repositories gains the server-first add form and a Pull now button; new Schedules pane. Rebuilt static export. Also restores the missing frontend/lib (api client + format helpers) the components import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
210 lines
6.7 KiB
Python
210 lines
6.7 KiB
Python
"""Git servers own their credentials: the encrypted token store, the per-server SSH
|
|
deploy key (public half visible, private half encrypted), and the resolution paths
|
|
that hand them to forge/git — including the now-live ``db:host:<hostname>`` scheme."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
|
|
import pytest
|
|
|
|
from handler.control import credentials
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import get_engine
|
|
|
|
|
|
@pytest.fixture
|
|
def secret_key(env, monkeypatch):
|
|
from cryptography.fernet import Fernet
|
|
|
|
from handler import config
|
|
|
|
key = Fernet.generate_key().decode()
|
|
monkeypatch.setenv("HANDLER_SECRET_KEY", key)
|
|
config.get_settings.cache_clear()
|
|
return key
|
|
|
|
|
|
# ------------------------------------------------------------------ secret store
|
|
|
|
|
|
def test_secretstore_roundtrip(secret_key):
|
|
from handler import secretstore
|
|
|
|
assert secretstore.enabled()
|
|
assert secretstore.decrypt(secretstore.encrypt("s3cr3t")) == "s3cr3t"
|
|
|
|
|
|
def test_secretstore_refuses_without_key(env):
|
|
from handler import secretstore
|
|
|
|
assert not secretstore.enabled()
|
|
with pytest.raises(secretstore.SecretStoreError, match="HANDLER_SECRET_KEY"):
|
|
secretstore.encrypt("s3cr3t")
|
|
|
|
|
|
def test_secretstore_wrong_key_is_a_clear_error(secret_key, monkeypatch):
|
|
from cryptography.fernet import Fernet
|
|
|
|
from handler import config, secretstore
|
|
|
|
ciphertext = secretstore.encrypt("s3cr3t")
|
|
monkeypatch.setenv("HANDLER_SECRET_KEY", Fernet.generate_key().decode())
|
|
config.get_settings.cache_clear()
|
|
with pytest.raises(secretstore.SecretStoreError, match="HANDLER_SECRET_KEY changed"):
|
|
secretstore.decrypt(ciphertext)
|
|
|
|
|
|
# ------------------------------------------------------------------ ssh keys
|
|
|
|
|
|
def test_generate_keypair_is_openssh_ed25519():
|
|
from handler import sshkeys
|
|
|
|
private, public = sshkeys.generate_keypair("handler@github.com")
|
|
assert private.startswith("-----BEGIN OPENSSH PRIVATE KEY-----")
|
|
assert public.startswith("ssh-ed25519 ")
|
|
assert public.endswith(" handler@github.com")
|
|
|
|
|
|
def test_materialize_private_key_is_0600_under_projects_root(env):
|
|
from handler import sshkeys
|
|
|
|
path = sshkeys.materialize_private_key("github.com", "KEYDATA")
|
|
assert path.startswith(str(env["tmp"] / "projects"))
|
|
assert os.path.basename(path) == "github.com"
|
|
mode = stat.S_IMODE(os.stat(path).st_mode)
|
|
assert mode == 0o600
|
|
with open(path) as fh:
|
|
assert fh.read() == "KEYDATA\n"
|
|
|
|
|
|
def test_materialize_sanitizes_hostname(env):
|
|
from handler import sshkeys
|
|
|
|
path = sshkeys.materialize_private_key("../evil", "K")
|
|
assert os.path.dirname(path).endswith(".ssh")
|
|
assert "/../" not in path[len(str(env["tmp"])):]
|
|
|
|
|
|
# ------------------------------------------------------------------ hosts API
|
|
|
|
|
|
def test_create_host_with_token_and_ssh_key(client, auth, secret_key):
|
|
r = client.post(
|
|
"/hosts",
|
|
json={
|
|
"hostname": "github.com",
|
|
"forge_type": "github",
|
|
"token": "ghp_secret",
|
|
"generate_ssh_key": True,
|
|
},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
body = r.json()
|
|
assert body["has_token"] is True
|
|
assert body["ssh_public_key"].startswith("ssh-ed25519 ")
|
|
# Secrets never leave the server, not even as keys in the payload.
|
|
assert "token" not in body
|
|
assert "token_enc" not in body
|
|
assert "ssh_private_key_enc" not in body
|
|
|
|
listed = client.get("/hosts", headers=auth).json()
|
|
assert listed[0]["has_token"] is True
|
|
assert "token_enc" not in listed[0]
|
|
|
|
# The row itself holds ciphertext, not the token.
|
|
with get_engine().begin() as conn:
|
|
row = repo.get_host(conn, "github.com")
|
|
assert row["token_enc"] != "ghp_secret"
|
|
from handler import secretstore
|
|
|
|
assert secretstore.decrypt(row["token_enc"]) == "ghp_secret"
|
|
|
|
|
|
def test_create_host_token_without_secret_key_is_400(client, auth):
|
|
r = client.post(
|
|
"/hosts",
|
|
json={"hostname": "github.com", "forge_type": "github", "token": "x"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 400
|
|
assert "HANDLER_SECRET_KEY" in r.json()["detail"]
|
|
|
|
|
|
def test_patch_host_rotate_and_clear(client, auth, secret_key):
|
|
r = client.post(
|
|
"/hosts",
|
|
json={
|
|
"hostname": "gitea.corp",
|
|
"forge_type": "gitea",
|
|
"token": "old",
|
|
"generate_ssh_key": True,
|
|
},
|
|
headers=auth,
|
|
)
|
|
first_key = r.json()["ssh_public_key"]
|
|
|
|
r = client.patch(
|
|
"/hosts/gitea.corp", json={"regenerate_ssh_key": True}, headers=auth
|
|
)
|
|
assert r.json()["ssh_public_key"] != first_key
|
|
|
|
r = client.patch("/hosts/gitea.corp", json={"clear_token": True}, headers=auth)
|
|
assert r.json()["has_token"] is False
|
|
|
|
r = client.patch("/hosts/gitea.corp", json={"clear_ssh_key": True}, headers=auth)
|
|
assert r.json()["ssh_public_key"] is None
|
|
|
|
|
|
# ------------------------------------------------------------------ resolution
|
|
|
|
|
|
def test_db_host_scheme_resolves_stored_token(secret_key):
|
|
from handler import secretstore
|
|
|
|
with get_engine().begin() as conn:
|
|
repo.create_host(
|
|
conn, "github.com", "github", token_enc=secretstore.encrypt("tok123")
|
|
)
|
|
assert credentials.resolve("db:host:github.com") == "tok123"
|
|
|
|
|
|
def test_db_host_scheme_missing_host_or_token(secret_key):
|
|
with pytest.raises(credentials.CredentialError, match="not registered"):
|
|
credentials.resolve("db:host:nowhere.example")
|
|
with get_engine().begin() as conn:
|
|
repo.create_host(conn, "bare.example", "gitea")
|
|
with pytest.raises(credentials.CredentialError, match="no stored token"):
|
|
credentials.resolve("db:host:bare.example")
|
|
|
|
|
|
def test_resolve_for_project_falls_back_to_server_token(secret_key):
|
|
from handler import secretstore
|
|
|
|
with get_engine().begin() as conn:
|
|
repo.create_host(
|
|
conn, "github.com", "github", token_enc=secretstore.encrypt("srv-tok")
|
|
)
|
|
project = {
|
|
"id": "p",
|
|
"git_remote": "git@github.com:me/repo.git",
|
|
"credential_ref": None,
|
|
}
|
|
assert credentials.resolve_for_project(project, conn) == "srv-tok"
|
|
# An explicit credential_ref still wins.
|
|
os.environ["X_TOKEN"] = "own-tok"
|
|
try:
|
|
project["credential_ref"] = "env:X_TOKEN"
|
|
assert credentials.resolve_for_project(project, conn) == "own-tok"
|
|
finally:
|
|
del os.environ["X_TOKEN"]
|
|
|
|
|
|
def test_resolve_for_project_none_without_anything(env):
|
|
with get_engine().begin() as conn:
|
|
project = {"id": "p", "git_remote": None, "credential_ref": None}
|
|
assert credentials.resolve_for_project(project, conn) is None
|