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

Generate Captive Portal Vouchers Action #302

Merged
merged 1 commit into from
Oct 27, 2024
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
1 change: 1 addition & 0 deletions custom_components/opnsense/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,4 @@
SERVICE_SYSTEM_REBOOT = "system_reboot"
SERVICE_SEND_WOL = "send_wol"
SERVICE_RELOAD_INTERFACE = "reload_interface"
SERVICE_GENERATE_VOUCHERS = "generate_vouchers"
90 changes: 89 additions & 1 deletion custom_components/opnsense/pyopnsense/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from collections.abc import Mapping
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import quote_plus, urlparse
from urllib.parse import quote, quote_plus, urlparse

import aiohttp
import awesomeversion
Expand All @@ -28,6 +28,32 @@ def wireguard_is_connected(past_time: datetime) -> bool:
return datetime.now().astimezone() - past_time <= timedelta(minutes=3)


def human_friendly_duration(seconds) -> str:
months, seconds = divmod(
seconds, 2419200
) # 28 days in a month (28 * 24 * 60 * 60 = 2419200 seconds)
weeks, seconds = divmod(seconds, 604800) # 604800 seconds in a week
days, seconds = divmod(seconds, 86400) # 86400 seconds in a day
hours, seconds = divmod(seconds, 3600) # 3600 seconds in an hour
minutes, seconds = divmod(seconds, 60) # 60 seconds in a minute

duration: list = []
if months > 0:
duration.append(f"{months} month{'s' if months > 1 else ''}")
if weeks > 0:
duration.append(f"{weeks} week{'s' if weeks > 1 else ''}")
if days > 0:
duration.append(f"{days} day{'s' if days > 1 else ''}")
if hours > 0:
duration.append(f"{hours} hour{'s' if hours > 1 else ''}")
if minutes > 0:
duration.append(f"{minutes} minute{'s' if minutes > 1 else ''}")
if seconds > 0 or not duration:
duration.append(f"{seconds} second{'s' if seconds != 1 else ''}")

return ", ".join(duration)


def get_ip_key(item) -> tuple:
address = item.get("address", None)

Expand Down Expand Up @@ -58,6 +84,10 @@ def dict_get(data: Mapping[str, Any], path: str, default=None):
return result


class VoucherServerError(Exception):
pass


class OPNsenseClient(ABC):
"""OPNsense Client"""

Expand Down Expand Up @@ -2081,3 +2111,61 @@ async def get_certificates(self) -> Mapping[str, Any]:
}
_LOGGER.debug(f"[get_certificates] certs: {certs}")
return certs

async def generate_vouchers(self, data: Mapping[str, Any]) -> list:
if data.get("voucher_server", None):
server = data.get("voucher_server")
else:
servers = await self._get("/api/captiveportal/voucher/listProviders")
if not isinstance(servers, list):
raise VoucherServerError(
f"Error getting list of voucher servers: {servers}"
)
if len(servers) == 0:
raise VoucherServerError("No voucher servers exist")
if len(servers) != 1:
raise VoucherServerError(
"More than one voucher server. Must specify voucher server name"
)
server: str = servers[0]
server_slug: str = quote(server)
payload: Mapping[str, Any] = data.copy()
payload.pop("voucher_server", None)
voucher_url: str = f"/api/captiveportal/voucher/generateVouchers/{server_slug}/"
_LOGGER.debug(f"[generate_vouchers] url: {voucher_url}, payload: {payload}")
vouchers: Mapping[str, Any] | list = await self._post(
voucher_url,
payload=payload,
)
if not isinstance(vouchers, list):
raise VoucherServerError(f"Error returned requesting vouchers: {vouchers}")
ordered_keys: list = [
"username",
"password",
"vouchergroup",
"starttime",
"expirytime",
"expiry_timestamp",
"validity_str",
"validity",
]
for voucher in vouchers:
if voucher.get("validity", None):
voucher["validity_str"] = human_friendly_duration(
voucher.get("validity")
)
if voucher.get("expirytime", None):
voucher["expiry_timestamp"] = voucher.get("expirytime")
voucher["expirytime"] = datetime.fromtimestamp(
self._try_to_int(voucher.get("expirytime")),
tz=timezone(datetime.now().astimezone().utcoffset()),
)

rearranged_voucher: Mapping[str, Any] = {
key: voucher[key] for key in ordered_keys if key in voucher
}
voucher.clear()
voucher.update(rearranged_voucher)

_LOGGER.debug(f"[generate_vouchers] vouchers: {vouchers}")
return vouchers
60 changes: 52 additions & 8 deletions custom_components/opnsense/services.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
import logging
from collections.abc import Mapping
from typing import Any

import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
)
from homeassistant.helpers import (
device_registry,
entity_registry,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import device_registry, entity_registry

from .const import (
DOMAIN,
OPNSENSE_CLIENT,
SERVICE_CLOSE_NOTICE,
SERVICE_GENERATE_VOUCHERS,
SERVICE_RELOAD_INTERFACE,
SERVICE_RESTART_SERVICE,
SERVICE_SEND_WOL,
Expand All @@ -24,6 +21,7 @@
SERVICE_SYSTEM_HALT,
SERVICE_SYSTEM_REBOOT,
)
from .pyopnsense import VoucherServerError

_LOGGER: logging.Logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -205,6 +203,34 @@ async def service_reload_interface(call: ServiceCall) -> None:
f"Reload Interface Failed: {call.data.get('interface')}"
)

async def service_generate_vouchers(call: ServiceCall) -> Mapping[str, Any]:
clients: list = await _get_clients(
call.data.get("device_id", []), call.data.get("entity_id", [])
)
voucher_list: list = []
for client in clients:
try:
vouchers: list = await client.generate_vouchers(call.data)
except VoucherServerError as e:
_LOGGER.error(f"Error getting vouchers from {client.name}. {e}")
raise ServiceValidationError(
f"Error getting vouchers from {client.name}. {e}"
) from e
_LOGGER.debug(
f"[service_generate_vouchers] client: {client.name}, data: {call.data}, vouchers: {vouchers}"
)
if isinstance(vouchers, list):
for voucher in vouchers:
if isinstance(voucher, Mapping):
new_voucher: Mapping[str, Any] = {"client": client.name}
new_voucher.update(voucher)
voucher.clear()
voucher.update(new_voucher)
voucher_list = voucher_list + vouchers
final_vouchers: Mapping[str, Any] = {"vouchers": voucher_list}
_LOGGER.debug(f"[service_generate_vouchers] vouchers: {final_vouchers}")
return final_vouchers

hass.services.async_register(
domain=DOMAIN,
service=SERVICE_CLOSE_NOTICE,
Expand Down Expand Up @@ -335,3 +361,21 @@ async def service_reload_interface(call: ServiceCall) -> None:
),
service_func=service_reload_interface,
)

hass.services.async_register(
domain=DOMAIN,
service=SERVICE_GENERATE_VOUCHERS,
schema=vol.Schema(
{
vol.Required("validity"): vol.Any(cv.string),
vol.Required("expirytime"): vol.Any(cv.string),
vol.Required("count"): vol.Any(cv.string),
vol.Required("vouchergroup"): vol.Any(cv.string),
vol.Optional("voucher_server"): vol.Any(cv.string),
vol.Optional("device_id"): vol.Any(cv.string),
vol.Optional("entity_id"): vol.Any(cv.string),
}
),
service_func=service_generate_vouchers,
supports_response=SupportsResponse.ONLY,
)
135 changes: 135 additions & 0 deletions custom_components/opnsense/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,138 @@ reload_interface:
filter:
- integration: opnsense
domain: sensor

generate_vouchers:
name: Generate Captive Portal Vouchers
fields:
validity:
name: Validity
description: "If manually entering a duration, enter in seconds"
required: true
advanced: false
example: ""
default: "14400"
selector:
select:
multiple: false
custom_value: true
mode: dropdown
options:
- label: "4 hours"
value: "14400"
- label: "8 hours"
value: "28800"
- label: "1 day"
value: "86400"
- label: "2 days"
value": "172800"
- label: "3 days"
value: "259200"
- label: "4 days"
value: "345600"
- label: "5 days"
value: "432000"
- label: "6 days"
value: "518400"
- label: "1 week"
value: "604800"
- label: "2 weeks"
value: "1209600"
expirytime:
name: Expires in
description: "If manually entering a duration, enter in seconds"
required: true
advanced: false
example: ""
default: "0"
selector:
select:
multiple: false
custom_value: true
mode: dropdown
options:
- label: "never"
value: "0"
- label: "6 hours"
value: "21600"
- label: "12 hours"
value: "43200"
- label: "1 day"
value: "86400"
- label: "2 days"
value: "172800"
- label: "3 days"
value: "259200"
- label: "4 days"
value: "345600"
- label: "5 days"
value: "432000"
- label: "6 days"
value: "518400"
- label: "1 week"
value: "604800"
- label: "2 weeks"
value: "1209600"
- label : "3 weeks"
value: "1814400"
- label: "1 month"
value: "2419200"
- label: "2 months"
value: "4838400"
- label: "3 months"
value: "7257600"
count:
name: Number of vouchers
description: ""
required: true
advanced: false
default: 1
example: ""
selector:
number:
min: 1
step: 1
mode: box
vouchergroup:
name: Groupname
description: ""
required: true
advanced: false
default: "Home Assistant"
example: ""
selector:
text:
voucher_server:
name: Captive Portal Voucher Server
description: "OPTIONAL: Only needed if there is more than one Captive Portal Voucher Server"
required: false
advanced: false
example: ""
selector:
text:
multiple_opnsense:
name: Only needed if there is more than one OPNsense Router
collapsed: true
fields:
device_id:
name: OPNsense Device
description: Select the OPNsense Router to call the command on. If not specified, the command will be sent to all OPNsense Routers.
required: false
selector:
device:
multiple: false
filter:
- integration: opnsense
entity:
- domain: sensor
entity_id:
name: OPNsense Entity
description: Pick any sensor in the OPNsense Router you want to call the command on. If not specified, the command will be sent to all OPNsense Routers.
example: "sensor.opnsense_interface_lan_status"
required: false
selector:
entity:
multiple: false
filter:
- integration: opnsense
domain: sensor
Loading