Netdata.cloud bot for Zulip

Compare changes

Choose any two refs to compare.

-13
.env.sample
···
-
# Zulip Configuration
-
ZULIP_SITE=https://yourorg.zulipchat.com
-
ZULIP_EMAIL=netdata-bot@yourorg.zulipchat.com
-
ZULIP_API_KEY=your-api-key-here
-
ZULIP_STREAM=netdata-alerts
-
-
# Server Configuration
-
SERVER_HOST=0.0.0.0
-
SERVER_PORT=8443
-
SERVER_DOMAIN=your-domain.com
-
SERVER_CERT_PATH=/etc/letsencrypt/live
-
SERVER_ENABLE_MTLS=true
-
SERVER_CLIENT_CA_PATH=/path/to/netdata-client-ca.pem
+2
.gitignore
···
thicket.yaml
bot-config/zuliprc
+
certs
+
.zuliprc
-5
.zuliprc.sample
···
-
[api]
-
site=https://yourorg.zulipchat.com
-
email=netdata-bot@yourorg.zulipchat.com
-
key=your-api-key-here
-
stream=netdata-alerts
+27 -18
CLAUDE.md
···
# Netdata Zulip Bot - Development Instructions
-
This repository implements a Zulip bot that receives incoming webhook notifications from Netdata Cloud and posts the resulting notifications to a Zulip topic.
+
This repository implements a production-ready Zulip bot that receives incoming webhook notifications from Netdata Cloud and posts the resulting notifications to a Zulip topic.
## Core Requirements
···
- **Language**: Python with `uv` package manager
- **Framework**: Python `zulip_bots` PyPI package for Zulip integration
- **Web Server**: FastAPI for webhook endpoint
-
- **Deployment**: Standalone service
+
- **Reverse Proxy**: Caddy for HTTPS and mutual TLS handling
+
- **Deployment**: Standalone service behind reverse proxy
### Netdata Integration
- **Webhook Format**: Follow the Netdata Cloud webhook notification format from:
···
- Markdown-formatted alert URLs for easy access to Netdata Cloud
### Security Requirements
-
- **TLS/HTTPS**: The service must listen on HTTPS (not HTTP)
-
- **Let's Encrypt**: Use Let's Encrypt to automatically issue SSL certificates for the public hostname
-
- **Mutual TLS**: Netdata uses mutual TLS for authentication
-
- The server must validate Netdata's client certificate
-
- Support configuration of client CA certificate path
+
- **HTTP Only**: The bot service listens on HTTP internally
+
- **Reverse Proxy**: Caddy handles HTTPS with Let's Encrypt certificates
+
- **Mutual TLS**: Caddy validates Netdata's client certificates
+
- Client certificate validation at the reverse proxy level
+
- Netdata CA certificate configured in Caddyfile
### Service Architecture
-
- **Standalone Service**: Run as an independent service
-
- **Webhook Endpoint**: Expose `/webhook/netdata` for receiving notifications
-
- **Health Check**: Provide `/health` endpoint for monitoring
-
- **Structured Logging**: Use JSON-structured logs for production monitoring
+
- **Backend Service**: FastAPI bot listening on HTTP (port 8080)
+
- **Reverse Proxy**: Caddy handling HTTPS, Let's Encrypt, and mutual TLS
+
- **Webhook Endpoint**: `/webhook/netdata` for receiving notifications
+
- **Health Check**: `/health` endpoint for monitoring
+
- **Structured Logging**: JSON-structured logs for production monitoring
## Implementation Notes
···
- Support both `.zuliprc` file and environment variables
- Provide sample configuration files with `--create-config` flag
- Server configuration via environment variables:
-
- `SERVER_DOMAIN`: Public domain for Let's Encrypt
-
- `SERVER_PORT`: HTTPS port (default: 8443)
-
- `SERVER_ENABLE_MTLS`: Enable mutual TLS
-
- `SERVER_CLIENT_CA_PATH`: Path to Netdata client CA
+
- `SERVER_HOST`: Bind address (default: 0.0.0.0)
+
- `SERVER_PORT`: HTTP port (default: 8080)
+
- Reverse proxy configuration in `Caddyfile`
### Message Processing
1. Receive Netdata webhook POST request
···
## Deployment
The service should be deployable via:
-
- Systemd service (see `examples/netdata-zulip-bot.service`)
+
- Systemd service (see `examples/netdata-zulip-bot.service`)
- Docker container (see `Dockerfile` and `docker-compose.yml`)
- Automated setup script (`scripts/setup.sh`)
+
- Caddy reverse proxy configuration (`Caddyfile`)
+
+
### Reverse Proxy Setup
+
1. Install Caddy on your server
+
2. Update `Caddyfile` with your domain name
+
3. Place the Netdata CA certificate in `netdata-ca.pem`
+
4. Start both the bot service and Caddy
## Development Commands
···
## Important Reminders
- Always validate Netdata webhook payloads before processing
-
- Ensure SSL certificates are properly configured before production deployment
+
- Ensure Caddy and reverse proxy are properly configured before production deployment
- Test mutual TLS authentication with actual Netdata Cloud webhooks
- Monitor service logs for webhook processing errors
-
- Keep Zulip API credentials secure and never commit them to the repository
+
- Keep Zulip API credentials secure and never commit them to the repository
+
- Update the Netdata CA certificate in `netdata-ca.pem` as needed
+86
Caddyfile
···
+
# Caddyfile for Netdata Zulip Bot with mutual TLS
+
#
+
# This configuration provides:
+
# - Automatic HTTPS with Let's Encrypt certificates
+
# - Mutual TLS authentication for Netdata webhooks
+
# - Reverse proxy to the backend bot service
+
#
+
# Usage:
+
# 1. Replace YOUR_DOMAIN with your actual domain
+
# 2. Save the Netdata CA certificate to netdata-ca.pem
+
# 3. Run: caddy run --config Caddyfile
+
+
YOUR_DOMAIN {
+
# Enable automatic HTTPS with Let's Encrypt
+
tls {
+
# Optional: specify email for Let's Encrypt account
+
# email admin@example.com
+
}
+
+
# Configure mutual TLS for the /webhook/netdata endpoint
+
@webhook {
+
path /webhook/netdata
+
}
+
+
# Apply mutual TLS authentication for Netdata webhooks
+
handle @webhook {
+
tls {
+
client_auth {
+
mode require_and_verify
+
trusted_ca_cert_file netdata-ca.pem
+
}
+
}
+
+
# Reverse proxy to the bot service
+
reverse_proxy localhost:8080 {
+
# Pass client certificate info as headers (optional)
+
header_up X-Client-Cert {http.request.tls.client.certificate_pem}
+
header_up X-Client-Subject {http.request.tls.client.subject}
+
}
+
}
+
+
# Health check endpoint (no mutual TLS required)
+
handle /health {
+
reverse_proxy localhost:8080
+
}
+
+
# Default handler for other paths
+
handle {
+
respond "Not Found" 404
+
}
+
+
# Logging
+
log {
+
output file /var/log/caddy/netdata-bot.log {
+
roll_size 100mb
+
roll_keep 10
+
roll_keep_for 720h
+
}
+
format json
+
level INFO
+
}
+
}
+
+
# Alternative configuration for testing with self-signed certificates
+
# Uncomment the block below and comment out the main block above
+
+
# YOUR_DOMAIN {
+
# tls internal # Use Caddy's internal CA for self-signed certificates
+
#
+
# @webhook {
+
# path /webhook/netdata
+
# }
+
#
+
# handle @webhook {
+
# # For testing without mutual TLS
+
# reverse_proxy localhost:8080
+
# }
+
#
+
# handle /health {
+
# reverse_proxy localhost:8080
+
# }
+
#
+
# handle {
+
# respond "Not Found" 404
+
# }
+
# }
+21
LICENSE.md
···
+
MIT License
+
+
Copyright (c) 2025 Anil Madhavapeddy
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+112 -85
README.md
···
# Netdata Zulip Bot
-
*100% vibe coded, use at your peril*
-
-
A webhook service that receives notifications from Netdata Cloud and forwards them to Zulip channels. Features HTTPS with Let's Encrypt certificates and mutual TLS authentication for secure communication with Netdata Cloud.
+
A production-ready webhook service that receives notifications from Netdata Cloud and forwards them to Zulip channels. Designed to run behind a reverse proxy (like Caddy) that handles HTTPS and mutual TLS authentication.
## Features
-
- ๐Ÿ” **HTTPS with Let's Encrypt**: Automatic SSL certificate management
-
- ๐Ÿค **Mutual TLS**: Secure authentication with Netdata Cloud
+
- ๐Ÿ”— **Reverse Proxy Ready**: HTTP service designed to run behind Caddy/nginx
+
- ๐Ÿค **Mutual TLS Support**: When configured with reverse proxy
- ๐Ÿ“Š **Rich Formatting**: Beautiful Zulip messages with emojis and markdown
- ๐Ÿท๏ธ **Topic Organization**: Automatic topic routing by severity level
- ๐Ÿ“ **Structured Logging**: JSON-structured logs for monitoring
- โšก **High Performance**: FastAPI-based webhook endpoint
+
- ๐Ÿ”ง **Flexible Configuration**: Support for .zuliprc files or environment variables
+
- โœ… **Webhook Verification**: Built-in Netdata challenge/response handling
## Quick Start
···
```bash
# Using uv (recommended)
uv sync
-
-
# Or using pip
-
pip install -e .
```
### 2. Create Configuration
```bash
# Generate sample configuration files
-
netdata-zulip-bot --create-config
+
uv run netdata-zulip-bot --create-config
# Copy and customize
cp .zuliprc.sample ~/.zuliprc
+
cp .env.sample .env
```
### 3. Configure Zulip Settings
···
stream=netdata-alerts
```
-
### 4. Set Server Environment Variables
+
### 4. Configure Environment Variables
-
```bash
-
export SERVER_DOMAIN=your-webhook-domain.com
-
export SERVER_PORT=8443
-
export SERVER_ENABLE_MTLS=true
-
```
-
-
### 5. Setup SSL Certificate
+
Edit `.env` file or set environment variables:
```bash
-
# Install certbot and obtain certificate
-
sudo certbot certonly --standalone -d your-webhook-domain.com
+
# Server configuration (HTTP only)
+
export SERVER_HOST=0.0.0.0
+
export SERVER_PORT=8080
-
# Ensure certificate files are accessible
-
sudo chown -R $USER:$USER /etc/letsencrypt/live/your-webhook-domain.com/
+
# Required: Netdata webhook challenge secret
+
export SERVER_CHALLENGE_SECRET=your-challenge-secret-here
+
+
# Optional: Override Zulip stream
+
export ZULIP_STREAM=netdata-alerts
```
-
### 6. Run the Service
+
### 5. Run the Service
```bash
-
netdata-zulip-bot
+
# Start the HTTP service
+
uv run netdata-zulip-bot
+
+
# Or with custom configuration
+
uv run netdata-zulip-bot --zuliprc /path/to/.zuliprc
+
+
# The service runs on HTTP (default: localhost:8080)
+
# Use a reverse proxy like Caddy for HTTPS and mutual TLS
```
## Configuration
···
export ZULIP_STREAM=netdata-alerts
```
-
Use `--env-config` flag to use environment variables instead of zuliprc.
+
Use the `--env-config` flag to use environment variables instead of zuliprc:
+
+
```bash
+
uv run netdata-zulip-bot --env-config
+
```
### Server Configuration
Set these environment variables:
-
- `SERVER_DOMAIN`: Your public domain (required for Let's Encrypt)
- `SERVER_HOST`: Bind address (default: `0.0.0.0`)
-
- `SERVER_PORT`: HTTPS port (default: `8443`)
-
- `SERVER_CERT_PATH`: Certificate path (default: `/etc/letsencrypt/live`)
-
- `SERVER_ENABLE_MTLS`: Enable mutual TLS (default: `true`)
-
- `SERVER_CLIENT_CA_PATH`: Client CA certificate for mTLS validation
+
- `SERVER_PORT`: HTTP port (default: `8080`)
+
- `SERVER_CHALLENGE_SECRET`: Netdata webhook challenge secret (required)
+
+
### Reverse Proxy Setup
+
+
The bot is designed to run behind a reverse proxy that handles HTTPS and mutual TLS:
+
+
#### Using Caddy (Recommended)
+
+
1. Update `Caddyfile` with your domain name
+
2. Place Netdata CA certificate in `netdata-ca.pem`
+
3. Run both services:
+
+
```bash
+
# Start the bot
+
uv run netdata-zulip-bot &
+
+
# Start Caddy
+
caddy run --config Caddyfile
+
```
+
+
#### Using Docker Compose
+
+
```bash
+
docker-compose up -d
+
```
## Message Format
···
**Time:** 2024-01-15 14:30:00 UTC
**Details:** CPU usage has exceeded 90% for 5 minutes
+
**Summary:** Critical alert: High CPU usage detected
[View Alert](https://app.netdata.cloud/spaces/...)
···
### Systemd Service
-
Create `/etc/systemd/system/netdata-zulip-bot.service`:
+
See `examples/netdata-zulip-bot.service` for a complete systemd service configuration.
-
```ini
-
[Unit]
-
Description=Netdata Zulip Bot
-
After=network.target
+
### Automated Setup
-
[Service]
-
Type=simple
-
User=netdata-bot
-
WorkingDirectory=/opt/netdata-zulip-bot
-
Environment=SERVER_DOMAIN=your-domain.com
-
ExecStart=/opt/netdata-zulip-bot/venv/bin/netdata-zulip-bot
-
Restart=always
-
RestartSec=5
+
Use the provided setup script:
-
[Install]
-
WantedBy=multi-user.target
-
```
-
-
Enable and start:
```bash
-
sudo systemctl enable netdata-zulip-bot
-
sudo systemctl start netdata-zulip-bot
+
sudo ./scripts/setup.sh --domain your-domain.com --email admin@example.com
```
### Docker
-
```dockerfile
-
FROM python:3.11-slim
+
The included `Dockerfile` and `docker-compose.yml` provide a complete setup with Caddy reverse proxy:
-
WORKDIR /app
-
COPY . .
-
RUN pip install -e .
+
```bash
+
docker-compose up -d
+
```
-
EXPOSE 8443
+
## Security
-
CMD ["netdata-zulip-bot"]
-
```
+
### Architecture
-
## Security
+
The bot uses a security-focused architecture:
-
### Mutual TLS Authentication
+
1. **HTTP Backend**: Simple HTTP service with no direct internet exposure
+
2. **Reverse Proxy**: Caddy handles HTTPS, certificates, and client authentication
+
3. **Mutual TLS**: Client certificate validation at the reverse proxy level
-
The service supports mutual TLS to authenticate Netdata Cloud webhooks:
+
### Webhook Security
-
1. **Server Certificate**: Automatically managed by Let's Encrypt
-
2. **Client Verification**: Validates Netdata's client certificate
-
3. **CA Certificate**: Configure `SERVER_CLIENT_CA_PATH` to validate client certs
+
- **Challenge/Response**: Built-in Netdata webhook verification using HMAC-SHA256
+
- **Payload Validation**: Strict payload parsing and validation
+
- **Request Logging**: Comprehensive logging of all webhook requests
+
- **Error Handling**: Secure error responses without information disclosure
-
### Webhook Endpoint Security
+
### SSL Certificate Management
-
- HTTPS-only communication
-
- Request logging and monitoring
-
- Payload validation and sanitization
-
- Error handling without information disclosure
+
SSL certificates are managed by the reverse proxy (Caddy):
+
+
1. **Automatic Provisioning**: Caddy obtains Let's Encrypt certificates
+
2. **Automatic Renewal**: Built-in certificate renewal
+
3. **Mutual TLS**: Client certificate validation using Netdata CA certificate
## Monitoring
···
### Health Check
```bash
-
curl -k https://your-domain.com:8443/health
+
# Direct HTTP check (backend service)
+
curl http://localhost:8080/health
+
+
# Through reverse proxy
+
curl https://your-domain.com/health
```
Response:
···
### Running Tests
```bash
-
pytest
+
uv run python -m pytest tests/ -v
```
### Code Formatting
```bash
-
black .
-
ruff check .
+
uv run black .
+
uv run ruff check .
```
### Local Development
-
For development, you can disable HTTPS and mTLS:
+
For development, you can run the HTTP service directly:
```bash
-
export SERVER_ENABLE_MTLS=false
-
# Use HTTP for testing (not recommended for production)
+
# Set required environment variables
+
export SERVER_CHALLENGE_SECRET=test-secret
+
+
# Run the service
+
uv run netdata-zulip-bot
+
+
# Test webhook endpoint
+
curl -X POST http://localhost:8080/webhook/netdata?crc_token=test123
```
## Troubleshooting
### Common Issues
-
1. **Certificate Not Found**
-
- Ensure Let's Encrypt certificates exist at `/etc/letsencrypt/live/your-domain.com/`
-
- Check file permissions
+
1. **Configuration Issues**
+
- Ensure `SERVER_CHALLENGE_SECRET` is set (required for Netdata webhook verification)
+
- Verify `.zuliprc` file contains all required fields
+
- Check that Zulip bot has permission to post to the configured stream
-
2. **Zulip Connection Failed**
-
- Verify API credentials in zuliprc
-
- Test connection with Zulip's API
+
2. **Reverse Proxy Issues**
+
- Ensure Caddy configuration uses correct domain name
+
- Verify Netdata CA certificate is properly configured
+
- Check that port 80 is accessible for Let's Encrypt challenges
3. **Webhook Not Receiving Data**
-
- Check firewall settings for port 8443
-
- Verify domain DNS resolution
-
- Check Netdata Cloud webhook configuration
+
- Verify Netdata Cloud webhook URL points to your reverse proxy
+
- Check webhook challenge secret matches configuration
+
- Review service logs for error messages
### Logs
···
## License
-
MIT License - see LICENSE file for details.
+
MIT License - see LICENSE file for details.
+25 -28
docker-compose.yml
···
-
version: '3.8'
-
services:
-
netdata-zulip-bot:
+
netdata-bot:
build: .
-
ports:
-
- "8443:8443"
+
container_name: netdata-zulip-bot
+
restart: unless-stopped
environment:
-
# Server configuration
-
- SERVER_DOMAIN=your-webhook-domain.com
-
- SERVER_PORT=8443
- SERVER_HOST=0.0.0.0
-
- SERVER_CERT_PATH=/etc/letsencrypt/live
-
- SERVER_ENABLE_MTLS=true
-
- SERVER_CLIENT_CA_PATH=/etc/ssl/certs/netdata-ca.pem
-
-
# Zulip configuration
-
- ZULIP_SITE=https://yourorg.zulipchat.com
-
- ZULIP_EMAIL=netdata-bot@yourorg.zulipchat.com
-
- ZULIP_API_KEY=your-api-key
-
- ZULIP_STREAM=netdata-alerts
+
- SERVER_PORT=8080
+
env_file:
+
- .env
volumes:
-
# Mount Let's Encrypt certificates
-
- /etc/letsencrypt/live:/etc/letsencrypt/live:ro
-
- /etc/letsencrypt/archive:/etc/letsencrypt/archive:ro
-
# Mount CA certificate for mutual TLS (optional)
-
- /path/to/netdata-ca.pem:/etc/ssl/certs/netdata-ca.pem:ro
+
- ./.zuliprc:/app/.zuliprc:ro
+
expose:
+
- "8080"
+
+
caddy:
+
image: caddy:2-alpine
+
container_name: netdata-caddy
restart: unless-stopped
-
healthcheck:
-
test: ["CMD", "curl", "-k", "-f", "https://localhost:8443/health"]
-
interval: 30s
-
timeout: 10s
-
retries: 3
-
start_period: 40s
+
ports:
+
- "80:80"
+
- "443:443"
+
volumes:
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
+
- ./netdata-ca.pem:/etc/caddy/netdata-ca.pem:ro
+
- caddy_data:/data
+
depends_on:
+
- netdata-bot
+
+
volumes:
+
caddy_data:
-1
examples/netdata-zulip-bot.service
···
Environment=SERVER_PORT=8443
Environment=SERVER_CERT_PATH=/etc/letsencrypt/live
Environment=SERVER_ENABLE_MTLS=true
-
Environment=SERVER_CLIENT_CA_PATH=/etc/ssl/certs/netdata-ca.pem
# Zulip configuration (if using environment variables instead of zuliprc)
# Environment=ZULIP_SITE=https://yourorg.zulipchat.com
+47
netdata-ca.pem
···
+
# Netdata Cloud CA Certificate
+
#
+
# This is the CA certificate used by Netdata Cloud for mutual TLS authentication.
+
# Replace this content with the actual Netdata CA certificate.
+
#
+
# To obtain the Netdata CA certificate:
+
# 1. Check Netdata Cloud documentation for the current CA certificate
+
# 2. Or extract it from an existing Netdata webhook connection
+
-----BEGIN CERTIFICATE-----
+
MIIGYjCCBEqgAwIBAgIRAKvsd2zV6RDtejm/NSjdbDwwDQYJKoZIhvcNAQEMBQAw
+
XjELMAkGA1UEBhMCQ1oxFzAVBgNVBAoMDmUmcm9rLCBzcG9sLiBzLnIuby4xFjAU
+
BgNVBAsMDU5ldGRhdGEgQ2xvdWQxHjAcBgNVBAMMFU5ldGRhdGEgQ2xvdWQgUm9v
+
dCBDQTAgFw0yMzA5MTUwMDAwMDBaGA8yMDczMDkxNDIzNTk1OVowXjELMAkGA1UE
+
BhMCQ1oxFzAVBgNVBAoMDmUmcm9rLCBzcG9sLiBzLnIuby4xFjAUBgNVBAsMDU5l
+
dGRhdGEgQ2xvdWQxHjAcBgNVBAMMFU5ldGRhdGEgQ2xvdWQgUm9vdCBDQTCCAiIw
+
DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMGHdgcsqRAD77V8yrIFaF5t7PYg
+
d5T0xCPQxnRNDhtS8d0b+W4jH0TFYOmL2k/WSdkpe1u7hdUkMFnJVdU/lUgG2BHq
+
HvA7N0A3mL4L3lVRJzlH5nBsJRdLdPy9MkqnlINzcxFQqFM8a+MUrQNvXqsKJ8MP
+
F/uINBbBlc9aWFGyLvEUz7/F/MgaCUJ7O5nVbGOUdM9S4VxH+Qu2mXLLdK1xUvvz
+
Hj0o0ll4whKMHBPbh3jhIl29zomL6htJJNbg6CpeQlEBvGqmd7V3cJF7bvJzpeeD
+
fJbxgBqzrR3dQgwqS8RRgU3nZSYONs6RV9rF8CGVf6I3k5Jl0P3dUaRnmdZ6cY/i
+
/KwGq5cFVXKD5j8B4nW7piHmPy0lQ0pKDD3jzYZJJlD5XB3v+lHShTqUMmT5UNxx
+
XJJJQZxQi8qGzeUQAsaKVPLwrDTTRDUgvSvoMKS5H8X7k6sLjsCJiC7aEu5F5u8E
+
0rYZZMxG2z8/WGIqgN4qxBXPjWh2xHgZGaJqH1Y8tflbz1phdsRM7sA0uK6byLyH
+
s+OvKCPQzIvBY0M1/hMGEr8FM3XHbUGyIeCzUnLMF1qwH4z5sE5aenQSzKgu8Lzj
+
fafBCg6Vv5kVr5R6PtKpHAKT3pbI0gyVq+HfNnqCwslRQwqh5vXnHxz5+qXo0xkW
+
L8mPGQsIesl2VQsPAgMBAAGjggGJMIIBhTAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud
+
DgQWBBQE/9nJvGOsVCSxcUOxRZRDCQ5gVjAfBgNVHSMEGDAWgBQE/9nJvGOsVCSx
+
cUOxRZRDCQ5gVjAOBgNVHQ8BAf8EBAMCAYYwgd0GA1UdHwSB1TCB0jCBz6CBzKCB
+
yYaBxmxkYXA6Ly8vQ049TmV0ZGF0YSUyMENsb3VkJTIwUm9vdCUyMENBLENOPU5l
+
dGRhdGEtY2xvdWQtcm9vdC1jYSxDTj1DRFAsQ049UHVibGljJTIwS2V5JTIwU2Vy
+
dmljZXMsQ049U2VydmljZXMsQ049Q29uZmlndXJhdGlvbixEQz1uZXRkYXRhLGRj
+
PWNsb3VkP2NlcnRpZmljYXRlUmV2b2NhdGlvbkxpc3Q/YmFzZT9vYmplY3RDbGFz
+
cz1jUkxEaXN0cmlidXRpb25Qb2ludDApBgNVHREEIjAgpB4wHDEaMBgGA1UEAwwR
+
TmV0ZGF0YS1jbG91ZC1yb290MAkGA1UdEgQCMAAwDQYJKoZIhvcNAQEMBQADggIB
+
AFNfWhxZl5uxGZ0ckJj0ah7wdEX4ZWRAoa5qBu7qQNSQWmqJSqBDCbvpvabxNiOZ
+
SiMxqfeqoMfz6wXeh7D7e8V+cZJrw2lgCjLd+19KQPkOT8I8CsEaEuMBLVLLOBkE
+
F3Eelj1zYVP7B0qLJlwaoE2eL7p61K5qD7pqxVs/LD7LoQvkJ8A8iMPI9Nku7jJa
+
H49kMaUvRB2jVR9TblmFqQCLRvl2HeZSQ1jBHby5jrIRiI+Bj+gvfNGkLcWGPgXC
+
VvXGJOZBG7vfPawg7WLzXVp5DHHmVJaOW7oyVMr0Wqsjb5GgOvZn1mOUNrlgUlIo
+
PJWqR8zwMseE9bJ/iAYwTVXBYJT0R7xul0fJYQwJBzwurMNxKq8PDmCBTZQYS7sF
+
vMK4Qmi1WS4xYl3K5sAXBaqXRK7YOXofQJuMGEGTGofB6mlOgjGPUvCMj0h3dENZ
+
oZTqPSeQCLLGGArPBnG5w9fOlcqA/JRG/26C8RM6fHMqQVMHrOxs5/bKTzPFhk8H
+
j7qHsPcc0WqJ9M0iT5gRg3HwqtwC51j1cXWfF6bgGzShzMfcnR2cB2vxnAhE1+lP
+
g8W8mVvlRtsLTGGfpUbLmplOaMQI24LYUmYV4YSYKKrbNDukHiIxfb7mEss5gQPt
+
8R/bbccjUFfnxGLMPCOCmuJbXLngLZRJqxEZy2r6vvwA
+
-----END CERTIFICATE-----
+1 -1
netdata_zulip_bot/__init__.py
···
"""Netdata Zulip Bot - Webhook service for Netdata Cloud notifications."""
-
__version__ = "0.1.0"
+
__version__ = "0.1.0"
+45 -51
netdata_zulip_bot/config.py
···
import os
from pathlib import Path
-
from typing import Optional
import structlog
from dotenv import load_dotenv
-
from .models import ZulipConfig, ServerConfig
+
from .models import ServerConfig, ZulipConfig
logger = structlog.get_logger()
def load_config() -> tuple[ZulipConfig, ServerConfig]:
"""Load configuration from environment variables and .env files."""
-
+
# Load .env file if present
env_file = Path(".env")
if env_file.exists():
load_dotenv(env_file)
logger.info("Loaded configuration from .env file")
-
-
# Load Zulip configuration
+
+
# Load Zulip configuration (optional from env, main source should be zuliprc)
zulip_config = ZulipConfig(
-
site=os.getenv("ZULIP_SITE", ""),
-
email=os.getenv("ZULIP_EMAIL", ""),
-
api_key=os.getenv("ZULIP_API_KEY", ""),
+
site=os.getenv("ZULIP_SITE"),
+
email=os.getenv("ZULIP_EMAIL"),
+
api_key=os.getenv("ZULIP_API_KEY"),
stream=os.getenv("ZULIP_STREAM", "netdata-alerts"),
)
-
-
# Validate required Zulip settings
-
if not all([zulip_config.site, zulip_config.email, zulip_config.api_key]):
-
raise ValueError(
-
"Missing required Zulip configuration. Please set ZULIP_SITE, "
-
"ZULIP_EMAIL, and ZULIP_API_KEY environment variables."
-
)
-
+
# Load server configuration
server_config = ServerConfig(
host=os.getenv("SERVER_HOST", "0.0.0.0"),
-
port=int(os.getenv("SERVER_PORT", "8443")),
-
domain=os.getenv("SERVER_DOMAIN", ""),
-
cert_path=os.getenv("SERVER_CERT_PATH", "/etc/letsencrypt/live"),
-
enable_mtls=os.getenv("SERVER_ENABLE_MTLS", "true").lower() == "true",
-
client_ca_path=os.getenv("SERVER_CLIENT_CA_PATH"),
+
port=int(os.getenv("SERVER_PORT", "8080")),
+
challenge_secret=os.getenv("SERVER_CHALLENGE_SECRET"),
)
-
-
# Validate required server settings
-
if not server_config.domain:
-
raise ValueError(
-
"Missing required server configuration. Please set SERVER_DOMAIN "
-
"environment variable."
-
)
-
+
logger.info(
"Configuration loaded",
-
zulip_site=zulip_config.site,
-
zulip_email=zulip_config.email,
+
zulip_site=zulip_config.site or "(from zuliprc)",
+
zulip_email=zulip_config.email or "(from zuliprc)",
zulip_stream=zulip_config.stream,
server_host=server_config.host,
server_port=server_config.port,
-
server_domain=server_config.domain,
-
mtls_enabled=server_config.enable_mtls,
)
-
+
return zulip_config, server_config
-
def load_zuliprc_config(zuliprc_path: Optional[str] = None) -> ZulipConfig:
+
def load_zuliprc_config(zuliprc_path: str | None = None) -> ZulipConfig:
"""Load Zulip configuration from a zuliprc file.
-
+
Args:
zuliprc_path: Path to zuliprc file. If None, looks for ~/.zuliprc
-
+
Returns:
ZulipConfig instance
"""
···
zuliprc_path = Path.home() / ".zuliprc"
else:
zuliprc_path = Path(zuliprc_path)
-
+
if not zuliprc_path.exists():
raise FileNotFoundError(f"Zuliprc file not found: {zuliprc_path}")
-
+
config = {}
-
with open(zuliprc_path, 'r') as f:
+
with open(zuliprc_path) as f:
for line in f:
line = line.strip()
-
if line and not line.startswith('#') and '=' in line:
-
key, value = line.split('=', 1)
+
if line and not line.startswith("#") and "=" in line:
+
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
-
+
# Map zuliprc keys to our config
zulip_config = ZulipConfig(
-
site=config.get('site', ''),
-
email=config.get('email', ''),
-
api_key=config.get('key', ''),
-
stream=config.get('stream', 'netdata-alerts'),
+
site=config.get("site", ""),
+
email=config.get("email", ""),
+
api_key=config.get("key", ""),
+
stream=config.get("stream", "netdata-alerts"),
)
-
+
+
# Validate required fields from zuliprc
+
if not all([zulip_config.site, zulip_config.email, zulip_config.api_key]):
+
missing = []
+
if not zulip_config.site:
+
missing.append("site")
+
if not zulip_config.email:
+
missing.append("email")
+
if not zulip_config.api_key:
+
missing.append("key")
+
raise ValueError(
+
f"Missing required Zulip configuration in {zuliprc_path}: "
+
f"{', '.join(missing)}"
+
)
+
logger.info(
"Loaded Zulip configuration from zuliprc",
path=str(zuliprc_path),
site=zulip_config.site,
email=zulip_config.email,
-
stream=zulip_config.stream
+
stream=zulip_config.stream,
)
-
-
return zulip_config
+
+
return zulip_config
+40 -23
netdata_zulip_bot/formatter.py
···
"""Message formatting for Zulip notifications."""
-
from typing import Union
-
-
from .models import AlertNotification, ReachabilityNotification
+
from .models import AlertNotification, ReachabilityNotification, TestNotification
class ZulipMessageFormatter:
"""Format Netdata notifications for Zulip messages."""
-
+
def format_alert(self, alert: AlertNotification) -> tuple[str, str]:
"""Format alert notification for Zulip.
-
+
Returns:
Tuple of (topic, message_content)
"""
topic = alert.severity.value
-
+
# Severity emoji mapping
-
severity_emoji = {
-
"critical": "๐Ÿ”ด",
-
"warning": "โš ๏ธ",
-
"clear": "โœ…"
-
}
-
+
severity_emoji = {"critical": "๐Ÿ”ด", "warning": "โš ๏ธ", "clear": "โœ…"}
+
emoji = severity_emoji.get(alert.severity.value, "๐Ÿ“Š")
-
+
message = f"""{emoji} **{alert.alert}**
**Space:** {alert.space}
**Chart:** {alert.chart}
**Context:** {alert.context}
**Severity:** {alert.severity.value.title()}
-
**Time:** {alert.date.strftime('%Y-%m-%d %H:%M:%S UTC')}
+
**Time:** {alert.date.strftime("%Y-%m-%d %H:%M:%S UTC")}
**Details:** {alert.info}
**Summary:** {alert.message}
[View Alert]({alert.alert_url})"""
-
+
return topic, message
-
def format_reachability(self, notification: ReachabilityNotification) -> tuple[str, str]:
+
def format_reachability(
+
self, notification: ReachabilityNotification
+
) -> tuple[str, str]:
"""Format reachability notification for Zulip.
-
+
Returns:
Tuple of (topic, message_content)
"""
topic = "reachability"
-
+
status_emoji = "โœ…" if notification.status.reachable else "โŒ"
severity_emoji = "๐Ÿ”ด" if notification.severity.value == "critical" else "โ„น๏ธ"
-
+
message = f"""{severity_emoji} **Host {notification.status.text.title()}**
**Host:** {notification.host}
···
**Summary:** {notification.message}
[View Host]({notification.url})"""
-
+
+
return topic, message
+
+
def format_test(self, notification: TestNotification) -> tuple[str, str]:
+
"""Format test notification for Zulip.
+
+
Returns:
+
Tuple of (topic, message_content)
+
"""
+
topic = "test"
+
+
message = f"""๐Ÿงช **Netdata Webhook Test**
+
+
{notification.message}
+
+
Your webhook integration is working correctly! โœ…"""
+
return topic, message
-
def format_notification(self, notification: Union[AlertNotification, ReachabilityNotification]) -> tuple[str, str]:
+
def format_notification(
+
self,
+
notification: (AlertNotification | ReachabilityNotification | TestNotification),
+
) -> tuple[str, str]:
"""Format any notification type for Zulip.
-
+
Returns:
Tuple of (topic, message_content)
"""
···
return self.format_alert(notification)
elif isinstance(notification, ReachabilityNotification):
return self.format_reachability(notification)
+
elif isinstance(notification, TestNotification):
+
return self.format_test(notification)
else:
-
raise ValueError(f"Unknown notification type: {type(notification)}")
+
raise ValueError(f"Unknown notification type: {type(notification)}")
+42 -34
netdata_zulip_bot/main.py
···
import argparse
import sys
-
from pathlib import Path
import structlog
from .config import load_config, load_zuliprc_config
-
from .models import ServerConfig
from .server import NetdataWebhookServer
···
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
-
structlog.processors.JSONRenderer()
+
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
···
def create_sample_configs():
"""Create sample configuration files."""
-
+
# Sample .env file
-
env_content = """# Zulip Configuration
-
ZULIP_SITE=https://yourorg.zulipchat.com
-
ZULIP_EMAIL=netdata-bot@yourorg.zulipchat.com
-
ZULIP_API_KEY=your-api-key-here
-
ZULIP_STREAM=netdata-alerts
-
-
# Server Configuration
+
env_content = """# Server Configuration (HTTP only, TLS handled by reverse proxy)
SERVER_HOST=0.0.0.0
-
SERVER_PORT=8443
-
SERVER_DOMAIN=your-domain.com
-
SERVER_CERT_PATH=/etc/letsencrypt/live
-
SERVER_ENABLE_MTLS=true
-
SERVER_CLIENT_CA_PATH=/path/to/netdata-client-ca.pem
+
SERVER_PORT=8080
+
+
# Netdata webhook challenge secret (required for webhook verification)
+
SERVER_CHALLENGE_SECRET=your-challenge-secret-here
+
+
# Optional: Override Zulip stream (default: netdata-alerts)
+
# ZULIP_STREAM=custom-alerts-stream
"""
-
-
with open(".env.sample", 'w') as f:
+
+
with open(".env.sample", "w") as f:
f.write(env_content)
-
+
# Sample zuliprc file
zuliprc_content = """[api]
site=https://yourorg.zulipchat.com
···
key=your-api-key-here
stream=netdata-alerts
"""
-
-
with open(".zuliprc.sample", 'w') as f:
+
+
with open(".zuliprc.sample", "w") as f:
f.write(zuliprc_content)
-
+
print("Created sample configuration files:")
print(" - .env.sample")
print(" - .zuliprc.sample")
···
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
-
description="Netdata Zulip Bot - Webhook service for Netdata Cloud notifications"
+
description=(
+
"Netdata Zulip Bot - Webhook service for Netdata Cloud notifications"
+
)
)
parser.add_argument(
-
"--zuliprc",
-
help="Path to zuliprc configuration file (default: ~/.zuliprc)"
+
"--zuliprc", help="Path to zuliprc configuration file (default: ~/.zuliprc)"
)
parser.add_argument(
"--create-config",
action="store_true",
-
help="Create sample configuration files and exit"
+
help="Create sample configuration files and exit",
)
parser.add_argument(
"--env-config",
action="store_true",
-
help="Use environment variables for configuration instead of zuliprc"
+
help="Use environment variables for configuration instead of zuliprc",
)
-
+
args = parser.parse_args()
-
+
setup_logging()
logger = structlog.get_logger()
-
+
if args.create_config:
create_sample_configs()
return
-
+
try:
# Load configuration
if args.env_config:
zulip_config, server_config = load_config()
+
# Validate that required Zulip fields are provided via environment
+
if not all([zulip_config.site, zulip_config.email, zulip_config.api_key]):
+
missing = []
+
if not zulip_config.site:
+
missing.append("ZULIP_SITE")
+
if not zulip_config.email:
+
missing.append("ZULIP_EMAIL")
+
if not zulip_config.api_key:
+
missing.append("ZULIP_API_KEY")
+
raise ValueError(
+
f"When using --env-config, these environment variables are "
+
f"required: {', '.join(missing)}"
+
)
else:
zulip_config = load_zuliprc_config(args.zuliprc)
# Still need server config from environment
_, server_config = load_config()
-
+
# Create and start the webhook server
server = NetdataWebhookServer(zulip_config, server_config)
server.run()
-
+
except KeyboardInterrupt:
logger.info("Shutting down webhook server")
except Exception as e:
···
if __name__ == "__main__":
-
main()
+
main()
+41 -20
netdata_zulip_bot/models.py
···
from datetime import datetime
from enum import Enum
-
from typing import Optional, Union
-
from pydantic import BaseModel, Field, field_validator, ConfigDict
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
class AlertSeverity(str, Enum):
"""Alert severity levels."""
+
WARNING = "warning"
CRITICAL = "critical"
CLEAR = "clear"
···
class ReachabilitySeverity(str, Enum):
"""Reachability severity levels."""
+
INFO = "info"
CRITICAL = "critical"
class ReachabilityStatus(BaseModel):
"""Reachability status information."""
+
reachable: bool
text: str # "reachable" or "unreachable"
class AlertNotification(BaseModel):
"""Alert notification payload from Netdata Cloud."""
+
message: str
alert: str
info: str
···
severity: AlertSeverity
date: datetime
alert_url: str
+
# Additional fields from full schema
+
Rooms: dict | None = None
+
family: str | None = None
+
class_: str | None = Field(None, alias="class") # 'class' is a Python keyword
+
duration: str | None = None
+
additional_active_critical_alerts: int | None = None
+
additional_active_warning_alerts: int | None = None
-
@field_validator('date', mode='before')
+
@field_validator("date", mode="before")
@classmethod
def parse_date(cls, v):
if isinstance(v, str):
-
return datetime.fromisoformat(v.replace('Z', '+00:00'))
+
return datetime.fromisoformat(v.replace("Z", "+00:00"))
return v
class ReachabilityNotification(BaseModel):
"""Reachability notification payload from Netdata Cloud."""
+
message: str
url: str
host: str
···
status: ReachabilityStatus
+
class TestNotification(BaseModel):
+
"""Test notification payload from Netdata Cloud."""
+
+
message: str
+
+
class WebhookPayload(BaseModel):
"""Union type for webhook payloads."""
-
+
@classmethod
-
def parse(cls, data: dict) -> Union[AlertNotification, ReachabilityNotification]:
+
def parse(
+
cls, data: dict
+
) -> AlertNotification | ReachabilityNotification | TestNotification:
"""Parse webhook payload and determine notification type."""
-
if 'alert' in data and 'chart' in data:
+
if "alert" in data and "chart" in data:
return AlertNotification(**data)
-
elif 'status' in data and 'host' in data:
+
elif "status" in data and "host" in data:
return ReachabilityNotification(**data)
+
elif len(data) == 1 and "message" in data:
+
# Test notification - only has a message field
+
return TestNotification(**data)
else:
raise ValueError(f"Unknown notification type: {data}")
class ZulipConfig(BaseModel):
"""Zulip bot configuration."""
-
site: str
-
email: str
-
api_key: str
-
stream: str
-
+
+
site: str | None = None
+
email: str | None = None
+
api_key: str | None = None
+
stream: str = "netdata-alerts"
+
model_config = ConfigDict(env_prefix="ZULIP_")
class ServerConfig(BaseModel):
"""Server configuration."""
+
host: str = "0.0.0.0"
-
port: int = 8443
-
domain: str # Required for Let's Encrypt
-
cert_path: str = "/etc/letsencrypt/live"
-
enable_mtls: bool = True
-
client_ca_path: Optional[str] = None
-
-
model_config = ConfigDict(env_prefix="SERVER_")
+
port: int = 8080 # Default HTTP port
+
challenge_secret: str | None = None # Netdata webhook challenge secret
+
+
model_config = ConfigDict(env_prefix="SERVER_")
+72 -78
netdata_zulip_bot/server.py
···
"""FastAPI webhook server for receiving Netdata notifications."""
-
import ssl
-
from pathlib import Path
-
from typing import Dict, Any
+
import base64
+
import hashlib
+
import hmac
import structlog
import uvicorn
-
from fastapi import FastAPI, HTTPException, Request, status
-
from fastapi.responses import JSONResponse
+
from fastapi import FastAPI, HTTPException, Query, Request, status
from .formatter import ZulipMessageFormatter
-
from .models import WebhookPayload, ZulipConfig, ServerConfig
+
from .models import ServerConfig, WebhookPayload, ZulipConfig
from .zulip_client import ZulipNotifier
logger = structlog.get_logger()
···
class NetdataWebhookServer:
"""FastAPI server for handling Netdata Cloud webhooks."""
-
+
def __init__(self, zulip_config: ZulipConfig, server_config: ServerConfig):
"""Initialize the webhook server."""
self.app = FastAPI(
title="Netdata Zulip Bot",
description="Webhook service for Netdata Cloud notifications",
-
version="0.1.0"
+
version="0.1.0",
)
self.zulip_config = zulip_config
self.server_config = server_config
self.formatter = ZulipMessageFormatter()
-
+
# Initialize Zulip client
try:
self.zulip_notifier = ZulipNotifier(zulip_config)
except Exception as e:
logger.error("Failed to initialize Zulip client", error=str(e))
raise
-
+
self._setup_routes()
self._setup_middleware()
-
+
def _setup_routes(self):
"""Setup FastAPI routes."""
-
+
@self.app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "healthy", "service": "netdata-zulip-bot"}
-
+
+
@self.app.get("/webhook/netdata")
+
async def netdata_webhook_challenge(crc_token: str = Query(...)):
+
"""Handle Netdata Cloud webhook challenge for verification."""
+
if not self.server_config.challenge_secret:
+
logger.error("Challenge secret not configured")
+
raise HTTPException(
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+
detail="Challenge secret not configured",
+
)
+
+
try:
+
# Create HMAC SHA-256 hash from crc_token and challenge secret
+
token_bytes = crc_token.encode("ascii")
+
secret_bytes = self.server_config.challenge_secret.encode("utf-8")
+
+
sha256_hash = hmac.new(
+
secret_bytes, msg=token_bytes, digestmod=hashlib.sha256
+
).digest()
+
+
# Create response with base64 encoded hash
+
response_token = "sha256=" + base64.b64encode(sha256_hash).decode(
+
"ascii"
+
)
+
+
logger.info(
+
"Responding to Netdata challenge", crc_token=crc_token[:16] + "..."
+
)
+
return {"response_token": response_token}
+
+
except Exception as e:
+
logger.error("Failed to process challenge", error=str(e))
+
raise HTTPException(
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+
detail="Failed to process challenge",
+
)
+
@self.app.post("/webhook/netdata")
async def netdata_webhook(request: Request):
"""Handle Netdata Cloud webhook notifications."""
···
# Get raw JSON data
body = await request.json()
logger.info("Received webhook", payload_keys=list(body.keys()))
-
+
# Parse and validate the payload
notification = WebhookPayload.parse(body)
logger.info("Parsed notification", type=type(notification).__name__)
-
+
# Format message for Zulip
topic, content = self.formatter.format_notification(notification)
logger.info("Formatted message", topic=topic)
-
+
# Send to Zulip
success = self.zulip_notifier.send_message(topic, content)
-
+
if success:
-
return {"status": "success", "message": "Notification sent to Zulip"}
+
return {
+
"status": "success",
+
"message": "Notification sent to Zulip",
+
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-
detail="Failed to send notification to Zulip"
+
detail="Failed to send notification to Zulip",
)
-
+
except ValueError as e:
logger.error("Invalid webhook payload", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
-
detail=f"Invalid payload format: {str(e)}"
+
detail=f"Invalid payload format: {str(e)}",
)
except Exception as e:
logger.error("Webhook processing failed", error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-
detail="Internal server error"
+
detail="Internal server error",
)
-
+
def _setup_middleware(self):
"""Setup middleware for logging and error handling."""
-
+
@self.app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all requests."""
···
"Request received",
method=request.method,
url=str(request.url),
-
client=client_host
+
client=client_host,
)
-
+
try:
response = await call_next(request)
logger.info(
"Request completed",
method=request.method,
url=str(request.url),
-
status_code=response.status_code
+
status_code=response.status_code,
)
return response
except Exception as e:
···
"Request failed",
method=request.method,
url=str(request.url),
-
error=str(e)
+
error=str(e),
)
raise
-
-
def get_ssl_context(self) -> ssl.SSLContext:
-
"""Create SSL context for HTTPS and mutual TLS."""
-
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
-
-
# Load server certificate and key
-
cert_path = Path(self.server_config.cert_path) / self.server_config.domain
-
cert_file = cert_path / "fullchain.pem"
-
key_file = cert_path / "privkey.pem"
-
-
if not cert_file.exists() or not key_file.exists():
-
logger.error(
-
"SSL certificate files not found",
-
cert_file=str(cert_file),
-
key_file=str(key_file)
-
)
-
raise FileNotFoundError(f"SSL certificate files not found at {cert_path}")
-
-
context.load_cert_chain(str(cert_file), str(key_file))
-
-
# Configure mutual TLS if enabled
-
if self.server_config.enable_mtls:
-
if self.server_config.client_ca_path:
-
ca_path = Path(self.server_config.client_ca_path)
-
if ca_path.exists():
-
context.load_verify_locations(str(ca_path))
-
context.verify_mode = ssl.CERT_REQUIRED
-
logger.info("Mutual TLS enabled", ca_path=str(ca_path))
-
else:
-
logger.warning("Client CA file not found, disabling mutual TLS", ca_path=str(ca_path))
-
context.verify_mode = ssl.CERT_NONE
-
else:
-
logger.warning("No client CA path configured, disabling mutual TLS")
-
context.verify_mode = ssl.CERT_NONE
-
else:
-
context.verify_mode = ssl.CERT_NONE
-
logger.info("Mutual TLS disabled")
-
-
return context
-
+
def run(self):
-
"""Run the webhook server with HTTPS and optional mutual TLS."""
+
"""Run the webhook server (HTTP only, TLS handled by reverse proxy)."""
try:
-
ssl_context = self.get_ssl_context()
-
logger.info(
-
"Starting Netdata Zulip webhook server",
+
"Starting Netdata Zulip webhook server (HTTP)",
host=self.server_config.host,
port=self.server_config.port,
-
domain=self.server_config.domain,
-
mtls_enabled=self.server_config.enable_mtls
)
-
+
uvicorn.run(
self.app,
host=self.server_config.host,
port=self.server_config.port,
-
ssl_context=ssl_context,
access_log=False, # We handle logging in middleware
)
-
+
except Exception as e:
logger.error("Failed to start server", error=str(e))
-
raise
+
raise
+22 -20
netdata_zulip_bot/zulip_client.py
···
class ZulipNotifier:
"""Handles sending notifications to Zulip."""
-
+
def __init__(self, config: ZulipConfig):
"""Initialize Zulip client with configuration."""
self.config = config
···
email=config.email,
api_key=config.api_key,
)
-
+
# Test connection
try:
result = self.client.get_profile()
-
if result['result'] != 'success':
+
if result["result"] != "success":
raise ConnectionError(f"Failed to connect to Zulip: {result}")
-
logger.info("Connected to Zulip", user=result['email'])
+
logger.info("Connected to Zulip", user=result["email"])
except Exception as e:
logger.error("Failed to initialize Zulip client", error=str(e))
raise
-
+
def send_message(self, topic: str, content: str) -> bool:
"""Send a message to the configured Zulip stream.
-
+
Args:
topic: The topic within the stream
content: The message content (markdown formatted)
-
+
Returns:
True if successful, False otherwise
"""
try:
-
result = self.client.send_message({
-
"type": "stream",
-
"to": self.config.stream,
-
"topic": topic,
-
"content": content,
-
})
-
-
if result['result'] == 'success':
+
result = self.client.send_message(
+
{
+
"type": "stream",
+
"to": self.config.stream,
+
"topic": topic,
+
"content": content,
+
}
+
)
+
+
if result["result"] == "success":
logger.info(
"Message sent to Zulip",
stream=self.config.stream,
topic=topic,
-
message_id=result.get('id')
+
message_id=result.get("id"),
)
return True
else:
···
"Failed to send message to Zulip",
stream=self.config.stream,
topic=topic,
-
error=result.get('msg', 'Unknown error')
+
error=result.get("msg", "Unknown error"),
)
return False
-
+
except Exception as e:
logger.error(
"Exception sending message to Zulip",
stream=self.config.stream,
topic=topic,
-
error=str(e)
+
error=str(e),
)
-
return False
+
return False
+1 -3
pyproject.toml
···
version = "0.1.0"
description = "Zulip bot for receiving Netdata Cloud webhook notifications"
authors = [
-
{name = "Your Name", email = "your.email@example.com"}
+
{name = "Anil Madhavapeddy", email = "anil@recoil.org"}
]
readme = "README.md"
requires-python = ">=3.11"
···
"zulip>=0.9.0",
"pydantic>=2.5.0",
"python-multipart>=0.0.6",
-
"certbot>=2.8.0",
-
"cryptography>=41.0.0",
"python-dotenv>=1.0.0",
"structlog>=23.2.0",
]
+1 -1
tests/__init__.py
···
-
"""Tests for the Netdata Zulip Bot."""
+
"""Tests for the Netdata Zulip Bot."""
+46 -49
tests/test_webhook.py
···
"""Tests for webhook functionality."""
-
import pytest
from datetime import datetime
-
from unittest.mock import Mock, patch
-
from netdata_zulip_bot.models import AlertNotification, ReachabilityNotification, WebhookPayload
+
import pytest
+
from netdata_zulip_bot.formatter import ZulipMessageFormatter
+
from netdata_zulip_bot.models import (
+
AlertNotification,
+
ReachabilityNotification,
+
WebhookPayload,
+
)
class TestWebhookPayload:
"""Test webhook payload parsing."""
-
+
def test_parse_alert_notification(self):
"""Test parsing alert notification payload."""
data = {
···
"space": "production",
"severity": "critical",
"date": "2024-01-15T14:30:00Z",
-
"alert_url": "https://app.netdata.cloud/spaces/abc/alerts/123"
+
"alert_url": "https://app.netdata.cloud/spaces/abc/alerts/123",
}
-
+
notification = WebhookPayload.parse(data)
assert isinstance(notification, AlertNotification)
assert notification.severity.value == "critical"
assert notification.alert == "high_cpu_usage"
assert isinstance(notification.date, datetime)
-
+
def test_parse_reachability_notification(self):
"""Test parsing reachability notification payload."""
data = {
···
"url": "https://app.netdata.cloud/hosts/web-server-01",
"host": "web-server-01",
"severity": "critical",
-
"status": {
-
"reachable": False,
-
"text": "unreachable"
-
}
+
"status": {"reachable": False, "text": "unreachable"},
}
-
+
notification = WebhookPayload.parse(data)
assert isinstance(notification, ReachabilityNotification)
assert notification.severity.value == "critical"
assert notification.host == "web-server-01"
assert not notification.status.reachable
-
+
def test_parse_unknown_payload_raises_error(self):
"""Test that unknown payload types raise ValueError."""
data = {"unknown_field": "value"}
-
+
with pytest.raises(ValueError, match="Unknown notification type"):
WebhookPayload.parse(data)
class TestZulipMessageFormatter:
"""Test Zulip message formatting."""
-
+
def setup_method(self):
"""Set up test fixtures."""
self.formatter = ZulipMessageFormatter()
-
+
def test_format_critical_alert(self):
"""Test formatting critical alert notification."""
alert = AlertNotification(
···
alert="high_cpu_usage",
info="CPU usage exceeded 90% for 5 minutes",
chart="system.cpu",
-
context="cpu.utilization",
+
context="cpu.utilization",
space="production",
severity="critical",
date=datetime(2024, 1, 15, 14, 30, 0),
-
alert_url="https://app.netdata.cloud/spaces/abc/alerts/123"
+
alert_url="https://app.netdata.cloud/spaces/abc/alerts/123",
)
-
+
topic, content = self.formatter.format_alert(alert)
-
+
assert topic == "critical"
assert "๐Ÿ”ด" in content
assert "**high_cpu_usage**" in content
assert "production" in content
assert "Critical" in content
assert "2024-01-15 14:30:00 UTC" in content
-
assert "[View Alert](https://app.netdata.cloud/spaces/abc/alerts/123)" in content
-
+
assert (
+
"[View Alert](https://app.netdata.cloud/spaces/abc/alerts/123)" in content
+
)
+
def test_format_warning_alert(self):
"""Test formatting warning alert notification."""
alert = AlertNotification(
message="Warning: High memory usage",
-
alert="high_memory_usage",
+
alert="high_memory_usage",
info="Memory usage at 85%",
chart="system.ram",
context="memory.utilization",
space="staging",
severity="warning",
date=datetime(2024, 1, 15, 10, 15, 0),
-
alert_url="https://app.netdata.cloud/spaces/def/alerts/456"
+
alert_url="https://app.netdata.cloud/spaces/def/alerts/456",
)
-
+
topic, content = self.formatter.format_alert(alert)
-
+
assert topic == "warning"
assert "โš ๏ธ" in content
assert "Warning" in content
-
+
def test_format_clear_alert(self):
"""Test formatting clear alert notification."""
alert = AlertNotification(
message="Alert cleared: CPU usage normal",
alert="high_cpu_usage",
info="CPU usage returned to normal levels",
-
chart="system.cpu",
+
chart="system.cpu",
context="cpu.utilization",
space="production",
severity="clear",
date=datetime(2024, 1, 15, 15, 0, 0),
-
alert_url="https://app.netdata.cloud/spaces/abc/alerts/123"
+
alert_url="https://app.netdata.cloud/spaces/abc/alerts/123",
)
-
+
topic, content = self.formatter.format_alert(alert)
-
+
assert topic == "clear"
assert "โœ…" in content
assert "Clear" in content
-
+
def test_format_reachability_unreachable(self):
"""Test formatting unreachable host notification."""
notification = ReachabilityNotification(
···
url="https://app.netdata.cloud/hosts/web-server-01",
host="web-server-01",
severity="critical",
-
status={
-
"reachable": False,
-
"text": "unreachable"
-
}
+
status={"reachable": False, "text": "unreachable"},
)
-
+
topic, content = self.formatter.format_reachability(notification)
-
+
assert topic == "reachability"
assert "๐Ÿ”ด" in content # critical severity
assert "โŒ" in content # unreachable status
assert "web-server-01" in content
assert "Unreachable" in content
assert "[View Host](https://app.netdata.cloud/hosts/web-server-01)" in content
-
+
def test_format_reachability_reachable(self):
"""Test formatting reachable host notification."""
notification = ReachabilityNotification(
message="Host web-server-01 is reachable again",
-
url="https://app.netdata.cloud/hosts/web-server-01",
+
url="https://app.netdata.cloud/hosts/web-server-01",
host="web-server-01",
severity="info",
-
status={
-
"reachable": True,
-
"text": "reachable"
-
}
+
status={"reachable": True, "text": "reachable"},
)
-
+
topic, content = self.formatter.format_reachability(notification)
-
+
assert topic == "reachability"
-
assert "โ„น๏ธ" in content # info severity
-
assert "โœ…" in content # reachable status
-
assert "Reachable" in content
+
assert "โ„น๏ธ" in content # info severity
+
assert "โœ…" in content # reachable status
+
assert "Reachable" in content
-215
uv.lock
···
requires-python = ">=3.11"
[[package]]
-
name = "acme"
-
version = "4.2.0"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "cryptography" },
-
{ name = "josepy" },
-
{ name = "pyopenssl" },
-
{ name = "pyrfc3339" },
-
{ name = "requests" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/48/df/d006c4920fd04b843c21698bd038968cb9caa3315608f55abde0f8e4ad6b/acme-4.2.0.tar.gz", hash = "sha256:0df68c0e1acb3824a2100013f8cd51bda2e1a56aa23447449d14c942959f0c41", size = 96820, upload-time = "2025-08-05T19:19:08.86Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/86/26/9ff889b5d762616bf92ecbeb1ab93faddfd7bf6068146340359e9a6beb43/acme-4.2.0-py3-none-any.whl", hash = "sha256:6292011bbfa5f966521b2fb9469982c24ff4c58e240985f14564ccf35372e79a", size = 101573, upload-time = "2025-08-05T19:18:45.266Z" },
-
]
-
-
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
-
name = "certbot"
-
version = "4.2.0"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "acme" },
-
{ name = "configargparse" },
-
{ name = "configobj" },
-
{ name = "cryptography" },
-
{ name = "distro" },
-
{ name = "josepy" },
-
{ name = "parsedatetime" },
-
{ name = "pyrfc3339" },
-
{ name = "pywin32", marker = "sys_platform == 'win32'" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/f2/e3/199262bf00c9bd5dfccfe0a64c26c2fb132b92511bee416c3408a54b4cf1/certbot-4.2.0.tar.gz", hash = "sha256:fb1e56ca8a072bec49ac0c7b5390a29cbf68c2c05f712259a9b3491de041c27b", size = 442984, upload-time = "2025-08-05T19:19:22.495Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/fa/e4/5176fcd1195ffd358bb1129baa0f411da7eede3d47eb39e05062b5f22105/certbot-4.2.0-py3-none-any.whl", hash = "sha256:8fcca0c1a06df9ce39e89b7d13c70506e1372823e8b5993633d21adb77581950", size = 409215, upload-time = "2025-08-05T19:19:06.803Z" },
-
]
-
-
[[package]]
name = "certifi"
version = "2025.8.3"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
-
name = "cffi"
-
version = "1.17.1"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "pycparser" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" },
-
{ url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" },
-
{ url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" },
-
{ url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" },
-
{ url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" },
-
{ url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" },
-
{ url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" },
-
{ url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" },
-
{ url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" },
-
{ url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" },
-
{ url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" },
-
{ url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" },
-
{ url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
-
{ url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
-
{ url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
-
{ url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
-
{ url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
-
{ url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
-
{ url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
-
{ url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
-
{ url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
-
{ url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
-
{ url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
-
{ url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
-
{ url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
-
{ url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
-
{ url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
-
{ url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
-
{ url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
-
{ url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
-
{ url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
-
{ url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
-
{ url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
-
{ url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
-
]
-
-
[[package]]
name = "charset-normalizer"
version = "3.4.3"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
-
name = "configargparse"
-
version = "1.7.1"
-
source = { registry = "https://pypi.org/simple" }
-
sdist = { url = "https://files.pythonhosted.org/packages/85/4d/6c9ef746dfcc2a32e26f3860bb4a011c008c392b83eabdfb598d1a8bbe5d/configargparse-1.7.1.tar.gz", hash = "sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9", size = 43958, upload-time = "2025-05-23T14:26:17.369Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" },
-
]
-
-
[[package]]
-
name = "configobj"
-
version = "5.0.9"
-
source = { registry = "https://pypi.org/simple" }
-
sdist = { url = "https://files.pythonhosted.org/packages/f5/c4/c7f9e41bc2e5f8eeae4a08a01c91b2aea3dfab40a3e14b25e87e7db8d501/configobj-5.0.9.tar.gz", hash = "sha256:03c881bbf23aa07bccf1b837005975993c4ab4427ba57f959afdd9d1a2386848", size = 101518, upload-time = "2024-09-21T12:47:46.315Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/a6/c4/0679472c60052c27efa612b4cd3ddd2a23e885dcdc73461781d2c802d39e/configobj-5.0.9-py2.py3-none-any.whl", hash = "sha256:1ba10c5b6ee16229c79a05047aeda2b55eb4e80d7c7d8ecf17ec1ca600c79882", size = 35615, upload-time = "2024-11-26T14:03:32.972Z" },
-
]
-
-
[[package]]
-
name = "cryptography"
-
version = "45.0.6"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" },
-
{ url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" },
-
{ url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" },
-
{ url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" },
-
{ url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" },
-
{ url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" },
-
{ url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" },
-
{ url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" },
-
{ url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" },
-
{ url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" },
-
{ url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" },
-
{ url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" },
-
{ url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" },
-
{ url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" },
-
{ url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" },
-
{ url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" },
-
{ url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" },
-
{ url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" },
-
{ url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" },
-
{ url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" },
-
{ url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" },
-
{ url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" },
-
{ url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" },
-
{ url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" },
-
{ url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" },
-
{ url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" },
-
{ url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" },
-
{ url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" },
-
{ url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" },
-
{ url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" },
-
]
-
-
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
···
]
[[package]]
-
name = "josepy"
-
version = "2.1.0"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "cryptography" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/9d/19/4ebe24c42c341c5868dff072b78d503fc1b0725d88ea619d2db68f5624a9/josepy-2.1.0.tar.gz", hash = "sha256:9beafbaa107ec7128e6c21d86b2bc2aea2f590158e50aca972dca3753046091f", size = 56189, upload-time = "2025-07-08T17:20:54.98Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/47/0e/e248f059986e376b87e546cd840d244370b9f7ef2b336456f0c2cfb2fef0/josepy-2.1.0-py3-none-any.whl", hash = "sha256:0eadf09b96821bdae9a8b14145425cb9fe0bbee64c6fdfce3ccd4ceb7d7efbbd", size = 29065, upload-time = "2025-07-08T17:20:53.504Z" },
-
]
-
-
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
···
version = "0.1.0"
source = { editable = "." }
dependencies = [
-
{ name = "certbot" },
-
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "pydantic" },
{ name = "python-dotenv" },
···
[package.metadata]
requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
-
{ name = "certbot", specifier = ">=2.8.0" },
-
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "fastapi", specifier = ">=0.104.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" },
{ name = "pydantic", specifier = ">=2.5.0" },
···
]
[[package]]
-
name = "parsedatetime"
-
version = "2.6"
-
source = { registry = "https://pypi.org/simple" }
-
sdist = { url = "https://files.pythonhosted.org/packages/a8/20/cb587f6672dbe585d101f590c3871d16e7aec5a576a1694997a3777312ac/parsedatetime-2.6.tar.gz", hash = "sha256:4cb368fbb18a0b7231f4d76119165451c8d2e35951455dfee97c62a87b04d455", size = 60114, upload-time = "2020-05-31T23:50:57.443Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/9d/a4/3dd804926a42537bf69fb3ebb9fd72a50ba84f807d95df5ae016606c976c/parsedatetime-2.6-py3-none-any.whl", hash = "sha256:cb96edd7016872f58479e35879294258c71437195760746faffedb692aef000b", size = 42548, upload-time = "2020-05-31T23:50:56.315Z" },
-
]
-
-
[[package]]
name = "pathspec"
version = "0.12.1"
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 = "pycparser"
-
version = "2.22"
-
source = { registry = "https://pypi.org/simple" }
-
sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
···
]
[[package]]
-
name = "pyopenssl"
-
version = "25.1.0"
-
source = { registry = "https://pypi.org/simple" }
-
dependencies = [
-
{ name = "cryptography" },
-
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
-
]
-
sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" },
-
]
-
-
[[package]]
-
name = "pyrfc3339"
-
version = "2.0.1"
-
source = { registry = "https://pypi.org/simple" }
-
sdist = { url = "https://files.pythonhosted.org/packages/f0/d2/6587e8ec3951cbd97c56333d11e0f8a3a4cb64c0d6ed101882b7b31c431f/pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b", size = 4573, upload-time = "2024-11-04T01:57:09.959Z" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/dd/ba/d778be0f6d8d583307b47ba481c95f88a59d5c6dfd5d136bc56656d1d17f/pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d", size = 5777, upload-time = "2024-11-04T01:57:08.185Z" },
-
]
-
-
[[package]]
name = "pytest"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
···
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
-
]
-
-
[[package]]
-
name = "pywin32"
-
version = "311"
-
source = { registry = "https://pypi.org/simple" }
-
wheels = [
-
{ url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" },
-
{ url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" },
-
{ url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" },
-
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
-
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
-
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
-
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
-
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
-
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
-
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
-
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
-
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]