haproxy manager
This commit is contained in:
parent
9c52edd53a
commit
305fffba42
10
Dockerfile
10
Dockerfile
@ -1,9 +1,15 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy -y && apt clean && rm -rf /var/lib/apt/lists/*
|
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot -y && apt clean && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /haproxy
|
WORKDIR /haproxy
|
||||||
COPY ./templates /haproxy/templates
|
COPY ./templates /haproxy/templates
|
||||||
COPY requirements.txt /haproxy/
|
COPY requirements.txt /haproxy/
|
||||||
COPY haproxy_manager.py /haproxy/
|
COPY haproxy_manager.py /haproxy/
|
||||||
|
COPY scripts /haproxy/scripts
|
||||||
|
RUN chmod +x /haproxy/scripts/*
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
RUN echo "0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew --no-random-sleep-on-renew" > /var/spool/cron/crontabs/root
|
||||||
EXPOSE 80 443 8000
|
EXPOSE 80 443 8000
|
||||||
#CMD ["python", "app.py"]
|
# Add health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
CMD ["/haproxy/scripts/start-up.sh"]
|
103
README.md
103
README.md
@ -1,2 +1,105 @@
|
|||||||
# HAProxy Manager Base
|
# HAProxy Manager Base
|
||||||
|
|
||||||
|
A Flask-based API service for managing HAProxy configurations, domains, and SSL certificates.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
Check the status of the HAProxy Manager service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /health
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"haproxy_status": "running",
|
||||||
|
"database": "connected"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Domain
|
||||||
|
Add a new domain with backend servers configuration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/domain
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"backend_name": "example_backend",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"name": "server1",
|
||||||
|
"address": "10.0.0.1",
|
||||||
|
"port": 8080,
|
||||||
|
"options": "check"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "server2",
|
||||||
|
"address": "10.0.0.2",
|
||||||
|
"port": 8080,
|
||||||
|
"options": "check backup"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"domain_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable SSL
|
||||||
|
Request and configure SSL certificate for a domain using Let's Encrypt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/ssl
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"domain": "example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Domain
|
||||||
|
Remove a domain and its associated backend configuration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/domain
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"domain": "example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Domain configuration removed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Automatic HAProxy configuration generation
|
||||||
|
- Let's Encrypt SSL certificate integration
|
||||||
|
- Backend server management
|
||||||
|
- Self-signed certificate generation for development
|
||||||
|
- Health monitoring
|
||||||
|
- Database-backed configuration storage
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- HAProxy
|
||||||
|
- Python 3.x
|
||||||
|
- Flask
|
||||||
|
- SQLite3
|
||||||
|
- Certbot (for Let's Encrypt certificates)
|
||||||
|
- OpenSSL (for self-signed certificates)
|
||||||
|
@ -5,7 +5,6 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
import jinja2
|
import jinja2
|
||||||
import socket
|
import socket
|
||||||
import shutil
|
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -18,7 +17,7 @@ SSL_CERTS_DIR = '/etc/haproxy/certs'
|
|||||||
def init_db():
|
def init_db():
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Create domains table
|
# Create domains table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS domains (
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
@ -28,7 +27,7 @@ def init_db():
|
|||||||
ssl_cert_path TEXT
|
ssl_cert_path TEXT
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Create backends table
|
# Create backends table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS backends (
|
CREATE TABLE IF NOT EXISTS backends (
|
||||||
@ -39,7 +38,7 @@ def init_db():
|
|||||||
FOREIGN KEY (domain_id) REFERENCES domains (id)
|
FOREIGN KEY (domain_id) REFERENCES domains (id)
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
# Create backend_servers table
|
# Create backend_servers table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS backend_servers (
|
CREATE TABLE IF NOT EXISTS backend_servers (
|
||||||
@ -57,7 +56,7 @@ def init_db():
|
|||||||
def generate_self_signed_cert(ssl_certs_dir):
|
def generate_self_signed_cert(ssl_certs_dir):
|
||||||
"""Generate a self-signed certificate for a domain."""
|
"""Generate a self-signed certificate for a domain."""
|
||||||
self_sign_cert = os.path.join(ssl_certs_dir, "default_self_signed_cert.pem")
|
self_sign_cert = os.path.join(ssl_certs_dir, "default_self_signed_cert.pem")
|
||||||
print(self_sign_cert)
|
print(self_sign_cert)
|
||||||
if os.path.exists(self_sign_cert):
|
if os.path.exists(self_sign_cert):
|
||||||
print("Self Signed Cert Found")
|
print("Self Signed Cert Found")
|
||||||
return True
|
return True
|
||||||
@ -68,14 +67,14 @@ def generate_self_signed_cert(ssl_certs_dir):
|
|||||||
DOMAIN = socket.gethostname()
|
DOMAIN = socket.gethostname()
|
||||||
# Generate private key and certificate
|
# Generate private key and certificate
|
||||||
subprocess.run([
|
subprocess.run([
|
||||||
'openssl', 'req', '-x509', '-newkey', 'rsa:4096',
|
'openssl', 'req', '-x509', '-newkey', 'rsa:4096',
|
||||||
'-keyout', '/tmp/key.pem',
|
'-keyout', '/tmp/key.pem',
|
||||||
'-out', '/tmp/cert.pem',
|
'-out', '/tmp/cert.pem',
|
||||||
'-days', '3650',
|
'-days', '3650',
|
||||||
'-nodes', # No passphrase
|
'-nodes', # No passphrase
|
||||||
'-subj', f'/CN={DOMAIN}'
|
'-subj', f'/CN={DOMAIN}'
|
||||||
], check=True)
|
], check=True)
|
||||||
|
|
||||||
# Combine cert and key for HAProxy
|
# Combine cert and key for HAProxy
|
||||||
with open(self_sign_cert, 'wb') as combined:
|
with open(self_sign_cert, 'wb') as combined:
|
||||||
for file in ['/tmp/cert.pem', '/tmp/key.pem']:
|
for file in ['/tmp/cert.pem', '/tmp/key.pem']:
|
||||||
@ -95,32 +94,55 @@ def is_process_running(process_name):
|
|||||||
template_loader = jinja2.FileSystemLoader(TEMPLATE_DIR)
|
template_loader = jinja2.FileSystemLoader(TEMPLATE_DIR)
|
||||||
template_env = jinja2.Environment(loader=template_loader)
|
template_env = jinja2.Environment(loader=template_loader)
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
try:
|
||||||
|
# Check if HAProxy is running
|
||||||
|
haproxy_running = is_process_running('haproxy')
|
||||||
|
|
||||||
|
# Check if database is accessible
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT 1')
|
||||||
|
cursor.fetchone()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'haproxy_status': 'running' if haproxy_running else 'stopped',
|
||||||
|
'database': 'connected'
|
||||||
|
}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unhealthy',
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
@app.route('/api/domain', methods=['POST'])
|
@app.route('/api/domain', methods=['POST'])
|
||||||
def add_domain():
|
def add_domain():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
domain = data.get('domain')
|
domain = data.get('domain')
|
||||||
backend_name = data.get('backend_name')
|
backend_name = data.get('backend_name')
|
||||||
servers = data.get('servers', [])
|
servers = data.get('servers', [])
|
||||||
|
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
# Add domain
|
# Add domain
|
||||||
cursor.execute('INSERT INTO domains (domain) VALUES (?)', (domain,))
|
cursor.execute('INSERT INTO domains (domain) VALUES (?)', (domain,))
|
||||||
domain_id = cursor.lastrowid
|
domain_id = cursor.lastrowid
|
||||||
|
|
||||||
# Add backend
|
# Add backend
|
||||||
cursor.execute('INSERT INTO backends (name, domain_id) VALUES (?, ?)',
|
cursor.execute('INSERT INTO backends (name, domain_id) VALUES (?, ?)',
|
||||||
(backend_name, domain_id))
|
(backend_name, domain_id))
|
||||||
backend_id = cursor.lastrowid
|
backend_id = cursor.lastrowid
|
||||||
|
|
||||||
# Add servers
|
# Add servers
|
||||||
for server in servers:
|
for server in servers:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO backend_servers
|
INSERT INTO backend_servers
|
||||||
(backend_id, server_name, server_address, server_port, server_options)
|
(backend_id, server_name, server_address, server_port, server_options)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
''', (backend_id, server['name'], server['address'],
|
''', (backend_id, server['name'], server['address'],
|
||||||
server['port'], server.get('options')))
|
server['port'], server.get('options')))
|
||||||
# Close cursor and connection
|
# Close cursor and connection
|
||||||
cursor.close()
|
cursor.close()
|
||||||
@ -132,28 +154,28 @@ def add_domain():
|
|||||||
def request_ssl():
|
def request_ssl():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
domain = data.get('domain')
|
domain = data.get('domain')
|
||||||
|
|
||||||
# Request Let's Encrypt certificate
|
# Request Let's Encrypt certificate
|
||||||
result = subprocess.run([
|
result = subprocess.run([
|
||||||
'certbot', 'certonly', '--standalone',
|
'certbot', 'certonly', '--standalone',
|
||||||
'--preferred-challenges', 'http',
|
'--preferred-challenges', 'http',
|
||||||
'-d', domain, '--non-interactive --http-01-port=8688'
|
'-d', domain, '--non-interactive --http-01-port=8688'
|
||||||
])
|
])
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Combine cert files and copy to HAProxy certs directory
|
# Combine cert files and copy to HAProxy certs directory
|
||||||
cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem'
|
cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem'
|
||||||
key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem'
|
key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem'
|
||||||
combined_path = f'{SSL_CERTS_DIR}/{domain}.pem'
|
combined_path = f'{SSL_CERTS_DIR}/{domain}.pem'
|
||||||
|
|
||||||
with open(combined_path, 'w') as combined:
|
with open(combined_path, 'w') as combined:
|
||||||
subprocess.run(['cat', cert_path, key_path], stdout=combined)
|
subprocess.run(['cat', cert_path, key_path], stdout=combined)
|
||||||
|
|
||||||
# Update database
|
# Update database
|
||||||
with sqlite3.connect(DB_FILE) as conn:
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
UPDATE domains
|
UPDATE domains
|
||||||
SET ssl_enabled = 1, ssl_cert_path = ?
|
SET ssl_enabled = 1, ssl_cert_path = ?
|
||||||
WHERE domain = ?
|
WHERE domain = ?
|
||||||
''', (combined_path, domain))
|
''', (combined_path, domain))
|
||||||
@ -164,15 +186,67 @@ def request_ssl():
|
|||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
return jsonify({'status': 'error', 'message': 'Failed to obtain SSL certificate'})
|
return jsonify({'status': 'error', 'message': 'Failed to obtain SSL certificate'})
|
||||||
|
|
||||||
|
@app.route('/api/domain', methods=['DELETE'])
|
||||||
|
def remove_domain():
|
||||||
|
data = request.get_json()
|
||||||
|
domain = data.get('domain')
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Domain is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(DB_FILE) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get domain ID and check if it exists
|
||||||
|
cursor.execute('SELECT id FROM domains WHERE domain = ?', (domain,))
|
||||||
|
domain_result = cursor.fetchone()
|
||||||
|
|
||||||
|
if not domain_result:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Domain not found'}), 404
|
||||||
|
|
||||||
|
domain_id = domain_result[0]
|
||||||
|
|
||||||
|
# Get backend IDs associated with this domain
|
||||||
|
cursor.execute('SELECT id FROM backends WHERE domain_id = ?', (domain_id,))
|
||||||
|
backend_ids = [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Delete backend servers
|
||||||
|
for backend_id in backend_ids:
|
||||||
|
cursor.execute('DELETE FROM backend_servers WHERE backend_id = ?', (backend_id,))
|
||||||
|
|
||||||
|
# Delete backends
|
||||||
|
cursor.execute('DELETE FROM backends WHERE domain_id = ?', (domain_id,))
|
||||||
|
|
||||||
|
# Delete domain
|
||||||
|
cursor.execute('DELETE FROM domains WHERE id = ?', (domain_id,))
|
||||||
|
|
||||||
|
# Delete SSL certificate if it exists
|
||||||
|
cursor.execute('SELECT ssl_cert_path FROM domains WHERE id = ? AND ssl_enabled = 1', (domain_id,))
|
||||||
|
cert_result = cursor.fetchone()
|
||||||
|
if cert_result and cert_result[0]:
|
||||||
|
try:
|
||||||
|
os.remove(cert_result[0])
|
||||||
|
except OSError:
|
||||||
|
pass # Ignore errors if file doesn't exist
|
||||||
|
|
||||||
|
# Regenerate HAProxy config
|
||||||
|
generate_config()
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'message': 'Domain configuration removed'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
def generate_config():
|
def generate_config():
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(DB_FILE)
|
conn = sqlite3.connect(DB_FILE)
|
||||||
# Enable dictionary-like access to rows
|
# Enable dictionary-like access to rows
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
query = '''
|
query = '''
|
||||||
SELECT
|
SELECT
|
||||||
d.id as domain_id,
|
d.id as domain_id,
|
||||||
d.domain,
|
d.domain,
|
||||||
d.ssl_enabled,
|
d.ssl_enabled,
|
||||||
@ -183,7 +257,7 @@ def generate_config():
|
|||||||
LEFT JOIN backends b ON d.id = b.domain_id
|
LEFT JOIN backends b ON d.id = b.domain_id
|
||||||
'''
|
'''
|
||||||
cursor.execute(query)
|
cursor.execute(query)
|
||||||
|
|
||||||
# Fetch and immediately convert to list of dicts to avoid any cursor issues
|
# Fetch and immediately convert to list of dicts to avoid any cursor issues
|
||||||
domains = [dict(domain) for domain in cursor.fetchall()]
|
domains = [dict(domain) for domain in cursor.fetchall()]
|
||||||
config_parts = []
|
config_parts = []
|
||||||
@ -226,7 +300,7 @@ def generate_config():
|
|||||||
SELECT * FROM backend_servers WHERE backend_id = ?
|
SELECT * FROM backend_servers WHERE backend_id = ?
|
||||||
''', (domain['backend_id'],))
|
''', (domain['backend_id'],))
|
||||||
servers = [dict(server) for server in cursor.fetchall()]
|
servers = [dict(server) for server in cursor.fetchall()]
|
||||||
|
|
||||||
if not servers:
|
if not servers:
|
||||||
print(f"No servers found for backend {domain['backend_name']}") # Debug log
|
print(f"No servers found for backend {domain['backend_name']}") # Debug log
|
||||||
continue
|
continue
|
||||||
@ -241,19 +315,21 @@ def generate_config():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error generating backend block for {domain['backend_name']}: {e}")
|
print(f"Error generating backend block for {domain['backend_name']}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Write complete configuration to tmp
|
# Write complete configuration to tmp
|
||||||
|
temp_config_path = "/etc/haproxy/haproxy.cfg"
|
||||||
|
|
||||||
config_content = '\n'.join(config_parts)
|
config_content = '\n'.join(config_parts)
|
||||||
print("Final config content:", config_content) # Debug log
|
print("Final config content:", config_content) # Debug log
|
||||||
|
|
||||||
# Write complete configuration to tmp
|
# Write complete configuration to tmp
|
||||||
# Check HAProxy Configuration, and reload if it works
|
# Check HAProxy Configuration, and reload if it works
|
||||||
with open("/tmp/haproxy_temp.cfg", 'w') as f:
|
with open(temp_config_path, 'w') as f:
|
||||||
f.write('\n'.join(config_parts))
|
f.write(config_content)
|
||||||
result = subprocess.run(['haproxy', '-c', '-f', "/tmp/haproxy_temp.cfg"], capture_output=True)
|
|
||||||
|
result = subprocess.run(['haproxy', '-c', '-f', temp_config_path], capture_output=True)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
shutil.copyfile("/tmp/haproxy_temp.cfg", HAPROXY_CONFIG_PATH)
|
print("HAProxy configuration check passed")
|
||||||
os.remove("/tmp/haproxy_temp.cfg")
|
|
||||||
if is_process_running('haproxy'):
|
if is_process_running('haproxy'):
|
||||||
subprocess.run(['echo', '"reload"', '|', 'socat', 'stdio', '/tmp/haproxy-cli'])
|
subprocess.run(['echo', '"reload"', '|', 'socat', 'stdio', '/tmp/haproxy-cli'])
|
||||||
else:
|
else:
|
||||||
|
@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
# Exit on error
|
# Exit on error
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
cron &
|
||||||
|
python /haproxy/haproxy_manager.py
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
|
|
||||||
backend {{ name }}-backend
|
backend {{ name }}-backend
|
||||||
|
|
||||||
option forwardfor
|
option forwardfor
|
||||||
http-request add-header X-CLIENT-IP %[src]
|
http-request add-header X-CLIENT-IP %[src]
|
||||||
{% if ssl_enabled %} ttp-request set-header X-Forwarded-Proto https if \{ ssl_fc \} {% endif %}
|
{% if ssl_enabled %}http-request set-header X-Forwarded-Proto https if { ssl_fc }{% endif %}
|
||||||
{% for server in servers %}
|
{% for server in servers %}
|
||||||
server {{ server.name }} {{ server.address }}:{{ server.port }} {{ server.options }}
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}
|
||||||
{% endfor %}
|
{% endfor %}
|
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
#Path Method {{ path }}
|
#Path Method {{ path }}
|
||||||
acl {{ path }}-acl path_beg {{ path }}
|
acl {{ path }}-acl path_beg {{ path }}
|
||||||
use_backend {{ name }}-backend if {{ path }}-acl
|
use_backend {{ name }}-backend if {{ path }}-acl
|
||||||
|
Loading…
x
Reference in New Issue
Block a user