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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ jobs:
uses: softprops/action-gh-release@v3
with:
files: custom_components/${{ steps.integration.outputs.integration }}/${{ steps.integration.outputs.integration }}.zip
# Echo the release's current state so attaching the asset can't flip it.
prerelease: ${{ github.event.release.prerelease }}
draft: ${{ github.event.release.draft }}
27 changes: 20 additions & 7 deletions custom_components/pfsense/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
)

from .const import (
CONF_API_KEY,
AUTH_METHOD_API_KEY,
CONF_AUTH_METHOD,
CONF_DEVICE_TRACKER_ENABLED,
CONF_DEVICE_TRACKER_SCAN_INTERVAL,
CONF_TLS_INSECURE,
Expand All @@ -43,7 +44,12 @@
SHOULD_RELOAD,
UNDO_UPDATE_LISTENER,
)
from .pypfsense import Client as pfSenseClient, PfSenseAuthError, PfSensePrivilegeError
from .pypfsense import (
Client as pfSenseClient,
PfSenseAuthError,
PfSensePrivilegeError,
client_from_config,
)
from .services import ServiceRegistrar

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -155,17 +161,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
options = entry.options

url = config[CONF_URL]
api_key = config.get(CONF_API_KEY)
if not api_key:
# Migrated-from-password entry that hasn't been re-authed yet.
raise ConfigEntryAuthFailed("no pfSense REST API key configured")
verify_ssl = config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
device_tracker_enabled = options.get(
CONF_DEVICE_TRACKER_ENABLED, DEFAULT_DEVICE_TRACKER_ENABLED
)

session = async_get_clientsession(hass, verify_ssl)
client = pfSenseClient(url, api_key, session, {"verify_ssl": verify_ssl})
try:
client = client_from_config(url, session, config, verify_ssl)
except PfSenseAuthError as err:
# Missing credentials (e.g. a half-migrated entry): send the user to
# reauth rather than retrying setup forever.
raise ConfigEntryAuthFailed(str(err)) from err
data = PfSenseData(client, entry, hass)
scan_interval = options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)

Expand Down Expand Up @@ -284,6 +291,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
v2 -> v3: XML-RPC username/password auth is gone. Strip the stored password
and force a reauth so the user can enter a REST API key. The unique id
(``slugify(netgate id)``) is unchanged, so entities re-attach afterwards.
v3 -> v4: stamp ``auth_method`` on entries that predate the multi-method
config flow. Every v3 entry used an API key.
"""
data = dict(config_entry.data)

Expand All @@ -299,6 +308,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# No API key yet: async_setup_entry raises ConfigEntryAuthFailed, which
# starts the reauth flow so the user can paste a key.

if config_entry.version == 3:
data.setdefault(CONF_AUTH_METHOD, AUTH_METHOD_API_KEY)
hass.config_entries.async_update_entry(config_entry, data=data, version=4)

return True


Expand Down
150 changes: 99 additions & 51 deletions custom_components/pfsense/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, CONF_URL, CONF_VERIFY_SSL
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify

from .const import (
AUTH_METHOD_API_KEY,
AUTH_METHOD_BASIC,
AUTH_METHOD_JWT,
CONF_API_KEY,
CONF_AUTH_METHOD,
CONF_DEVICE_TRACKER_CONSIDER_HOME,
CONF_DEVICE_TRACKER_ENABLED,
CONF_DEVICE_TRACKER_SCAN_INTERVAL,
Expand All @@ -30,15 +41,24 @@
DOMAIN,
)
from .pypfsense import (
Client,
PfSenseAuthError,
PfSenseConnectionError,
PfSenseNotFoundError,
PfSensePrivilegeError,
client_from_config,
)

_LOGGER = logging.getLogger(__name__)

_EXAMPLE_URL = "https://pfsense.local:8444"

# Credential fields shown per auth method (besides URL / verify_ssl / name).
_CRED_FIELDS: dict[str, tuple[str, ...]] = {
AUTH_METHOD_API_KEY: (CONF_API_KEY,),
AUTH_METHOD_BASIC: (CONF_USERNAME, CONF_PASSWORD),
AUTH_METHOD_JWT: (CONF_USERNAME, CONF_PASSWORD),
}


class InvalidURL(Exception):
"""The URL is missing a scheme or host."""
Expand All @@ -51,36 +71,70 @@ def _normalize_url(raw: str) -> str:
return f"{parts.scheme}://{parts.netloc}"


async def _validate(hass, url: str, api_key: str, verify_ssl: bool) -> dict:
"""Return the system_info dict, or raise a typed pypfsense error."""
def _cred_schema(method: str, defaults: dict) -> dict:
"""Return a voluptuous schema fragment for one method's credential fields."""
return {
vol.Required(field, default=defaults.get(field, "")): str
for field in _CRED_FIELDS[method]
}


async def _get_system_info(hass, url: str, verify_ssl: bool, creds: dict) -> dict:
"""Build the right client and return its system_info, or raise a typed error."""
session = async_get_clientsession(hass, verify_ssl)
client = Client(url, api_key, session, {"verify_ssl": verify_ssl})
client = client_from_config(url, session, creds, verify_ssl)
return await client.get_system_info()


class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for pfSense."""

# Bumping this triggers async_migrate_entry. v3 == XML-RPC -> REST API v2.
VERSION = 3
# v3 == XML-RPC -> REST API v2 (api key only).
# v4 == multi-method auth; entries carry ``auth_method``.
VERSION = 4

def __init__(self) -> None:
"""Initialize the flow state."""
self._reauth_entry: config_entries.ConfigEntry | None = None

async def async_step_user(self, user_input=None):
"""Initial setup step."""
"""Let the user pick an authentication method."""
return self.async_show_menu(
step_id="user",
menu_options=[
AUTH_METHOD_API_KEY,
AUTH_METHOD_BASIC,
AUTH_METHOD_JWT,
],
)

async def async_step_api_key(self, user_input=None):
"""Configure API-key auth."""
return await self._auth_step(AUTH_METHOD_API_KEY, user_input)

async def async_step_basic(self, user_input=None):
"""Configure username + password (HTTP Basic) auth."""
return await self._auth_step(AUTH_METHOD_BASIC, user_input)

async def async_step_jwt(self, user_input=None):
"""Configure JWT auth (token minted from username + password)."""
return await self._auth_step(AUTH_METHOD_JWT, user_input)

async def _auth_step(self, method: str, user_input):
"""Show the per-method form and validate the connection on submit."""
errors: dict[str, str] = {}
user_input = user_input or {}

if user_input:
creds = {
CONF_AUTH_METHOD: method,
**{f: str(user_input[f]).strip() for f in _CRED_FIELDS[method]},
}
verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
name = user_input.get(CONF_NAME) or None
try:
url = _normalize_url(user_input[CONF_URL])
api_key = user_input[CONF_API_KEY].strip()
verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
name = user_input.get(CONF_NAME) or None

system_info = await _validate(self.hass, url, api_key, verify_ssl)
system_info = await _get_system_info(self.hass, url, verify_ssl, creds)

await self.async_set_unique_id(
slugify(system_info["netgate_device_id"])
Expand All @@ -94,11 +148,7 @@ async def async_step_user(self, user_input=None):

return self.async_create_entry(
title=name,
data={
CONF_URL: url,
CONF_API_KEY: api_key,
CONF_VERIFY_SSL: verify_ssl,
},
data={CONF_URL: url, CONF_VERIFY_SSL: verify_ssl, **creds},
)
except InvalidURL:
errors["base"] = "invalid_url_format"
Expand All @@ -109,57 +159,58 @@ async def async_step_user(self, user_input=None):
except PfSenseNotFoundError:
errors["base"] = "api_not_found"
except PfSenseConnectionError as err:
if "certificate" in str(err).lower() or "ssl" in str(err).lower():
text = str(err).lower()
if "certificate" in text or "ssl" in text:
errors["base"] = "cannot_connect_ssl"
else:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error validating pfSense connection")
errors["base"] = "unknown"

schema = vol.Schema(
{
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
vol.Required(
CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")
): str,
vol.Optional(
CONF_VERIFY_SSL,
default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
): bool,
vol.Optional(CONF_NAME, default=user_input.get(CONF_NAME, "")): str,
}
)
schema = {
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
**_cred_schema(method, user_input),
vol.Optional(
CONF_VERIFY_SSL,
default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
): bool,
vol.Optional(CONF_NAME, default=user_input.get(CONF_NAME, "")): str,
}
return self.async_show_form(
step_id="user",
data_schema=schema,
step_id=method,
data_schema=vol.Schema(schema),
errors=errors,
description_placeholders={"example_url": "https://pfsense.local:8444"},
description_placeholders={"example_url": _EXAMPLE_URL},
)

async def async_step_import(self, user_input):
"""Handle YAML import."""
return await self.async_step_user(user_input)
"""Handle YAML import (legacy entries carried an API key)."""
return await self.async_step_api_key(user_input)

async def async_step_reauth(self, entry_data):
"""Triggered when auth fails, or by the v2 -> v3 migration."""
"""Triggered when auth fails, or by an older-version migration."""
self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(self, user_input=None):
"""Ask the user for an API key for an existing entry."""
"""Re-prompt for whichever credentials the entry's auth method needs."""
errors: dict[str, str] = {}
entry = self._reauth_entry
assert entry is not None
method = entry.data.get(CONF_AUTH_METHOD, AUTH_METHOD_API_KEY)

if user_input is not None:
api_key = user_input[CONF_API_KEY].strip()
url = entry.data[CONF_URL]
verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
creds = {
CONF_AUTH_METHOD: method,
**{f: str(user_input[f]).strip() for f in _CRED_FIELDS[method]},
}
try:
system_info = await _validate(self.hass, url, api_key, verify_ssl)
system_info = await _get_system_info(self.hass, url, verify_ssl, creds)
except PfSenseAuthError:
errors["base"] = "invalid_auth"
except PfSensePrivilegeError:
Expand All @@ -173,18 +224,16 @@ async def async_step_reauth_confirm(self, user_input=None):
new_unique_id = slugify(system_info["netgate_device_id"])
if entry.unique_id and entry.unique_id != new_unique_id:
return self.async_abort(reason="wrong_device")
new_data = {
CONF_URL: url,
CONF_API_KEY: api_key,
CONF_VERIFY_SSL: verify_ssl,
}
self.hass.config_entries.async_update_entry(entry, data=new_data)
self.hass.config_entries.async_update_entry(
entry,
data={CONF_URL: url, CONF_VERIFY_SSL: verify_ssl, **creds},
)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")

return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
data_schema=vol.Schema(_cred_schema(method, {})),
errors=errors,
description_placeholders={"name": entry.title},
)
Expand All @@ -200,7 +249,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle the pfSense options flow."""

def __init__(self) -> None:
"""Initialize the object."""
"""Initialize the flow state."""
self.new_options: dict | None = None

async def async_step_init(self, user_input=None):
Expand Down Expand Up @@ -251,10 +300,9 @@ async def async_step_device_tracker(self, user_input=None):
"""Let the user pick which MACs to track from the live ARP table."""
entry = self.config_entry
url = entry.data[CONF_URL]
api_key = entry.data[CONF_API_KEY]
verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
session = async_get_clientsession(self.hass, verify_ssl)
client = Client(url, api_key, session, {"verify_ssl": verify_ssl})
client = client_from_config(url, session, entry.data, verify_ssl)

if user_input is None and (arp_table := await client.get_arp_table(True)):
selected_devices = entry.options.get(CONF_DEVICES, [])
Expand Down
14 changes: 11 additions & 3 deletions custom_components/pfsense/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,18 @@
DEFAULT_USERNAME = "admin"
DOMAIN = "pfsense"

# REST API v2 auth. The pre-2.x integration used username + password over
# XML-RPC; v2 authenticates with an API key generated on the box at
# System > REST API > Keys and sent as the ``x-api-key`` header.
# REST API v2 auth. pfRest v2 accepts three mutually-exclusive schemes; the
# config entry records which one it uses in ``CONF_AUTH_METHOD``.
# api_key -> ``x-api-key`` header (key generated at System > REST API > Keys)
# basic -> HTTP Basic with a pfSense local username + password
# jwt -> Bearer token minted from POST /api/v2/auth/jwt with those creds
CONF_API_KEY = "api_key"
CONF_AUTH_METHOD = "auth_method"

AUTH_METHOD_API_KEY = "api_key"
AUTH_METHOD_BASIC = "basic"
AUTH_METHOD_JWT = "jwt"
AUTH_METHODS = (AUTH_METHOD_API_KEY, AUTH_METHOD_BASIC, AUTH_METHOD_JWT)

UNDO_UPDATE_LISTENER = "undo_update_listener"

Expand Down
2 changes: 1 addition & 1 deletion custom_components/pfsense/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"requirements": [
"mac-vendor-lookup>=0.1.11"
],
"version": "0.10.1"
"version": "0.10.2"
}
Loading
Loading