Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ run_data/
.env
*.egg-info/
.pytest_cache/
dist/
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Control the default behavior of `nginx-proxy`:
| `DOCKER_SWARM` | `ignore` | Treats every container like local by defeault. Set `enable` for Swarm support, `strict` for Swarm-only or`exclude` to not include swarm containers |
| `SWARM_DOCKER_HOST` | - | URL of the Swarm manager socket (e.g., `tcp://manager:2375`). |
| `CERTAPI_URL` | - | External Certificate API URL. |
| `CERTAPI_BATCH_DOMAINS` | `true` | When using `CERTAPI_URL`, request safe domain batching (`batch_domains=true`) to avoid recursive domain-order errors. |
| `CHALLENGE_DIR` | `/etc/nginx/challenges/` | Base directory for acme challenge store, when requesting certificates with acme. `.well-known/acme-challenge` folder lives inside this.|
| `CLOUDFLARE_API_KEY_KEY*` | - | Cloudflare api keys to issue DNS certificates.|

Expand Down Expand Up @@ -227,4 +228,4 @@ We are constantly improving `nginx-proxy` to make it the most robust and versati
- **High Availability & Multi-Node Swarm Deployment:** Comprehensive guides and templates for deploying `nginx-proxy` in a multi-node Swarm environment for maximum uptime.
- **100% Test Coverage:** Ensuring rock-solid stability and reliability for every release.
- **Remote Swarm Cluster Support:** Monitor and route traffic for Swarm clusters running on remote nodes seamlessly.
- **Pangolin Support:** Integration with Pangolin as an alternative reverse-proxy engine.
- **Pangolin Support:** Integration with Pangolin as an alternative reverse-proxy engine.
79 changes: 42 additions & 37 deletions getssl
Original file line number Diff line number Diff line change
@@ -1,27 +1,32 @@
#!/usr/bin/env python3
import argparse
import sys
import os
from nginx import Nginx
from nginx_proxy import SSL
from nginx_proxy.certificate_backend import build_certificate_backend
from urllib.parse import urlparse


def print_usage():
print("Usage: Obtain Let'sEncrypt ssl certificate for a domain or multiple domains")
print()
print(" getssl <hostname1> [hostname2 ...]")
print()
print("Note: This script respects environment variables: CERTAPI_URL, SSL_DIR, CHALLENGE_DIR, etc.")
exit(1)
def parse_args():
parser = argparse.ArgumentParser(
description="Obtain Let's Encrypt SSL certificate for one or more domains.",
epilog="This script respects environment variables: CERTAPI_URL, SSL_DIR, CHALLENGE_DIR, etc.",
)
parser.add_argument(
"--force",
action="store_true",
help="Skip certapi/nginx-proxy self-verification before requesting the certificate.",
)
parser.add_argument("domains", nargs="+", help="Domain(s) to include in the certificate.")
return parser.parse_args()


def flatten_2d_array(two_d_array):
return [item for sublist in two_d_array for item in sublist]


if __name__ == "__main__":
if len(sys.argv) < 2:
print_usage()

arg_set = set(sys.argv[1:])
if any(x in arg_set for x in ['-h', '--help', 'help']):
print_usage()
args = parse_args()

# Load configuration from environment variables (consistent with NginxProxyApp)
def _strip_end(s: str, char="/") -> str:
Expand All @@ -42,16 +47,7 @@ if __name__ == "__main__":
if port is None:
port = 443 if parsed.scheme == "https" else 80

mock_server_config["certapi"] = {
"url": certapi_url,
"host": parsed.hostname,
"scheme": parsed.scheme,
"port": port
}

class MockServer:
def __init__(self, config):
self.config = config
mock_server_config["certapi"] = {"url": certapi_url, "host": parsed.hostname, "scheme": parsed.scheme, "port": port}

config_path = os.path.join(conf_dir, "conf.d/gen-ssl-direct.conf")
# Make sure challenge dir exists for local mode
Expand All @@ -62,22 +58,31 @@ if __name__ == "__main__":
pass # Might fail if readonly, but then Nginx might scream

nginx = Nginx.Nginx(config_path, challenge_dir=challenge_dir)

# Initialize SSL
ssl_manager = SSL.SSL(
ssl_dir,
nginx=nginx,
update_threshold_seconds=cert_renew_threshold_days * 24 * 3600,
server=MockServer(mock_server_config)

backend_info = build_certificate_backend(
ssl_dir,
nginx,
config=mock_server_config,
renew_threshold_days=max(10, cert_renew_threshold_days),
)

domains = [x for x in sys.argv[1:] if not x.startswith("-")]
if not domains:
print("No domains provided.")
print_usage()

try:
ssl_manager.register_certificate(domains)
result = backend_info.backend.obtain(
args.domains,
key_type="ecdsa",
batch_domains=backend_info.batch_domains,
self_verify=not args.force,
)
if len(result.issued):
print(
"[ New Certificates ] : ",
", ".join(flatten_2d_array(sorted([x.domains for x in result.issued]))),
)
if len(result.existing):
print(
"[ Reuse Existing ] : ",
", ".join(flatten_2d_array(sorted([x.domains for x in result.existing]))),
)
# Attempt to reload nginx to pick up changes (if running)
print("Reloading nginx...")
if not nginx.reload():
Expand Down
1 change: 1 addition & 0 deletions nginx_proxy/Host.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def remove_container(self, container_id) -> None:
if container_id in self.container_set:
for path, location in self.locations.items():
removed = location.remove(container_id) or removed
location.remove_backend_extras(container_id)
if location.isempty():
deletions.append(path)
self.container_set.remove(container_id)
Expand Down
35 changes: 35 additions & 0 deletions nginx_proxy/Location.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ def __init__(self, name, is_websocket_backend=False, is_http_backend=True):

def update_extras(self, extras: Dict[str, Any]):
for x in extras:
if x == "injected_by_backend" and isinstance(extras[x], dict):
existing = self.extras.setdefault("injected_by_backend", {})
for backend_id, directives in extras[x].items():
normalized = directives if isinstance(directives, list) else [directives]
existing[backend_id] = list(dict.fromkeys(normalized))
self._sync_injected_from_backend_map()
continue
if x in self.extras:
data = self.extras[x]
if type(data) in (dict, set):
Expand All @@ -33,6 +40,34 @@ def update_extras(self, extras: Dict[str, Any]):
else:
self.extras[x] = extras[x]

def _sync_injected_from_backend_map(self):
backend_map = self.extras.get("injected_by_backend")
if not isinstance(backend_map, dict):
return

merged: list[str] = []
seen = set()
for directives in backend_map.values():
if not isinstance(directives, list):
directives = [directives]
for directive in directives:
if directive not in seen:
seen.add(directive)
merged.append(directive)
self.extras["injected"] = merged

def remove_backend_extras(self, backend_id: str):
backend_map = self.extras.get("injected_by_backend")
if not isinstance(backend_map, dict):
return
if backend_id in backend_map:
del backend_map[backend_id]
self._sync_injected_from_backend_map()
if not backend_map:
del self.extras["injected_by_backend"]
if not self.extras.get("injected"):
self.extras.pop("injected", None)

def add(self, container: BackendTarget):
if not any(c.id == container.id for c in self.backends):
self.backends.append(container)
Expand Down
Loading
Loading