Skip to content

feat: Provision to add Custom TLS #2519

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions press/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@
"press.press.doctype.press_webhook_log.press_webhook_log.clean_logs_older_than_24_hours",
"press.press.doctype.virtual_disk_snapshot.virtual_disk_snapshot.sync_all_snapshots_from_aws",
"press.press.doctype.payment_due_extension.payment_due_extension.remove_payment_due_extension",
"press.press.doctype.tls_certificate.tls_certificate.alert_custom_provider_tls_renewal",
],
"hourly": [
"press.press.doctype.site.backups.cleanup_local",
Expand Down
76 changes: 69 additions & 7 deletions press/press/doctype/tls_certificate/tls_certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,6 @@

frappe.ui.form.on('TLS Certificate', {
refresh: function (frm) {
frm.add_custom_button(__('Obtain Certificate'), () => {
frm.call({
method: 'obtain_certificate',
doc: frm.doc,
callback: (result) => frm.refresh(),
});
});
if (frm.doc.wildcard) {
frm.add_custom_button(__('Trigger Callback'), () => {
frm.call({
Expand All @@ -19,6 +12,27 @@ frappe.ui.form.on('TLS Certificate', {
});
});
}

frm.trigger('show_obtain_certificate');
frm.trigger('toggle_read_only');
frm.trigger('toggle_hidden');
frm.trigger('toggle_copy_private_key');
},

provider: function (frm) {
frm.trigger('show_obtain_certificate');
frm.trigger('toggle_read_only');
frm.trigger('toggle_hidden');
frm.trigger('toggle_copy_private_key');
},

wildcard: function (frm) {
frm.trigger('toggle_read_only');
frm.trigger('toggle_hidden');
frm.trigger('toggle_copy_private_key');
},

toggle_copy_private_key: function (frm) {
if (!frm.doc.wildcard) {
frm.add_custom_button('Copy Private Key', () => {
frappe.confirm(
Expand All @@ -30,6 +44,54 @@ frappe.ui.form.on('TLS Certificate', {
() => frappe.utils.copy_to_clipboard(frm.doc.private_key),
);
});
} else {
if (frm.doc.provider == "Let's Encrypt") {
console.log("Let's Encrypt");
frm.remove_custom_button('Copy Private Key');
}
}
},

show_obtain_certificate: function (frm) {
if (frm.doc.provider == "Let's Encrypt") {
frm.add_custom_button(__('Obtain Certificate'), () => {
frm.call({
method: 'obtain_certificate',
doc: frm.doc,
callback: (result) => frm.refresh(),
});
});
} else {
frm.remove_custom_button(__('Obtain Certificate'));
}
},

toggle_read_only: function (frm) {
let fields = [
'certificate',
'private_key',
'intermediate_chain',
'full_chain',
'issued_on',
'expires_on',
'team',
];
fields.forEach(function (field) {
frm.set_df_property(
field,
'read_only',
frm.doc.provider == "Let's Encrypt",
);
frm.refresh_field(field);
});
},

toggle_hidden: function (frm) {
frm.set_df_property(
'private_key',
'hidden',
frm.doc.provider == "Let's Encrypt",
);
frm.refresh_field('private_key');
},
});
14 changes: 8 additions & 6 deletions press/press/doctype/tls_certificate/tls_certificate.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"section_break_10",
"decoded_certificate",
"certificate",
"full_chain",
"intermediate_chain",
"full_chain",
"private_key",
"section_break_cvcg",
"error",
Expand Down Expand Up @@ -50,8 +50,7 @@
"fieldname": "decoded_certificate",
"fieldtype": "Code",
"label": "Decoded Certificate",
"read_only": 1,
"read_only_depends_on": "eval: doc.provider === \"Let's Encrypt\""
"read_only": 1
},
{
"fieldname": "status",
Expand All @@ -77,7 +76,7 @@
"fieldtype": "Select",
"label": "RSA Key Size",
"options": "2048\n3072\n4096",
"read_only_depends_on": "eval: doc.wildcard",
"read_only_depends_on": "eval: doc.wildcard && doc.provider === 'Other'",
"reqd": 1
},
{
Expand All @@ -103,20 +102,23 @@
"hide_border": 1
},
{
"description": "Only Domain\u2019s Certificate",
"fieldname": "certificate",
"fieldtype": "Code",
"label": "Certificate",
"read_only": 1,
"read_only_depends_on": "eval: doc.provider === \"Let's Encrypt\""
},
{
"devscription": "Certificate + Intermediate + Trust chain for non lets encrypt certificates",
"fieldname": "full_chain",
"fieldtype": "Code",
"label": "Full Chain",
"read_only": 1,
"read_only_depends_on": "eval: doc.provider == \"Let's Encrypt\""
},
{
"description": "Chain certificate to establish trust (Intermediate + Trust chain for non lets encrypt certificates)",
"fieldname": "intermediate_chain",
"fieldtype": "Code",
"label": "Intermediate Chain",
Expand Down Expand Up @@ -169,7 +171,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-03-30 18:03:13.270000",
"modified": "2025-04-07 14:24:48.585403",
"modified_by": "Administrator",
"module": "Press",
"name": "TLS Certificate",
Expand Down Expand Up @@ -207,4 +209,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}
88 changes: 88 additions & 0 deletions press/press/doctype/tls_certificate/tls_certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import frappe
import OpenSSL
from frappe.model.document import Document
from frappe.query_builder.functions import Date

from press.api.site import check_dns_cname_a
from press.exceptions import (
Expand Down Expand Up @@ -61,6 +62,16 @@
def after_insert(self):
self.obtain_certificate()

def validate(self):
if self.provider == "Other":
if not self.team:
frappe.throw("Team is mandatory for custom TLS certificates.")

Check warning on line 68 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L67-L68

Added lines #L67 - L68 were not covered by tests

self.configure_full_chain()
self.validate_key_length()
self.validate_key_certificate_association()
self._extract_certificate_details()

Check warning on line 73 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L70-L73

Added lines #L70 - L73 were not covered by tests

def on_update(self):
if self.is_new():
return
Expand All @@ -72,6 +83,7 @@
def obtain_certificate(self):
if self.provider != "Let's Encrypt":
return

if self.retry_count >= RETRY_LIMIT:
frappe.throw("Retry limit exceeded. Please check the error and try again.", TLSRetryLimitExceeded)
(
Expand All @@ -83,6 +95,7 @@
frappe.session.data,
get_current_team(),
)

frappe.set_user(frappe.get_value("Team", team, "user"))
frappe.enqueue_doc(
self.doctype,
Expand Down Expand Up @@ -200,6 +213,50 @@
self.issued_on = datetime.strptime(x509.get_notBefore().decode(), "%Y%m%d%H%M%SZ")
self.expires_on = datetime.strptime(x509.get_notAfter().decode(), "%Y%m%d%H%M%SZ")

def configure_full_chain(self):
if not self.full_chain:
self.full_chain = f"{self.certificate}\n{self.intermediate_chain}"

Check warning on line 218 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L217-L218

Added lines #L217 - L218 were not covered by tests

def _get_private_key_object(self):
try:
return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, self.private_key)
except OpenSSL.crypto.Error as e:
log_error("TLS Private Key Exception", certificate=self.name)
raise e

Check warning on line 225 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L221-L225

Added lines #L221 - L225 were not covered by tests

def _get_certificate_object(self):
try:
return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, self.full_chain)
except OpenSSL.crypto.Error as e:
log_error("Custom TLS Certificate Exception", certificate=self.name)
raise e

Check warning on line 232 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L228-L232

Added lines #L228 - L232 were not covered by tests

def validate_key_length(self):
private_key = self._get_private_key_object()

Check warning on line 235 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L235

Added line #L235 was not covered by tests

if private_key.bits() != int(self.rsa_key_size):
frappe.throw(

Check warning on line 238 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L237-L238

Added lines #L237 - L238 were not covered by tests
f"Private key length does not match the selected RSA key size. Expected {self.rsa_key_size} bits, got {private_key.bits()} bits."
)

def validate_key_certificate_association(self):
context = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
context.use_privatekey(self._get_private_key_object())
context.use_certificate(self._get_certificate_object())

Check warning on line 245 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L243-L245

Added lines #L243 - L245 were not covered by tests

try:
context.check_privatekey()
self.status = "Active"
self.retry_count = 0
self.error = None
except OpenSSL.SSL.Error as e:
self.error = repr(e)
log_error("TLS Key Certificate Association Exception", certificate=self.name)
frappe.throw("Private Key and Certificate do not match")

Check warning on line 255 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L247-L255

Added lines #L247 - L255 were not covered by tests
finally:
if self.error:
self.status = "Failure"

Check warning on line 258 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L257-L258

Added lines #L257 - L258 were not covered by tests


get_permission_query_conditions = get_permission_query_conditions_for_doctype("TLS Certificate")

Expand Down Expand Up @@ -264,7 +321,9 @@
for certificate in pending:
if tls_renewal_queue_size and (renewals_attempted >= tls_renewal_queue_size):
break

site = frappe.db.get_value("Site Domain", {"tls_certificate": certificate.name}, "site")

try:
if not should_renew(site, certificate):
continue
Expand All @@ -286,6 +345,35 @@
frappe.db.commit()


def alert_custom_provider_tls_renewal():
seven_days = frappe.utils.add_days(None, 7).date()
fifteen_days = frappe.utils.add_days(None, 15).date()

Check warning on line 350 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L349-L350

Added lines #L349 - L350 were not covered by tests

tls_cert = frappe.qb.DocType("TLS Certificate")

Check warning on line 352 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L352

Added line #L352 was not covered by tests

# Notify team members 15 days and 7 days before expiry

query = (

Check warning on line 356 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L356

Added line #L356 was not covered by tests
frappe.qb.from_(tls_cert)
.select(tls_cert.name, tls_cert.domain, tls_cert.team, tls_cert.expires_on)
.where(tls_cert.status.isin(["Active", "Failure"]))
.where((Date(tls_cert.expires_on) == seven_days) | (Date(tls_cert.expires_on) == fifteen_days))
.where(tls_cert.provider == "Other")
)

pending = query.run(as_dict=True)

Check warning on line 364 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L364

Added line #L364 was not covered by tests

for certificate in pending:
if certificate.team:
notify_email = frappe.get_value("Team", certificate.team, "notify_email")

Check warning on line 368 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L366-L368

Added lines #L366 - L368 were not covered by tests

frappe.sendmail(

Check warning on line 370 in press/press/doctype/tls_certificate/tls_certificate.py

View check run for this annotation

Codecov / codecov/patch

press/press/doctype/tls_certificate/tls_certificate.py#L370

Added line #L370 was not covered by tests
recipients=notify_email,
subject=f"TLS Certificate Renewal Required: {certificate.name}",
message=f"TLS Certificate {certificate.name} is due for renewal on {certificate.expires_on}. Please renew the certificate to avoid service disruption.",
)


def update_server_tls_certifcate(server, certificate):
try:
proxysql_admin_password = None
Expand Down
Loading