22 Commits
1.0.4 ... main

Author SHA1 Message Date
d4386f53f5 Updated docker compose file 2026-01-26 09:15:03 +01:00
8f443379cd Auto login with Authelia 2026-01-23 14:07:09 +01:00
a47b57f26a Auto login with Authelia 2026-01-23 13:59:30 +01:00
cd401e108f Auto login with Authelia 2026-01-23 13:51:41 +01:00
3d11470f81 Auto login with Authelia 2026-01-23 13:41:39 +01:00
877ecf32b4 Fixed login with only ip:port access 2026-01-23 13:32:19 +01:00
3b78959901 New changes after reset 2026-01-23 13:14:19 +01:00
6e110289cd Changed app for proxy and https++ 2026-01-23 10:39:52 +01:00
afab617c5c Changed app for proxy and https++ 2026-01-23 09:48:57 +01:00
b737826d11 Changed app for proxy and https++ 2026-01-23 09:36:32 +01:00
f9704f6dd3 Changed app for proxy and https++ 2026-01-23 09:30:05 +01:00
75a3ec9d7e Changed app for proxy and https++ 2026-01-23 08:38:34 +01:00
21f27e0d27 Changed app for proxy and https++ 2026-01-22 16:52:46 +01:00
7ed6100f05 Changed app for proxy and https++ 2026-01-22 16:49:12 +01:00
c3efa127d9 Changed app for proxy and https++ 2026-01-22 16:42:55 +01:00
e4fed12143 Changed app for proxy and https++ 2026-01-22 16:32:02 +01:00
7fc81a0f65 dockerfile 2026-01-22 16:26:21 +01:00
611031ec6c Added reset password because - I do not wanna talk about it 2026-01-22 16:19:04 +01:00
686bce2df9 Merge remote changes with local bulk delete feature
- Resolved conflict in templates/index.html
- Kept local delete modal responsive styles
- Merged documentation updates from remote (DOCKER.md, README.md)
2026-01-16 11:24:42 +01:00
efbb5725d3 Merge branch '1.0.4' 2026-01-16 11:19:07 +01:00
acd37a9c15 Merge pull request 'Small changes, added license' (#1) from 1.0.3 into main
Reviewed-on: #1
2026-01-13 14:25:10 +01:00
203d5e0575 Small changes, added license 2026-01-13 14:24:19 +01:00
10 changed files with 1098 additions and 15 deletions

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ BUI*
build.sh
docker-compose.dist*
GITEA.md
PROXY_AUTHELIA_SETUP.md
alias.rune.pm.config

View File

@@ -134,3 +134,7 @@ To access from other devices on your network:
1. Find your server's IP address
2. Access via `http://YOUR_IP:5172`
3. Make sure your firewall allows connections on port 5172
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View File

@@ -11,15 +11,16 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
COPY malias_wrapper.py .
COPY malias.py .
COPY reset_password.py .
COPY templates/ templates/
COPY static/ static/
# Create data directory
RUN mkdir -p /app/data
# Copy entrypoint script
# Copy entrypoint script and make scripts executable
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
RUN chmod +x docker-entrypoint.sh reset_password.py
# Expose port
EXPOSE 5172
@@ -28,6 +29,7 @@ EXPOSE 5172
ENV PYTHONUNBUFFERED=1
ENV FLASK_DEBUG=False
ENV FLASK_ENV=production
ENV ENABLE_PROXY=false
# Run the application via entrypoint
ENTRYPOINT ["./docker-entrypoint.sh"]

432
PROXY_SETUP.md Normal file
View File

@@ -0,0 +1,432 @@
# Reverse Proxy Setup Guide
This guide helps you configure reverse proxies (Nginx, Traefik, Zoraxy, Authelia, Caddy, etc.) to work with Mailcow Alias Manager.
---
## 🔧 ENABLE_PROXY Configuration
The application supports **two access modes** controlled by the `ENABLE_PROXY` environment variable in `docker-compose.yml`:
### **Mode 1: Direct Access (ENABLE_PROXY=false)** - DEFAULT
Use this when accessing the application directly via IP:port **without a reverse proxy**.
**docker-compose.yml:**
```yaml
environment:
- ENABLE_PROXY=false # Default setting
```
**Access:** `http://192.168.1.100:5172` (replace with your server IP)
**Features:**
- ✅ Works over HTTP (no HTTPS required)
- ✅ Standard cookie behavior (SameSite=Lax)
- ✅ No proxy configuration needed
- ✅ Simple login flow
- ✅ Perfect for internal/LAN access
**When to use:**
- Accessing from internal network only
- No reverse proxy in place
- Testing or development
- Simple single-server setup
---
### **Mode 2: Proxy Access (ENABLE_PROXY=true)**
Use this when running **behind a reverse proxy** (Authelia, Zoraxy, Nginx, Traefik, Caddy).
**docker-compose.yml:**
```yaml
environment:
- ENABLE_PROXY=true
```
**Access:** `https://alias.yourdomain.com` (through your reverse proxy)
**Features:**
- ✅ ProxyFix middleware handles X-Forwarded-* headers
- ✅ HTTPS redirect support
- ✅ Secure cookies (HTTPS only)
- ✅ Works with authentication proxies (Authelia)
- ✅ Multi-proxy chain support
**When to use:**
- Accessing from internet via domain name
- Behind Nginx, Traefik, Caddy, HAProxy
- Behind authentication proxy (Authelia, Authentik)
- SSL/TLS termination at proxy
- Production deployments with HTTPS
**Special Feature - Authelia Auto-Login:**
When `ENABLE_PROXY=true` and you're using Authelia, the app automatically logs you in using Authelia's authentication headers. **No app password needed!** Simply authenticate through Authelia, and you'll be logged into the Mailcow Alias Manager automatically.
---
### **Switching Between Modes**
To switch from one mode to another:
1. **Edit `docker-compose.yml`**
```yaml
# Change this line:
- ENABLE_PROXY=false # or true
```
2. **Restart the container**
```bash
docker compose down
docker compose up -d
```
3. **Verify mode in logs**
```bash
docker compose logs mailcow-alias-manager | grep "ACCESS MODE"
```
You should see either:
- `ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)`
- `ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)`
4. **Clear browser cookies** (IMPORTANT!)
- Press F12 → Application → Cookies
- Delete all cookies for your domain
- Close and reopen browser
5. **Login again**
---
### **Quick Reference Table**
| Access Method | ENABLE_PROXY | Access URL | Cookie Mode |
|--------------|--------------|------------|-------------|
| Direct IP:port | `false` (default) | `http://192.168.1.100:5172` | HTTP, SameSite=Lax |
| Nginx/Traefik | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Authelia + Zoraxy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Caddy | `true` | `https://alias.example.com` | HTTPS, SameSite=None |
| Local dev (`python3 app.py`) | N/A (not set) | `http://localhost:5172` | HTTP, SameSite=Lax |
---
## ✅ Built-in Proxy Support (When ENABLE_PROXY=true)
The application includes **ProxyFix middleware** that automatically handles:
- ✅ HTTPS detection via `X-Forwarded-Proto`
- ✅ Host header forwarding via `X-Forwarded-Host`
- ✅ Client IP forwarding via `X-Forwarded-For`
- ✅ Path prefix support via `X-Forwarded-Prefix`
**Works with 2 proxies in chain** (e.g., Zoraxy → Authelia → App)
---
## Common Proxy Configurations
### **Zoraxy** (Your Current Setup)
Zoraxy automatically sets the required headers. Just configure:
1. **Upstream Target**: `http://localhost:5172` (or your Docker IP)
2. **Enable WebSocket**: Yes (optional, recommended)
3. **Enable Proxy Headers**: Yes (should be default)
**Example Zoraxy Config:**
```json
{
"name": "mailcow-alias-manager",
"upstream": "http://localhost:5172",
"domain": "aliases.yourdomain.com"
}
```
---
### **Authelia** (Authentication Proxy)
If using Authelia in front of the app:
1. **Authelia forwards the user after login** - this should work automatically
2. **Make sure Authelia passes these headers**:
- `X-Forwarded-Proto`
- `X-Forwarded-Host`
- `X-Forwarded-For`
**Common Issue**: Authelia redirects → App login page
**Solution**: The app has its own authentication. You have two options:
- **Option A**: Disable app login (requires code modification)
- **Option B**: Use Authelia for network access, app login for API access
---
### **Nginx**
```nginx
server {
listen 443 ssl http2;
server_name aliases.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:5172;
# Required headers for ProxyFix
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Optional: WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
---
### **Traefik**
**docker-compose.yml:**
```yaml
services:
mailcow-alias-manager:
image: gitlab.pm/rune/malias-web:latest
container_name: mailcow-alias-manager
volumes:
- ./data:/app/data
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.malias.rule=Host(`aliases.yourdomain.com`)"
- "traefik.http.routers.malias.entrypoints=websecure"
- "traefik.http.routers.malias.tls=true"
- "traefik.http.routers.malias.tls.certresolver=letsencrypt"
- "traefik.http.services.malias.loadbalancer.server.port=5172"
networks:
- traefik
networks:
traefik:
external: true
```
Traefik automatically sets `X-Forwarded-*` headers by default.
---
### **Caddy**
```caddy
aliases.yourdomain.com {
reverse_proxy localhost:5172
}
```
Caddy automatically handles all proxy headers - no configuration needed!
---
## 🔧 Troubleshooting
### **Issue: "NetworkError when attempting to fetch resource"**
**Causes:**
1. Mixed HTTP/HTTPS content
2. CORS blocking requests
3. Session cookie not being set
4. Proxy not forwarding headers
**Solutions:**
#### **1. Check Proxy Headers**
Your proxy MUST forward these headers:
```
X-Forwarded-Proto: https
X-Forwarded-Host: aliases.yourdomain.com
X-Forwarded-For: <client-ip>
```
#### **2. Check Browser Console**
Open browser DevTools (F12) → Console tab → Look for errors:
- **CORS errors**: Proxy misconfiguration
- **Mixed content**: HTTP resources on HTTPS page
- **401 Unauthorized**: Session/cookie issue
#### **3. Test Direct Access**
```bash
# Test without proxy
curl http://localhost:5172/
# Should return HTML (login page)
```
If this works but proxy doesn't, it's a proxy configuration issue.
#### **4. Check Session Cookies**
In browser DevTools → Application tab → Cookies:
- **Domain**: Should match your proxy domain
- **Path**: `/`
- **HttpOnly**: Should be `true`
- **Secure**: Depends on your setup
---
### **Issue: Login works but redirects to HTTP instead of HTTPS**
**Solution**: Already fixed in latest version via `PREFERRED_URL_SCHEME='https'` in app config.
If still happening:
1. Verify proxy sends `X-Forwarded-Proto: https`
2. Check `docker compose logs -f mailcow-alias-manager`
3. Rebuild image: `docker compose up -d --build`
---
### **Issue: "Invalid password" but password is correct**
This is NOT a proxy issue - this is an authentication issue:
```bash
# Reset password
docker exec -it mailcow-alias-manager python3 reset_password.py "newpass"
```
---
### **Issue: 502 Bad Gateway**
**Causes:**
1. App not running
2. Wrong upstream port
3. Container network issue
**Check:**
```bash
# Is container running?
docker ps | grep mailcow-alias-manager
# Check logs
docker compose logs -f mailcow-alias-manager
# Test internal connectivity
docker exec mailcow-alias-manager curl http://localhost:5172
```
---
## 🔒 Security Best Practices
### **1. HTTPS Only**
Always use HTTPS in production. HTTP is only for local testing.
### **2. Restrict Access**
Use firewall or proxy rules to restrict access:
```nginx
# Nginx: Restrict to internal network only
allow 192.168.1.0/24;
deny all;
```
### **3. Use Authelia/Authentik**
Add SSO layer for additional security:
- Users authenticate via Authelia
- Then app login provides API access
- Two-factor authentication recommended
### **4. Keep Session Cookies Secure**
The app sets:
- `HttpOnly=True` - Prevents JavaScript access
- `SameSite=Lax` - CSRF protection
For HTTPS-only deployments, you can enforce secure cookies:
```python
# In app.py, change:
SESSION_COOKIE_SECURE=True # Only send cookie over HTTPS
```
---
## 📊 Testing Your Setup
### **1. Basic Connectivity**
```bash
curl -v https://aliases.yourdomain.com/
# Should return 200 OK with HTML
```
### **2. Login Test**
```bash
curl -v -X POST https://aliases.yourdomain.com/login \
-H "Content-Type: application/json" \
-d '{"password":"yourpassword"}' \
-c cookies.txt
# Check response - should be JSON with status: success
```
### **3. Session Test**
```bash
curl -v https://aliases.yourdomain.com/ \
-b cookies.txt
# Should return main page HTML (not login redirect)
```
---
## 🆘 Still Having Issues?
### **Enable Debug Logging**
**docker-compose.yml:**
```yaml
environment:
- FLASK_DEBUG=True # Enable debug mode
```
Restart:
```bash
docker compose down
docker compose up -d
docker compose logs -f
```
### **Check Application Logs**
```bash
# View logs
docker compose logs -f mailcow-alias-manager
# Check for errors
docker compose logs mailcow-alias-manager | grep -i error
```
### **Test Without Proxy**
Temporarily bypass proxy to isolate the issue:
```bash
# Direct access test
curl http://localhost:5172/
```
If this works, the issue is with proxy configuration, not the app.
---
## ✅ Summary
**The app is already configured for reverse proxies!**
Key points:
- ✅ ProxyFix middleware is enabled
- ✅ Handles X-Forwarded-* headers automatically
- ✅ HTTPS redirect support built-in
- ✅ Session cookies work behind proxies
- ✅ No code changes needed
Just make sure your proxy forwards the standard headers and you're good to go! 🚀

View File

@@ -1,4 +1,4 @@
# Mailcow Alias Manager Web Interface
# Mailcow Alias Manager Web Interface..
A Python-based web application for managing mail aliases on a Mailcow instance. Built with Flask and a dark theme interface for internal network use. This application uses [malias](https://gitlab.pm/rune/malias) as base. Malias it self is a CLI application for managing aliases on a Mailcow Instance. If you already are using the CLI instance you can copy the the DB file over to your `/data` directory and you'll keep you API key etc. If you start with a blank DB you can use the `Sync Aliases` function to populate existing aliases to your local db.
@@ -68,7 +68,32 @@ The application will be available at: `http://localhost:5172`
## Configuration
### 1. Configure Mailcow Connection
### 1. Access Mode Configuration
The application supports two access modes:
**Direct Access (Default)** - Access via IP:port
- Set `ENABLE_PROXY=false` in `docker-compose.yml` (default)
- Access at: `http://your-server-ip:5172`
- Works over HTTP (no HTTPS required)
- **Login:** Enter app password
- Perfect for internal/LAN access
**Proxy Access** - Access via reverse proxy (Authelia, Nginx, Traefik, etc.)
- Set `ENABLE_PROXY=true` in `docker-compose.yml`
- Access at: `https://alias.yourdomain.com` (through your proxy)
- Requires HTTPS
- **Login:** Automatic when using Authelia (no password needed!)
- See [PROXY_SETUP.md](PROXY_SETUP.md) for detailed configuration
**To switch modes:**
1. Edit `docker-compose.yml` and change `ENABLE_PROXY` value
2. Restart: `docker compose down && docker compose up -d`
3. Clear browser cookies and login again
---
### 2. Configure Mailcow Connection
You can configure your Mailcow server connection either:
@@ -295,3 +320,7 @@ For manual Python installation:
- Verify your Mailcow server is accessible from the machine running this app
- Check that the API key is valid and has the correct permissions
- Review logs in `data/malias2.log`
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

464
app.py
View File

@@ -1,53 +1,350 @@
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
from flask import Flask, render_template, request, jsonify, redirect, url_for, session, make_response
from functools import wraps
from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import timedelta
import malias_wrapper as malias_w
import os
import argparse
import sys
import secrets
import logging
import json
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger('mailcow-alias-manager')
app = Flask(__name__)
app.secret_key = os.urandom(24) # Secret key for session management
app.secret_key = os.getenv('SECRET_KEY', 'malias-default-secret-key-please-change') # Consistent secret key
# Configure proxy mode based on environment variable
# ENABLE_PROXY=true: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
# ENABLE_PROXY=false: Direct IP:port access (default)
ENABLE_PROXY = os.getenv('ENABLE_PROXY', 'false').lower() in ('true', '1', 'yes')
if ENABLE_PROXY:
# Mode: Behind reverse proxy (Authelia/Zoraxy/Nginx/etc.)
logger.info("=" * 60)
logger.info(" ACCESS MODE: Reverse Proxy (ENABLE_PROXY=true)")
logger.info("=" * 60)
logger.info(" Configuration:")
logger.info(" - ProxyFix middleware: ACTIVE")
logger.info(" - Trusted proxies: 2 (e.g., Zoraxy + Authelia)")
logger.info(" - Cookie security: HTTPS only (Secure=True)")
logger.info(" - SameSite policy: None (cross-origin auth)")
logger.info(" - URL scheme: https://")
logger.info("=" * 60)
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=2, # Trust X-Forwarded-For with 2 proxies
x_proto=2, # Trust X-Forwarded-Proto (http/https)
x_host=2, # Trust X-Forwarded-Host
x_prefix=2 # Trust X-Forwarded-Prefix
)
cookie_secure = True
cookie_samesite = 'None'
preferred_scheme = 'https'
else:
# Mode: Direct IP:port access (no proxy)
logger.info("=" * 60)
logger.info(" ACCESS MODE: Direct IP:Port (ENABLE_PROXY=false)")
logger.info("=" * 60)
logger.info(" Configuration:")
logger.info(" - ProxyFix middleware: DISABLED")
logger.info(" - Cookie security: HTTP allowed (Secure=False)")
logger.info(" - SameSite policy: Lax (standard mode)")
logger.info(" - URL scheme: http://")
logger.info(" - Access via: http://your-ip:5172")
logger.info("=" * 60)
# Don't apply ProxyFix for direct access
cookie_secure = False
cookie_samesite = 'Lax'
preferred_scheme = 'http'
# Initialize database on startup
malias_w.init_database()
# Session configuration - dynamic based on proxy mode
app.config.update(
PERMANENT_SESSION_LIFETIME=timedelta(hours=24),
SESSION_COOKIE_NAME='malias_session',
SESSION_COOKIE_SECURE=cookie_secure,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE=cookie_samesite,
SESSION_COOKIE_PATH='/',
SESSION_COOKIE_DOMAIN=None,
SESSION_REFRESH_EACH_REQUEST=True,
PREFERRED_URL_SCHEME=preferred_scheme
)
@app.after_request
def after_request(response):
"""Process the response before it's sent"""
if ENABLE_PROXY:
# Set CORS headers for proxy mode (needed for Authelia/Zoraxy)
response.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')
response.headers['Access-Control-Allow-Credentials'] = 'true'
# Cache control (applies to both modes)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
def get_authelia_user():
"""Helper to get authenticated user from Authelia headers or session cookie"""
# Check several possible header variations (ORDER MATTERS - most specific first!)
auth_headers = [
'X-Authelia-Username', # Authelia standard header (used by Zoraxy)
'Remote-User', # Common standard
'X-Remote-User',
'X-Forwarded-User',
'REMOTE_USER',
'Http-Remote-User',
'Http-X-Remote-User',
'X-Authenticated-User'
]
for header in auth_headers:
user = request.headers.get(header)
if user:
logger.info(f"✅ Authelia user detected via header '{header}': {user}")
return user
# Check Zoraxy forwarded headers (sometimes encoded differently)
if 'X-Forwarded-Headers' in request.headers:
try:
# Some reverse proxies encode headers as JSON
fwd_headers = json.loads(request.headers.get('X-Forwarded-Headers'))
for header in auth_headers:
if header in fwd_headers:
user = fwd_headers[header]
logger.info(f"✅ Authelia user detected via forwarded headers - {header}: {user}")
return user
except:
pass
# WORKAROUND: If Authelia session cookie exists and we're coming from auth.rune.pm,
# assume user is authenticated (Authelia not forwarding headers properly)
if ENABLE_PROXY and 'authelia_session' in request.cookies:
referer = request.headers.get('Referer', '')
if 'auth.rune.pm' in referer or request.headers.get('X-Forwarded-Proto') == 'https':
# Valid Authelia session exists - assume authenticated
# Use a generic identifier since we don't have the actual username
pseudo_user = f"authelia_user_{request.cookies.get('authelia_session')[:8]}"
logger.info(f"✅ Authelia authentication detected via session cookie (headers not forwarded)")
logger.info(f" Using pseudo-user identifier: {pseudo_user}")
logger.info(f" NOTE: Configure Authelia to forward Remote-User header for proper username")
return pseudo_user
# Log when no Authelia user found (for debugging)
if ENABLE_PROXY:
logger.debug("⚠️ No Authelia headers found in request")
logger.debug(f"Available headers: {list(request.headers.keys())}")
return None
def login_required(f):
"""Decorator to require login for routes"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Auto-login with Authelia (only when ENABLE_PROXY=true)
if ENABLE_PROXY:
authelia_user = get_authelia_user()
# If Authelia authenticated the user, auto-login
if authelia_user:
if not session.get('logged_in') or session.get('authelia_user') != authelia_user:
logger.info(f"🔐 Auto-login via Authelia in API route: {authelia_user}")
session.clear()
session.permanent = True
session['logged_in'] = True
session['authelia_user'] = authelia_user
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session.modified = True
# Store additional info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login in API route: {authelia_user}")
return f(*args, **kwargs)
# Regular session check (when ENABLE_PROXY=false or no Authelia headers)
if not session.get('logged_in'):
logger.warning("Access denied: User not authenticated")
if request.is_json:
return jsonify({'status': 'error', 'message': 'Not authenticated', 'redirect': '/login'}), 401
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Login page"""
"""Login page or JSON login endpoint"""
# Validate session mode matches current proxy setting
if session.get('logged_in'):
session_mode = session.get('auth_method', 'unknown')
# Clear session if mode mismatch
if ENABLE_PROXY and session_mode == 'local':
logger.info("⚠️ Session mode mismatch: Clearing local session (proxy mode enabled)")
session.clear()
elif not ENABLE_PROXY and session_mode == 'authelia':
logger.info("⚠️ Session mode mismatch: Clearing authelia session (proxy mode disabled)")
session.clear()
# Auto-login when ENABLE_PROXY=true and Authelia headers are present
if ENABLE_PROXY:
authelia_user = get_authelia_user()
if authelia_user:
# User authenticated by Authelia - auto-login
if not session.get('logged_in'):
logger.info(f"🔐 Auto-login: User '{authelia_user}' authenticated by Authelia")
session.clear()
session.permanent = True
session['logged_in'] = True
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session['authelia_user'] = authelia_user
session.modified = True
# Get additional Authelia info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
# Already logged in via Authelia - redirect to main page
return redirect(url_for('index'))
else:
# ENABLE_PROXY=true but no Authelia headers found
logger.warning("⚠️ ENABLE_PROXY=true but no Authelia headers detected!")
logger.warning(" Make sure your reverse proxy forwards authentication headers")
logger.warning(f" Available headers: {list(request.headers.keys())}")
# Handle form submission for local authentication (only when ENABLE_PROXY=false)
if request.method == 'POST':
password = request.json.get('password', '')
logger.info("Login attempt with password (redacted)")
if malias_w.verify_password(password):
logger.info("Login successful with local password")
session.clear()
session.permanent = True
session['logged_in'] = True
return jsonify({'status': 'success', 'message': 'Login successful'})
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'local'
session.modified = True
# Return JSON response
response = jsonify({'status': 'success', 'message': 'Login successful'})
return response
else:
logger.warning("Login failed: Invalid password")
return jsonify({'status': 'error', 'message': 'Invalid password'})
# Check if already logged in
# If already logged in, redirect to index
if session.get('logged_in'):
return redirect(url_for('index'))
# Check cookies and client IP for troubleshooting
if app.debug:
logger.info(f"Cookies: {request.cookies}")
logger.info(f"Client IP: {request.remote_addr}")
logger.info(f"X-Forwarded-For: {request.headers.get('X-Forwarded-For')}")
# Log all headers to see what's coming from Authelia
logger.info(f"All headers: {dict(request.headers)}")
# Show login form
return render_template('login.html')
@app.route('/logout')
def logout():
"""Logout"""
session.pop('logged_in', None)
return redirect(url_for('login'))
# Get auth method before clearing session
auth_method = session.get('auth_method')
authelia_user = session.get('authelia_user')
# Clear local session
session.clear()
# If user was authenticated via Authelia, redirect to app login (not Authelia logout)
# This keeps the Authelia session active for other apps
if ENABLE_PROXY and (auth_method == 'authelia' or authelia_user):
logger.info(f"Logout for Authelia user - redirecting to app login page")
# Just redirect back to login page - Authelia session stays active
response = redirect(url_for('login'))
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
return response
# Default case: redirect to login page
response = redirect(url_for('login'))
# Clear cookie by setting expired date
response.set_cookie(app.config['SESSION_COOKIE_NAME'], '', expires=0)
return response
@app.route('/')
def index():
"""Main page - requires login"""
# Auto-login with Authelia (only when ENABLE_PROXY=true)
if ENABLE_PROXY:
authelia_user = get_authelia_user()
if authelia_user and not session.get('logged_in'):
# Auto-login for users authenticated by Authelia
logger.info(f"🔐 Auto-login via Authelia for user: {authelia_user}")
session.clear()
session.permanent = True
session['logged_in'] = True
session['authelia_user'] = authelia_user
session['user_token'] = secrets.token_urlsafe(32)
session['auth_method'] = 'authelia'
session.modified = True
# Store additional Authelia info (check both Remote-* and X-Authelia-* headers)
session['remote_email'] = (request.headers.get('X-Authelia-Email') or
request.headers.get('Remote-Email', ''))
session['remote_name'] = (request.headers.get('X-Authelia-DisplayName') or
request.headers.get('Remote-Name', ''))
session['remote_groups'] = (request.headers.get('X-Authelia-Groups') or
request.headers.get('Remote-Groups', ''))
logger.info(f"✅ Auto-login successful: {authelia_user} ({session.get('remote_email', 'no email')})")
return render_template('index.html')
# Check if logged in
if not session.get('logged_in'):
return redirect(url_for('login'))
# Debug logging
if app.debug and session.get('logged_in'):
logger.info(f"User authenticated with method: {session.get('auth_method', 'unknown')}")
# Show main page
return render_template('index.html')
@app.route('/list_aliases', methods=['POST'])
@@ -65,6 +362,7 @@ def list_aliases():
result = malias_w.get_all_aliases(page=page, per_page=20)
return jsonify({'status': 'success', 'data': result})
except Exception as e:
logger.exception("Error listing aliases")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/sync_aliases', methods=['POST'])
@@ -76,6 +374,7 @@ def sync_aliases():
result = f"Aliases synchronized successfully! Added {count} new aliases to local DB."
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error syncing aliases")
return jsonify({'status': 'error', 'message': f"Error syncing: {str(e)}"})
@app.route('/get_domains', methods=['POST'])
@@ -88,6 +387,7 @@ def get_domains():
result = f"Domains: {', '.join(domain_list)}"
return jsonify({'status': 'success', 'message': result, 'domains': domain_list})
except Exception as e:
logger.exception("Error getting domains")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/create_alias', methods=['POST'])
@@ -106,6 +406,7 @@ def create_alias():
result = f"Alias {alias} created successfully for {goto}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error creating alias")
return jsonify({'status': 'error', 'message': f"Error creating alias: {str(e)}"})
@app.route('/delete_alias', methods=['POST'])
@@ -123,6 +424,7 @@ def delete_alias_route():
result = f"Alias {alias} deleted successfully"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error deleting alias")
return jsonify({'status': 'error', 'message': f"Error deleting alias: {str(e)}"})
@app.route('/delete_aliases_bulk', methods=['POST'])
@@ -177,6 +479,7 @@ def delete_aliases_bulk():
'details': result
})
except Exception as e:
logger.exception("Error deleting aliases bulk")
return jsonify({'status': 'error', 'message': f"Error deleting aliases: {str(e)}"})
@app.route('/create_timed_alias', methods=['POST'])
@@ -195,6 +498,7 @@ def create_timed_alias():
result = f"Timed alias created for {username} on domain {domain}"
return jsonify({'status': 'success', 'message': result})
except Exception as e:
logger.exception("Error creating timed alias")
return jsonify({'status': 'error', 'message': f"Error: {str(e)}"})
@app.route('/search', methods=['POST'])
@@ -217,6 +521,7 @@ def search():
return jsonify({'status': 'success', 'message': message, 'query': query})
except Exception as e:
logger.exception("Error searching aliases")
return jsonify({'status': 'error', 'message': f"Search error: {str(e)}"})
@app.route('/config', methods=['GET', 'POST'])
@@ -262,8 +567,148 @@ def change_password_route():
malias_w.change_password(old_password, new_password)
return jsonify({'status': 'success', 'message': 'Password changed successfully'})
except Exception as e:
logger.exception("Error changing password")
return jsonify({'status': 'error', 'message': str(e)})
# Add a health check endpoint for proxies
@app.route('/health')
def health_check():
"""Health check endpoint for proxies"""
return jsonify({
'status': 'ok',
'authenticated': session.get('logged_in', False),
'authelia_user': session.get('authelia_user', None),
'auth_method': session.get('auth_method'),
'version': '1.0.2',
'cookies': {k: '***' for k in request.cookies.keys()}, # Only show cookie names
'authelia_headers_present': get_authelia_user() is not None,
'zoraxy_headers_present': 'X-Forwarded-Server' in request.headers
})
# Add a debugging endpoint
@app.route('/debug')
def debug_info():
"""Show debug information about the current request"""
# Always allow this in production to help with troubleshooting
debug_data = {
'headers': dict(request.headers),
'cookies': {k: '***' for k in request.cookies.keys()}, # Don't expose cookie values
'session': {k: ('***' if k in ['user_token', 'csrf_token'] else v) for k, v in session.items()} if session else {},
'remote_addr': request.remote_addr,
'scheme': request.scheme,
'host': request.host,
'path': request.path,
'is_secure': request.is_secure,
'x_forwarded_for': request.headers.get('X-Forwarded-For'),
'x_forwarded_proto': request.headers.get('X-Forwarded-Proto'),
'x_forwarded_host': request.headers.get('X-Forwarded-Host'),
'x_forwarded_prefix': request.headers.get('X-Forwarded-Prefix'),
'remote_user': get_authelia_user(),
}
# Additional server information
debug_data['server_info'] = {
'cookie_settings': {
'SESSION_COOKIE_NAME': app.config['SESSION_COOKIE_NAME'],
'SESSION_COOKIE_SECURE': app.config['SESSION_COOKIE_SECURE'],
'SESSION_COOKIE_HTTPONLY': app.config['SESSION_COOKIE_HTTPONLY'],
'SESSION_COOKIE_SAMESITE': app.config['SESSION_COOKIE_SAMESITE'],
'SESSION_COOKIE_PATH': app.config['SESSION_COOKIE_PATH'],
},
'app_config': {
'DEBUG': app.debug,
'PREFERRED_URL_SCHEME': app.config['PREFERRED_URL_SCHEME'],
'ENABLE_PROXY': ENABLE_PROXY
}
}
response = jsonify(debug_data)
response.headers['Cache-Control'] = 'no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Dedicated endpoint for header diagnostics
@app.route('/headers')
def show_headers():
"""Show all request headers - useful for debugging proxies"""
# Log headers to help diagnose issues with Zoraxy/Authelia
logger.info(f"Headers endpoint: All headers received: {dict(request.headers)}")
return jsonify({
'headers': dict(request.headers),
'remote_addr': request.remote_addr,
'authelia_user': get_authelia_user()
})
# Self-test endpoint for cookies
@app.route('/cookie-test')
def cookie_test():
"""Test cookie handling"""
# Clear any existing cookie
resp = make_response(jsonify({'status': 'ok', 'message': 'Cookie set test'}))
# Set a test cookie with the same attributes as session
resp.set_cookie(
'malias_test_cookie',
'test-value',
max_age=3600,
secure=app.config['SESSION_COOKIE_SECURE'],
httponly=app.config['SESSION_COOKIE_HTTPONLY'],
samesite='None',
path=app.config['SESSION_COOKIE_PATH']
)
return resp
# Endpoint to check if test cookie is set
@app.route('/cookie-check')
def cookie_check():
"""Check if test cookie was properly set"""
test_cookie = request.cookies.get('malias_test_cookie')
return jsonify({
'test_cookie_present': test_cookie is not None,
'test_cookie_value': test_cookie if test_cookie else None,
'all_cookies': {k: '***' for k in request.cookies.keys()}
})
# New endpoint to test Zoraxy auth configuration
@app.route('/authelia-test')
def authelia_test():
"""Test if Authelia headers are correctly passed through Zoraxy"""
all_headers = dict(request.headers)
authelia_headers = {}
# Check for common Authelia-related headers
auth_related_headers = [
'Remote-User', 'X-Remote-User', 'Remote-Groups', 'X-Remote-Groups',
'Remote-Name', 'X-Remote-Name', 'Remote-Email', 'X-Remote-Email',
'X-Authelia-URL', 'X-Original-URL', 'X-Forwarded-Proto'
]
for header in auth_related_headers:
if header.lower() in [h.lower() for h in all_headers.keys()]:
for actual_header in all_headers.keys():
if header.lower() == actual_header.lower():
authelia_headers[actual_header] = all_headers[actual_header]
# Check for auth cookies
auth_cookies = {}
for cookie_name in request.cookies:
if 'auth' in cookie_name.lower():
auth_cookies[cookie_name] = '***' # Hide actual value
return jsonify({
'request_host': request.host,
'authelia_user_detected': get_authelia_user() is not None,
'authelia_user': get_authelia_user(),
'authelia_headers': authelia_headers,
'auth_cookies': auth_cookies,
'all_headers_count': len(all_headers),
'zoraxy_detected': any('zoraxy' in h.lower() for h in all_headers.keys()) or 'X-Forwarded-Server' in all_headers,
'host_header': request.headers.get('Host'),
'referer': request.headers.get('Referer'),
})
if __name__ == '__main__':
# Parse command-line arguments
parser = argparse.ArgumentParser(
@@ -314,6 +759,9 @@ Environment Variables (Docker):
else:
debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() in ('true', '1', 'yes')
# Set log level based on debug mode
logger.setLevel(logging.DEBUG if debug_mode else logging.INFO)
# Port
if args.port is not None:
port = args.port
@@ -343,4 +791,6 @@ Environment Variables (Docker):
print('=' * 60)
print()
logger.info(f"Starting Mailcow Alias Manager - Debug: {debug_mode}, Host: {host}, Port: {port}")
app.run(debug=debug_mode, host=host, port=port)

View File

@@ -8,6 +8,12 @@
# Recommended Settings:
# - Production: FLASK_ENV=production, FLASK_DEBUG=False (default)
# - Development: FLASK_ENV=development, FLASK_DEBUG=True
#
# Reverse Proxy Support:
# ----------------------
# This application is configured to work behind reverse proxies (Nginx, Traefik, Zoraxy, Authelia, etc.)
# The ProxyFix middleware automatically handles X-Forwarded-* headers for HTTPS detection
# No additional configuration needed for most standard proxy setups
services:
mailcow-alias-manager:
@@ -33,3 +39,28 @@ services:
# Host binding (default: 0.0.0.0 for Docker)
- FLASK_HOST=0.0.0.0
# Secret key for sessions - CRITICAL for multi-worker Gunicorn!
# All workers must use the SAME secret key to decode session cookies
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
# IMPORATNT! The key below is just an example. GENERATE YOUR OWN KEY WITH COMMAND ABOVE!
- SECRET_KEY=dca6920115b8bbc13c346c75d668a49849590c77d9baaf903296582f24ff816a
# Reverse Proxy Mode
# ------------------
# Set to 'true' when accessing through reverse proxy (Authelia, Zoraxy, Nginx, etc.)
# Set to 'false' for direct IP:port access (e.g., http://192.168.1.100:5172)
#
# ENABLE_PROXY=true (Proxy Mode):
# - ProxyFix middleware enabled (handles X-Forwarded-* headers)
# - Secure cookies required (HTTPS only)
# - SameSite=None (allows cross-origin authentication)
# - Access via: https://alias.yourdomain.com
#
# ENABLE_PROXY=false (Direct Mode) - DEFAULT:
# - ProxyFix middleware disabled
# - Standard cookies (HTTP allowed)
# - SameSite=Lax (standard browser behavior)
# - Access via: http://your-server-ip:5172
#
- ENABLE_PROXY=false

103
reset_password.py Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
Password Reset Script for Mailcow Alias Manager
Usage: python3 reset_password.py [new_password]
"""
import sys
import sqlite3
import bcrypt
from pathlib import Path
# Database location
project_dir = Path(__file__).parent
filepath = project_dir.joinpath('data')
database = filepath.joinpath('malias2.db')
def reset_password(new_password):
"""Reset the admin password"""
if not database.exists():
print(f"❌ Error: Database not found at {database}")
print(" Make sure the application has been run at least once to create the database.")
return False
try:
# Hash the new password
password_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt())
# Update database
conn = sqlite3.connect(database)
cursor = conn.cursor()
# Check if auth table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='auth'")
if not cursor.fetchone():
print("❌ Error: Auth table not found in database")
conn.close()
return False
# Update password
cursor.execute('UPDATE auth SET password_hash = ? WHERE id = 0', (password_hash.decode('utf-8'),))
# Verify update
if cursor.rowcount == 0:
print("❌ Error: No password record found. Creating new one...")
cursor.execute('INSERT INTO auth VALUES (?, ?)', (0, password_hash.decode('utf-8')))
conn.commit()
conn.close()
print("=" * 60)
print(" ✅ Password Reset Successful!")
print("=" * 60)
print(f" New password: {new_password}")
print(f" Database: {database}")
print("=" * 60)
print()
print("You can now login with the new password.")
print()
return True
except Exception as e:
print(f"❌ Error resetting password: {e}")
return False
def main():
print()
print("=" * 60)
print(" Mailcow Alias Manager - Password Reset")
print("=" * 60)
print()
# Get new password
if len(sys.argv) > 1:
new_password = sys.argv[1]
else:
print("Enter new password (or press Ctrl+C to cancel):")
new_password = input("New password: ").strip()
if not new_password:
print("❌ Error: Password cannot be empty")
return 1
if len(new_password) < 6:
print("⚠️ Warning: Password is less than 6 characters")
response = input("Continue anyway? (y/n): ").strip().lower()
if response != 'y':
print("Password reset cancelled.")
return 0
print()
if reset_password(new_password):
return 0
else:
return 1
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n\nPassword reset cancelled.")
sys.exit(0)

View File

@@ -16,6 +16,12 @@
box-sizing: border-box;
}
.footer {
font-size: 16px !important;
color: #707170FF;
text-align: center;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1a1a1a;
@@ -273,6 +279,13 @@
justify-content: flex-end;
}
.footer {
font-size: 12px;
color: #707170FF;
align: center;
padding-top: 20%;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
body {
@@ -942,6 +955,7 @@
try {
const response = await fetch('/list_aliases', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -970,6 +984,7 @@
try {
const response = await fetch(`/${actionName}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
}
@@ -995,6 +1010,7 @@
try {
const response = await fetch('/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1019,7 +1035,9 @@
async function openConfig() {
try {
const response = await fetch('/config');
const response = await fetch('/config', {
credentials: 'include'
});
const config = await response.json();
document.getElementById('mailcowServer').value = config.mailcow_server || '';
@@ -1047,6 +1065,7 @@
try {
const response = await fetch('/config', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1081,6 +1100,7 @@
try {
const response = await fetch('/change_password', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1128,6 +1148,7 @@
try {
const response = await fetch('/create_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1176,6 +1197,7 @@
try {
const response = await fetch('/search', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1368,6 +1390,7 @@
try {
const response = await fetch('/delete_aliases_bulk', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1401,6 +1424,7 @@
try {
const response = await fetch('/delete_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1463,6 +1487,7 @@
try {
const response = await fetch('/create_timed_alias', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
@@ -1498,5 +1523,9 @@
if (e.target === this) closeTimedAlias();
});
</script>
</body>
<div class="footer">
<p style="padding-top:2%">&copy; <a href="https://opensource.org/license/mit">MIT</a> 2026 Rune Olsen <a href="https://blog.rune.pm">Blog</a> &#8212; <a href="https://gitlab.pm/rune/malias-web">Source Code</a></p>
</div>
</html>

View File

@@ -192,6 +192,7 @@
headers: {
'Content-Type': 'application/json'
},
credentials: 'include', // CRITICAL: Include cookies in request
body: JSON.stringify({ password: password })
});