This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
# UniFi Controller
|
||||||
|
UNIFI_HOST=https://your-unifi-host
|
||||||
|
UNIFI_SITE_ID=your-site-id
|
||||||
|
UNIFI_API_TOKEN=your-api-token
|
||||||
|
UNIFI_VERIFY_SSL=false
|
||||||
|
|
||||||
|
# Config
|
||||||
|
CONFIG_FILE=config/rules.yaml
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# NTFY Notifications (optional)
|
||||||
|
NTFY_URL=
|
||||||
|
NTFY_TOPIC=
|
||||||
|
NTFY_API_KEY=
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Build and Push Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.reeseapps.com
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: gitea.reeseapps.com/services/firewall
|
||||||
|
tags: |
|
||||||
|
type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
type=ref,event=tag
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Containerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
config/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: Remote Attach",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "attach",
|
||||||
|
"connect": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 5678
|
||||||
|
},
|
||||||
|
"pathMappings": [
|
||||||
|
{
|
||||||
|
"localRoot": "${workspaceFolder}",
|
||||||
|
"remoteRoot": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Dev commands (always run all three)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest -v # 35 tests, all mocked (no live API calls)
|
||||||
|
uv run ruff check # lint
|
||||||
|
uv run pyright # strict type checking
|
||||||
|
```
|
||||||
|
|
||||||
|
Order matters: fix lint/typecheck errors before touching tests.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `main.py` — entry point: load config → fetch public IPs → process rules → NTFY notify
|
||||||
|
- `unifi_firewall.py` — UniFi Network API v1 client (TypedDicts match `network_v10.4.57_openapi.json`)
|
||||||
|
- `ip_lookup.py` — fetches public IPv4/IPv6 via `curl ifconfig.me`
|
||||||
|
- `config/rules.yaml` — rule definitions (zones, IPs, ports, actions)
|
||||||
|
|
||||||
|
API base: `{UNIFI_HOST}/proxy/network/integration/v1/sites/{UNIFI_SITE_ID}`. Auth: `X-API-Key` header.
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
Required: `UNIFI_HOST`, `UNIFI_SITE_ID`, `UNIFI_API_TOKEN`
|
||||||
|
Optional: `CONFIG_FILE` (default: `config/rules.yaml`), `UNIFI_VERIFY_SSL`, `LOG_LEVEL`, `DEBUG`, `NTFY_URL`, `NTFY_TOPIC`, `NTFY_API_KEY`
|
||||||
|
|
||||||
|
See `.env.example` for full list. Shared UniFi creds with `../ddns`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All tests are unit tests with mocks. No live API calls, no external services needed.
|
||||||
|
|
||||||
|
To run a single file: `uv run pytest tests/test_unifi_firewall.py -v`
|
||||||
|
To run a single test: `uv run pytest tests/test_unifi_firewall.py::TestBuildPolicyPayload::test_with_dest_ports -v`
|
||||||
|
|
||||||
|
## Containerfile
|
||||||
|
|
||||||
|
Builds with podman, uses uv to sync deps. CMD runs `main.py` as a one-shot. Config is baked in at build time via `COPY config/`.
|
||||||
|
|
||||||
|
## OpenAPI spec
|
||||||
|
|
||||||
|
`network_v10.4.57_openapi.json` is the source of truth for API schemas. TypedDicts in `unifi_firewall.py` must match it. When adding fields, verify against the spec first.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
# The installer requires curl (and certificates) to download the release archive
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates
|
||||||
|
|
||||||
|
# Download the latest installer
|
||||||
|
ADD https://astral.sh/uv/install.sh /uv-installer.sh
|
||||||
|
|
||||||
|
# Run the installer then remove it
|
||||||
|
RUN sh /uv-installer.sh && rm /uv-installer.sh
|
||||||
|
|
||||||
|
# Ensure the installed binary is on the `PATH`
|
||||||
|
ENV PATH="/root/.local/bin/:$PATH"
|
||||||
|
|
||||||
|
# Copy the project into the container (config is mounted at runtime)
|
||||||
|
COPY pyproject.toml uv.lock main.py unifi_firewall.py ip_lookup.py /app/
|
||||||
|
|
||||||
|
# Sync the project into a new environment, using the frozen lockfile
|
||||||
|
WORKDIR /app
|
||||||
|
RUN uv sync --frozen
|
||||||
|
|
||||||
|
# Run the firewall update script
|
||||||
|
CMD ["uv", "run", "main.py"]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# UniFi Firewall Updater
|
||||||
|
|
||||||
|
Automatically updates UniFi Dream Machine firewall rules with your current public IPv4 and IPv6 addresses via the UniFi Network API.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This tool:
|
||||||
|
|
||||||
|
- Fetches your public IPv4 and IPv6 addresses
|
||||||
|
- Reads firewall rule definitions from a YAML config file
|
||||||
|
- Creates or updates policies on your UniFi controller to use those addresses
|
||||||
|
- Optionally sends notifications via NTFY when rules change
|
||||||
|
|
||||||
|
Designed to run as a one-shot script (e.g., via cron or systemd timer) whenever your public IP changes.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in your UniFi credentials
|
||||||
|
2. Edit `config/rules.yaml` to define your firewall rules
|
||||||
|
3. Install dependencies and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
| ------------------ | -------- | -------------------------------------------------------- |
|
||||||
|
| `UNIFI_HOST` | Yes | UniFi controller URL (e.g., `https://10.1.0.1`) |
|
||||||
|
| `UNIFI_SITE_ID` | Yes | UniFi site ID |
|
||||||
|
| `UNIFI_API_TOKEN` | Yes | UniFi Network API token |
|
||||||
|
| `UNIFI_VERIFY_SSL` | No | Verify SSL certificates (default: `false`) |
|
||||||
|
| `CONFIG_FILE` | No | Path to rules YAML (default: `config/rules.yaml`) |
|
||||||
|
| `LOG_LEVEL` | No | Log level: DEBUG, INFO, WARNING, ERROR (default: `INFO`) |
|
||||||
|
| `DEBUG` | No | Attach debugpy on port 5678 (default: `false`) |
|
||||||
|
| `NTFY_URL` | No | NTFY server URL for notifications |
|
||||||
|
| `NTFY_TOPIC` | No | NTFY topic to publish to |
|
||||||
|
| `NTFY_API_KEY` | No | NTFY API key for authentication |
|
||||||
|
|
||||||
|
### Rules YAML
|
||||||
|
|
||||||
|
Each rule defines a firewall policy that will use your public IP as the source:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- name: "Allow External to Gateway HTTP(S)"
|
||||||
|
source_zone: "External"
|
||||||
|
dest_zone: "DMZ"
|
||||||
|
ip_version: "IPV6"
|
||||||
|
action: "ALLOW"
|
||||||
|
allow_return_traffic: true
|
||||||
|
protocol: "tcp"
|
||||||
|
dest_ports: [80, 443]
|
||||||
|
dest_port_ranges:
|
||||||
|
- start: 8000
|
||||||
|
stop: 8080
|
||||||
|
logging_enabled: false
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
| ---------------------- | -------- | ------------------------------------------------ |
|
||||||
|
| `name` | Yes | Unique policy name |
|
||||||
|
| `source_zone` | Yes | Source zone name (e.g., `WAN`, `External`) |
|
||||||
|
| `dest_zone` | Yes | Destination zone name (e.g., `LAN`, `DMZ`) |
|
||||||
|
| `ip_version` | No | `IPV4` or `IPV6` (default: `IPV4`) |
|
||||||
|
| `action` | No | `ALLOW`, `BLOCK`, or `REJECT` (default: `ALLOW`) |
|
||||||
|
| `allow_return_traffic` | No | Allow return traffic (default: `true`) |
|
||||||
|
| `protocol` | No | Protocol filter (e.g., `tcp`, `udp`) |
|
||||||
|
| `dest_ports` | No | List of destination ports |
|
||||||
|
| `dest_port_ranges` | No | List of `{start, stop}` port ranges |
|
||||||
|
| `logging_enabled` | No | Enable policy logging (default: `false`) |
|
||||||
|
| `enabled` | No | Enable the policy (default: `true`) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest -v # Run tests (all mocked, no live API calls)
|
||||||
|
uv run ruff check # Lint
|
||||||
|
uv run pyright # Type check (strict mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/test_unifi_firewall.py::TestBuildPolicyPayload::test_with_dest_ports -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container
|
||||||
|
|
||||||
|
Build and run with Podman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -t unifi-firewall .
|
||||||
|
podman run --env-file .env -v $(pwd)/config:/app/config unifi-firewall
|
||||||
|
```
|
||||||
|
|
||||||
|
Config is mounted at runtime via volume. Environment variables are passed at runtime.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Loads rules from `config/rules.yaml`
|
||||||
|
2. Fetches public IPv4/IPv6 via `curl ifconfig.me`
|
||||||
|
3. Lists zones from UniFi to resolve zone names to IDs
|
||||||
|
4. For each rule:
|
||||||
|
- Finds existing policy by name
|
||||||
|
- Skips if IP already matches
|
||||||
|
- Updates if policy exists with different IP
|
||||||
|
- Creates if policy does not exist
|
||||||
|
5. Sends NTFY notification if rules were created, updated, or failed
|
||||||
|
|
||||||
|
## API Details
|
||||||
|
|
||||||
|
Uses UniFi Network API v1 at:
|
||||||
|
|
||||||
|
```
|
||||||
|
{UNIFI_HOST}/proxy/network/integration/v1/sites/{UNIFI_SITE_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
Authentication via `X-API-Key` header. See `network_v10.4.57_openapi.json` for the full schema.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Public IP address lookup via ifconfig.me."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_ipv4() -> str:
|
||||||
|
"""Fetch public IPv4 address from ifconfig.me."""
|
||||||
|
logger.debug("Executing: curl -4 ifconfig.me")
|
||||||
|
result = subprocess.run(["curl", "-4", "ifconfig.me"], capture_output=True, text=True, check=False)
|
||||||
|
ip = result.stdout.strip()
|
||||||
|
logger.debug("IPv4 response: %s", ip)
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def get_ipv6() -> str:
|
||||||
|
"""Fetch public IPv6 address from ifconfig.me."""
|
||||||
|
logger.debug("Executing: curl -6 ifconfig.me")
|
||||||
|
result = subprocess.run(["curl", "-6", "ifconfig.me"], capture_output=True, text=True, check=False)
|
||||||
|
ip = result.stdout.strip()
|
||||||
|
logger.debug("IPv6 response: %s", ip)
|
||||||
|
return ip
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
"""
|
||||||
|
UniFi firewall rule updater.
|
||||||
|
|
||||||
|
Updates UniFi Dream Machine firewall rules with the host machine's public
|
||||||
|
IPv4 and IPv6 addresses via the UniFi Network API.
|
||||||
|
|
||||||
|
Exported env vars:
|
||||||
|
UNIFI_HOST - UniFi controller URL (e.g., https://10.1.0.1)
|
||||||
|
UNIFI_SITE_ID - UniFi site ID
|
||||||
|
UNIFI_API_TOKEN - UniFi API token
|
||||||
|
UNIFI_VERIFY_SSL - verify SSL certificates (default: false)
|
||||||
|
CONFIG_FILE - path to YAML file with firewall rules (default: config/rules.yaml)
|
||||||
|
LOG_LEVEL - logging level (DEBUG, INFO, WARNING, ERROR; default: INFO)
|
||||||
|
DEBUG - if true, starts debugpy and waits for a remote debugger connection on port 5678
|
||||||
|
NTFY_URL - NTFY notification server URL
|
||||||
|
NTFY_TOPIC - NTFY topic to send notifications to
|
||||||
|
NTFY_API_KEY - NTFY API key for authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from ip_lookup import get_ipv4, get_ipv6
|
||||||
|
from unifi_firewall import (
|
||||||
|
FirewallZone,
|
||||||
|
build_policy_payload,
|
||||||
|
create_policy,
|
||||||
|
get_session,
|
||||||
|
get_source_ip_from_policy,
|
||||||
|
list_policies,
|
||||||
|
list_zones,
|
||||||
|
update_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
if DEBUG:
|
||||||
|
import debugpy # noqa: T100
|
||||||
|
|
||||||
|
debugpy.listen(("0.0.0.0", 5678)) # noqa: T100
|
||||||
|
print("DEBUG: debugpy listening on 0.0.0.0:5678, waiting for debugger to attach...")
|
||||||
|
debugpy.wait_for_client() # noqa: T100
|
||||||
|
print("DEBUG: debugger attached, resuming execution...")
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yaml import CLoader as Loader
|
||||||
|
except ImportError:
|
||||||
|
from yaml import Loader # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=log_level,
|
||||||
|
format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.setLevel(log_level)
|
||||||
|
|
||||||
|
UNIFI_HOST = os.getenv("UNIFI_HOST")
|
||||||
|
UNIFI_SITE_ID = os.getenv("UNIFI_SITE_ID")
|
||||||
|
UNIFI_API_TOKEN = os.getenv("UNIFI_API_TOKEN")
|
||||||
|
UNIFI_VERIFY_SSL = os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true"
|
||||||
|
|
||||||
|
CONFIG_FILE = os.getenv("CONFIG_FILE", "config/rules.yaml")
|
||||||
|
|
||||||
|
NTFY_URL = os.getenv("NTFY_URL", "")
|
||||||
|
NTFY_TOPIC = os.getenv("NTFY_TOPIC", "")
|
||||||
|
NTFY_API_KEY = os.getenv("NTFY_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
class PortRange(TypedDict):
|
||||||
|
"""Port range definition."""
|
||||||
|
|
||||||
|
start: int
|
||||||
|
stop: int
|
||||||
|
|
||||||
|
|
||||||
|
class RuleConfig(TypedDict, total=False):
|
||||||
|
"""Firewall rule configuration from YAML."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
source_zone: str
|
||||||
|
dest_zone: str
|
||||||
|
ip_version: Literal["IPV4", "IPV6"]
|
||||||
|
action: Literal["ALLOW", "BLOCK", "REJECT"]
|
||||||
|
allow_return_traffic: bool
|
||||||
|
protocol: str | None
|
||||||
|
dest_ports: list[int] | None
|
||||||
|
dest_port_ranges: list[PortRange] | None
|
||||||
|
logging_enabled: bool
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RulesConfig(TypedDict):
|
||||||
|
"""Top-level rules configuration."""
|
||||||
|
|
||||||
|
rules: list[RuleConfig]
|
||||||
|
|
||||||
|
|
||||||
|
class RuleChange(TypedDict):
|
||||||
|
"""Record of a rule change."""
|
||||||
|
|
||||||
|
rule_name: str
|
||||||
|
action: Literal["created", "updated", "skipped", "failed"]
|
||||||
|
ip: str
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def send_ntfy_notification(title: str, message: str, priority: int = 3) -> None:
|
||||||
|
"""Send an NTFY notification."""
|
||||||
|
if not NTFY_URL or not NTFY_TOPIC:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Title": title,
|
||||||
|
"Priority": str(priority),
|
||||||
|
}
|
||||||
|
if NTFY_API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {NTFY_API_KEY}"
|
||||||
|
logger.info("Sending NTFY notification: %s", title)
|
||||||
|
response = requests.post(
|
||||||
|
f"{NTFY_URL}/{NTFY_TOPIC}",
|
||||||
|
data=message.encode(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
logger.info("NTFY notification sent: %s", title)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Failed to send NTFY notification: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str) -> list[RuleConfig]:
|
||||||
|
"""Load firewall rules from YAML config file."""
|
||||||
|
logger.info("Loading config file: %s", path)
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
config: RulesConfig = yaml.load(f, Loader)
|
||||||
|
rules = config.get("rules", [])
|
||||||
|
logger.debug("Loaded %d rule(s) from config", len(rules))
|
||||||
|
return rules
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
logger.error("Config file not found: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
logger.error("Failed to parse config file: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_zone_map(zones: list[FirewallZone]) -> dict[str, str]:
|
||||||
|
"""Build a mapping of zone names to zone IDs."""
|
||||||
|
zone_map: dict[str, str] = {}
|
||||||
|
for zone in zones:
|
||||||
|
zone_map[zone["name"]] = zone["id"]
|
||||||
|
logger.debug("Zone map: %s", zone_map)
|
||||||
|
return zone_map
|
||||||
|
|
||||||
|
|
||||||
|
def process_rule(
|
||||||
|
session: requests.Session,
|
||||||
|
host: str,
|
||||||
|
site_id: str,
|
||||||
|
rule: RuleConfig,
|
||||||
|
zone_map: dict[str, str],
|
||||||
|
public_ip: str,
|
||||||
|
) -> RuleChange:
|
||||||
|
"""Process a single firewall rule: create, update, or skip."""
|
||||||
|
rule_name = rule.get("name", "unnamed")
|
||||||
|
ip_version = rule.get("ip_version", "IPV4")
|
||||||
|
source_zone = rule.get("source_zone", "WAN")
|
||||||
|
dest_zone = rule.get("dest_zone", "LAN")
|
||||||
|
|
||||||
|
logger.info("=== Processing rule: %s ===", rule_name)
|
||||||
|
|
||||||
|
# Get zone IDs
|
||||||
|
src_zone_id = zone_map.get(source_zone)
|
||||||
|
dst_zone_id = zone_map.get(dest_zone)
|
||||||
|
|
||||||
|
if not src_zone_id:
|
||||||
|
error_msg = f"Source zone '{source_zone}' not found"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
if not dst_zone_id:
|
||||||
|
error_msg = f"Destination zone '{dest_zone}' not found"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
# Find existing policy by name
|
||||||
|
try:
|
||||||
|
existing_policies = list_policies(session, host, site_id, name_filter=rule_name)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f"Failed to list policies: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
existing_policy = existing_policies[0] if existing_policies else None
|
||||||
|
|
||||||
|
# Check if policy exists and IP matches
|
||||||
|
if existing_policy:
|
||||||
|
existing_ip = get_source_ip_from_policy(existing_policy)
|
||||||
|
if existing_ip == public_ip:
|
||||||
|
logger.info("Rule '%s' already has correct IP (%s), skipping", rule_name, public_ip)
|
||||||
|
return {"rule_name": rule_name, "action": "skipped", "ip": public_ip, "error": None}
|
||||||
|
|
||||||
|
# Update existing policy
|
||||||
|
logger.info("Rule '%s' exists with IP %s, updating to %s", rule_name, existing_ip, public_ip)
|
||||||
|
try:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name=rule_name,
|
||||||
|
source_ip=public_ip,
|
||||||
|
ip_version=ip_version, # type: ignore[arg-type]
|
||||||
|
src_zone_id=src_zone_id,
|
||||||
|
dst_zone_id=dst_zone_id,
|
||||||
|
action_type=rule.get("action", "ALLOW"), # type: ignore[arg-type]
|
||||||
|
allow_return_traffic=rule.get("allow_return_traffic", True),
|
||||||
|
protocol=rule.get("protocol"),
|
||||||
|
dest_ports=rule.get("dest_ports"),
|
||||||
|
dest_port_ranges=rule.get("dest_port_ranges"), # type: ignore[arg-type]
|
||||||
|
logging_enabled=rule.get("logging_enabled", False),
|
||||||
|
enabled=rule.get("enabled", True),
|
||||||
|
)
|
||||||
|
update_policy(session, host, site_id, existing_policy["id"], payload)
|
||||||
|
logger.info("Rule '%s' updated successfully", rule_name)
|
||||||
|
return {"rule_name": rule_name, "action": "updated", "ip": public_ip, "error": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f"Failed to update rule: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
# Create new policy
|
||||||
|
logger.info("Rule '%s' does not exist, creating with IP %s", rule_name, public_ip)
|
||||||
|
try:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name=rule_name,
|
||||||
|
source_ip=public_ip,
|
||||||
|
ip_version=ip_version, # type: ignore[arg-type]
|
||||||
|
src_zone_id=src_zone_id,
|
||||||
|
dst_zone_id=dst_zone_id,
|
||||||
|
action_type=rule.get("action", "ALLOW"), # type: ignore[arg-type]
|
||||||
|
allow_return_traffic=rule.get("allow_return_traffic", True),
|
||||||
|
protocol=rule.get("protocol"),
|
||||||
|
dest_ports=rule.get("dest_ports"),
|
||||||
|
dest_port_ranges=rule.get("dest_port_ranges"), # type: ignore[arg-type]
|
||||||
|
logging_enabled=rule.get("logging_enabled", False),
|
||||||
|
enabled=rule.get("enabled", True),
|
||||||
|
)
|
||||||
|
create_policy(session, host, site_id, payload)
|
||||||
|
logger.info("Rule '%s' created successfully", rule_name)
|
||||||
|
return {"rule_name": rule_name, "action": "created", "ip": public_ip, "error": None}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
error_msg = f"Failed to create rule: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
logger.info("=== UniFi Firewall Update Starting ===")
|
||||||
|
logger.debug("Log level: %s", log_level)
|
||||||
|
logger.debug("UNIFI_HOST: %s", UNIFI_HOST)
|
||||||
|
logger.debug("UNIFI_SITE_ID: %s", UNIFI_SITE_ID)
|
||||||
|
logger.debug("UNIFI_API_TOKEN: %s", "****" if UNIFI_API_TOKEN else "not set")
|
||||||
|
logger.debug("UNIFI_VERIFY_SSL: %s", UNIFI_VERIFY_SSL)
|
||||||
|
logger.debug("CONFIG_FILE: %s", CONFIG_FILE)
|
||||||
|
|
||||||
|
# Validate required env vars
|
||||||
|
if not all([UNIFI_HOST, UNIFI_SITE_ID, UNIFI_API_TOKEN]):
|
||||||
|
logger.error("UNIFI_HOST, UNIFI_SITE_ID, and UNIFI_API_TOKEN must be set!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
assert UNIFI_HOST is not None
|
||||||
|
assert UNIFI_SITE_ID is not None
|
||||||
|
assert UNIFI_API_TOKEN is not None
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
rules = load_config(CONFIG_FILE)
|
||||||
|
if not rules:
|
||||||
|
logger.warning("No rules found in config file")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fetch public IPs
|
||||||
|
public_ipv4 = get_ipv4()
|
||||||
|
if not public_ipv4:
|
||||||
|
logger.error("Failed to fetch public IPv4 address")
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("Public IPv4: %s", public_ipv4)
|
||||||
|
|
||||||
|
public_ipv6 = get_ipv6()
|
||||||
|
if not public_ipv6:
|
||||||
|
logger.error("Failed to fetch public IPv6 address")
|
||||||
|
sys.exit(1)
|
||||||
|
logger.info("Public IPv6: %s", public_ipv6)
|
||||||
|
|
||||||
|
# Create session and list zones
|
||||||
|
session = get_session(UNIFI_HOST, UNIFI_API_TOKEN, UNIFI_VERIFY_SSL)
|
||||||
|
try:
|
||||||
|
zones = list_zones(session, UNIFI_HOST, UNIFI_SITE_ID)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("Failed to list firewall zones: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
zone_map = build_zone_map(zones)
|
||||||
|
|
||||||
|
# Process rules
|
||||||
|
changes: list[RuleChange] = []
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
ip_version = rule.get("ip_version", "IPV4")
|
||||||
|
public_ip = public_ipv4 if ip_version == "IPV4" else public_ipv6
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
logger.warning("Skipping rule '%s': no %s address available", rule.get("name"), ip_version)
|
||||||
|
continue
|
||||||
|
|
||||||
|
change = process_rule(session, UNIFI_HOST, UNIFI_SITE_ID, rule, zone_map, public_ip)
|
||||||
|
changes.append(change)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
created = [c for c in changes if c["action"] == "created"]
|
||||||
|
updated = [c for c in changes if c["action"] == "updated"]
|
||||||
|
skipped = [c for c in changes if c["action"] == "skipped"]
|
||||||
|
failed = [c for c in changes if c["action"] == "failed"]
|
||||||
|
|
||||||
|
logger.info("=== Summary ===")
|
||||||
|
logger.info("Created: %d", len(created))
|
||||||
|
logger.info("Updated: %d", len(updated))
|
||||||
|
logger.info("Skipped: %d", len(skipped))
|
||||||
|
logger.info("Failed: %d", len(failed))
|
||||||
|
|
||||||
|
# Build NTFY message
|
||||||
|
ntfy_lines: list[str] = []
|
||||||
|
for c in created:
|
||||||
|
ntfy_lines.append(f"+ {c['rule_name']}: {c['ip']}")
|
||||||
|
for c in updated:
|
||||||
|
ntfy_lines.append(f"~ {c['rule_name']}: {c['ip']}")
|
||||||
|
for c in failed:
|
||||||
|
ntfy_lines.append(f"! {c['rule_name']}: {c['error']}")
|
||||||
|
|
||||||
|
if ntfy_lines:
|
||||||
|
ntfy_message = "\n".join(ntfy_lines)
|
||||||
|
has_changes = bool(created or updated)
|
||||||
|
has_failures = bool(failed)
|
||||||
|
if has_failures:
|
||||||
|
ntfy_title = "Firewall Update Failed"
|
||||||
|
ntfy_priority = 4
|
||||||
|
elif has_changes:
|
||||||
|
ntfy_title = "Firewall Rules Updated"
|
||||||
|
ntfy_priority = 4
|
||||||
|
else:
|
||||||
|
ntfy_title = "Firewall Rules Unchanged"
|
||||||
|
ntfy_priority = 2
|
||||||
|
send_ntfy_notification(ntfy_title, ntfy_message, priority=ntfy_priority)
|
||||||
|
|
||||||
|
logger.info("=== UniFi Firewall Update Complete ===")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
[project]
|
||||||
|
name = "firewall"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.34.2",
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pyyaml>=6.0.0",
|
||||||
|
"python-dotenv>=1.0.0",
|
||||||
|
"debugpy>=1.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
typeCheckingMode = "strict"
|
||||||
|
extraPaths = ["/var/home/ducoterra/.local/lib/python3.14/site-packages"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"debugpy>=1.8.21",
|
||||||
|
"pyright>=1.1.411",
|
||||||
|
"ruff>=0.16.1",
|
||||||
|
]
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for ip_lookup module."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from ip_lookup import get_ipv4, get_ipv6
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetIpv4:
|
||||||
|
def test_returns_stripped_ip(self) -> None:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = " 1.2.3.4 \n"
|
||||||
|
|
||||||
|
with patch("ip_lookup.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
ip = get_ipv4()
|
||||||
|
|
||||||
|
assert ip == "1.2.3.4"
|
||||||
|
mock_run.assert_called_once_with(["curl", "-4", "ifconfig.me"], capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
def test_empty_response(self) -> None:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
|
||||||
|
with patch("ip_lookup.subprocess.run", return_value=mock_result):
|
||||||
|
ip = get_ipv4()
|
||||||
|
|
||||||
|
assert ip == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetIpv6:
|
||||||
|
def test_returns_stripped_ip(self) -> None:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = " 2001:db8::1 \n"
|
||||||
|
|
||||||
|
with patch("ip_lookup.subprocess.run", return_value=mock_result) as mock_run:
|
||||||
|
ip = get_ipv6()
|
||||||
|
|
||||||
|
assert ip == "2001:db8::1"
|
||||||
|
mock_run.assert_called_once_with(["curl", "-6", "ifconfig.me"], capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
def test_empty_response(self) -> None:
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
|
||||||
|
with patch("ip_lookup.subprocess.run", return_value=mock_result):
|
||||||
|
ip = get_ipv6()
|
||||||
|
|
||||||
|
assert ip == ""
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Tests for main module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from main import (
|
||||||
|
RuleConfig,
|
||||||
|
build_zone_map,
|
||||||
|
load_config,
|
||||||
|
process_rule,
|
||||||
|
send_ntfy_notification,
|
||||||
|
)
|
||||||
|
from unifi_firewall import FirewallZone
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildZoneMap:
|
||||||
|
def test_builds_map_from_zones(self) -> None:
|
||||||
|
zones: list[FirewallZone] = [
|
||||||
|
{"id": "zone-wan", "name": "WAN", "networkIds": [], "metadata": {}},
|
||||||
|
{"id": "zone-lan", "name": "LAN", "networkIds": [], "metadata": {}},
|
||||||
|
]
|
||||||
|
|
||||||
|
zone_map = build_zone_map(zones)
|
||||||
|
|
||||||
|
assert zone_map == {"WAN": "zone-wan", "LAN": "zone-lan"}
|
||||||
|
|
||||||
|
def test_empty_zones(self) -> None:
|
||||||
|
zone_map = build_zone_map([])
|
||||||
|
assert zone_map == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
def test_loads_valid_config(self, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "rules.yaml"
|
||||||
|
config_file.write_text(
|
||||||
|
"""
|
||||||
|
rules:
|
||||||
|
- name: "Test Rule"
|
||||||
|
source_zone: "WAN"
|
||||||
|
dest_zone: "LAN"
|
||||||
|
ip_version: "IPV4"
|
||||||
|
action: "ALLOW"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
rules = load_config(str(config_file))
|
||||||
|
|
||||||
|
assert len(rules) == 1
|
||||||
|
assert rules[0].get("name") == "Test Rule"
|
||||||
|
|
||||||
|
def test_exits_on_missing_file(self) -> None:
|
||||||
|
with patch("sys.exit") as mock_exit:
|
||||||
|
load_config("/nonexistent/rules.yaml")
|
||||||
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
|
def test_exits_on_invalid_yaml(self, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "rules.yaml"
|
||||||
|
config_file.write_text("invalid: yaml: content: [")
|
||||||
|
|
||||||
|
with patch("sys.exit") as mock_exit:
|
||||||
|
load_config(str(config_file))
|
||||||
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendNtfyNotification:
|
||||||
|
def test_sends_notification(self) -> None:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("main.NTFY_URL", "https://ntfy.sh"),
|
||||||
|
patch("main.NTFY_TOPIC", "test"),
|
||||||
|
patch("main.NTFY_API_KEY", "test-key"),
|
||||||
|
patch("main.requests.post", return_value=mock_response) as mock_post,
|
||||||
|
):
|
||||||
|
send_ntfy_notification("Title", "Message", priority=4)
|
||||||
|
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_kwargs = mock_post.call_args
|
||||||
|
assert call_kwargs[0][0] == "https://ntfy.sh/test"
|
||||||
|
assert call_kwargs[1]["data"] == b"Message"
|
||||||
|
assert call_kwargs[1]["headers"]["Title"] == "Title"
|
||||||
|
assert call_kwargs[1]["headers"]["Priority"] == "4"
|
||||||
|
|
||||||
|
def test_skips_when_no_url(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("main.NTFY_URL", ""),
|
||||||
|
patch("main.requests.post") as mock_post,
|
||||||
|
):
|
||||||
|
send_ntfy_notification("Title", "Message")
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_handles_request_error(self) -> None:
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("main.NTFY_URL", "https://ntfy.sh"),
|
||||||
|
patch("main.NTFY_TOPIC", "test"),
|
||||||
|
patch("main.requests.post", side_effect=req.RequestException("timeout")),
|
||||||
|
):
|
||||||
|
# Should not raise, only log warning
|
||||||
|
send_ntfy_notification("Title", "Message")
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessRule:
|
||||||
|
def test_creates_new_rule(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = MagicMock(json=MagicMock(return_value={"data": []}), raise_for_status=MagicMock())
|
||||||
|
mock_session.post.return_value = MagicMock(
|
||||||
|
json=MagicMock(return_value={"id": "new-id", "name": "Test Rule"}),
|
||||||
|
raise_for_status=MagicMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
rule: RuleConfig = {
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source_zone": "WAN",
|
||||||
|
"dest_zone": "LAN",
|
||||||
|
"ip_version": "IPV4",
|
||||||
|
"action": "ALLOW",
|
||||||
|
}
|
||||||
|
zone_map = {"WAN": "wan-id", "LAN": "lan-id"}
|
||||||
|
|
||||||
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
|
assert change["action"] == "created"
|
||||||
|
assert change["rule_name"] == "Test Rule"
|
||||||
|
assert change["error"] is None
|
||||||
|
|
||||||
|
def test_skips_rule_with_matching_ip(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
existing_policy = {
|
||||||
|
"id": "pol-1",
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source": {
|
||||||
|
"zoneId": "wan-id",
|
||||||
|
"trafficFilter": {
|
||||||
|
"type": "IP_ADDRESS",
|
||||||
|
"ipAddressFilter": {
|
||||||
|
"type": "IP_ADDRESSES",
|
||||||
|
"items": [{"type": "IP_ADDRESS", "value": "1.2.3.4"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_session.get.return_value = MagicMock(json=MagicMock(return_value={"data": [existing_policy]}), raise_for_status=MagicMock())
|
||||||
|
|
||||||
|
rule: RuleConfig = {
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source_zone": "WAN",
|
||||||
|
"dest_zone": "LAN",
|
||||||
|
"ip_version": "IPV4",
|
||||||
|
}
|
||||||
|
zone_map = {"WAN": "wan-id", "LAN": "lan-id"}
|
||||||
|
|
||||||
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
|
assert change["action"] == "skipped"
|
||||||
|
mock_session.put.assert_not_called()
|
||||||
|
mock_session.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_updates_rule_with_different_ip(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
existing_policy = {
|
||||||
|
"id": "pol-1",
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source": {
|
||||||
|
"zoneId": "wan-id",
|
||||||
|
"trafficFilter": {
|
||||||
|
"type": "IP_ADDRESS",
|
||||||
|
"ipAddressFilter": {
|
||||||
|
"type": "IP_ADDRESSES",
|
||||||
|
"items": [{"type": "IP_ADDRESS", "value": "5.6.7.8"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_session.get.return_value = MagicMock(json=MagicMock(return_value={"data": [existing_policy]}), raise_for_status=MagicMock())
|
||||||
|
mock_session.put.return_value = MagicMock(json=MagicMock(return_value={"id": "pol-1"}), raise_for_status=MagicMock())
|
||||||
|
|
||||||
|
rule: RuleConfig = {
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source_zone": "WAN",
|
||||||
|
"dest_zone": "LAN",
|
||||||
|
"ip_version": "IPV4",
|
||||||
|
}
|
||||||
|
zone_map = {"WAN": "wan-id", "LAN": "lan-id"}
|
||||||
|
|
||||||
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
|
assert change["action"] == "updated"
|
||||||
|
mock_session.put.assert_called_once()
|
||||||
|
|
||||||
|
def test_fails_on_missing_source_zone(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
|
||||||
|
rule: RuleConfig = {
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source_zone": "UNKNOWN",
|
||||||
|
"dest_zone": "LAN",
|
||||||
|
"ip_version": "IPV4",
|
||||||
|
}
|
||||||
|
zone_map = {"LAN": "lan-id"}
|
||||||
|
|
||||||
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
|
assert change["action"] == "failed"
|
||||||
|
assert "Source zone" in (change["error"] or "")
|
||||||
|
|
||||||
|
def test_fails_on_api_error(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.side_effect = Exception("Connection error")
|
||||||
|
|
||||||
|
rule: RuleConfig = {
|
||||||
|
"name": "Test Rule",
|
||||||
|
"source_zone": "WAN",
|
||||||
|
"dest_zone": "LAN",
|
||||||
|
"ip_version": "IPV4",
|
||||||
|
}
|
||||||
|
zone_map = {"WAN": "wan-id", "LAN": "lan-id"}
|
||||||
|
|
||||||
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
|
assert change["action"] == "failed"
|
||||||
|
assert "Failed to list policies" in (change["error"] or "")
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Tests for unifi_firewall module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from unifi_firewall import (
|
||||||
|
FirewallPolicyCreatePayload,
|
||||||
|
build_policy_payload,
|
||||||
|
create_policy,
|
||||||
|
get_policy,
|
||||||
|
get_session,
|
||||||
|
get_source_ip_from_policy,
|
||||||
|
list_policies,
|
||||||
|
list_zones,
|
||||||
|
update_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockHttpError(Exception):
|
||||||
|
"""Mock HTTP error for testing."""
|
||||||
|
|
||||||
|
|
||||||
|
class MockResponse:
|
||||||
|
def __init__(self, json_data: Any, status_code: int = 200) -> None:
|
||||||
|
self.json_data = json_data
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
def json(self) -> Any:
|
||||||
|
return self.json_data
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise MockHttpError(f"HTTP {self.status_code}")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSession:
|
||||||
|
def test_creates_session_with_headers(self) -> None:
|
||||||
|
session = get_session("https://unifi.local", "test-token", True)
|
||||||
|
|
||||||
|
assert session.headers["X-API-Key"] == "test-token"
|
||||||
|
assert session.headers["Content-Type"] == "application/json"
|
||||||
|
assert session.verify is True
|
||||||
|
|
||||||
|
def test_verify_ssl_false(self) -> None:
|
||||||
|
session = get_session("https://unifi.local", "test-token", False)
|
||||||
|
|
||||||
|
assert session.verify is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestListZones:
|
||||||
|
def test_returns_zones_from_data_key(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = MockResponse({"data": [{"id": "zone-1", "name": "WAN", "networkIds": [], "metadata": {}}]})
|
||||||
|
|
||||||
|
zones = list_zones(mock_session, "https://unifi.local", "site-1")
|
||||||
|
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert zones[0]["name"] == "WAN"
|
||||||
|
mock_session.get.assert_called_once_with("https://unifi.local/proxy/network/integration/v1/sites/site-1/firewall/zones")
|
||||||
|
|
||||||
|
def test_returns_zones_as_list(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = MockResponse([{"id": "zone-1", "name": "LAN", "networkIds": [], "metadata": {}}])
|
||||||
|
|
||||||
|
zones = list_zones(mock_session, "https://unifi.local", "site-1")
|
||||||
|
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert zones[0]["name"] == "LAN"
|
||||||
|
|
||||||
|
|
||||||
|
class TestListPolicies:
|
||||||
|
def test_returns_policies_without_filter(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = MockResponse({"data": []})
|
||||||
|
|
||||||
|
policies = list_policies(mock_session, "https://unifi.local", "site-1")
|
||||||
|
|
||||||
|
assert policies == []
|
||||||
|
mock_session.get.assert_called_once_with("https://unifi.local/proxy/network/integration/v1/sites/site-1/firewall/policies", params={})
|
||||||
|
|
||||||
|
def test_returns_policies_with_name_filter(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = MockResponse({"data": []})
|
||||||
|
|
||||||
|
policies = list_policies(mock_session, "https://unifi.local", "site-1", name_filter="Test Rule")
|
||||||
|
|
||||||
|
assert policies == []
|
||||||
|
call_args = mock_session.get.call_args
|
||||||
|
assert call_args[1]["params"]["filter"] == "name.like('Test Rule')"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPolicy:
|
||||||
|
def test_returns_single_policy(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
policy_data = {"id": "pol-1", "name": "Test", "source": {"zoneId": "z1", "trafficFilter": None}, "destination": {"zoneId": "z2", "trafficFilter": None}, "action": {"type": "ALLOW", "allowReturnTraffic": True}, "ipProtocolScope": {"ipVersion": "IPV4"}, "enabled": True, "loggingEnabled": False, "index": 0, "metadata": {}}
|
||||||
|
mock_session.get.return_value = MockResponse(policy_data)
|
||||||
|
|
||||||
|
policy = get_policy(mock_session, "https://unifi.local", "site-1", "pol-1")
|
||||||
|
|
||||||
|
assert policy["id"] == "pol-1"
|
||||||
|
mock_session.get.assert_called_once_with("https://unifi.local/proxy/network/integration/v1/sites/site-1/firewall/policies/pol-1")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreatePolicy:
|
||||||
|
def test_creates_policy(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
result = {"id": "new-pol", "name": "New Rule", "source": {"zoneId": "z1", "trafficFilter": None}, "destination": {"zoneId": "z2", "trafficFilter": None}, "action": {"type": "ALLOW", "allowReturnTraffic": True}, "ipProtocolScope": {"ipVersion": "IPV4"}, "enabled": True, "loggingEnabled": False, "index": 0, "metadata": {}}
|
||||||
|
mock_session.post.return_value = MockResponse(result, status_code=201)
|
||||||
|
|
||||||
|
payload: FirewallPolicyCreatePayload = build_policy_payload(
|
||||||
|
name="New Rule",
|
||||||
|
source_ip="1.2.3.4",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="z1",
|
||||||
|
dst_zone_id="z2",
|
||||||
|
action_type="ALLOW",
|
||||||
|
)
|
||||||
|
policy = create_policy(mock_session, "https://unifi.local", "site-1", payload)
|
||||||
|
|
||||||
|
assert policy["id"] == "new-pol"
|
||||||
|
mock_session.post.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePolicy:
|
||||||
|
def test_updates_policy(self) -> None:
|
||||||
|
mock_session = MagicMock()
|
||||||
|
result = {"id": "pol-1", "name": "Updated Rule", "source": {"zoneId": "z1", "trafficFilter": None}, "destination": {"zoneId": "z2", "trafficFilter": None}, "action": {"type": "ALLOW", "allowReturnTraffic": True}, "ipProtocolScope": {"ipVersion": "IPV4"}, "enabled": True, "loggingEnabled": False, "index": 0, "metadata": {}}
|
||||||
|
mock_session.put.return_value = MockResponse(result)
|
||||||
|
|
||||||
|
payload: FirewallPolicyCreatePayload = build_policy_payload(
|
||||||
|
name="Updated Rule",
|
||||||
|
source_ip="5.6.7.8",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="z1",
|
||||||
|
dst_zone_id="z2",
|
||||||
|
action_type="ALLOW",
|
||||||
|
)
|
||||||
|
policy = update_policy(mock_session, "https://unifi.local", "site-1", "pol-1", payload)
|
||||||
|
|
||||||
|
assert policy["id"] == "pol-1"
|
||||||
|
mock_session.put.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPolicyPayload:
|
||||||
|
def test_basic_allow_ipv4(self) -> None:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name="Test Rule",
|
||||||
|
source_ip="1.2.3.4",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="wan-zone",
|
||||||
|
dst_zone_id="lan-zone",
|
||||||
|
action_type="ALLOW",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["name"] == "Test Rule"
|
||||||
|
assert payload["source"]["zoneId"] == "wan-zone"
|
||||||
|
assert payload["destination"]["zoneId"] == "lan-zone"
|
||||||
|
assert payload["action"]["type"] == "ALLOW"
|
||||||
|
assert payload["action"]["allowReturnTraffic"] is True
|
||||||
|
assert payload["ipProtocolScope"]["ipVersion"] == "IPV4"
|
||||||
|
assert payload["enabled"] is True
|
||||||
|
assert payload["loggingEnabled"] is False
|
||||||
|
|
||||||
|
# Check source IP filter
|
||||||
|
tf = payload["source"]["trafficFilter"]
|
||||||
|
assert tf is not None
|
||||||
|
assert tf["type"] == "IP_ADDRESS"
|
||||||
|
assert tf["ipAddressFilter"]["type"] == "IP_ADDRESSES"
|
||||||
|
assert tf["ipAddressFilter"]["items"][0]["value"] == "1.2.3.4"
|
||||||
|
|
||||||
|
def test_with_dest_ports(self) -> None:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name="Test Rule",
|
||||||
|
source_ip="1.2.3.4",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="wan-zone",
|
||||||
|
dst_zone_id="lan-zone",
|
||||||
|
action_type="ALLOW",
|
||||||
|
dest_ports=[22, 80, 443],
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_tf = payload["destination"]["trafficFilter"]
|
||||||
|
assert dest_tf is not None
|
||||||
|
assert dest_tf["type"] == "PORT"
|
||||||
|
port_filter = dest_tf["portFilter"]
|
||||||
|
assert port_filter is not None
|
||||||
|
assert port_filter["type"] == "PORTS"
|
||||||
|
assert len(port_filter["items"]) == 3
|
||||||
|
assert port_filter["items"][0] == {"type": "PORT_NUMBER", "value": 22}
|
||||||
|
|
||||||
|
def test_with_port_ranges(self) -> None:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name="Test Rule",
|
||||||
|
source_ip="1.2.3.4",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="wan-zone",
|
||||||
|
dst_zone_id="lan-zone",
|
||||||
|
action_type="ALLOW",
|
||||||
|
dest_port_ranges=[{"start": 8000, "stop": 9000}],
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_tf = payload["destination"]["trafficFilter"]
|
||||||
|
assert dest_tf is not None
|
||||||
|
assert dest_tf["type"] == "PORT"
|
||||||
|
port_filter = dest_tf["portFilter"]
|
||||||
|
assert port_filter is not None
|
||||||
|
assert port_filter["items"][0] == {"type": "PORT_NUMBER_RANGE", "start": 8000, "stop": 9000}
|
||||||
|
|
||||||
|
def test_with_protocol(self) -> None:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name="Test Rule",
|
||||||
|
source_ip="1.2.3.4",
|
||||||
|
ip_version="IPV6",
|
||||||
|
src_zone_id="wan-zone",
|
||||||
|
dst_zone_id="lan-zone",
|
||||||
|
action_type="ALLOW",
|
||||||
|
protocol="tcp",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["ipProtocolScope"]["ipVersion"] == "IPV6"
|
||||||
|
pf = payload["ipProtocolScope"]["protocolFilter"]
|
||||||
|
assert pf is not None
|
||||||
|
assert pf["protocol"] == {"name": "TCP"}
|
||||||
|
|
||||||
|
def test_block_action(self) -> None:
|
||||||
|
payload = build_policy_payload(
|
||||||
|
name="Block Rule",
|
||||||
|
source_ip="5.6.7.8",
|
||||||
|
ip_version="IPV4",
|
||||||
|
src_zone_id="wan-zone",
|
||||||
|
dst_zone_id="lan-zone",
|
||||||
|
action_type="BLOCK",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["action"]["type"] == "BLOCK"
|
||||||
|
assert "allowReturnTraffic" not in payload["action"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSourceIpFromPolicy:
|
||||||
|
def test_extracts_ip_from_policy(self) -> None:
|
||||||
|
policy: dict[str, Any] = {
|
||||||
|
"id": "pol-1",
|
||||||
|
"name": "Test",
|
||||||
|
"source": {
|
||||||
|
"zoneId": "wan-zone",
|
||||||
|
"trafficFilter": {
|
||||||
|
"type": "IP_ADDRESS",
|
||||||
|
"ipAddressFilter": {
|
||||||
|
"type": "IP_ADDRESSES",
|
||||||
|
"items": [{"type": "IP_ADDRESS", "value": "1.2.3.4"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"destination": {"zoneId": "lan-zone", "trafficFilter": None},
|
||||||
|
"action": {"type": "ALLOW", "allowReturnTraffic": True},
|
||||||
|
"ipProtocolScope": {"ipVersion": "IPV4"},
|
||||||
|
"enabled": True,
|
||||||
|
"loggingEnabled": False,
|
||||||
|
"index": 0,
|
||||||
|
"metadata": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = get_source_ip_from_policy(policy) # type: ignore[arg-type]
|
||||||
|
assert ip == "1.2.3.4"
|
||||||
|
|
||||||
|
def test_returns_none_no_traffic_filter(self) -> None:
|
||||||
|
policy: dict[str, Any] = {
|
||||||
|
"source": {"zoneId": "wan-zone", "trafficFilter": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = get_source_ip_from_policy(policy) # type: ignore[arg-type]
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
def test_returns_none_wrong_filter_type(self) -> None:
|
||||||
|
policy: dict[str, Any] = {
|
||||||
|
"source": {
|
||||||
|
"zoneId": "wan-zone",
|
||||||
|
"trafficFilter": {"type": "NETWORK"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = get_source_ip_from_policy(policy) # type: ignore[arg-type]
|
||||||
|
assert ip is None
|
||||||
|
|
||||||
|
def test_returns_none_empty_items(self) -> None:
|
||||||
|
policy: dict[str, Any] = {
|
||||||
|
"source": {
|
||||||
|
"zoneId": "wan-zone",
|
||||||
|
"trafficFilter": {
|
||||||
|
"type": "IP_ADDRESS",
|
||||||
|
"ipAddressFilter": {"type": "IP_ADDRESSES", "items": []},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ip = get_source_ip_from_policy(policy) # type: ignore[arg-type]
|
||||||
|
assert ip is None
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""UniFi Network API client for firewall policy management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Literal, TypedDict, cast
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Response TypedDicts ==========
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallZone(TypedDict):
|
||||||
|
"""Firewall zone from GET /firewall/zones."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
networkIds: list[str]
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicySource(TypedDict):
|
||||||
|
"""Firewall policy source."""
|
||||||
|
|
||||||
|
zoneId: str
|
||||||
|
trafficFilter: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyDestination(TypedDict):
|
||||||
|
"""Firewall policy destination."""
|
||||||
|
|
||||||
|
zoneId: str
|
||||||
|
trafficFilter: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyActionAllowDto(TypedDict):
|
||||||
|
"""Allow action for firewall policy."""
|
||||||
|
|
||||||
|
type: Literal["ALLOW"]
|
||||||
|
allowReturnTraffic: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyActionBlockDto(TypedDict):
|
||||||
|
"""Block action for firewall policy."""
|
||||||
|
|
||||||
|
type: Literal["BLOCK"]
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyActionRejectDto(TypedDict):
|
||||||
|
"""Reject action for firewall policy."""
|
||||||
|
|
||||||
|
type: Literal["REJECT"]
|
||||||
|
|
||||||
|
|
||||||
|
FirewallPolicyAction = FirewallPolicyActionAllowDto | FirewallPolicyActionBlockDto | FirewallPolicyActionRejectDto
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyIpv4ProtocolScopeDto(TypedDict):
|
||||||
|
"""IPv4 protocol scope for firewall policy."""
|
||||||
|
|
||||||
|
ipVersion: Literal["IPV4"]
|
||||||
|
protocolFilter: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyIpv6ProtocolScopeDto(TypedDict):
|
||||||
|
"""IPv6 protocol scope for firewall policy."""
|
||||||
|
|
||||||
|
ipVersion: Literal["IPV6"]
|
||||||
|
protocolFilter: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
FirewallPolicyIpProtocolScope = FirewallPolicyIpv4ProtocolScopeDto | FirewallPolicyIpv6ProtocolScopeDto
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicy(TypedDict):
|
||||||
|
"""Firewall policy from GET /firewall/policies."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
source: FirewallPolicySource
|
||||||
|
destination: FirewallPolicyDestination
|
||||||
|
action: FirewallPolicyAction
|
||||||
|
ipProtocolScope: FirewallPolicyIpProtocolScope
|
||||||
|
enabled: bool
|
||||||
|
loggingEnabled: bool
|
||||||
|
index: int
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
description: str | None
|
||||||
|
connectionStateFilter: list[str] | None
|
||||||
|
ipsecFilter: str | None
|
||||||
|
schedule: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Request TypedDicts ==========
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallPolicyCreatePayload(TypedDict):
|
||||||
|
"""Payload for POST/PUT /firewall/policies."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
source: FirewallPolicySource
|
||||||
|
destination: FirewallPolicyDestination
|
||||||
|
action: FirewallPolicyAction
|
||||||
|
ipProtocolScope: FirewallPolicyIpProtocolScope
|
||||||
|
enabled: bool
|
||||||
|
loggingEnabled: bool
|
||||||
|
description: str | None
|
||||||
|
connectionStateFilter: list[str] | None
|
||||||
|
ipsecFilter: str | None
|
||||||
|
schedule: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Session helper ==========
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(host: str, api_token: str, verify_ssl: bool) -> requests.Session:
|
||||||
|
"""Create an authenticated requests session for the UniFi API."""
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update(
|
||||||
|
{
|
||||||
|
"X-API-Key": api_token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.verify = verify_ssl
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API functions ==========
|
||||||
|
|
||||||
|
|
||||||
|
def _api_url(host: str, site_id: str, path: str) -> str:
|
||||||
|
"""Build full API URL."""
|
||||||
|
return f"{host}/proxy/network/integration/v1/sites/{site_id}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_zones(session: requests.Session, host: str, site_id: str) -> list[FirewallZone]:
|
||||||
|
"""List all firewall zones."""
|
||||||
|
url = _api_url(host, site_id, "/firewall/zones")
|
||||||
|
logger.debug("Listing firewall zones from %s", url)
|
||||||
|
response = session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", data) if isinstance(data, dict) else data # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
def list_policies(session: requests.Session, host: str, site_id: str, name_filter: str | None = None) -> list[FirewallPolicy]:
|
||||||
|
"""List firewall policies, optionally filtered by name."""
|
||||||
|
url = _api_url(host, site_id, "/firewall/policies")
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if name_filter:
|
||||||
|
params["filter"] = f'name.like(\'{name_filter}\')'
|
||||||
|
logger.debug("Listing firewall policies from %s (filter=%s)", url, params.get("filter"))
|
||||||
|
response = session.get(url, params=params)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("data", data) if isinstance(data, dict) else data # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
def get_policy(session: requests.Session, host: str, site_id: str, policy_id: str) -> FirewallPolicy:
|
||||||
|
"""Get a single firewall policy by ID."""
|
||||||
|
url = _api_url(host, site_id, f"/firewall/policies/{policy_id}")
|
||||||
|
logger.debug("Getting firewall policy %s from %s", policy_id, url)
|
||||||
|
response = session.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def create_policy(session: requests.Session, host: str, site_id: str, payload: FirewallPolicyCreatePayload) -> FirewallPolicy:
|
||||||
|
"""Create a new firewall policy."""
|
||||||
|
url = _api_url(host, site_id, "/firewall/policies")
|
||||||
|
logger.debug("Creating firewall policy '%s' at %s", payload["name"], url)
|
||||||
|
response = session.post(url, json=cast(dict[str, Any], payload))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def update_policy(session: requests.Session, host: str, site_id: str, policy_id: str, payload: FirewallPolicyCreatePayload) -> FirewallPolicy:
|
||||||
|
"""Update an existing firewall policy (full replace)."""
|
||||||
|
url = _api_url(host, site_id, f"/firewall/policies/{policy_id}")
|
||||||
|
logger.debug("Updating firewall policy %s at %s", policy_id, url)
|
||||||
|
response = session.put(url, json=cast(dict[str, Any], payload))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Payload builder ==========
|
||||||
|
|
||||||
|
|
||||||
|
def build_policy_payload(
|
||||||
|
name: str,
|
||||||
|
source_ip: str,
|
||||||
|
ip_version: Literal["IPV4", "IPV6"],
|
||||||
|
src_zone_id: str,
|
||||||
|
dst_zone_id: str,
|
||||||
|
action_type: Literal["ALLOW", "BLOCK", "REJECT"],
|
||||||
|
allow_return_traffic: bool = True,
|
||||||
|
protocol: str | None = None,
|
||||||
|
dest_ports: list[int] | None = None,
|
||||||
|
dest_port_ranges: list[dict[str, int]] | None = None,
|
||||||
|
logging_enabled: bool = False,
|
||||||
|
enabled: bool = True,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> FirewallPolicyCreatePayload:
|
||||||
|
"""Build a full firewall policy payload from rule configuration."""
|
||||||
|
# Build source with IP filter
|
||||||
|
source: FirewallPolicySource = {
|
||||||
|
"zoneId": src_zone_id,
|
||||||
|
"trafficFilter": {
|
||||||
|
"type": "IP_ADDRESS",
|
||||||
|
"ipAddressFilter": {
|
||||||
|
"type": "IP_ADDRESSES",
|
||||||
|
"matchOpposite": False,
|
||||||
|
"items": [{"type": "IP_ADDRESS", "value": source_ip}],
|
||||||
|
},
|
||||||
|
"portFilter": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build destination with optional port filter
|
||||||
|
dest_port_items: list[dict[str, Any]] = []
|
||||||
|
if dest_ports:
|
||||||
|
dest_port_items.extend({"type": "PORT_NUMBER", "value": p} for p in dest_ports)
|
||||||
|
if dest_port_ranges:
|
||||||
|
dest_port_items.extend(
|
||||||
|
{"type": "PORT_NUMBER_RANGE", "start": r["start"], "stop": r["stop"]} for r in dest_port_ranges
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_port_filter: dict[str, Any] | None = None
|
||||||
|
if dest_port_items:
|
||||||
|
dest_port_filter = {
|
||||||
|
"type": "PORTS",
|
||||||
|
"matchOpposite": False,
|
||||||
|
"items": dest_port_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
destination: FirewallPolicyDestination = {
|
||||||
|
"zoneId": dst_zone_id,
|
||||||
|
"trafficFilter": (
|
||||||
|
{
|
||||||
|
"type": "PORT",
|
||||||
|
"portFilter": dest_port_filter,
|
||||||
|
}
|
||||||
|
if dest_port_filter
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build action
|
||||||
|
if action_type == "ALLOW":
|
||||||
|
action: FirewallPolicyAction = {"type": "ALLOW", "allowReturnTraffic": allow_return_traffic}
|
||||||
|
elif action_type == "BLOCK":
|
||||||
|
action = {"type": "BLOCK"}
|
||||||
|
else:
|
||||||
|
action = {"type": "REJECT"}
|
||||||
|
|
||||||
|
# Build protocol scope
|
||||||
|
protocol_filter: dict[str, Any] | None = None
|
||||||
|
if protocol:
|
||||||
|
protocol_filter = {
|
||||||
|
"type": "NAMED_PROTOCOL",
|
||||||
|
"matchOpposite": False,
|
||||||
|
"protocol": {
|
||||||
|
"name": protocol.upper(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip_version == "IPV4":
|
||||||
|
ip_protocol_scope: FirewallPolicyIpProtocolScope = {
|
||||||
|
"ipVersion": "IPV4",
|
||||||
|
"protocolFilter": protocol_filter,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
ip_protocol_scope = {
|
||||||
|
"ipVersion": "IPV6",
|
||||||
|
"protocolFilter": protocol_filter,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"source": source,
|
||||||
|
"destination": destination,
|
||||||
|
"action": action,
|
||||||
|
"ipProtocolScope": ip_protocol_scope,
|
||||||
|
"enabled": enabled,
|
||||||
|
"loggingEnabled": logging_enabled,
|
||||||
|
"description": description,
|
||||||
|
"connectionStateFilter": None,
|
||||||
|
"ipsecFilter": None,
|
||||||
|
"schedule": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== IP extraction ==========
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_ip_from_policy(policy: FirewallPolicy) -> str | None:
|
||||||
|
"""Extract the source IP address from a firewall policy."""
|
||||||
|
source = policy.get("source")
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
|
||||||
|
traffic_filter = source.get("trafficFilter")
|
||||||
|
if not traffic_filter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tf_type: str | None = traffic_filter.get("type")
|
||||||
|
if tf_type != "IP_ADDRESS":
|
||||||
|
return None
|
||||||
|
|
||||||
|
ip_filter: dict[str, Any] | None = traffic_filter.get("ipAddressFilter")
|
||||||
|
if not ip_filter:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ip_filter_type: str | None = ip_filter.get("type")
|
||||||
|
if ip_filter_type != "IP_ADDRESSES":
|
||||||
|
return None
|
||||||
|
|
||||||
|
items: list[Any] | None = ip_filter.get("items")
|
||||||
|
if not items or len(items) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_item: dict[str, Any] | None = cast(dict[str, Any], items[0]) if isinstance(items[0], dict) else None
|
||||||
|
if not first_item:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if first_item.get("type") != "IP_ADDRESS":
|
||||||
|
return None
|
||||||
|
|
||||||
|
value: str | None = first_item.get("value") if isinstance(first_item.get("value"), str) else None
|
||||||
|
return value
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "debugpy"
|
||||||
|
version = "1.8.21"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "firewall"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "debugpy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "debugpy" },
|
||||||
|
{ name = "pyright" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "debugpy", specifier = ">=1.8.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0.0" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||||
|
{ name = "requests", specifier = ">=2.34.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "debugpy", specifier = ">=1.8.21" },
|
||||||
|
{ name = "pyright", specifier = ">=1.1.411" },
|
||||||
|
{ name = "ruff", specifier = ">=0.16.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyright"
|
||||||
|
version = "1.1.411"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user