Skip to content

Commit f848b12

Browse files
akremenetskyKulv3r
authored andcommitted
First implementation of certificates
The Secret Manager service is extended with an ability to issue certificates. Only `dns_core` method/provider is supported for the first version. This method supposed DNS challenges via Core DNS that is available from the internet. Examples: ```bash curl --location 'http://10.20.0.2:11010/v1/secret/certificates/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer MY_TOKEN' \ --data-raw '{ "name": "my-cert", "project_id": "00000000-0000-0000-0000-000000000000", "method": { "kind": "dns_core" }, "constructor": { "kind": "plain" }, "email": "user@genesis-core.tech", "domains": ["test0.cdns.genesis-core.tech"] }' ``` Also it's possible to specify domains with wildcards. ```bash curl --location 'http://10.20.0.2:11010/v1/secret/certificates/' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer MY_TOKEN' \ --data-raw '{ "name": "my-cert", "project_id": "00000000-0000-0000-0000-000000000000", "method": { "kind": "dns_core" }, "constructor": { "kind": "plain" }, "email": "user@genesis-core.tech", "domains": ["*.test1.cdns.genesis-core.tech", "test1.cdns.genesis-core.tech"] }' ```
1 parent 13a58c3 commit f848b12

17 files changed

Lines changed: 1425 additions & 143 deletions

File tree

etc/genesis_universal_agent/genesis_universal_agent.conf

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ connection_pool_size = 2
1111
[universal_agent]
1212
orch_endpoint = http://localhost:11011
1313
status_endpoint = http://localhost:11012
14-
caps_drivers = CoreCapabilityDriver,PasswordCapabilityDriver
14+
caps_drivers = CoreCapabilityDriver,PasswordCapabilityDriver,CoreDNSCertificateCapabilityDriver
1515

1616

1717
[universal_agent_scheduler]
18-
capabilities = em_core_*,password
18+
capabilities = em_core_*,password,certificate
1919

2020

2121
[CoreCapabilityDriver]
@@ -26,3 +26,10 @@ project_id = 12345678-c625-4fee-81d5-f691897b8142
2626
em_core_compute_nodes = /v1/nodes/
2727
em_core_config_configs = /v1/config/configs/
2828
em_core_secret_passwords = /v1/secret/passwords/
29+
em_core_secret_certificates = /v1/secret/certificates/
30+
31+
32+
[CoreDNSCertificateCapabilityDriver]
33+
username = admin
34+
password = admin
35+
user_api_base_url = http://localhost:11010/v1
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Copyright 2025 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
from __future__ import annotations
17+
18+
import logging
19+
import typing as tp
20+
21+
from cryptography import x509
22+
from restalchemy.dm import filters as dm_filters
23+
from restalchemy.storage import exceptions as ra_exc
24+
from gcl_sdk.agents.universal.dm import models
25+
from gcl_sdk.agents.universal.clients.backend import base
26+
from gcl_sdk.agents.universal.clients.backend import exceptions
27+
28+
from gcl_certbot_plugin import acme
29+
from gcl_certbot_plugin import clients as dns_clients
30+
from gcl_certbot_plugin.acme import acme_lib_client
31+
32+
from genesis_core.secret.dm import models as secret_dm
33+
from genesis_core.agent.universal.drivers.secret.dm import models as driver_dm
34+
35+
LOG = logging.getLogger(__name__)
36+
37+
38+
class CertBotBackendClient(base.AbstractBackendClient):
39+
"""Cert bot backend client."""
40+
41+
DEFAULT_PRIVATE_KEY_PATH = "/etc/genesis_core/certbot/privkey.pem"
42+
43+
def __init__(
44+
self,
45+
dns_client: dns_clients.TinyDNSCoreClient,
46+
admin_email: str,
47+
private_key_path: str = DEFAULT_PRIVATE_KEY_PATH,
48+
) -> None:
49+
self._dns_client = dns_client
50+
self._admin_email = admin_email
51+
self._client_acme: acme_lib_client.ClientV2 | None = None
52+
self._private_key = acme.get_or_create_client_private_key(
53+
private_key_path
54+
)
55+
56+
def _get_or_create_acme_client(self) -> acme_lib_client.ClientV2:
57+
if self._client_acme is None:
58+
self._client_acme = acme.get_acme_client(
59+
self._private_key, self._admin_email
60+
)
61+
return self._client_acme
62+
63+
def get(self, resource: models.Resource) -> dict[str, tp.Any]:
64+
"""Get the resource value in dictionary format."""
65+
try:
66+
cert = driver_dm.Certificate.objects.get_one(
67+
filters={
68+
"uuid": dm_filters.EQ(resource.uuid),
69+
},
70+
)
71+
except ra_exc.RecordNotFound:
72+
raise exceptions.ResourceNotFound(resource=resource)
73+
74+
return cert.to_resource_value()
75+
76+
def create(self, resource: models.Resource) -> dict[str, tp.Any]:
77+
"""Creates the resource. Returns the created resource."""
78+
try:
79+
self.get(resource)
80+
except exceptions.ResourceNotFound:
81+
pass
82+
else:
83+
raise exceptions.ResourceAlreadyExists(resource=resource)
84+
85+
cert = secret_dm.Certificate.from_ua_resource(resource)
86+
87+
# Create cert via DNS
88+
pkey_pem, csr_pem, fullchain_pem = acme.create_cert(
89+
self._get_or_create_acme_client(),
90+
self._dns_client,
91+
cert.domains,
92+
)
93+
cert_x509 = x509.load_pem_x509_certificate(fullchain_pem.encode())
94+
expiration_at = cert_x509.not_valid_after_utc
95+
96+
# Build storagable password and save
97+
driver_cert = driver_dm.Certificate.from_cert_resource(
98+
resource, pkey_pem, csr_pem, fullchain_pem, expiration_at
99+
)
100+
101+
driver_cert.save()
102+
return driver_cert.to_resource_value()
103+
104+
def update(self, resource: models.Resource) -> dict[str, tp.Any]:
105+
"""Update the resource. Returns the updated resource."""
106+
try:
107+
cert = driver_dm.Certificate.objects.get_one(
108+
filters={
109+
"uuid": dm_filters.EQ(resource.uuid),
110+
},
111+
)
112+
except ra_exc.RecordNotFound:
113+
raise exceptions.ResourceNotFound(resource=resource)
114+
115+
# TODO(akremenetsky): It's tricky logic to update the cert
116+
# if domains changed. Need to check domains intersection,
117+
# check wildcards
118+
if set(cert["meta"]["domains"]) != set(resource.value["domains"]):
119+
LOG.error("Not implemented yet")
120+
raise NotImplementedError("Domains changed")
121+
# return cert.to_resource_value()
122+
123+
# Should the cert be renewed?
124+
if not cert.is_under_threshold(cert):
125+
return cert.to_resource_value()
126+
127+
pkey_pem, csr_pem, fullchain_pem = acme.renew_cert(
128+
self._get_or_create_acme_client(),
129+
self._dns_client,
130+
resource.value["domains"],
131+
cert.pkey.encode(),
132+
)
133+
cert_x509 = x509.load_pem_x509_certificate(fullchain_pem.encode())
134+
expiration_at = cert_x509.not_valid_after_utc
135+
136+
driver_cert = driver_dm.Certificate.from_cert_resource(
137+
resource, pkey_pem, csr_pem, fullchain_pem, expiration_at
138+
)
139+
140+
cert.delete()
141+
driver_cert.save()
142+
return driver_cert.to_resource_value()
143+
144+
def list(self, kind: str, **kwargs) -> list[dict[str, tp.Any]]:
145+
"""Lists all resources by kind."""
146+
certs = driver_dm.Certificate.objects.get_all()
147+
148+
return [cert.to_resource_value() for cert in certs]
149+
150+
def delete(self, resource: models.Resource) -> None:
151+
"""Delete the resource."""
152+
try:
153+
self.get(resource)
154+
except exceptions.ResourceNotFound:
155+
raise exceptions.ResourceNotFound(resource=resource)
156+
157+
cert = driver_dm.Certificate.objects.get_one(
158+
filters={
159+
"uuid": dm_filters.EQ(resource.uuid),
160+
}
161+
)
162+
acme.revoke_cert(self._get_or_create_acme_client(), cert.fullchain)
163+
cert.delete()
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright 2025 Genesis Corporation.
2+
#
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
from __future__ import annotations
17+
18+
import logging
19+
20+
from gcl_sdk.agents.universal.drivers import direct
21+
from gcl_sdk.agents.universal.storage import fs
22+
from gcl_sdk.clients.http import base as core_client_base
23+
from gcl_certbot_plugin import clients as dns_clients
24+
25+
from genesis_core.agent.universal.drivers.secret.backend import (
26+
cert as cert_back,
27+
)
28+
29+
30+
LOG = logging.getLogger(__name__)
31+
32+
AGENT_WORK_DIR = "/var/lib/genesis/universal_agent/"
33+
34+
35+
class CoreDNSCertificateCapabilityDriver(direct.DirectAgentDriver):
36+
"""Certificate capability driver."""
37+
38+
def __init__(
39+
self, user_api_base_url: str, username: str, password: str
40+
) -> None:
41+
storage = fs.FileAgentStorage(AGENT_WORK_DIR, "cert_cap_storage.json")
42+
43+
auth = core_client_base.CoreIamAuthenticator(
44+
base_url=user_api_base_url, username=username, password=password
45+
)
46+
dns_client = dns_clients.TinyDNSCoreClient(
47+
base_url=user_api_base_url, auth=auth
48+
)
49+
50+
client = cert_back.CertBotBackendClient(
51+
dns_client, "admin@genesis-core.tech"
52+
)
53+
54+
super().__init__(storage=storage, client=client)
55+
56+
def get_capabilities(self) -> list[str]:
57+
"""Returns a list of capabilities supported by the driver."""
58+
return ["certificate"]

genesis_core/agent/universal/drivers/secret/dm/models.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# under the License.
1616
from __future__ import annotations
1717

18+
import datetime
1819
import typing as tp
1920

2021
from restalchemy.dm import properties
@@ -23,27 +24,29 @@
2324
from restalchemy.storage.sql import orm
2425
from gcl_sdk.agents.universal.dm import models as ua_models
2526

27+
from genesis_core.common import constants as c
2628
from genesis_core.secret import constants as sc
27-
from genesis_core.secret.dm import models as secret_dm
2829

2930

30-
class Password(
31+
class Secret(
3132
models.ModelWithUUID,
3233
models.ModelWithTimestamp,
33-
orm.SQLStorableMixin,
3434
):
35-
__tablename__ = "storage_passwords"
36-
3735
status = properties.property(
3836
types.Enum([s.value for s in sc.SecretStatus]),
3937
default=sc.SecretStatus.NEW.value,
4038
)
39+
# Some additional metadata about the secret
40+
meta = properties.property(types.Dict(), default=lambda: {})
41+
42+
43+
class Password(Secret, orm.SQLStorableMixin):
44+
__tablename__ = "storage_passwords"
45+
4146
value = properties.property(
4247
types.String(min_length=1, max_length=512),
4348
required=True,
4449
)
45-
# Some additional metadata about the secret
46-
meta = properties.property(types.Dict(), default=lambda: {})
4750

4851
@classmethod
4952
def from_password_resource(
@@ -59,3 +62,67 @@ def from_password_resource(
5962
status=sc.SecretStatus.ACTIVE.value,
6063
meta=meta,
6164
)
65+
66+
67+
class Certificate(Secret, orm.SQLStorableMixin):
68+
__tablename__ = "storage_certs"
69+
70+
pkey = properties.property(
71+
types.String(min_length=1, max_length=10240),
72+
required=True,
73+
)
74+
fullchain = properties.property(
75+
types.String(min_length=1, max_length=10240),
76+
required=True,
77+
)
78+
csr = properties.property(
79+
types.String(min_length=1, max_length=10240),
80+
required=True,
81+
)
82+
expiration_at = properties.property(types.UTCDateTimeZ())
83+
84+
@classmethod
85+
def from_cert_resource(
86+
cls,
87+
resource: ua_models.TargetResource,
88+
pkey_pem: bytes,
89+
csr_pem: bytes,
90+
fullchain_pem: str,
91+
expiration_at: datetime.datetime,
92+
) -> Certificate:
93+
meta = resource.value.copy()
94+
meta["status"] = sc.SecretStatus.ACTIVE.value
95+
96+
return cls(
97+
uuid=resource.uuid,
98+
pkey=pkey_pem.decode(),
99+
fullchain=fullchain_pem,
100+
csr=csr_pem.decode(),
101+
status=sc.SecretStatus.ACTIVE.value,
102+
expiration_at=expiration_at,
103+
meta=meta,
104+
)
105+
106+
def is_under_threshold(self) -> bool:
107+
if self.expiration_at < datetime.datetime.now(
108+
tz=datetime.timezone.utc
109+
):
110+
return True
111+
else:
112+
delta = self.expiration_at - datetime.datetime.now(
113+
tz=datetime.timezone.utc
114+
)
115+
return delta.days < self.meta["expiration_threshold"]
116+
117+
def to_resource_value(self) -> dict[str, tp.Any]:
118+
expiration_at = self.expiration_at.replace(
119+
tzinfo=datetime.timezone.utc
120+
)
121+
expiration_at = expiration_at.strftime(c.DEFAULT_DATETIME_FORMAT)
122+
123+
value = self.meta
124+
value["key"] = self.pkey
125+
value["cert"] = self.fullchain
126+
value["expiration_at"] = expiration_at
127+
value["overcome_threshold"] = self.is_under_threshold()
128+
return value

genesis_core/secret/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
DEFAULT_SQL_LIMIT = 100
2020
PASSWORD_KIND = "password"
21+
CERTIFICATE_KIND = "certificate"
2122

2223

2324
class SecretStatus(str, enum.Enum):

0 commit comments

Comments
 (0)