Caddy Knocker is a secure, configurable, and self-hosted service that provides a "knock-knock" single-packet authorization (SPA) gateway for your Caddy v2 reverse proxy. It allows you to keep your services completely private, opening them up on-demand only for authorized IP addresses.
This is ideal for homelab environments where you want to expose services to the internet without a persistent VPN connection, while minimizing your public-facing attack surface.
- API Key Authentication: Secure your knock endpoint with multiple, configurable API keys.
- Configurable TTL: Each API key can have its own Time-To-Live (TTL), defining how long a whitelisted IP remains active.
- Remote Whitelisting: Grant specific admin keys permission to whitelist any IP or CIDR range, not just their own.
- Static IP/CIDR Whitelisting: Always allow certain IP addresses or ranges to bypass the dynamic whitelist.
- Path-Based Exclusion: Exclude specific URL paths (like health checks or public APIs) from authentication entirely.
- IPv6 First-Class Citizen: Full support for IPv6 and IPv4 in whitelisting, trusted proxies, and Docker networking.
- Secure by Default: Built-in protection against IP spoofing via a trusted proxy mechanism.
- Test-Driven Development: A comprehensive test suite ensures code correctness and reliability.
This project uses GitHub Actions for continuous integration and deployment.
- CI (
ci.yml
): On every pull request tomain
, this workflow runs the full Python test suite and then performs a live integration test with Docker Compose to ensure the Caddy and Knocker services work together correctly. - Docker Publish (
docker-publish.yml
): On every push tomain
, this workflow builds and publishes a multi-arch Docker image to the GitHub Container Registry (ghcr.io).
This project is designed to be deployed as a set of Docker containers using the provided docker-compose.yml
file. It uses the pre-built image from the GitHub Container Registry.
For a formal API specification and a summary of the architectural choices, please see:
* Docker and Docker Compose installed.
* A public-facing server to run the containers.
-
Configuration:
- Rename
knocker.example.yaml
toknocker.yaml
. - Crucially, change the default API keys in
knocker.yaml
to your own secure, random strings. - Review the
trusted_proxies
list inknocker.yaml
. The defaults are suitable for most Docker setups, but you should verify they match your Docker network's subnets if you have a custom configuration. - Create a
Caddyfile
in theknocker
directory. See the "Caddy Integration" section below for examples.
- Rename
-
Update
docker-compose.yml
:- Open the
docker-compose.yml
file. - Change the
image
line for theknocker
service to point to your own GitHub repository:image: ghcr.io/YOUR_GITHUB_USERNAME/YOUR_REPOSITORY_NAME:latest
- Open the
-
Run the Service:
docker compose up -d
This will pull the pre-built
knocker
image and start both theknocker
andcaddy
services.
The service is configured entirely through the knocker.yaml
file.
-
server
:host
&port
: The address the internal server listens on. Should generally be left as is.trusted_proxies
: A list of IPs or CIDR ranges. The service will only trust theX-Forwarded-For
header from these addresses. This is a critical security setting.
-
whitelist
:storage_path
: The location inside the container where thewhitelist.json
file is stored. This is mounted to a Docker volume for persistence.
-
api_keys
:- A list of key objects.
name
: A friendly name for the key.key
: The secret API key string.ttl
: The duration in seconds that an IP will be whitelisted for.allow_remote_whitelist
: A boolean (true
orfalse
). Iftrue
, this key can be used to whitelist any IP/CIDR passed in the request body. Iffalse
, it can only whitelist the IP of the device making the request.
-
security
:always_allowed_ips
: A list of IPv4 or IPv6 addresses or CIDR ranges that will always be allowed to pass the/verify
endpoint, regardless of whether they are in the dynamic whitelist. This is useful for permanently allowing access to trusted IPs, such as the IP of a reverse proxy or an admin workstation.
security: always_allowed_ips: - "1.2.3.4" - "192.168.1.0/24" - "2001:db8::/32"
excluded_paths
: A list of URL paths that will bypass the IP whitelist check entirely. Any request whose path starts with one of these values will be allowed. This is useful for exposing health check endpoints or public API routes.
security: excluded_paths: - "/api/v1/status" - "/metrics"
To protect your services, you will use Caddy's forward_auth
directive.
-
Define a Reusable Snippet: It's best practice to define a snippet in your
Caddyfile
for the auth check. -
Protect Your Services: Import the snippet for any service you want to protect.
Example Caddyfile
:
# Caddyfile
# Define a reusable snippet for the knock-knock check.
# It points to the knocker service using Docker's internal DNS.
(knocker_auth) {
forward_auth knocker:8000 {
uri /verify
copy_headers X-Forwarded-For
}
}
# The public endpoint for performing the knock.
# Make sure this domain points to your Caddy server's IP.
knock.your-domain.com {
reverse_proxy knocker:8000
}
# An example protected service.
jellyfin.your-domain.com {
import knocker_auth # Apply the forward_auth check
reverse_proxy jellyfin_service_name:8096
}
If you are enabling knocking for IPs behind tailscale or other IPs, you may face issues due to how userland-proxy works, you may get different request IP from the actual ip address.
Disabling Userland-proxy should fix it, but make sure to test your setup. You may also use host networking.
When a user is not whitelisted, Caddy's forward_auth
directive will return a 401 Unauthorized
response with an empty body.
Important Note: Caddy's handle_errors
directive does not work with forward_auth
responses. The error response comes directly from the authentication service (knocker), not from Caddy itself, so handle_errors
cannot intercept or modify these responses.
If you need custom error pages for unauthorized access, you have a few alternatives:
- Modify the knocker service: Update the
/verify
endpoint to return custom HTML content in 401 responses (requires code changes). - Use a different approach: Instead of
forward_auth
, you could implement authorization at the application level. - Accept the default: Use the standard 401 response for unauthorized access.
Example of the standard behavior:
jellyfin.your-domain.com {
import knocker_auth # This will return empty 401 responses for unauthorized users
reverse_proxy jellyfin_service_name:8096
}
This endpoint validates an API key and whitelists an IP.
-
Headers:
X-Api-Key
: Your secret API key.
-
Body (Optional):
- To whitelist a remote IP/CIDR (requires
allow_remote_whitelist: true
):{"ip_address": "YOUR_TARGET_IP_OR_CIDR"}
- To whitelist a remote IP/CIDR (requires
-
Example (Whitelisting your own IP):
curl -i -H "X-Api-Key: YOUR_SECRET_KEY" https://knock.your-domain.com/knock
-
Success Response (
200 OK
):{ "whitelisted_entry": "1.2.3.4", "expires_at": 1672534800, "expires_in_seconds": 3600 }
This endpoint is used by Caddy's forward_auth
to check if the client's IP is whitelisted. It returns 200 OK
on success and 401 Unauthorized
on failure.
The project includes a full test suite. To run the tests locally:
-
Install Dependencies:
pip install -r src/requirements.txt
-
Run Pytest:
python3 -m pytest