Skip to content
Merged
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
69 changes: 66 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ SKALE Node CLI, part of the SKALE suite of validator tools, is the command line
4. [sChain commands (Standard)](#schain-commands-standard)
5. [Health commands (Standard)](#health-commands-standard)
6. [SSL commands (Standard)](#ssl-commands-standard)
7. [Logs commands (Standard)](#logs-commands-standard)
7. [SGX commands (Standard)](#sgx-commands-standard)
8. [Logs commands (Standard)](#logs-commands-standard)
3. [Passive Node Usage (`skale` - Passive Build)](#passive-node-usage-skale---passive-build)
1. [Top level commands (Passive)](#top-level-commands-passive)
2. [Passive node commands](#passive-node-commands)
Expand All @@ -32,8 +33,9 @@ SKALE Node CLI, part of the SKALE suite of validator tools, is the command line
5. [Fair Wallet commands](#fair-wallet-commands)
6. [Fair Logs commands](#fair-logs-commands)
7. [Fair SSL commands](#fair-ssl-commands)
8. [Fair Staking commands](#fair-staking-commands)
9. [Passive Fair Node commands](#passive-fair-node-commands)
8. [Fair SGX commands](#fair-sgx-commands)
9. [Fair Staking commands](#fair-staking-commands)
10. [Passive Fair Node commands](#passive-fair-node-commands)
5. [Exit codes](#exit-codes)
6. [Development](#development)

Expand Down Expand Up @@ -499,6 +501,48 @@ Options:
* `--port/-p` - Port to start healthcheck server (default: `4536`).
* `--no-client` - Skip client connection (only make sure server started without errors).

### SGX commands (Standard)

> Prefix: `skale sgx`

Manage the client certificate that node services use to authenticate to the SGX wallet.
The files live in `~/.skale/node_data/sgx_certs` and are read by the SKALE containers.
These commands work directly with those files and the SGX server; they do not go through
the node API.

#### SGX certificate status

Show the certificate files, the certificate details and its expiry.

```shell
skale sgx status [--json] [--check]
```

Options:

* `--json` - Show data in JSON format.
* `--check` - Also verify that the SGX server accepts the certificate.

#### Renew SGX certificate

Issue a new client certificate from the SGX server and install it. The current
certificate stays in place until the new one is signed and verified against the server.
The previous files are copied to `~/.skale/node_data/sgx_certs_backup/<timestamp>`.
If the SGX server requires manual approval of signing requests, the command prints the
request hash and waits until it is approved. Node services pick up the new certificate
on their next SGX request; no restart is needed. `skale health sgx` confirms afterwards
that node services reach the SGX server.

```shell
skale sgx renew [--yes] [--timeout <SECONDS>] [--skip-verify]
```

Options:

* `--yes` - Do not ask for confirmation.
* `--timeout` - Seconds to wait for the SGX server to sign the request (default: `600`).
* `--skip-verify` - Install the certificate without testing it against the SGX server first.

### Logs commands (Standard)

> Prefix: `skale logs`
Expand Down Expand Up @@ -1119,6 +1163,25 @@ Options:
* `--no-client` - Skip client connection for openssl check.
* `--no-wss` - Skip WSS server starting for skaled check.

### Fair SGX commands

> Prefix: `fair sgx`

Manage the client certificate that node services use to authenticate to the SGX wallet.
See [SGX commands (Standard)](#sgx-commands-standard) for details; the behaviour is the same.

#### Fair SGX Status

```shell
fair sgx status [--json] [--check]
```

#### Fair SGX Renew

```shell
fair sgx renew [--yes] [--timeout <SECONDS>] [--skip-verify]
```

### Fair Staking commands

> Prefix: `fair staking`
Expand Down
191 changes: 191 additions & 0 deletions node_cli/cli/sgx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
#
# This file is part of node-cli
#
# Copyright (C) 2026 SKALE Labs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import json

import click
from terminaltables import SingleTable

from node_cli.configs.sgx import SGX_SIGN_TIMEOUT
from node_cli.core.sgx import (
SgxCertificateError,
check_certificate,
get_certificate_status,
get_server_options,
renew_certificate,
)
from node_cli.utils.decorators import check_inited, check_user
from node_cli.utils.exit_codes import CLIExitCodes
from node_cli.utils.helper import abort_if_false, error_exit
from node_cli.utils.settings import get_sgx_url
from node_cli.utils.texts import safe_load_texts

G_TEXTS = safe_load_texts()
TEXTS = G_TEXTS['sgx']


@click.group()
def sgx_cli():
pass


@sgx_cli.group('sgx', help=TEXTS['help'])
def sgx():
pass


@sgx.command('options', help=TEXTS['options']['help'])
@click.option('--json', 'json_format', is_flag=True, help=G_TEXTS['common']['json'])
@check_inited
@check_user
def options(json_format: bool) -> None:
_configured_sgx_url()
status, payload = get_server_options()
if status != 'ok':
error_exit(payload, exit_code=CLIExitCodes.BAD_API_RESPONSE)
if json_format:
print(json.dumps(payload))
else:
rows = [['SGX option', 'Value']]
for group, values in payload.items():
entries = (
[(f'{group}.{key}', value) for key, value in values.items()]
if isinstance(values, dict)
else [(group, values)]
)
rows.extend(
[key, value if isinstance(value, str) else json.dumps(value)]
for key, value in entries
)
print(SingleTable(rows).table)


@sgx.command('cert-status', help=TEXTS['status']['help'])
@click.option('--json', 'json_format', is_flag=True, help=G_TEXTS['common']['json'])
@click.option('--check', is_flag=True, help=TEXTS['status']['check'])
def cert_status(json_format: bool, check: bool) -> None:
try:
info = get_certificate_status()
except SgxCertificateError as err:
error_exit(str(err), exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR)
check_error = None
if check:
try:
info['server_version'] = check_certificate(_configured_sgx_url())
except SgxCertificateError as err:
check_error = str(err)
if json_format:
if check_error:
info['check_error'] = check_error
print(json.dumps(info))
else:
print_certificate_status(info)
if check_error:
error_exit(check_error, exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR)


@sgx.command('renew', help=TEXTS['renew']['help'])
@click.option(
'--yes',
is_flag=True,
callback=abort_if_false,
expose_value=False,
prompt=TEXTS['renew']['prompt'],
)
@click.option(
'--timeout',
type=int,
default=SGX_SIGN_TIMEOUT,
show_default=True,
help=TEXTS['renew']['timeout'],
)
@click.option('--skip-verify', is_flag=True, help=TEXTS['renew']['skip_verify'])
@check_inited
@check_user
def renew(timeout: int, skip_verify: bool) -> None:
sgx_url = _configured_sgx_url()
try:
result = renew_certificate(sgx_url, timeout=timeout, verify=not skip_verify, log=print)
except SgxCertificateError as err:
error_exit(str(err), exit_code=CLIExitCodes.OPERATION_EXECUTION_ERROR)
print_certificate_status(result)
if result['backup']:
print(TEXTS['renew']['backup'].format(path=result['backup']))
print(TEXTS['renew']['done'])


def _configured_sgx_url() -> str:
try:
sgx_url = get_sgx_url()
except Exception as err: # settings files are missing or invalid
error_exit(f'Cannot read node settings: {err}', exit_code=CLIExitCodes.NODE_STATE_ERROR)
if not sgx_url:
error_exit(TEXTS['no_sgx'], exit_code=CLIExitCodes.NODE_STATE_ERROR)
return sgx_url


def print_certificate_status(info: dict) -> None:
present = info['present']
rows = [
['SGX client certificate', ''],
['Directory', info['directory']],
['Private key', _presence(present['key'])],
['Signing request', _presence(present['csr'])],
['Certificate', _presence(present['crt'])],
]
if 'subject' in info:
rows.extend(
[
['Subject CN', info['subject']],
['Issuer CN', info['issuer']],
['Valid from', info['not_valid_before']],
['Valid until', info['not_valid_after']],
['Days left', str(info['days_left'])],
['SHA-256', info['fingerprint_sha256']],
['Key matches', _yes_no(info['key_matches'])],
]
)
if info.get('server_version'):
rows.append(['SGX server', f'accepted the certificate, version {info["server_version"]}'])
print(SingleTable(rows).table)
for notice in _notices(info):
print(notice)


def _notices(info: dict) -> list[str]:
notices = []
if not info['complete']:
notices.append(TEXTS['status']['missing'])
elif info.get('expired'):
notices.append(TEXTS['status']['expired'])
elif info.get('expires_soon'):
notices.append(TEXTS['status']['expires_soon'].format(days=info['days_left']))
if info.get('key_matches') is False:
notices.append(TEXTS['status']['key_mismatch'])
return notices


def _presence(present: bool) -> str:
return 'present' if present else 'missing'


def _yes_no(value: bool | None) -> str:
if value is None:
return 'unknown'
return 'yes' if value else 'no'
2 changes: 2 additions & 0 deletions node_cli/configs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

SKALE_DIR = os.path.join(G_CONF_HOME, '.skale')
SKALE_TMP_DIR = os.path.join(SKALE_DIR, '.tmp')
AUTH_DIR = Path(SKALE_DIR) / 'auth'
ADMIN_API_TOKEN_PATH = AUTH_DIR / 'admin-api.token'

NODE_DATA_PATH = os.path.join(SKALE_DIR, 'node_data')
SCHAIN_NODE_DATA_PATH = os.path.join(NODE_DATA_PATH, 'schains')
Expand Down
2 changes: 1 addition & 1 deletion node_cli/configs/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
'update-safe',
],
'health': ['containers', 'schains'],
'info': ['sgx'],
'info': ['sgx', 'sgx-options'],
'schains': ['config', 'list', 'dkg-statuses', 'firewall-rules', 'repair', 'get'],
'ssl': ['status', 'upload'],
'wallet': ['info', 'send-eth'],
Expand Down
54 changes: 54 additions & 0 deletions node_cli/configs/sgx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
#
# This file is part of node-cli
#
# Copyright (C) 2026 SKALE Labs
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import os

from node_cli.configs import NODE_DATA_PATH, SGX_CERTS_PATH

# File names are fixed by the sgx client library that node services use; it expects
# exactly these three entries in the certificate directory.
SGX_KEY_FILENAME = 'sgx.key'
SGX_CSR_FILENAME = 'sgx.csr'
SGX_CRT_FILENAME = 'sgx.crt'

SGX_CERTS_BACKUP_PATH = os.path.join(NODE_DATA_PATH, 'sgx_certs_backup')

# The SGX wallet signs certificate requests over plain HTTP on the port that follows
# its main port, which is how the sgx client library derives the address as well.
SGX_CSR_SERVER_PORT_OFFSET = 1

SGX_KEY_SIZE = 2048
SGX_RPC_TIMEOUT = 60
SGX_SIGN_POLL_INTERVAL = 10
SGX_SIGN_TIMEOUT = 600
SGX_CERT_EXPIRY_WARNING_DAYS = 30

__all__ = [
'SGX_CERTS_PATH',
'SGX_CERTS_BACKUP_PATH',
'SGX_CERT_EXPIRY_WARNING_DAYS',
'SGX_CRT_FILENAME',
'SGX_CSR_FILENAME',
'SGX_CSR_SERVER_PORT_OFFSET',
'SGX_KEY_FILENAME',
'SGX_KEY_SIZE',
'SGX_RPC_TIMEOUT',
'SGX_SIGN_POLL_INTERVAL',
'SGX_SIGN_TIMEOUT',
]
Loading
Loading