Skip to content
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

NAS-132257 / 25.04 / Add TNC service #15198

Open
wants to merge 72 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
92fcde3
Add basic truenas_cloud plugin
sonicaj Nov 13, 2024
2533518
Add endpoint to get registration uri
sonicaj Nov 13, 2024
672fcae
Add docstring
sonicaj Nov 13, 2024
3277dd1
Add basic db model for tnc
sonicaj Nov 14, 2024
138efa7
Get basic do_update to be in place
sonicaj Nov 14, 2024
8e9feac
Have claim token be explicitly generated
sonicaj Nov 17, 2024
227000b
Add tnc mixin
sonicaj Nov 17, 2024
146ad3f
Add basic tnc finalize svc
sonicaj Nov 17, 2024
55d42d7
Add separate columns for system id token based off jwt/claim tokens
sonicaj Nov 17, 2024
944cc10
Add logic to finalize system registration
sonicaj Nov 17, 2024
7156867
Store decoded token in db
sonicaj Nov 21, 2024
de12fe5
Add basic query for tnc hostname
sonicaj Nov 21, 2024
5b46b7e
Update migration
sonicaj Nov 30, 2024
7d2231d
Handle client connect error in tnc mixin
sonicaj Nov 30, 2024
9922298
Add some metadata to tnc hostname service
sonicaj Nov 30, 2024
86ee3b1
Add ip field to db
sonicaj Nov 30, 2024
853ae8a
Add bits to enable registration/update of hostname
sonicaj Nov 30, 2024
b223b2b
Add basic acme service to retrieve acme config
sonicaj Nov 30, 2024
538ab95
Add tnc authenticator for cert issuance
sonicaj Nov 30, 2024
7fd61ee
Normalize acme config so middleware can consume it
sonicaj Dec 2, 2024
fb816e5
Get basic cert issuance to work
sonicaj Dec 2, 2024
6812c38
Update migration
sonicaj Dec 8, 2024
deeba50
Refind db a bit more
sonicaj Dec 8, 2024
76b627f
Add endpoint to retrieve ip choices for tnc
sonicaj Dec 8, 2024
3c3d41e
Add validation for tn_connect.update
sonicaj Dec 8, 2024
4843b7d
Add status reason to tnc config
sonicaj Dec 8, 2024
6d4e774
Add status updates when tnc config is modified
sonicaj Dec 8, 2024
f6c1c8e
Add method to set status of tnc
sonicaj Dec 8, 2024
33d2a64
Don't return jwt token in tnc config
sonicaj Dec 8, 2024
f52fb83
Unset current configured token etc
sonicaj Dec 8, 2024
610fa87
Set status when generating claim token
sonicaj Dec 8, 2024
ed68abf
Add status management for finalizing registration
sonicaj Dec 8, 2024
31c88d3
Add endpoint for triggering cert generation
sonicaj Dec 9, 2024
6ccd7d7
Add certificate column to tnc table
sonicaj Dec 9, 2024
895b103
Install cert retrieved from acme provider
sonicaj Dec 9, 2024
20431cd
Fix migration
sonicaj Dec 9, 2024
d3d2cdd
Fix enum bug
sonicaj Dec 9, 2024
56ba93b
Fix TNC authenticator
sonicaj Dec 9, 2024
78da35c
Update migration
sonicaj Dec 9, 2024
1c5abaf
Add functionality to unset system from tnc
sonicaj Dec 10, 2024
34e2351
Refactor revoking cert logic
sonicaj Dec 10, 2024
147e066
Revoke tnc cert when tnc is disabled
sonicaj Dec 10, 2024
9c58170
Remove redundant todos
sonicaj Dec 10, 2024
0291e69
Update migration
sonicaj Dec 12, 2024
461ba30
Use PUT to new hostnames endpoint
sonicaj Dec 12, 2024
e88dea6
Update ips with TNC if they change
sonicaj Dec 12, 2024
da1feaf
Trigger registration and cert generation tasks
sonicaj Dec 12, 2024
cd783dc
Inject content-type header if not specified
sonicaj Dec 12, 2024
2accacb
Minor fixes
sonicaj Dec 12, 2024
7c7cc90
Simplify urls
sonicaj Dec 12, 2024
8dd4b6c
Register tn_connect.config event
sonicaj Dec 12, 2024
1c79cd2
Add roles for tnc
sonicaj Dec 12, 2024
a3397b1
Send tnc event on config change
sonicaj Dec 12, 2024
07f7756
Add logging for TNC in separate file
sonicaj Dec 12, 2024
c37b0a4
Make create_cert a job for better tracing
sonicaj Dec 12, 2024
3090526
Initiate cert generation if middleware was restarted
sonicaj Dec 12, 2024
d1fe089
Use unique name for TNC
sonicaj Dec 12, 2024
b085edc
Update migration
sonicaj Dec 18, 2024
deab754
Address reviews
sonicaj Dec 18, 2024
c61f513
Handle edge case when middleware gets restarted in between registrati…
sonicaj Dec 18, 2024
1b4bf70
Update UI with TNC cert
sonicaj Dec 18, 2024
ca7de38
Handle edge case when nginx is not updated with TNC cert
sonicaj Dec 18, 2024
d8e1bea
Add cert attachment delegate for TNC
sonicaj Dec 18, 2024
776ddf9
Do not allow changing anything for tnc cert
sonicaj Dec 18, 2024
1061b69
Make sure renewal works as intended
sonicaj Dec 19, 2024
033e7fa
Prevent changing cert when TNC is configured
sonicaj Dec 19, 2024
e689462
Minor fixes
sonicaj Dec 19, 2024
1dacce9
Update migration
sonicaj Dec 19, 2024
80fb5a0
Minor fixes
sonicaj Dec 19, 2024
5898888
Kick off finalizing registration after a slight delay
sonicaj Dec 21, 2024
e97df47
Do not use hardcoded tos
sonicaj Dec 21, 2024
b3f3f34
Cleanup validation txt entry
sonicaj Dec 21, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
TNC Support

Revision ID: 83d9689fcbc8
Revises: 19cdc9f2d2df
Create Date: 2024-12-19 12:30:41.855489+00:00
"""
from alembic import op
import sqlalchemy as sa


revision = '83d9689fcbc8'
down_revision = '19cdc9f2d2df'
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
'truenas_connect',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('enabled', sa.BOOLEAN(), nullable=False),
sa.Column('jwt_token', sa.TEXT(), nullable=True),
sa.Column('ips', sa.TEXT(), nullable=False, server_default='[]'),
sa.Column('registration_details', sa.TEXT(), nullable=False, server_default='{}'),
sa.Column('status', sa.String(length=255), nullable=False),
sa.Column('certificate_id', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('pk_truenas_connect')),
sa.ForeignKeyConstraint(
['certificate_id'], ['system_certificate.id'],
name=op.f('fk_truenas_connect_certificate_id_system_certificate')
),
sqlite_autoincrement=True,
)
with op.batch_alter_table('truenas_connect', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_truenas_connect_certificate_id'), ['certificate_id'], unique=False)


def downgrade():
pass
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/v25_04_0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from .system_ntpserver import * # noqa
from .system_reboot import * # noqa
from .system_security import * # noqa
from .tn_connect import * # noqa
from .truenas import * # noqa
from .user import * # noqa
from .vendor import * # noqa
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
'ACMEDNSAuthenticatorDeleteResult', 'ACMEDNSAuthenticatorSchemasArgs', 'ACMEDNSAuthenticatorSchemasResult',
'ACMEDNSAuthenticatorPerformChallengeArgs', 'ACMEDNSAuthenticatorPerformChallengeResult', 'Route53SchemaArgs',
'ACMECustomDNSAuthenticatorReturns', 'CloudFlareSchemaArgs', 'OVHSchemaArgs', 'ShellSchemaArgs',
'TrueNASConnectSchemaArgs',
]


Expand All @@ -32,6 +33,15 @@ class ACMECustomDNSAuthenticatorReturns(BaseModel):
result: dict


class TrueNASConnectSchema(BaseModel):
pass


@single_argument_args('attributes')
class TrueNASConnectSchemaArgs(TrueNASConnectSchema):
pass


class CloudFlareSchema(BaseModel):
authenticator: Literal['cloudflare']
cloudflare_email: NonEmptyString | None = Field(default=None, description='Cloudflare Email')
Expand Down Expand Up @@ -133,7 +143,7 @@ class ACMEDNSAuthenticatorDeleteResult(BaseModel):

@single_argument_args('acme_dns_authenticator_performance_challenge')
class ACMEDNSAuthenticatorPerformChallengeArgs(BaseModel):
authenticator: int
authenticator: int | None
key: LongString
domain: str
challenge: LongString
Expand Down
53 changes: 53 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/tn_connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from pydantic import IPvAnyAddress

from middlewared.api.base import BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args


__all__ = [
'TNCEntry', 'TNCGetRegistrationURIArgs', 'TNCGetRegistrationURIResult', 'TNCUpdateArgs', 'TNCUpdateResult',
'TNCGenerateClaimTokenArgs', 'TNCGenerateClaimTokenResult', 'TNCIPChoicesArgs', 'TNCIPChoicesResult',
]


class TNCEntry(BaseModel):
id: int
enabled: bool
registration_details: dict
ips: list[NonEmptyString]
status: NonEmptyString
status_reason: NonEmptyString
certificate: int | None


@single_argument_args('tn_connect_update')
class TNCUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass):
enabled: bool
ips: list[IPvAnyAddress]


class TNCUpdateResult(BaseModel):
result: TNCEntry


class TNCGetRegistrationURIArgs(BaseModel):
pass


class TNCGetRegistrationURIResult(BaseModel):
result: NonEmptyString


class TNCGenerateClaimTokenArgs(BaseModel):
pass


class TNCGenerateClaimTokenResult(BaseModel):
result: NonEmptyString


class TNCIPChoicesArgs(BaseModel):
pass


class TNCIPChoicesResult(BaseModel):
result: dict[str, str]
2 changes: 2 additions & 0 deletions src/middlewared/middlewared/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
FAILOVER_LOGFILE = '/var/log/failover.log'
LOGFILE = '/var/log/middlewared.log'
NETDATA_API_LOGFILE = '/var/log/netdata_api.log'
TRUENAS_CONNECT_LOGFILE = '/var/log/truenas_connect.log'
ZETTAREPL_LOGFILE = '/var/log/zettarepl.log'


Expand Down Expand Up @@ -101,6 +102,7 @@ def configure_logging(self, output_option: str):
('docker_image', DOCKER_IMAGE_LOGFILE, self.log_format),
('failover', FAILOVER_LOGFILE, self.log_format),
('netdata_api', NETDATA_API_LOGFILE, self.log_format),
('truenas_connect', TRUENAS_CONNECT_LOGFILE, self.log_format),
('zettarepl', ZETTAREPL_LOGFILE,
'[%(asctime)s] %(levelname)-8s [%(threadName)s] [%(name)s] %(message)s'),
]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Authenticator:
NAME = NotImplementedError
PROPAGATION_DELAY = NotImplementedError
SCHEMA_MODEL = NotImplementedError
INTERNAL = False

def __init__(self, middleware, attributes):
self.middleware = middleware
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .ovh import OVHAuthenticator
from .route53 import Route53Authenticator
from .shell import ShellAuthenticator
from .truenas_connect import TrueNASConnectAuthenticator


class AuthenticatorFactory:
Expand All @@ -21,8 +22,8 @@ def authenticator(self, name):
raise CallError(f'Unable to locate {name!r} authenticator.', errno=errno.ENOENT)
return self._creators[name]

def get_authenticators(self):
return self._creators
def get_authenticators(self, include_internal=False):
return {k: v for k, v in self._creators.items() if v.INTERNAL is False or include_internal}


auth_factory = AuthenticatorFactory()
Expand All @@ -31,5 +32,6 @@ def get_authenticators(self):
Route53Authenticator,
OVHAuthenticator,
ShellAuthenticator,
TrueNASConnectAuthenticator,
]:
auth_factory.register(authenticator)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import json
import logging
import requests

from middlewared.api.current import TrueNASConnectSchemaArgs
from middlewared.plugins.truenas_connect.mixin import auth_headers
from middlewared.plugins.truenas_connect.urls import LECA_DNS_URL, LECA_CLEANUP_URL
from middlewared.service import CallError

from .base import Authenticator


logger = logging.getLogger('truenas_connect')


class TrueNASConnectAuthenticator(Authenticator):

NAME = 'tn_connect'
PROPAGATION_DELAY = 20
SCHEMA_MODEL = TrueNASConnectSchemaArgs
INTERNAL = True

@staticmethod
async def validate_credentials(middleware, data):
pass

def _perform(self, domain, validation_name, validation_content):
try:
self._perform_internal(domain, validation_name, validation_content)
except CallError:
raise
except Exception as e:
raise CallError(f'Failed to perform {self.NAME} challenge for {domain!r} domain: {e}')

def _perform_internal(self, domain, validation_name, validation_content):
logger.debug(
'Performing %r challenge for %r domain with %r validation name and %r validation content',
self.NAME, domain, validation_name, validation_content,
)
response = requests.post(LECA_DNS_URL, data=json.dumps({
sonicaj marked this conversation as resolved.
Show resolved Hide resolved
'token': validation_content,
'hostnames': [domain], # We should be using validation name here
}), headers=auth_headers(self.attributes), timeout=30)
if response.status_code != 201:
raise CallError(
f'Failed to perform {self.NAME} challenge for {domain!r} domain with '
f'{response.status_code!r} status code: {response.text}'
)

logger.debug('Successfully performed %r challenge for %r domain', self.NAME, domain)

def _cleanup(self, domain, validation_name, validation_content):
logger.debug('Cleaning up %r challenge for %r domain', self.NAME, domain)
try:
requests.delete(
LECA_CLEANUP_URL, headers=auth_headers(self.attributes), timeout=30, data=json.dumps({
'hostnames': [validation_name], # We use validation name here instead of domain as Zack advised
})
)
except Exception:
# We do not make this fatal as it does not matter if we fail to clean-up
logger.debug('Failed to cleanup %r challenge for %r domain', self.NAME, domain, exc_info=True)
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ def cleanup_challenge(self, data):

@private
def get_authenticator(self, authenticator):
auth_details = self.middleware.call_sync('acme.dns.authenticator.get_instance', authenticator)
if authenticator is None:
auth_details = {
'attributes': {'authenticator': 'tn_connect', **self.middleware.call_sync('tn_connect.config_internal')}
}
else:
auth_details = self.middleware.call_sync('acme.dns.authenticator.get_instance', authenticator)

return self.get_authenticator_internal(
auth_details['attributes']['authenticator']
)(self.middleware, auth_details['attributes'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class BodyDict(typing.TypedDict):

class ACMEClientAndKeyData(typing.TypedDict):
uri: str
tos: bool
tos: bool | str
new_account_uri: str
new_nonce_uri: str
new_order_uri: str
Expand All @@ -25,7 +25,7 @@ def get_acme_client_and_key(data: ACMEClientAndKeyData) -> tuple[client.ClientV2
"""
Expected data dict should contain the following
- uri: str
- tos: bool
- tos: bool | str
- new_account_uri: str
- new_nonce_uri: str
- new_order_uri: str
Expand Down
24 changes: 24 additions & 0 deletions src/middlewared/middlewared/plugins/acme_protocol_/revoke_cert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from OpenSSL import crypto

import josepy as jose
from acme import errors, messages

from middlewared.service import CallError, Service

from .client_utils import get_acme_client_and_key


class ACMEService(Service):

class Config:
namespace = 'acme'
private = True

def revoke_certificate(self, acme_client_key_payload, certificate):
acme_client, key = get_acme_client_and_key(acme_client_key_payload)
try:
acme_client.revoke(
jose.ComparableX509(crypto.load_certificate(crypto.FILETYPE_PEM, certificate)), 0
)
except (errors.ClientError, messages.Error) as e:
raise CallError(f'Failed to revoke certificate: {e}')
25 changes: 13 additions & 12 deletions src/middlewared/middlewared/plugins/crypto_/certificates.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import datetime
import josepy as jose

from acme import errors, messages
from OpenSSL import crypto

import middlewared.sqlalchemy as sa

Expand Down Expand Up @@ -605,6 +601,13 @@ async def do_update(self, job, id_, data):
if any(new.get(k) != old.get(k) for k in ('name', 'revoked', 'renew_days', 'add_to_trusted_store')):

verrors = ValidationErrors()
tnc_config = await self.middleware.call('tn_connect.config')
if tnc_config['certificate'] == id_:
verrors.add(
'certificate_update.name',
'This certificate is being used by TrueNAS Connect service and cannot be modified'
)
verrors.check()

if new['name'] != old['name']:
await validate_cert_name(
Expand Down Expand Up @@ -717,17 +720,15 @@ def do_delete(self, job, id_, force):

if certificate.get('acme') and not certificate['expired']:
# We won't try revoking a certificate which has expired already
client, key = self.middleware.call_sync(
'acme.get_acme_client_and_key', certificate['acme']['directory'], True
)

try:
client.revoke(
jose.ComparableX509(crypto.load_certificate(crypto.FILETYPE_PEM, certificate['certificate'])), 0
self.middleware.call_sync(
'acme.revoke_certificate', self.middleware.call_sync(
'acme.get_acme_client_and_key_payload', certificate['acme']['directory'], True
), certificate['certificate'],
)
except (errors.ClientError, messages.Error) as e:
except CallError:
if not force:
raise CallError(f'Failed to revoke certificate: {e}')
raise

response = self.middleware.call_sync(
'datastore.delete',
Expand Down
6 changes: 6 additions & 0 deletions src/middlewared/middlewared/plugins/system_general/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async def validate_general_settings(self, data, old_config, schema):
f'When "{wildcard}" has been selected, selection of other addresses is not allowed'
)

tnc_config = await self.middleware.call('tn_connect.config')
certificate_id = data.get('ui_certificate')
cert = await self.middleware.call(
'certificate.query',
Expand All @@ -179,6 +180,11 @@ async def validate_general_settings(self, data, old_config, schema):
f'{schema}.ui_certificate',
'Please specify a valid certificate which exists in the system'
)
elif tnc_config['certificate'] and tnc_config['certificate'] != certificate_id:
verrors.add(
f'{schema}.ui_certificate',
'Certificate cannot be changed when TrueNAS Connect has been configured'
)
else:
verrors.extend(
await self.middleware.call(
Expand Down
Empty file.
Loading
Loading