A simple web application for authenticating users and maintaining an IP whitelist with configurable expiration and maximum entry limits.
- Web-based authentication interface
- Persistent IP whitelist storage with expiration
- Internal endpoint for retrieving whitelist as text file
- Integration with Bunkerweb API (intended to be used for updating greylist)
- Docker containerization with environment variable configuration
- Support for multiple credentials
- LDAP authentication support (with defaults targeting LLDAP)
Yes, this was vibe-coded. But it was also carefully reviewed and designed for minimum complexity.
You can either build the image locally or use the pre-built image from GitHub Container Registry.
docker pull ghcr.io/stempler/hello-ip:latest- Build the Docker image:
docker build -t hello-ip .- Generate password hashes using the helper script:
python hash_password.py secret123 admin
python hash_password.py password456 user1- Run the container with hashed credentials:
docker run -d \
-p 8080:8080 \
-p 8081:8081 \
-v whitelist-data:/data \
-e CREDENTIALS='{"admin": "pbkdf2:sha256:...", "user1": "pbkdf2:sha256:..."}' \
-e BASE_PATH="/" \
-e ENTRY_VALIDITY_HOURS=24 \
-e MAX_ENTRIES=1000 \
--name hello-ip \
hello-ip- Access the web interface at
http://localhost:8080 - Retrieve the whitelist at
http://localhost:8081/whitelist.txt
| Variable | Description | Default |
|---|---|---|
BASE_PATH |
Base URL path for the application | / |
MAIN_PORT |
Port for main web interface | 8080 |
INTERNAL_PORT |
Port for whitelist endpoint | 8081 |
ENTRY_VALIDITY_HOURS |
Hours until whitelist entry expires | 24 |
MAX_ENTRIES |
Maximum number of whitelist entries | 1000 |
DATABASE_PATH |
SQLite database file path | /data/whitelist.db |
CREDENTIALS |
JSON string of credential IDs to password hashes | {} |
GUNICORN_WORKERS |
Number of gunicorn worker processes for main app | 4 |
GUNICORN_THREADS |
Number of threads per worker | 2 |
BUNKERWEB_ENABLED |
Enable BunkerWeb API integration | false |
BUNKERWEB_API_URL |
Base URL for BunkerWeb API | `` |
BUNKERWEB_USERNAME |
BunkerWeb API username for basic auth | `` |
BUNKERWEB_PASSWORD |
BunkerWeb API password for basic auth | `` |
BUNKERWEB_JOB_PLUGIN |
Job plugin name to trigger | greylist |
BUNKERWEB_JOB_NAME |
Job name to trigger | greylist-download |
BUNKERWEB_UNBAN_ENABLED |
Automatically unban whitelisted IPs in BunkerWeb | false |
LDAP_ENABLED |
Enable LDAP authentication | false |
LDAP_SERVER |
LDAP server URL | ldap://localhost:3890 |
LDAP_BASE_DN |
Base DN for user searches | dc=example,dc=com |
LDAP_BIND_DN |
Service account DN for binding | uid=admin,ou=people,dc=example,dc=com |
LDAP_BIND_PASSWORD |
Service account password | `` |
LDAP_USER_DN_TEMPLATE |
Template for user DN | uid={},ou=people,{} |
LDAP_USER_FILTER |
Filter to find users | (&(objectClass=person)(uid={})) |
LDAP_USE_TLS |
Use STARTTLS for connection | false |
LDAP_FALLBACK_LOCAL |
Fall back to local credentials if LDAP fails | true |
LDAP_ALLOWED_GROUP |
Optional group name or DN. If set, only users in this group can authenticate | (empty) |
LDAP_GROUP_DN_TEMPLATE |
Template for constructing group DN from group name | cn={},ou=groups,{} |
LDAP_GROUP_OBJECT_CLASS |
LDAP objectClass for groups (e.g., groupOfNames, group, posixGroup) |
groupOfNames |
LOG_LEVEL |
Python logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | INFO |
The CREDENTIALS environment variable should be a JSON object mapping credential IDs to password hashes (not plain text passwords). Use the hash_password.py helper script to generate hashes.
Important: Never store plain text passwords in the CREDENTIALS environment variable. Always use password hashes generated by the helper script.
Use the hash_password.py script to generate password hashes:
# Generate a hash for a single password
python hash_password.py mypassword
# Generate a JSON object ready for CREDENTIALS env var
python hash_password.py mypassword admin
# Output: {"admin": "pbkdf2:sha256:600000$..."}
# Combine multiple credentials
python hash_password.py secret123 admin
python hash_password.py password456 user1
# Then combine the outputs into a single JSON object:
# {"admin": "pbkdf2:sha256:...", "user1": "pbkdf2:sha256:..."}Example CREDENTIALS environment variable:
{"admin": "pbkdf2:sha256:600000$...", "user1": "pbkdf2:sha256:600000$..."}GET /- Authentication web interfacePOST /auth- Authenticate and add IP to whitelist- Request body:
{"credential_id": "admin", "password": "secret123"} - Returns 200 on success, 403 on authentication failure
- Request body:
GET /health- Health check endpoint
GET /whitelist.txt- Returns text file with valid IP addresses (one per line)GET /whitelist.json- Returns JSON with all whitelist entry information- Query parameter
?all=true- Include expired entries (default: only valid entries) - Response format:
{ "count": 5, "valid_only": true, "entries": [ { "ip": "192.168.1.1", "credential_id": "admin", "auth_time": "2024-01-15T10:30:00", "expires_at": "2024-01-16T10:30:00", "is_valid": true, "remaining_seconds": 86400 } ] }
- Query parameter
This project uses mise-en-place (formerly rtx) for tool version management with automatic virtualenv activation.
- Install mise-en-place if you haven't already:
# Follow installation instructions at https://mise.jdx.dev/getting-started.html- Install the required tools (this will install Python 3.11):
mise install- Activate the mise environment (add to your shell config for automatic activation):
eval "$(mise activate)"-
Navigate to the project directory - mise will automatically:
- Use Python 3.11
- Create the
.venvvirtual environment if it doesn't exist - Activate the virtual environment automatically
-
Install dependencies:
pip install --upgrade pip
pip install -r requirements.txt- Generate password hashes and set environment variables:
# Generate password hash
python hash_password.py secret123 admin
# Output: {"admin": "pbkdf2:sha256:..."}
# Set environment variables (use the hash from above)
export CREDENTIALS='{"admin": "pbkdf2:sha256:..."}'
export DATABASE_PATH="./whitelist.db"- Run the application:
python app.pyNote: The .mise.toml file configures Python 3.11 and uses automatic virtualenv activation. When you're in the project directory with mise activated, the virtual environment will be automatically created (if needed) and activated. No manual python -m venv or source .venv/bin/activate needed!
If you prefer not to use mise-en-place, ensure you have Python 3.11+ installed and follow steps 4-7 above.
The Docker image uses gunicorn as a production WSGI server instead of Flask's development server. The application runs with:
- Main app (port 8080): Production WSGI server with configurable workers
- Internal app (port 8081): Lightweight server for internal whitelist endpoint
You can configure gunicorn workers and threads via environment variables:
-e GUNICORN_WORKERS=4 \
-e GUNICORN_THREADS=2For local development, you can still use python app.py which uses Flask's development server (with the warning).
- Passwords are hashed using Werkzeug's secure password hashing
- Credentials are stored only in environment variables (not in the database)
- SQL injection prevention via parameterized queries
- Separate ports for internal vs public access
- Input validation for IP addresses
- Non-root user in Docker container
- Production WSGI server (gunicorn) in Docker image
The application uses SQLite with one table:
whitelist_entries: Stores IP addresses, credential IDs, authentication time, and expiration time
Note: Credentials are not stored in the database. They are configured via the CREDENTIALS environment variable and verified at runtime.
The application supports LDAP as an alternative authentication backend. When enabled, users can authenticate against an LDAP server instead of (or in addition to) local credentials.
Default values are configured to work with LLDAP (Light LDAP).
| Variable | Default | Description |
|---|---|---|
LDAP_ENABLED |
false |
Enable LDAP authentication |
LDAP_SERVER |
ldap://localhost:3890 |
LDAP server URL (LLDAP default port is 3890) |
LDAP_BASE_DN |
dc=example,dc=com |
Base DN for user searches |
LDAP_BIND_DN |
uid=admin,ou=people,dc=example,dc=com |
Service account DN for initial binding |
LDAP_BIND_PASSWORD |
(empty) | Service account password |
LDAP_USER_DN_TEMPLATE |
uid={},ou=people,{} |
Template for constructing user DN (placeholders: username, base_dn) |
LDAP_USER_FILTER |
(&(objectClass=person)(uid={})) |
LDAP filter to find users (placeholder: username) |
LDAP_USE_TLS |
false |
Use STARTTLS for secure connection |
LDAP_FALLBACK_LOCAL |
true |
Fall back to local credentials if LDAP auth fails |
LDAP_ALLOWED_GROUP |
(empty) | Optional group name or DN. If set, only users in this group can authenticate |
LDAP_GROUP_DN_TEMPLATE |
cn={},ou=groups,{} |
Template for constructing group DN from group name (placeholders: group_name, base_dn) |
When LDAP_ALLOWED_GROUP is configured, authentication is restricted to users who are members of the specified LDAP group. This provides an additional layer of access control on top of password authentication.
Requirements:
LDAP_BIND_DNandLDAP_BIND_PASSWORDmust be configured (service account is required for group membership checks)- The group must exist in your LDAP directory
Group Format:
- Group name (CN):
whitelist-users→ will construct DN ascn=whitelist-users,ou=groups,{base_dn}usingLDAP_GROUP_DN_TEMPLATE - Full DN:
cn=whitelist-users,ou=groups,dc=example,dc=com→ used as-is
How it works:
- The service account binds and searches for the user to obtain their DN
- The application then searches for the allowed group and checks if the user's DN is in the group's
memberattribute - If the user is not in the group, authentication is denied immediately (fail-fast)
- If the user is in the group, password authentication proceeds by verifying the user's password
Note: Group membership is checked after finding the user's DN but before password verification. This provides efficient fail-fast behavior: users not in the allowed group are rejected without attempting password verification.
- If
LDAP_ENABLED=true:- Service account binds and searches for the user's DN
- If
LDAP_ALLOWED_GROUPis configured, check if the user is a member of the allowed group - If user is not in the allowed group, authentication fails immediately
- If user is in the allowed group (or no group restriction), proceed to password verification by binding as the user
- If LDAP authentication fails and
LDAP_FALLBACK_LOCAL=true, it tries local credentials - If
LDAP_ENABLED=false, only local credentials are used
docker run -d \
-e LDAP_ENABLED=true \
-e LDAP_SERVER=ldap://lldap:3890 \
-e LDAP_BASE_DN=dc=example,dc=com \
-e LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com \
-e LDAP_BIND_PASSWORD=admin_password \
-e LDAP_ALLOWED_GROUP=whitelist-users \
-e LDAP_GROUP_DN_TEMPLATE=cn={},ou=groups,{} \
...Note: When using LDAP_ALLOWED_GROUP, you must also configure LDAP_BIND_DN and LDAP_BIND_PASSWORD as the service account is required to check group membership.
docker run -d \
-p 8080:8080 \
-p 8081:8081 \
-v whitelist-data:/data \
-e LDAP_ENABLED=true \
-e LDAP_SERVER="ldap://lldap:3890" \
-e LDAP_BASE_DN="dc=example,dc=com" \
-e LDAP_BIND_DN="uid=admin,ou=people,dc=example,dc=com" \
-e LDAP_BIND_PASSWORD="admin_password" \
-e LDAP_FALLBACK_LOCAL=false \
--name hello-ip \
hello-ip- The
LDAP_USER_DN_TEMPLATEuses{}as placeholders: the first{}is replaced with the username, the second withLDAP_BASE_DN - If
LDAP_BIND_DNandLDAP_BIND_PASSWORDare provided, the application will use them to search for users; otherwise, it constructs the user DN directly from the template - LLDAP uses port 3890 by default (not the standard LDAP port 389)
- For LDAPS (LDAP over SSL), use
ldaps://in the server URL instead of enablingLDAP_USE_TLS
The application supports optional integration with the BunkerWeb API to automatically trigger jobs when the whitelist changes.
To enable BunkerWeb integration, set the following environment variables:
-e BUNKERWEB_ENABLED=true \
-e BUNKERWEB_API_URL="http://bunkerweb:8888" \
-e BUNKERWEB_USERNAME="api_user" \
-e BUNKERWEB_PASSWORD="api_password" \
-e BUNKERWEB_JOB_PLUGIN="greylist" \
-e BUNKERWEB_JOB_NAME="greylist-download" \
-e BUNKERWEB_UNBAN_ENABLED=trueOptional Configuration:
BUNKERWEB_UNBAN_ENABLED: When set totrue, automatically unban the whitelisted IP address in BunkerWeb after triggering the job. This ensures newly whitelisted IPs are immediately unbanned. Default:false.
-
Authentication: When a whitelist entry is added, the application authenticates with the BunkerWeb API using basic authentication (username/password) at the
/authendpoint. -
Token Management: The authentication token is cached and reused for subsequent API calls. The token is refreshed automatically when it expires.
-
Cache Clearing: Before triggering the job, the application clears relevant cache files to ensure the job uses fresh data.
-
Job Triggering: After clearing cache, the application triggers the configured job by sending a POST request to
/jobs/runwith the following payload:{ "jobs": [ { "plugin": "greylist", "name": "greylist-download" } ] } -
IP Unbanning (Optional): If
BUNKERWEB_UNBAN_ENABLEDis set totrue, after triggering the job, the application will automatically unban the whitelisted IP address by sending a DELETE request to/bans. This ensures that newly whitelisted IPs are immediately unbanned in BunkerWeb. -
Concurrency Handling: All BunkerWeb API calls are serialized using a lock mechanism to prevent conflicts when multiple whitelist updates occur simultaneously.
-
Error Handling: BunkerWeb integration failures are logged but do not affect whitelist operations. The application continues to function normally even if BunkerWeb is unavailable.
docker run -d \
-p 8080:8080 \
-p 8081:8081 \
-v whitelist-data:/data \
-e CREDENTIALS='{"admin": "pbkdf2:sha256:..."}' \
-e BUNKERWEB_ENABLED=true \
-e BUNKERWEB_API_URL="http://bunkerweb:8080" \
-e BUNKERWEB_USERNAME="bunkerweb_user" \
-e BUNKERWEB_PASSWORD="bunkerweb_pass" \
-e BUNKERWEB_JOB_PLUGIN="greylist" \
-e BUNKERWEB_JOB_NAME="greylist-download" \
-e BUNKERWEB_UNBAN_ENABLED=true \
--name hello-ip \
hello-ipMIT