Skip to content

Commit f522f75

Browse files
committed
Merge 'feat/use-certapi' into master
2 parents 3e173a2 + f1bb03a commit f522f75

File tree

7 files changed

+269
-21
lines changed

7 files changed

+269
-21
lines changed

.github/workflows/docker-image.yml

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,46 @@ on:
44
push:
55
tags:
66
- '*'
7+
branches:
8+
- master
79

810
jobs:
911
docker:
1012
runs-on: ubuntu-latest
1113
steps:
12-
-
13-
name: Checkout
14+
- name: Checkout
1415
uses: actions/checkout@v3
15-
-
16-
name: Set up QEMU
16+
17+
- name: Set up QEMU
1718
uses: docker/setup-qemu-action@v2
18-
-
19-
name: Set up Docker Buildx
19+
20+
- name: Set up Docker Buildx
2021
uses: docker/setup-buildx-action@v2
21-
-
22-
name: Login to DockerHub
22+
23+
- name: Login to DockerHub
2324
uses: docker/login-action@v2
2425
with:
2526
username: mesudip
2627
password: ${{ secrets.DOCKERHUB_TOKEN }}
2728

28-
-
29-
name: Build and push
29+
- name: Build and push for tags
30+
if: startsWith(github.ref, 'refs/tags/')
3031
uses: docker/build-push-action@v3
3132
with:
3233
file: Dockerfile
3334
context: .
34-
platforms: linux/amd64, linux/arm64
35+
platforms: linux/amd64,linux/arm64
3536
push: true
3637
tags: |
3738
mesudip/nginx-proxy:${{ github.ref_name }}
3839
mesudip/nginx-proxy:latest
40+
41+
- name: Build and push for main branch
42+
if: github.ref == 'refs/heads/master'
43+
uses: docker/build-push-action@v3
44+
with:
45+
file: Dockerfile
46+
context: .
47+
platforms: linux/amd64,linux/arm64
48+
push: true
49+
tags: mesudip/nginx-proxy:${{ github.sha }}

.gitignore

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
venv/
2+
.venv/
3+
24
__pycache__/
35
/.idea/
46
/.run_data/
5-
/run_data
6-
.env
7-
.venv
7+
run_data/
8+
.env

acme_nginx/Cloudflare.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import json
2+
import time
3+
from os import getenv
4+
import traceback
5+
from urllib.request import urlopen, Request
6+
7+
8+
class Cloudflare(object):
9+
name='cloudflare'
10+
def __init__(self):
11+
self.token = getenv('CLOUDFLARE_API_TOKEN')
12+
self.account_id=getenv('CLOUDFLARE_ACCOUNT_ID')
13+
self.api = "https://api.cloudflare.com/client/v4"
14+
if not self.token:
15+
raise Exception('CLOUDFLARE_API_TOKEN not found in environment')
16+
17+
self._zones_cache = None
18+
self._zones_cache_time = 0 # Unix timestamp of last cache update
19+
20+
def check_token(self):
21+
if self.account_id:
22+
api_url = "{0}/accounts/{1}/tokens/verify".format(self.api, self.account_id)
23+
request_headers = self._cloudflare_headers()
24+
try:
25+
response = urlopen(Request(api_url, headers=request_headers))
26+
if response.getcode() == 200:
27+
result = json.loads(response.read().decode('utf8'))
28+
if result.get('success') and result.get('result', {}).get('status') == 'active':
29+
print("Cloudflare API Token is valid and active.")
30+
return True
31+
else:
32+
print(f"Cloudflare API Token verification failed: {result.get('messages', result.get('errors'))}")
33+
return False
34+
else:
35+
print(f"Cloudflare API Token verification failed with status code: {response.getcode()}")
36+
return False
37+
except Exception as e:
38+
print(f"Error during Cloudflare API Token verification: {e}")
39+
return False
40+
else:
41+
print("CLOUDFLARE_ACCOUNT_ID not set. Cannot verify token without account ID.")
42+
return False
43+
44+
def _cloudflare_headers(self):
45+
return {
46+
"Content-Type": "application/json",
47+
"Authorization": "Bearer "+self.token
48+
}
49+
def _get_zones(self):
50+
""" Fetch and cache Cloudflare zones """
51+
# Cache for 1 day (86400 seconds)
52+
if self._zones_cache and (time.time() - self._zones_cache_time) < 86400:
53+
return self._zones_cache
54+
request_headers = self._cloudflare_headers()
55+
api_url = "{0}/zones?per_page=50".format(self.api)
56+
response = urlopen(Request(api_url, headers=request_headers))
57+
if response.getcode() != 200:
58+
raise Exception(json.loads(response.read().decode('utf8')))
59+
60+
zones = json.loads(response.read().decode('utf8'))['result']
61+
# print("Zone cache",json.dumps([x['name'] for x in zones],indent=2))
62+
self._zones_cache = zones
63+
self._zones_cache_time = time.time()
64+
return zones
65+
66+
def _get_zone_id(self, domain):
67+
""" Determine Cloudflare Zone ID for a given domain """
68+
zones = self._get_zones()
69+
for zone in zones:
70+
if zone['name'] == domain:
71+
return zone['id']
72+
raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
73+
74+
def determine_domain(self, domain):
75+
""" Determine registered domain in API """
76+
# For Cloudflare, we need the base domain to get the zone ID
77+
# The domain passed here might be a subdomain or wildcard, e.g., 'sub.example.com' or '*.example.com'
78+
# We need to find the root domain (e.g., 'example.com') that is registered as a Cloudflare zone.
79+
parts = domain.split('.')
80+
err=None
81+
for i in range(len(parts)):
82+
potential_domain = ".".join(parts[i:])
83+
try:
84+
self._get_zone_id(potential_domain)
85+
return potential_domain
86+
except Exception as e:
87+
err=e
88+
continue
89+
if err:
90+
raise err
91+
else:
92+
raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
93+
94+
def create_record(self, name, data, domain):
95+
"""
96+
Create DNS record
97+
Params:
98+
name, string, record name (e.g., _acme-challenge.example.com)
99+
data, string, record data (e.g., ACME challenge token)
100+
domain, string, dns domain (e.g., example.com)
101+
Return:
102+
record_id, string, created record id
103+
"""
104+
registered_domain = self.determine_domain(domain)
105+
zone_id = self._get_zone_id(registered_domain)
106+
api_url = "{0}/zones/{1}/dns_records".format(self.api, zone_id)
107+
request_headers = self._cloudflare_headers()
108+
request_data = {
109+
"type": "TXT",
110+
"name": name,
111+
"content": data,
112+
"ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
113+
"proxied": False
114+
}
115+
response = urlopen(Request(
116+
api_url,
117+
data=json.dumps(request_data).encode('utf8'),
118+
headers=request_headers)
119+
)
120+
121+
if response.getcode() != 200:
122+
raise Exception(json.loads(response.read().decode('utf8')))
123+
result=response.read().decode('utf8')
124+
print("Cloudflare create record",name,result)
125+
return json.loads(result)['result']['id']
126+
127+
def delete_record(self, record, domain):
128+
"""
129+
Delete DNS record
130+
Params:
131+
record, string, record id number
132+
domain, string, dns domain
133+
"""
134+
registered_domain = self.determine_domain(domain)
135+
zone_id = self._get_zone_id(registered_domain)
136+
api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api, zone_id, record)
137+
request_headers = self._cloudflare_headers()
138+
request = Request(api_url, headers=request_headers)
139+
request.get_method = lambda: 'DELETE'
140+
response = urlopen(request)
141+
result=response.read().decode('utf8')
142+
print(f"Delete dns record [{response.getcode()}]",result)
143+
if response.getcode() != 200:
144+
raise Exception(json.loads(response.read().decode('utf8')))

acme_nginx/test_cloudflare.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
from acme_nginx.Cloudflare import Cloudflare
3+
4+
def run_cloudflare_tests():
5+
print("--- Cloudflare API Test Script ---")
6+
7+
# Ensure environment variables are set
8+
if not os.getenv('CLOUDFLARE_API_TOKEN'):
9+
print("Error: CLOUDFLARE_API_TOKEN environment variable not set.")
10+
print("Please set it before running the test.")
11+
return
12+
if not os.getenv('CLOUDFLARE_ACCOUNT_ID'):
13+
print("Warning: CLOUDFLARE_ACCOUNT_ID environment variable not set.")
14+
print("Some tests (like token verification) might not work without it.")
15+
# Continue, as other functions like listing zones might still work
16+
17+
try:
18+
cf = Cloudflare()
19+
20+
# 1. Check token
21+
print("\n1. Checking Cloudflare API Token...")
22+
if cf.check_token():
23+
print("Token check successful.")
24+
else:
25+
print("Token check failed or account ID not set. Please check your environment variables.")
26+
# Depending on the failure, we might want to exit here or continue with other tests
27+
# For now, let's continue to see if other operations work (e.g., if only account_id is missing)
28+
29+
# 2. List zones and print domains/zones
30+
print("\n2. Listing Cloudflare Zones...")
31+
zones = cf._get_zones() # Accessing internal method for testing purposes
32+
if zones:
33+
print(f"Found {len(zones)} zones:")
34+
for zone in zones:
35+
print(f" Domain: {zone['name']}, Zone ID: {zone['id']}")
36+
else:
37+
print("No zones found or an error occurred while fetching zones.")
38+
39+
# 3. Try adding and deleting a record
40+
print("\n3. Attempting to add and delete a test TXT record...")
41+
# IMPORTANT: Replace with a domain you own and manage in Cloudflare for actual testing
42+
# and ensure it's a domain that won't cause issues with a temporary TXT record.
43+
# For a real test, you might need to dynamically determine a domain from your zones.
44+
test_domain = os.getenv('CLOUDFLARE_TEST_DOMAIN') # e.g., "example.com"
45+
if not test_domain:
46+
print("Skipping record add/delete test: CLOUDFLARE_TEST_DOMAIN environment variable not set.")
47+
print("Please set CLOUDFLARE_TEST_DOMAIN to a domain you manage in Cloudflare to run this test.")
48+
return
49+
50+
test_record_name = f"_acme-challenge.test.{test_domain}"
51+
test_record_data = "test_data_12345"
52+
record_id = None
53+
54+
try:
55+
print(f" Adding TXT record '{test_record_name}' with data '{test_record_data}' to domain '{test_domain}'...")
56+
record_id = cf.create_record(test_record_name, test_record_data, test_domain)
57+
print(f" Record added successfully. Record ID: {record_id}")
58+
59+
print(f" Deleting record ID: {record_id} from domain '{test_domain}'...")
60+
cf.delete_record(record_id, test_domain)
61+
print(" Record deleted successfully.")
62+
63+
except Exception as e:
64+
print(f" Error during record add/delete test: {e}")
65+
if record_id:
66+
print(f" Attempting to clean up record {record_id} if it was created...")
67+
try:
68+
cf.delete_record(record_id, test_domain)
69+
print(" Cleanup successful.")
70+
except Exception as cleanup_e:
71+
print(f" Cleanup failed: {cleanup_e}")
72+
73+
except Exception as e:
74+
print(f"An unexpected error occurred during Cloudflare tests: {e}")
75+
76+
if __name__ == "__main__":
77+
run_cloudflare_tests()

main.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ def eventLoop():
7373
process_network_event(eventAction, event)
7474
elif eventType == "container":
7575
if eventAction == "health_status":
76-
break
77-
#process_container_health_event(event)
76+
# process_container_health_event(event)
77+
continue
7878
else:
7979
process_container_event(eventAction, event)
8080

@@ -119,7 +119,8 @@ def process_network_event(action, event):
119119
try:
120120
server = WebServer(client)
121121
eventLoop()
122-
except (KeyboardInterrupt, SystemExit):
122+
except (KeyboardInterrupt, SystemExit) as e:
123+
# traceback.print_exception(e)
123124
print("-------------------------------\nPerforming Graceful ShutDown !!")
124125
if server is not None:
125126
server.cleanup()

nginx_proxy/WebServer.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from nginx_proxy import Container
1919
from nginx_proxy import ProxyConfigData
2020
from nginx_proxy.Host import Host
21-
21+
from acme_nginx.Cloudflare import Cloudflare
2222

2323
class WebServer():
2424
def __init__(self, client: DockerClient, *args):
@@ -36,7 +36,19 @@ def __init__(self, client: DockerClient, *args):
3636
self.template = Template(file.read())
3737
file.close()
3838
self.learn_yourself()
39-
self.ssl_processor = post_processors.SslCertificateProcessor(self.nginx, self, start_ssl_thread=False,ssl_dir=self.config['ssl_dir'])
39+
40+
wildcard_dns_provider = None
41+
if os.getenv('CLOUDFLARE_API_TOKEN'):
42+
wildcard_dns_provider = Cloudflare()
43+
# Add other providers here if needed, e.g., elif os.getenv('DIGITALOCEAN_API_TOKEN'): wildcard_dns_provider = 'digitalocean'
44+
45+
self.ssl_processor = post_processors.SslCertificateProcessor(
46+
self.nginx,
47+
self,
48+
start_ssl_thread=False,
49+
ssl_dir=self.config['ssl_dir'],
50+
default_wildcard_dns_provider=wildcard_dns_provider
51+
)
4052
self.basic_auth_processor = post_processors.BasicAuthProcessor( self.config['conf_dir'] + "/basic_auth")
4153
self.redirect_processor = post_processors.RedirectProcessor()
4254

run_local.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@
77
#
88

99
mkdir -p ./.run_data/conf.d
10-
#
11-
DUMMY_NGINX=y CHALLENGE_DIR=./.run_data/acme_challenges SSL_DIR=./.run_data NGINX_CONF_DIR=./.run_data python3 main.py
10+
11+
LETSENCRYPT_API=https://acme-staging-v02.api.letsencrypt.org/directory \
12+
CHALLENGE_DIR=./.run_data/acme_challenges \
13+
DUMMY_NGINX=y SSL_DIR=./.run_data NGINX_CONF_DIR=./.run_data python3 main.py

0 commit comments

Comments
 (0)