pfsense-mcp-server 0.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pfsense_mcp/__init__.py +1 -0
- pfsense_mcp/api_version.py +26 -0
- pfsense_mcp/application.py +80 -0
- pfsense_mcp/capabilities.py +87 -0
- pfsense_mcp/config.py +256 -0
- pfsense_mcp/diagnostics.py +37 -0
- pfsense_mcp/endpoints.py +231 -0
- pfsense_mcp/errors.py +56 -0
- pfsense_mcp/factory.py +24 -0
- pfsense_mcp/logging_setup.py +86 -0
- pfsense_mcp/models/__init__.py +3 -0
- pfsense_mcp/models/acme_settings.py +25 -0
- pfsense_mcp/models/arp_table_entry.py +39 -0
- pfsense_mcp/models/auth_key.py +31 -0
- pfsense_mcp/models/bind_settings.py +59 -0
- pfsense_mcp/models/carp_status.py +26 -0
- pfsense_mcp/models/cron_job.py +37 -0
- pfsense_mcp/models/dhcp_lease.py +46 -0
- pfsense_mcp/models/dhcp_server.py +81 -0
- pfsense_mcp/models/dhcp_static_mapping.py +59 -0
- pfsense_mcp/models/diagnostics_table.py +27 -0
- pfsense_mcp/models/dns_resolver_host_override.py +33 -0
- pfsense_mcp/models/dns_resolver_settings.py +57 -0
- pfsense_mcp/models/email_notification_settings.py +60 -0
- pfsense_mcp/models/firewall.py +167 -0
- pfsense_mcp/models/firewall_advanced_settings.py +25 -0
- pfsense_mcp/models/firewall_alias.py +44 -0
- pfsense_mcp/models/firewall_nat_outbound_mode.py +23 -0
- pfsense_mcp/models/firewall_nat_port_forward.py +81 -0
- pfsense_mcp/models/firewall_traffic_shaper_limiter.py +123 -0
- pfsense_mcp/models/free_radius_eap.py +79 -0
- pfsense_mcp/models/gateways.py +104 -0
- pfsense_mcp/models/interface_bridge.py +33 -0
- pfsense_mcp/models/interface_config.py +141 -0
- pfsense_mcp/models/interfaces.py +100 -0
- pfsense_mcp/models/ntp_settings.py +53 -0
- pfsense_mcp/models/ntp_time_server.py +31 -0
- pfsense_mcp/models/pf_sense_user.py +51 -0
- pfsense_mcp/models/pf_sense_user_group.py +35 -0
- pfsense_mcp/models/service_status.py +31 -0
- pfsense_mcp/models/ssh_settings.py +29 -0
- pfsense_mcp/models/system.py +40 -0
- pfsense_mcp/models/system_certificate.py +46 -0
- pfsense_mcp/models/system_ha_sync.py +95 -0
- pfsense_mcp/models/system_package.py +35 -0
- pfsense_mcp/models/system_rest_api_settings.py +71 -0
- pfsense_mcp/models/system_tunable.py +29 -0
- pfsense_mcp/models/system_version.py +29 -0
- pfsense_mcp/pfsense_client.py +516 -0
- pfsense_mcp/pfsense_write_client.py +75 -0
- pfsense_mcp/profiles.py +46 -0
- pfsense_mcp/py.typed +1 -0
- pfsense_mcp/recovery.py +95 -0
- pfsense_mcp/rest_api_client.py +100 -0
- pfsense_mcp/rollback.py +40 -0
- pfsense_mcp/server.py +13 -0
- pfsense_mcp/tls.py +54 -0
- pfsense_mcp/tools/__init__.py +2 -0
- pfsense_mcp/tools/audit.py +70 -0
- pfsense_mcp/tools/read/__init__.py +1 -0
- pfsense_mcp/tools/read/acme_settings.py +18 -0
- pfsense_mcp/tools/read/arp_table.py +16 -0
- pfsense_mcp/tools/read/auth_keys.py +22 -0
- pfsense_mcp/tools/read/bind_settings.py +16 -0
- pfsense_mcp/tools/read/carp_status.py +18 -0
- pfsense_mcp/tools/read/cron_jobs.py +16 -0
- pfsense_mcp/tools/read/dhcp_leases.py +20 -0
- pfsense_mcp/tools/read/dhcp_servers.py +22 -0
- pfsense_mcp/tools/read/dhcp_static_mappings.py +21 -0
- pfsense_mcp/tools/read/diagnostics_tables.py +18 -0
- pfsense_mcp/tools/read/dns_resolver_host_overrides.py +16 -0
- pfsense_mcp/tools/read/dns_resolver_settings.py +18 -0
- pfsense_mcp/tools/read/email_notification_settings.py +24 -0
- pfsense_mcp/tools/read/firewall_advanced_settings.py +16 -0
- pfsense_mcp/tools/read/firewall_aliases.py +27 -0
- pfsense_mcp/tools/read/firewall_apply_status.py +19 -0
- pfsense_mcp/tools/read/firewall_nat_outbound_mode.py +17 -0
- pfsense_mcp/tools/read/firewall_nat_port_forwards.py +28 -0
- pfsense_mcp/tools/read/firewall_rules.py +22 -0
- pfsense_mcp/tools/read/firewall_states.py +28 -0
- pfsense_mcp/tools/read/firewall_states_size.py +18 -0
- pfsense_mcp/tools/read/firewall_traffic_shaper_limiters.py +18 -0
- pfsense_mcp/tools/read/freeradius_eap.py +18 -0
- pfsense_mcp/tools/read/gateway_status.py +22 -0
- pfsense_mcp/tools/read/gateways.py +22 -0
- pfsense_mcp/tools/read/interface_bridges.py +20 -0
- pfsense_mcp/tools/read/interface_configs.py +26 -0
- pfsense_mcp/tools/read/interfaces.py +23 -0
- pfsense_mcp/tools/read/ntp_settings.py +18 -0
- pfsense_mcp/tools/read/ntp_time_servers.py +16 -0
- pfsense_mcp/tools/read/service_status.py +20 -0
- pfsense_mcp/tools/read/ssh_settings.py +18 -0
- pfsense_mcp/tools/read/system_certificates.py +22 -0
- pfsense_mcp/tools/read/system_hasync.py +22 -0
- pfsense_mcp/tools/read/system_packages.py +16 -0
- pfsense_mcp/tools/read/system_restapi_settings.py +21 -0
- pfsense_mcp/tools/read/system_status.py +20 -0
- pfsense_mcp/tools/read/system_tunables.py +16 -0
- pfsense_mcp/tools/read/system_version.py +18 -0
- pfsense_mcp/tools/read/user_groups.py +20 -0
- pfsense_mcp/tools/read/users.py +25 -0
- pfsense_mcp/tools/registry.py +425 -0
- pfsense_mcp/tools/write/__init__.py +3 -0
- pfsense_mcp/transport/__init__.py +3 -0
- pfsense_mcp/transport/base.py +33 -0
- pfsense_mcp/transport/http.py +31 -0
- pfsense_mcp/transport/mock.py +21 -0
- pfsense_mcp/write_api_client.py +105 -0
- pfsense_mcp/write_audit.py +110 -0
- pfsense_mcp/write_endpoints.py +33 -0
- pfsense_mcp/write_types.py +49 -0
- pfsense_mcp_server-0.2.2.dist-info/METADATA +309 -0
- pfsense_mcp_server-0.2.2.dist-info/RECORD +116 -0
- pfsense_mcp_server-0.2.2.dist-info/WHEEL +4 -0
- pfsense_mcp_server-0.2.2.dist-info/entry_points.txt +2 -0
- pfsense_mcp_server-0.2.2.dist-info/licenses/LICENSE +21 -0
pfsense_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pfsense_mcp: local MCP server for read-only pfSense REST API access."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""API version abstraction.
|
|
2
|
+
|
|
3
|
+
Endpoint definitions declare a minimum required API version. The
|
|
4
|
+
active version is configured once, at startup. Only RestApiClient and
|
|
5
|
+
this module need to change when the pfSense REST API's URL-level
|
|
6
|
+
version changes — endpoints.py, models/, and tools/ are unaffected as
|
|
7
|
+
long as response shapes stay compatible (a separate, model-level
|
|
8
|
+
concern).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiVersion(str, Enum):
|
|
17
|
+
V2 = "v2"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_VERSION_ORDER: dict[ApiVersion, int] = {
|
|
21
|
+
ApiVersion.V2: 2,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def version_at_least(current: ApiVersion, minimum: ApiVersion) -> bool:
|
|
26
|
+
return _VERSION_ORDER[current] >= _VERSION_ORDER[minimum]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Application — owns startup, dependency construction, and lifecycle.
|
|
2
|
+
|
|
3
|
+
server.py's only responsibility is to construct and run this class.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from .config import ConfigurationError, load_api_key, load_config, load_logging_config
|
|
14
|
+
from .diagnostics import build_diagnostics_report
|
|
15
|
+
from .factory import build_pfsense_client
|
|
16
|
+
from .logging_setup import DEFAULT_LOG_DIR as LOG_DIR
|
|
17
|
+
from .logging_setup import configure_logging, shutdown_logging
|
|
18
|
+
from .tools.registry import ToolRegistry
|
|
19
|
+
from .transport.http import HttpTransport
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Application:
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._mcp = FastMCP("pfsense-mcp-server")
|
|
25
|
+
self._transport: HttpTransport | None = None
|
|
26
|
+
|
|
27
|
+
def run(self) -> None:
|
|
28
|
+
try:
|
|
29
|
+
self._bootstrap()
|
|
30
|
+
self._mcp.run()
|
|
31
|
+
finally:
|
|
32
|
+
self.shutdown()
|
|
33
|
+
|
|
34
|
+
def _bootstrap(self) -> None:
|
|
35
|
+
try:
|
|
36
|
+
log_max_bytes, log_backup_count = load_logging_config()
|
|
37
|
+
except ConfigurationError as exc:
|
|
38
|
+
print(f"pfsense-mcp-server: configuration error: {exc}", file=sys.stderr)
|
|
39
|
+
raise SystemExit(1) from None
|
|
40
|
+
|
|
41
|
+
redaction_filter = configure_logging(LOG_DIR, max_bytes=log_max_bytes, backup_count=log_backup_count)
|
|
42
|
+
logger = logging.getLogger("pfsense_mcp")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
config = load_config()
|
|
46
|
+
api_key = load_api_key(config)
|
|
47
|
+
redaction_filter.register_secret(api_key)
|
|
48
|
+
|
|
49
|
+
transport, pfsense_client = build_pfsense_client(config, api_key)
|
|
50
|
+
self._transport = transport
|
|
51
|
+
|
|
52
|
+
registry = ToolRegistry(
|
|
53
|
+
self._mcp,
|
|
54
|
+
pfsense_client,
|
|
55
|
+
config.identity,
|
|
56
|
+
config.profile.capabilities,
|
|
57
|
+
allowed_tools=config.allowed_tools,
|
|
58
|
+
)
|
|
59
|
+
registry.register_all()
|
|
60
|
+
except ConfigurationError as exc:
|
|
61
|
+
logger.error("startup_failed: %s", exc)
|
|
62
|
+
print(f"pfsense-mcp-server: configuration error: {exc}", file=sys.stderr)
|
|
63
|
+
raise SystemExit(1) from None
|
|
64
|
+
|
|
65
|
+
report = build_diagnostics_report(config, type(self._transport).__name__)
|
|
66
|
+
logger.info(
|
|
67
|
+
"startup_ok identity=%s profile=%s capabilities=%s tls_mode=%s api_version=%s transport=%s",
|
|
68
|
+
report.identity,
|
|
69
|
+
report.profile_name,
|
|
70
|
+
",".join(report.capabilities),
|
|
71
|
+
report.tls_mode,
|
|
72
|
+
report.api_version,
|
|
73
|
+
report.transport_type,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def shutdown(self) -> None:
|
|
77
|
+
if self._transport is not None:
|
|
78
|
+
self._transport.close()
|
|
79
|
+
self._transport = None
|
|
80
|
+
shutdown_logging()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Capability model — the long-term authorization unit for this
|
|
2
|
+
server, replacing a simple read/write split."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from enum import Enum, auto
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Capability(Enum):
|
|
10
|
+
SYSTEM_READ = auto()
|
|
11
|
+
INTERFACE_READ = auto()
|
|
12
|
+
GATEWAY_READ = auto()
|
|
13
|
+
FIREWALL_READ = auto()
|
|
14
|
+
ALIAS_READ = auto()
|
|
15
|
+
SERVICE_READ = auto()
|
|
16
|
+
SYSTEM_INFO_READ = auto()
|
|
17
|
+
INTERFACE_CONFIG_READ = auto()
|
|
18
|
+
FIREWALL_NAT_READ = auto()
|
|
19
|
+
USER_READ = auto()
|
|
20
|
+
SYSTEM_CERTIFICATE_READ = auto()
|
|
21
|
+
USER_GROUP_READ = auto()
|
|
22
|
+
DHCP_LEASE_READ = auto()
|
|
23
|
+
DHCP_STATIC_MAPPING_READ = auto()
|
|
24
|
+
DHCP_SERVER_READ = auto()
|
|
25
|
+
INTERFACE_VIRTUAL_READ = auto()
|
|
26
|
+
STATUS_CARP_READ = auto()
|
|
27
|
+
SYSTEM_RESTAPI_SETTINGS_READ = auto()
|
|
28
|
+
SYSTEM_HA_SYNC_READ = auto()
|
|
29
|
+
SERVICES_DNS_RESOLVER_READ = auto()
|
|
30
|
+
DIAGNOSTICS_ARP_READ = auto()
|
|
31
|
+
FIREWALL_TRAFFIC_SHAPER_READ = auto()
|
|
32
|
+
FIREWALL_ADVANCED_SETTINGS_READ = auto()
|
|
33
|
+
SYSTEM_PACKAGE_READ = auto()
|
|
34
|
+
SYSTEM_TUNABLE_READ = auto()
|
|
35
|
+
SYSTEM_NOTIFICATIONS_READ = auto()
|
|
36
|
+
SERVICES_BIND_READ = auto()
|
|
37
|
+
SERVICES_NTP_READ = auto()
|
|
38
|
+
SERVICES_SSH_READ = auto()
|
|
39
|
+
SERVICES_CRON_READ = auto()
|
|
40
|
+
SERVICES_ACME_READ = auto()
|
|
41
|
+
SERVICES_FREERADIUS_READ = auto()
|
|
42
|
+
DIAGNOSTICS_TABLES_READ = auto()
|
|
43
|
+
AUTH_KEYS_READ = auto()
|
|
44
|
+
# Not usable until a separate, explicitly authorized implementation phase:
|
|
45
|
+
FIREWALL_WRITE = auto()
|
|
46
|
+
ALIAS_WRITE = auto()
|
|
47
|
+
SERVICE_WRITE = auto()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
SUPPORTED_CAPABILITIES_THIS_BUILD: frozenset[Capability] = frozenset(
|
|
51
|
+
{
|
|
52
|
+
Capability.SYSTEM_READ,
|
|
53
|
+
Capability.INTERFACE_READ,
|
|
54
|
+
Capability.GATEWAY_READ,
|
|
55
|
+
Capability.FIREWALL_READ,
|
|
56
|
+
Capability.ALIAS_READ,
|
|
57
|
+
Capability.SERVICE_READ,
|
|
58
|
+
Capability.SYSTEM_INFO_READ,
|
|
59
|
+
Capability.INTERFACE_CONFIG_READ,
|
|
60
|
+
Capability.FIREWALL_NAT_READ,
|
|
61
|
+
Capability.USER_READ,
|
|
62
|
+
Capability.SYSTEM_CERTIFICATE_READ,
|
|
63
|
+
Capability.USER_GROUP_READ,
|
|
64
|
+
Capability.DHCP_LEASE_READ,
|
|
65
|
+
Capability.DHCP_STATIC_MAPPING_READ,
|
|
66
|
+
Capability.DHCP_SERVER_READ,
|
|
67
|
+
Capability.INTERFACE_VIRTUAL_READ,
|
|
68
|
+
Capability.STATUS_CARP_READ,
|
|
69
|
+
Capability.SYSTEM_RESTAPI_SETTINGS_READ,
|
|
70
|
+
Capability.SYSTEM_HA_SYNC_READ,
|
|
71
|
+
Capability.SERVICES_DNS_RESOLVER_READ,
|
|
72
|
+
Capability.DIAGNOSTICS_ARP_READ,
|
|
73
|
+
Capability.FIREWALL_TRAFFIC_SHAPER_READ,
|
|
74
|
+
Capability.FIREWALL_ADVANCED_SETTINGS_READ,
|
|
75
|
+
Capability.SYSTEM_PACKAGE_READ,
|
|
76
|
+
Capability.SYSTEM_TUNABLE_READ,
|
|
77
|
+
Capability.SYSTEM_NOTIFICATIONS_READ,
|
|
78
|
+
Capability.SERVICES_BIND_READ,
|
|
79
|
+
Capability.SERVICES_NTP_READ,
|
|
80
|
+
Capability.SERVICES_SSH_READ,
|
|
81
|
+
Capability.SERVICES_CRON_READ,
|
|
82
|
+
Capability.SERVICES_ACME_READ,
|
|
83
|
+
Capability.SERVICES_FREERADIUS_READ,
|
|
84
|
+
Capability.DIAGNOSTICS_TABLES_READ,
|
|
85
|
+
Capability.AUTH_KEYS_READ,
|
|
86
|
+
}
|
|
87
|
+
)
|
pfsense_mcp/config.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Configuration loading for the pfSense MCP server.
|
|
2
|
+
|
|
3
|
+
All configuration is environment-driven. Nothing is inferred, nothing
|
|
4
|
+
falls back to a discovered file, and no key file is silently selected.
|
|
5
|
+
Missing or invalid configuration fails closed via ConfigurationError.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import unicodedata
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from errno import ELOOP
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from urllib.parse import unquote, urlsplit
|
|
18
|
+
|
|
19
|
+
from .api_version import ApiVersion
|
|
20
|
+
from .errors import ConfigurationError
|
|
21
|
+
from .profiles import Profile, get_profile
|
|
22
|
+
from .tls import TLSMode, validate_tls_ca_file, validate_tls_settings
|
|
23
|
+
|
|
24
|
+
_REQUIRED_VARS = ("PFSENSE_API_URL", "PFSENSE_IDENTITY", "PFSENSE_API_KEY_FILE")
|
|
25
|
+
_IDENTITY_MAX_LENGTH = 128
|
|
26
|
+
_KEY_FILE_MAX_BYTES = 16 * 1024
|
|
27
|
+
_KEY_LINE_MAX_LENGTH = 4096
|
|
28
|
+
_LOG_MAX_BYTES_MIN = 1024
|
|
29
|
+
_LOG_MAX_BYTES_MAX = 100_000_000
|
|
30
|
+
_LOG_BACKUP_COUNT_MIN = 1
|
|
31
|
+
_LOG_BACKUP_COUNT_MAX = 100
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PfSenseConfig:
|
|
36
|
+
base_url: str
|
|
37
|
+
identity: str
|
|
38
|
+
key_file: Path
|
|
39
|
+
tls_mode: TLSMode
|
|
40
|
+
tls_ca_file: Path | None
|
|
41
|
+
api_version: ApiVersion
|
|
42
|
+
profile: Profile
|
|
43
|
+
log_max_bytes: int
|
|
44
|
+
log_backup_count: int
|
|
45
|
+
allowed_tools: frozenset[str] | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _open_key_file(key_file: Path) -> int:
|
|
49
|
+
nofollow = getattr(os, "O_NOFOLLOW", None)
|
|
50
|
+
if nofollow is None:
|
|
51
|
+
raise ConfigurationError("Secure key-file loading is unsupported on this platform")
|
|
52
|
+
|
|
53
|
+
flags = os.O_RDONLY | nofollow | getattr(os, "O_CLOEXEC", 0)
|
|
54
|
+
try:
|
|
55
|
+
return os.open(key_file, flags)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
if exc.errno == ELOOP:
|
|
58
|
+
raise ConfigurationError(f"Key file must not be a symbolic link: {key_file}") from None
|
|
59
|
+
raise ConfigurationError(f"Key file could not be opened: {key_file}") from None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate_key_file_descriptor(key_file: Path, descriptor: int) -> None:
|
|
63
|
+
try:
|
|
64
|
+
metadata = os.fstat(descriptor)
|
|
65
|
+
except OSError:
|
|
66
|
+
raise ConfigurationError(f"Key file metadata could not be read: {key_file}") from None
|
|
67
|
+
|
|
68
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
69
|
+
raise ConfigurationError(f"Key file is not a regular file: {key_file}")
|
|
70
|
+
if metadata.st_uid != os.geteuid():
|
|
71
|
+
raise ConfigurationError(f"Key file must be owned by the current user: {key_file}")
|
|
72
|
+
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
|
73
|
+
raise ConfigurationError(f"Key file must not grant permissions to group or other users: {key_file}")
|
|
74
|
+
if metadata.st_size > _KEY_FILE_MAX_BYTES:
|
|
75
|
+
raise ConfigurationError(f"Key file exceeds the maximum allowed size: {key_file}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _contains_control_characters(value: str) -> bool:
|
|
79
|
+
return any(
|
|
80
|
+
ord(character) < 32 or ord(character) == 127 or unicodedata.category(character) in {"Cf", "Zl", "Zp"}
|
|
81
|
+
for character in value
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_base_url(raw: str) -> str:
|
|
86
|
+
if raw != raw.strip() or _contains_control_characters(raw) or _contains_control_characters(unquote(raw)):
|
|
87
|
+
raise ConfigurationError("PFSENSE_API_URL must not contain surrounding whitespace or control characters")
|
|
88
|
+
try:
|
|
89
|
+
parsed = urlsplit(raw)
|
|
90
|
+
port = parsed.port
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise ConfigurationError("PFSENSE_API_URL is not a valid URL") from None
|
|
93
|
+
if parsed.scheme.lower() != "https":
|
|
94
|
+
raise ConfigurationError("PFSENSE_API_URL must use https")
|
|
95
|
+
if not parsed.hostname:
|
|
96
|
+
raise ConfigurationError("PFSENSE_API_URL must include a hostname")
|
|
97
|
+
if parsed.username is not None or parsed.password is not None:
|
|
98
|
+
raise ConfigurationError("PFSENSE_API_URL must not include user information")
|
|
99
|
+
if parsed.query or parsed.fragment:
|
|
100
|
+
raise ConfigurationError("PFSENSE_API_URL must not include a query string or fragment")
|
|
101
|
+
if parsed.path not in ("", "/"):
|
|
102
|
+
raise ConfigurationError("PFSENSE_API_URL must not include a path")
|
|
103
|
+
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
|
104
|
+
return f"https://{host}{f':{port}' if port is not None else ''}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_identity(raw: str) -> str:
|
|
108
|
+
identity = raw.strip()
|
|
109
|
+
if not identity:
|
|
110
|
+
raise ConfigurationError("PFSENSE_IDENTITY must not be empty or whitespace")
|
|
111
|
+
if len(identity) > _IDENTITY_MAX_LENGTH:
|
|
112
|
+
raise ConfigurationError(f"PFSENSE_IDENTITY must be at most {_IDENTITY_MAX_LENGTH} characters")
|
|
113
|
+
if _contains_control_characters(identity):
|
|
114
|
+
raise ConfigurationError("PFSENSE_IDENTITY must not contain control characters")
|
|
115
|
+
return identity
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_positive_int(raw: str, var_name: str) -> int:
|
|
119
|
+
try:
|
|
120
|
+
value = int(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
raise ConfigurationError(f"{var_name} must be an integer (got {raw!r})") from None
|
|
123
|
+
if value <= 0:
|
|
124
|
+
raise ConfigurationError(f"{var_name} must be positive (got {value})")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_bounded_int(raw: str, var_name: str, *, minimum: int, maximum: int) -> int:
|
|
129
|
+
value = _parse_positive_int(raw, var_name)
|
|
130
|
+
if not minimum <= value <= maximum:
|
|
131
|
+
raise ConfigurationError(f"{var_name} must be between {minimum} and {maximum} (got {value})")
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_allowed_tools(source: Mapping[str, str]) -> frozenset[str] | None:
|
|
136
|
+
if "PFSENSE_ALLOWED_TOOLS" not in source:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
raw = source["PFSENSE_ALLOWED_TOOLS"]
|
|
140
|
+
if _contains_control_characters(raw):
|
|
141
|
+
raise ConfigurationError("PFSENSE_ALLOWED_TOOLS must not contain control characters")
|
|
142
|
+
if not raw.strip():
|
|
143
|
+
return frozenset()
|
|
144
|
+
|
|
145
|
+
names = [part.strip() for part in raw.split(",")]
|
|
146
|
+
if any(not name for name in names):
|
|
147
|
+
raise ConfigurationError("PFSENSE_ALLOWED_TOOLS must be a comma-separated list without empty entries")
|
|
148
|
+
if any("*" in name or "?" in name or "[" in name or "]" in name for name in names):
|
|
149
|
+
raise ConfigurationError("PFSENSE_ALLOWED_TOOLS accepts exact tool names only; wildcards are not allowed")
|
|
150
|
+
if any(not name.startswith("pfsense_") or not name.replace("_", "").isalnum() for name in names):
|
|
151
|
+
raise ConfigurationError("PFSENSE_ALLOWED_TOOLS contains an invalid tool name")
|
|
152
|
+
return frozenset(names)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_logging_config(env: dict[str, str] | None = None) -> tuple[int, int]:
|
|
156
|
+
"""Parse just the logging size/rotation settings. Kept separate
|
|
157
|
+
from load_config() so logging can be configured correctly even if
|
|
158
|
+
some other part of configuration fails to validate."""
|
|
159
|
+
source = env if env is not None else os.environ
|
|
160
|
+
max_bytes = _parse_bounded_int(
|
|
161
|
+
source.get("PFSENSE_LOG_MAX_BYTES", "5000000"),
|
|
162
|
+
"PFSENSE_LOG_MAX_BYTES",
|
|
163
|
+
minimum=_LOG_MAX_BYTES_MIN,
|
|
164
|
+
maximum=_LOG_MAX_BYTES_MAX,
|
|
165
|
+
)
|
|
166
|
+
backup_count = _parse_bounded_int(
|
|
167
|
+
source.get("PFSENSE_LOG_BACKUP_COUNT", "5"),
|
|
168
|
+
"PFSENSE_LOG_BACKUP_COUNT",
|
|
169
|
+
minimum=_LOG_BACKUP_COUNT_MIN,
|
|
170
|
+
maximum=_LOG_BACKUP_COUNT_MAX,
|
|
171
|
+
)
|
|
172
|
+
return max_bytes, backup_count
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_config(env: dict[str, str] | None = None) -> PfSenseConfig:
|
|
176
|
+
source = env if env is not None else os.environ
|
|
177
|
+
|
|
178
|
+
missing = [name for name in _REQUIRED_VARS if not source.get(name)]
|
|
179
|
+
if missing:
|
|
180
|
+
raise ConfigurationError(f"Missing required environment variable(s): {', '.join(missing)}")
|
|
181
|
+
|
|
182
|
+
base_url = _validate_base_url(source["PFSENSE_API_URL"])
|
|
183
|
+
identity = _validate_identity(source["PFSENSE_IDENTITY"])
|
|
184
|
+
key_file = Path(source["PFSENSE_API_KEY_FILE"]).expanduser()
|
|
185
|
+
|
|
186
|
+
tls_mode_raw = source.get("PFSENSE_TLS_MODE", "strict").strip().lower()
|
|
187
|
+
try:
|
|
188
|
+
tls_mode = TLSMode(tls_mode_raw)
|
|
189
|
+
except ValueError:
|
|
190
|
+
valid = ", ".join(m.value for m in TLSMode)
|
|
191
|
+
raise ConfigurationError(f"PFSENSE_TLS_MODE must be one of: {valid} (got {tls_mode_raw!r})") from None
|
|
192
|
+
|
|
193
|
+
tls_ca_file_raw = source.get("PFSENSE_TLS_CA_FILE")
|
|
194
|
+
tls_ca_file = Path(tls_ca_file_raw).expanduser() if tls_ca_file_raw else None
|
|
195
|
+
validate_tls_settings(tls_mode, tls_ca_file)
|
|
196
|
+
validate_tls_ca_file(tls_mode, tls_ca_file)
|
|
197
|
+
|
|
198
|
+
api_version_raw = source.get("PFSENSE_API_VERSION", "v2").strip().lower()
|
|
199
|
+
try:
|
|
200
|
+
api_version = ApiVersion(api_version_raw)
|
|
201
|
+
except ValueError:
|
|
202
|
+
valid = ", ".join(v.value for v in ApiVersion)
|
|
203
|
+
raise ConfigurationError(f"PFSENSE_API_VERSION must be one of: {valid} (got {api_version_raw!r})") from None
|
|
204
|
+
|
|
205
|
+
profile_raw = source.get("PFSENSE_PROFILE", "auditor").strip().lower()
|
|
206
|
+
profile = get_profile(profile_raw)
|
|
207
|
+
allowed_tools = _parse_allowed_tools(source)
|
|
208
|
+
|
|
209
|
+
log_max_bytes, log_backup_count = load_logging_config(env)
|
|
210
|
+
|
|
211
|
+
return PfSenseConfig(
|
|
212
|
+
base_url=base_url,
|
|
213
|
+
identity=identity,
|
|
214
|
+
key_file=key_file,
|
|
215
|
+
tls_mode=tls_mode,
|
|
216
|
+
tls_ca_file=tls_ca_file,
|
|
217
|
+
api_version=api_version,
|
|
218
|
+
profile=profile,
|
|
219
|
+
allowed_tools=allowed_tools,
|
|
220
|
+
log_max_bytes=log_max_bytes,
|
|
221
|
+
log_backup_count=log_backup_count,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_api_key(config: PfSenseConfig) -> str:
|
|
226
|
+
"""Validate and read the key through one non-following descriptor.
|
|
227
|
+
|
|
228
|
+
The returned value must never be logged, printed, or included in any
|
|
229
|
+
exception message by any caller of this function.
|
|
230
|
+
"""
|
|
231
|
+
descriptor = _open_key_file(config.key_file)
|
|
232
|
+
try:
|
|
233
|
+
_validate_key_file_descriptor(config.key_file, descriptor)
|
|
234
|
+
try:
|
|
235
|
+
first_line = os.read(descriptor, _KEY_LINE_MAX_LENGTH + 1).split(b"\n", maxsplit=1)[0]
|
|
236
|
+
except OSError:
|
|
237
|
+
raise ConfigurationError(f"Key file could not be read: {config.key_file}") from None
|
|
238
|
+
finally:
|
|
239
|
+
try:
|
|
240
|
+
os.close(descriptor)
|
|
241
|
+
except OSError:
|
|
242
|
+
raise ConfigurationError(f"Key file descriptor could not be closed: {config.key_file}") from None
|
|
243
|
+
|
|
244
|
+
if len(first_line) > _KEY_LINE_MAX_LENGTH:
|
|
245
|
+
raise ConfigurationError(f"Key file first line is too long: {config.key_file}")
|
|
246
|
+
if b"\x00" in first_line:
|
|
247
|
+
raise ConfigurationError(f"Key file first line contains a NUL byte: {config.key_file}")
|
|
248
|
+
if any(byte < 32 or byte == 127 for byte in first_line):
|
|
249
|
+
raise ConfigurationError(f"Key file first line contains control characters: {config.key_file}")
|
|
250
|
+
try:
|
|
251
|
+
key = first_line.decode("utf-8").strip()
|
|
252
|
+
except UnicodeDecodeError:
|
|
253
|
+
raise ConfigurationError(f"Key file first line is not valid UTF-8: {config.key_file}") from None
|
|
254
|
+
if not key:
|
|
255
|
+
raise ConfigurationError(f"Key file is empty: {config.key_file}")
|
|
256
|
+
return key
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Local server health diagnostics.
|
|
2
|
+
|
|
3
|
+
Reports configuration validity, TLS mode, active API version,
|
|
4
|
+
registered capabilities, and transport type — using only state
|
|
5
|
+
already resolved during startup. Never contacts pfSense.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .config import PfSenseConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DiagnosticsReport:
|
|
17
|
+
identity: str
|
|
18
|
+
profile_name: str
|
|
19
|
+
capabilities: tuple[str, ...]
|
|
20
|
+
tls_mode: str
|
|
21
|
+
api_version: str
|
|
22
|
+
transport_type: str
|
|
23
|
+
log_max_bytes: int
|
|
24
|
+
log_backup_count: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_diagnostics_report(config: PfSenseConfig, transport_type: str) -> DiagnosticsReport:
|
|
28
|
+
return DiagnosticsReport(
|
|
29
|
+
identity=config.identity,
|
|
30
|
+
profile_name=config.profile.name,
|
|
31
|
+
capabilities=tuple(sorted(c.name for c in config.profile.capabilities)),
|
|
32
|
+
tls_mode=config.tls_mode.value,
|
|
33
|
+
api_version=config.api_version.value,
|
|
34
|
+
transport_type=transport_type,
|
|
35
|
+
log_max_bytes=config.log_max_bytes,
|
|
36
|
+
log_backup_count=config.log_backup_count,
|
|
37
|
+
)
|