Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
eaef4c2
feat(server): Scaffold setup_agent_auth
20vikash May 1, 2026
b6454ef
feat(server): Generate ED25519 key pair, set private key to agent
20vikash May 1, 2026
d6e8876
feat(server): Playbook for agent auth
20vikash May 1, 2026
567135f
feat(agent): Verify response token
20vikash May 1, 2026
47d2b04
refactor(server): Use raw format for public key, pkcs for private
20vikash May 2, 2026
bc16af3
refactor(agent): Also verify response token in raw_request
20vikash May 2, 2026
2db84d8
feat(server): Setup Agent Auth in proxy and database
20vikash May 2, 2026
62628f5
fix(server): Move ED25519 key generation to BaseServer
20vikash May 2, 2026
d841835
feat(server): Update Proxy and Database DOCTYPE
20vikash May 2, 2026
f82609a
refactor(server): Move setup_agent_auth whitelist to BaseServer
20vikash May 3, 2026
56a5522
feat(server): Setup Agent Auth in server setup time
20vikash May 3, 2026
6c19b5c
fix(agent): Don't verify agent responses
20vikash May 3, 2026
f293457
feat(api): Verify agent in whitelists which agent requests
20vikash May 3, 2026
f5f4882
refactor(agent-job): Add agent type annotation to poll_random_jobs
20vikash May 5, 2026
87e76f9
refactor(agent): Send agent signed long lived token
20vikash May 8, 2026
3963c33
feat(agent): Schedule to check for token regeneration daily
20vikash May 8, 2026
df10f26
fix(agent-auth): Handle token regeneration edge cases
20vikash May 9, 2026
6d72f95
feat(callback): Update status given by agent
20vikash May 12, 2026
231704e
feat(agent-job): Publish realtime for each step
20vikash May 12, 2026
956d8a4
refactor(agent-job): Move agent step publish into publish_update
20vikash May 12, 2026
ee77c1d
feat(agent-job): Retry schedule for undelivered jobs
20vikash May 15, 2026
5019d32
feat(press-settings): Add feature flag for agent job push
20vikash May 17, 2026
386b8b1
fix(agent-auth): Dual token window should match ansible timeout
20vikash May 17, 2026
7089750
fix(agent-auth): Reload to avoid stale regenerate public key
20vikash May 17, 2026
2ca8279
fix(agent): Validate callback token format
20vikash May 17, 2026
0f1c0b9
fix(server): Check ansible status before setup equals 1
20vikash May 17, 2026
db8cff1
fix(server): Detach private key and attach agent token to playbook
20vikash May 18, 2026
fcd7d53
fix(server): Save agent auth in setup server
20vikash May 18, 2026
82c95c0
fix(agent): Add validation for agent public keys
20vikash May 18, 2026
6e9f1d6
refactor(callbacks): Increase rate limit from 10 to 500 for update job
20vikash May 18, 2026
596671a
fix(server): Idempotent agent auth setup
20vikash May 18, 2026
0d40b33
fix(callbacks): Add server to filter
20vikash May 18, 2026
f4bf67d
fix(callbacks): Check the instance of job
20vikash May 18, 2026
62c2792
fix(agent-job): Remove undelivered jobs cache after its done
20vikash May 18, 2026
c364466
refactor(callbacks): Enqueue handle polled jobs
20vikash May 18, 2026
acd656a
fix(agent): Throw permission error if verification failed
20vikash May 18, 2026
ad03118
Merge remote-tracking branch 'upstream/develop' into press_agent
20vikash May 18, 2026
2c18ae3
refactor(server): Reduce server on_update complexity
20vikash May 18, 2026
7766468
feat(agent-auth): Add tests
20vikash May 18, 2026
9cd1279
fix(test): Fix mock tests
20vikash May 18, 2026
f9fe94a
feat(test): Add more agent auth tests
20vikash May 18, 2026
afa304e
fix(test-server): Only mock cache.delete_key
20vikash May 18, 2026
dfdf8dc
fix(test): Test fixes
20vikash May 18, 2026
450103d
fix(lint): Fix lint issues
20vikash May 18, 2026
bd1d17a
fix(server): Fix set db healthcheck
20vikash May 18, 2026
0f7c045
fix(test-audit): Fix flaky backup audit by using relative timestamps
20vikash May 18, 2026
29deace
revert(test-audit): Revert flaky test_audit changes
20vikash May 18, 2026
1d07e4f
refactor(agent): Use HS256 and hand off retry and regenerate to agent
20vikash May 24, 2026
5a77ccc
Merge branch 'develop' into press_agent
20vikash May 24, 2026
eb2ab59
fix(ruff): Fix ruff issues
20vikash May 24, 2026
1997a5a
feat(agent): Add test cases
20vikash May 24, 2026
90dec3a
fix(test): Add update_feature patch
20vikash May 24, 2026
1de5014
fix(server): Ignore update_feature on tests
20vikash May 24, 2026
3d64c7d
chore(server): Remove sync_database_server_public_status line
20vikash May 24, 2026
3cd7f3d
fix(callbacks): Fix test cases
20vikash May 24, 2026
5ce545a
chore(agent): Remove printing payload
20vikash May 24, 2026
2984ca1
fix(cspell): Fix formatting
20vikash May 25, 2026
d8f2660
fix(database-server): Fix database server fields
20vikash May 25, 2026
b290e6a
fix(server): Add missing fields
20vikash May 25, 2026
e94d400
Merge branch 'develop' into press_agent
20vikash May 25, 2026
4482173
fix(site): Fix ruff issues
20vikash May 25, 2026
0a9e34e
fix(server): Fix semgrep rules
20vikash May 25, 2026
95c099d
Merge branch 'develop' into press_agent
20vikash May 25, 2026
ed9e891
Merge branch 'develop' into press_agent
20vikash May 26, 2026
a3329e4
Merge branch 'develop' into press_agent
20vikash May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -794,4 +794,4 @@
"libgnutls",
"UDF"
]
}
}
57 changes: 57 additions & 0 deletions press/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import frappe
import frappe.utils
import jwt
import requests
from frappe.utils.password import get_decrypted_password
from requests.exceptions import HTTPError
Expand Down Expand Up @@ -949,13 +950,62 @@ def _make_req(self, method, path, data, files, agent_job_id):
return requests.request(method, url, headers=headers, files=file_objects, verify=verify)
return requests.request(method, url, headers=headers, json=data, verify=verify, timeout=(10, 30))

def get_secret(self):
key = "agent_auth_secret"

secret = frappe.cache().get_value(key)
if not secret:
press_settings = frappe.get_single("Press Settings")
secret = press_settings.get_password("secret")

if not secret:
raise ValueError("Agent auth secret not configured")

frappe.cache().set_value(key, secret)

return secret

def _verify_request_token(self, token: str):
secret = self.get_secret()

try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
options={
"require": ["exp", "server", "jti"],
},
)

except jwt.ExpiredSignatureError as err:
raise ValueError("Token expired") from err

except jwt.InvalidTokenError as err:
raise ValueError("Invalid token") from err

if payload["server"] != self.server:
raise ValueError("Invalid server")

return True

def extract_and_verify_token(self, token):
if not token:
frappe.throw("Unsigned request from agent", frappe.PermissionError)

try:
self._verify_request_token(token=token)
except ValueError:
frappe.throw_permission_error()

def request(self, method, path, data=None, files=None, agent_job=None, raises=True):
self.raise_if_past_requests_have_failed()
response = json_response = None
try:
agent_job_id = agent_job.name if agent_job else None
response = self._make_req(method, path, data, files, agent_job_id)
json_response = response.json()

if raises and response.status_code >= 400:
output = "\n\n".join([json_response.get("output", ""), json_response.get("traceback", "")])
if output == "\n\n":
Expand Down Expand Up @@ -1026,6 +1076,7 @@ def raw_request(self, method, path, data=None, raises=True, timeout=None):
timeout = timeout or (10, 30)
response = requests.request(method, url, headers=headers, json=data, timeout=timeout)
json_response = response.json()

if raises:
response.raise_for_status()
return json_response
Expand Down Expand Up @@ -1265,6 +1316,12 @@ def fetch_bench_status(self, bench):
def get_snapshot(self, bench: str):
return self.get(f"process-snapshot/{bench}")

def enable_feature_flag(self):
return self.post("server/feature/enable")

def disable_feature_flag(self):
return self.post("server/feature/disable")

def run_after_migrate_steps(self, site):
data = {
"admin_password": site.get_password("admin_password"),
Expand Down
61 changes: 61 additions & 0 deletions press/api/agent_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import base64
import json
from typing import TYPE_CHECKING

import frappe
from frappe.rate_limiter import rate_limit

from press.agent import Agent

if TYPE_CHECKING:
from press.press.doctype.server.server import BaseServer


def extract_server_from_token(token: str):
try:
parts = token.split(".")

if len(parts) != 3:
return None

payload_b64 = parts[1]

# fix padding
payload_b64 += "=" * (-len(payload_b64) % 4)

payload = json.loads(base64.urlsafe_b64decode(payload_b64))

return payload.get("server"), payload.get("server_type")

except Exception:
return None


def verify_agent():
agent_token = frappe.request.headers.get("X-Agent-Token")

if not agent_token:
frappe.throw_permission_error()

token_data = extract_server_from_token(agent_token)

if not token_data:
frappe.throw_permission_error()

server, server_type = token_data

agent = Agent(server)
agent.extract_and_verify_token(agent_token)

return server, server_type


@frappe.whitelist(allow_guest=True)
@rate_limit(limit=10, seconds=60)
def regenerate_token():
server, server_type = verify_agent()

doc: BaseServer = frappe.get_doc(server_type, server)

secret = doc._generate_secret()
return doc.sign_agent_token(secret)
54 changes: 53 additions & 1 deletion press/api/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
from __future__ import annotations

import ipaddress
import json
from typing import Any

import frappe
from frappe.rate_limiter import rate_limit

from press.agent import Agent
from press.press.doctype.agent_job.agent_job import handle_polled_job
from press.api.agent_auth import verify_agent
from press.press.doctype.agent_job.agent_job import handle_polled_job, retry_undelivered_jobs
from press.utils import log_error


Expand Down Expand Up @@ -113,8 +116,57 @@ def callback(job_id: str | None = None):
if not server:
frappe.throw("Not permitted", frappe.ValidationError)

verify_agent()

job = verify_job_id(server, job_id)
if not job:
frappe.throw("Invalid Job Id", frappe.ValidationError)

frappe.enqueue(handle_job_updates, server=server, job_identifier=job_id)


@frappe.whitelist(allow_guest=True)
@rate_limit(limit=500, seconds=60)
def update_job(job: str) -> None:
if not job:
return

server, _ = verify_agent()

parsed_job: dict[str, Any] = json.loads(job)

job_doc = frappe.get_value(
"Agent Job",
fieldname=[
"name",
"job_id",
"status",
"callback_failure_count",
"job_type",
],
filters={"job_id": parsed_job["id"], "server": server},
as_dict=True,
)

if not job_doc:
return

handle_polled_job(
polled_job=parsed_job,
job=job_doc,
raise_callback_exception=True,
)


@frappe.whitelist(allow_guest=True)
def retry_undelivered():
server, server_type = verify_agent()

server_obj = frappe._dict(
{
"server": server,
"server_type": server_type,
}
)

retry_undelivered_jobs(server_obj, use_exponential_backoff=False, use_queue_protection=True)
9 changes: 3 additions & 6 deletions press/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from frappe.utils.password import get_decrypted_password

from press.api.account import is_limits_exceeded
from press.api.agent_auth import verify_agent
from press.api.analytics import auto_timespan_timegrain, get_rounded_boundaries, get_rounded_boundary
from press.api.bench import all as all_benches
from press.api.site import protected
Expand Down Expand Up @@ -987,20 +988,16 @@ def rename(name, title):


@frappe.whitelist(allow_guest=True)
def benches_are_idle(server: str, access_token: str) -> None:
def benches_are_idle() -> None:
"""Shut down the secondary server if all benches are idle.

This function is only triggered by secondary servers:
https://github.com/frappe/agent/pull/346/files#diff-7355d9c50cadfa3f4c74fc77a4ad8ab08e4da8f6c3326bbf9b0de0f00a0aa0daR87-R93
"""
from passlib.hash import pbkdf2_sha256 as pbkdf2

server_doc = frappe.get_cached_doc("Server", server)
agent_password = server_doc.get_password("agent_password")
current_user = frappe.session.user

if not pbkdf2.verify(agent_password, access_token):
return
server, _ = verify_agent()

primary_server, is_server_scaled_up = frappe.db.get_value(
"Server", {"secondary_server": server}, ["name", "scaled_up"]
Expand Down
47 changes: 47 additions & 0 deletions press/api/tests/test_agent_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from unittest import TestCase
from unittest.mock import Mock, patch

import frappe

from press.api.agent_auth import verify_agent


class TestAgentAuth(TestCase):
def tearDown(self):
frappe.db.rollback()

def test_verify_agent_throws_without_token(self):
frappe.local.request = frappe._dict({"headers": {}})

self.assertRaises(
frappe.PermissionError,
verify_agent,
)

@patch("press.api.agent_auth.extract_server_from_token")
@patch("press.api.agent_auth.Agent")
def test_verify_agent_calls_extract_and_verify_token(
self,
mock_agent,
mock_extract_server,
):
mock_extract_server.return_value = (
"test-server",
"Server",
)

mock_instance = Mock()
mock_agent.return_value = mock_instance

frappe.local.request = frappe._dict({"headers": {"X-Agent-Token": "test-token"}})

server, server_type = verify_agent()

self.assertEqual(server, "test-server")
self.assertEqual(server_type, "Server")

mock_extract_server.assert_called_once_with("test-token")

mock_agent.assert_called_once_with("test-server")

mock_instance.extract_and_verify_token.assert_called_once_with("test-token")
60 changes: 60 additions & 0 deletions press/api/tests/test_callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import json
from unittest import TestCase
from unittest.mock import patch

import frappe

from press.api.callbacks import (
retry_undelivered,
update_job,
)


class TestCallbacks(TestCase):
def tearDown(self):
frappe.db.rollback()

def test_update_job_returns_when_job_missing(self):
self.assertIsNone(update_job(job=None))

@patch("press.api.callbacks.handle_polled_job")
@patch("press.api.callbacks.verify_agent")
@patch("frappe.get_value")
def test_update_job_calls_handle_polled_job(
self,
mock_get_value,
mock_verify,
mock_handle_polled_job,
):
mock_verify.return_value = (
"test-server",
"Server",
)

mock_get_value.return_value = {
"name": "job-1",
"job_id": "123",
"status": "Running",
"callback_failure_count": 0,
"job_type": "Deploy",
}

update_job(job=json.dumps({"id": "123"}))

mock_handle_polled_job.assert_called_once()

@patch("press.api.callbacks.retry_undelivered_jobs")
@patch("press.api.callbacks.verify_agent")
def test_retry_undelivered(
self,
mock_verify,
mock_retry_jobs,
):
mock_verify.return_value = (
"test-server",
"Server",
)

retry_undelivered()

mock_retry_jobs.assert_called_once()
2 changes: 1 addition & 1 deletion press/playbooks/roles/agent/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
- name: Generate Agent Configuration File
become: yes
become_user: frappe
command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %}'
command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %} {% if agent_token is defined and agent_token is truthy %}--agent-token {{ agent_token }}{% endif %}'
args:
chdir: /home/frappe/agent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
- name: Generate Agent Configuration File
become: yes
become_user: frappe
command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %}'
command: '/home/frappe/agent/env/bin/agent setup config --name {{ server }} --workers {{ workers }} {% if proxy_ip is defined and proxy_ip is truthy %}--proxy-ip {{ proxy_ip }}{% endif %} {% if agent_sentry_dsn is defined and agent_sentry_dsn is truthy %}--sentry-dsn {{ agent_sentry_dsn }}{% endif %} {% if agent_token is defined and agent_token is truthy %}--agent-token {{ agent_token }}{% endif %}'
args:
chdir: /home/frappe/agent

Expand Down
Loading
Loading