Skip to content
Open
Show file tree
Hide file tree
Changes from 22 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: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@
"phpmyadmin",
"pids",
"Pjpw",
"pkcs",
"pkgs",
"pmadb",
"pmezard",
Expand Down Expand Up @@ -593,6 +594,7 @@
"sprintf",
"squashfs",
"Srednekolymsk",
"srem",
"Starke",
"stdc",
"stime",
Expand Down
97 changes: 97 additions & 0 deletions press/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
from __future__ import annotations

import _io # type: ignore
import base64
import json
import os
import re
import time
from contextlib import suppress
from datetime import date
from typing import TYPE_CHECKING, Any, Literal

import frappe
import frappe.utils
import requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from frappe.utils.password import get_decrypted_password
from requests.exceptions import HTTPError

Expand All @@ -26,6 +29,7 @@
if TYPE_CHECKING:
from io import BufferedReader

from press.press.doctype.agent_auth.agent_auth import AgentAuth
from press.press.doctype.agent_job.agent_job import AgentJob
from press.press.doctype.app_patch.app_patch import AgentPatchConfig, AppPatch
from press.press.doctype.bench.bench import Bench
Expand Down Expand Up @@ -949,13 +953,105 @@ 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_agent_public_key(self):
key = f"{self.server}_agent_public_key"

public_key = frappe.cache().get_value(key)

if not public_key:
agent_auth: AgentAuth = frappe.get_doc(
"Agent Auth",
self.server,
)

public_key = agent_auth.public_key

if not frappe.cache().get_value(f"{self.server}_regenerate_public_key"):
# Don't set cache while regenerating. Old public key may get cached again.
frappe.cache().set_value(
key,
public_key,
expires_in_sec=3600,
)

return public_key

def get_regenerate_public_key(self):
key = f"{self.server}_regenerate_public_key"

regenerate_key = frappe.cache().get_value(key)
if not regenerate_key:
agent_auth: AgentAuth = frappe.get_doc(
"Agent Auth",
self.server,
)

if agent_auth.regenerate_public_key:
agent_auth.regenerate_public_key = None
agent_auth.save(ignore_permissions=True)

return None

return regenerate_key

def _verify_request_token(self, token: str):
from cryptography.exceptions import InvalidSignature

payload_b64, signature_b64 = token.split(".")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

payload_bytes = base64.urlsafe_b64decode(payload_b64 + "==")
signature = base64.urlsafe_b64decode(signature_b64 + "==")

public_keys = [self.get_agent_public_key()]

regenerate_key = self.get_regenerate_public_key()

if regenerate_key:
public_keys.append(regenerate_key)

verified = False

for key in public_keys:
try:
public_key = Ed25519PublicKey.from_public_bytes(base64.b64decode(key))

public_key.verify(signature, payload_bytes)

verified = True
break

except InvalidSignature:
pass

if not verified:
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
raise ValueError("Invalid token signature")

payload = json.loads(payload_bytes)

if payload["exp"] < (time.time() - 60):
raise ValueError("Token expired")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

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

return True

def extract_and_verify_token(self, token):
if not token:
raise ValueError("Unsigned request from agent")

self._verify_request_token(
token=token,
)

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 +1122,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
13 changes: 13 additions & 0 deletions press/api/agent_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import frappe

from press.agent import Agent


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

if not agent_token:
frappe.throw_permission_error()

agent = Agent(server)
agent.extract_and_verify_token(agent_token)
34 changes: 34 additions & 0 deletions press/api/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
from __future__ import annotations

import ipaddress
import json

import frappe
from frappe.rate_limiter import rate_limit

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

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

verify_agent(server)
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated

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=10, seconds=60)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
def update_job(job, server):
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
flag = frappe.db.get_single_value("Press Settings", "push_feature")
if not flag:
return

if not job:
return

verify_agent(server)

job = json.loads(job)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

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

Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
handle_polled_job(polled_job=job, job=job_doc)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
5 changes: 4 additions & 1 deletion press/api/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import frappe
from frappe.rate_limiter import rate_limit

from press.api.agent_auth import verify_agent
from press.exceptions import AlertRuleNotEnabled
from press.press.doctype.monitor_server.monitor_server import get_monitor_server_ips
from press.utils import log_error, servers_using_alternative_port_for_communication
Expand Down Expand Up @@ -159,7 +160,9 @@ def get_targets_method_rate_limit() -> int:

@frappe.whitelist(allow_guest=True)
@rate_limit(limit=get_targets_method_rate_limit, seconds=MONITORING_ENDPOINT_RATE_LIMIT_WINDOW_SECONDS)
def targets(token: str | None = None):
def targets(server: str, token: str | None = None):
verify_agent(server)
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated

if not token:
frappe.throw_permission_error()
monitor_token = frappe.db.get_single_value("Press Settings", "monitor_token", cache=True)
Expand Down
9 changes: 3 additions & 6 deletions press/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from frappe.utils.caching import redis_cache
from frappe.utils.password import get_decrypted_password

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 @@ -974,20 +975,16 @@ def rename(name, title):


@frappe.whitelist(allow_guest=True)
def benches_are_idle(server: str, access_token: str) -> None:
def benches_are_idle(server: str) -> None:
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
"""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
verify_agent(server)

primary_server, is_server_scaled_up = frappe.db.get_value(
"Server", {"secondary_server": server}, ["name", "scaled_up"]
Expand Down
2 changes: 2 additions & 0 deletions press/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
scheduler_events = {
"weekly_long": ["press.press.doctype.marketplace_app.events.auto_review_for_missing_steps"],
"daily": [
"press.press.doctype.agent_auth.agent_auth.regenerate_token",
"press.experimental.doctype.referral_bonus.referral_bonus.credit_referral_bonuses",
"press.press.doctype.log_counter.log_counter.record_counts",
"press.press.doctype.incident.incident.notify_ignored_servers",
Expand Down Expand Up @@ -354,6 +355,7 @@
"press.workflow_engine.doctype.press_workflow.press_workflow.retry_workflow_callbacks",
],
"* * * * *": [
"press.press.doctype.agent_job.agent_job.retry_poll",
"press.press.doctype.virtual_disk_snapshot.virtual_disk_snapshot.sync_physical_backup_snapshots",
"press.workflow_engine.doctype.press_workflow_task.press_workflow_task.retry_tasks",
"press.press.doctype.deploy_candidate_build.deploy_candidate_build.run_scheduled_builds",
Expand Down
1 change: 1 addition & 0 deletions press/playbooks/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- role: mariadb_memory_allocator
- role: nginx
- role: agent
- role: setup_agent_auth
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
- role: node_exporter
- role: mysqld_exporter
- role: deadlock_logger
Expand Down
1 change: 1 addition & 0 deletions press/playbooks/proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- role: user
- role: nginx
- role: agent
- role: setup_agent_auth
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
- role: proxy
- role: node_exporter
- role: user_ssh_certificate
Expand Down
6 changes: 6 additions & 0 deletions press/playbooks/roles/setup_agent_auth/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
- name: Write agent token
copy:
content: "{{ agent_token }}"
dest: "/home/frappe/agent/agent.token"
mode: '0600'
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions press/playbooks/self_hosted.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- role: user
- role: nginx
- role: agent
- role: setup_agent_auth
- role: bench
- role: docker
- role: node_exporter
Expand Down
1 change: 1 addition & 0 deletions press/playbooks/self_hosted_db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- role: mariadb
- role: nginx
- role: agent
- role: setup_agent_auth
- role: node_exporter
- role: mysqld_exporter
- role: deadlock_logger
Expand Down
1 change: 1 addition & 0 deletions press/playbooks/self_hosted_proxy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
- role: user
- role: nginx
- role: agent
- role: setup_agent_auth
- role: proxy
- role: docker
1 change: 1 addition & 0 deletions press/playbooks/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- role: user
- role: nginx
- role: agent
- role: setup_agent_auth
Comment thread
tanmoysrt marked this conversation as resolved.
Outdated
- role: mount
- role: bench
- role: docker
Expand Down
8 changes: 8 additions & 0 deletions press/playbooks/setup_agent_auth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
- name: Setup agent auth
hosts: all
become: yes
become_user: frappe
gather_facts: no
roles:
- role: setup_agent_auth
Empty file.
8 changes: 8 additions & 0 deletions press/press/doctype/agent_auth/agent_auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Frappe and contributors
// For license information, please see license.txt

// frappe.ui.form.on("Agent Auth", {
// refresh(frm) {

// },
// });
Loading