Skip to content

Commit 8bf3956

Browse files
committed
ingress: Refactor the cert handling part
1 parent 361d02a commit 8bf3956

File tree

6 files changed

+345
-77
lines changed

6 files changed

+345
-77
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
#!/usr/bin/env python3
2+
3+
from dns_providers import DNSProviderFactory
4+
import argparse
5+
import os
6+
import subprocess
7+
import sys
8+
from typing import List, Optional, Tuple
9+
10+
# Add script directory to path to import dns_providers
11+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12+
13+
14+
class CertManager:
15+
"""Certificate management using DNS provider infrastructure."""
16+
17+
def __init__(self, provider_type: Optional[str] = None):
18+
"""Initialize cert manager with DNS provider."""
19+
# Use the same DNS provider factory
20+
self.provider_type = provider_type or self._detect_provider_type()
21+
self.provider = DNSProviderFactory.create_provider(self.provider_type)
22+
23+
def _detect_provider_type(self) -> str:
24+
"""Detect provider type (reuse factory logic)."""
25+
return DNSProviderFactory._detect_provider_type()
26+
27+
def install_plugin(self) -> bool:
28+
"""Install certbot plugin for the current provider."""
29+
if not self.provider.CERTBOT_PACKAGE:
30+
print(f"No certbot package defined for {self.provider_type}")
31+
return False
32+
33+
print(f"Installing certbot plugin: {self.provider.CERTBOT_PACKAGE}")
34+
35+
# Use virtual environment pip if available
36+
pip_cmd = ["pip", "install", self.provider.CERTBOT_PACKAGE]
37+
if "VIRTUAL_ENV" in os.environ:
38+
venv_pip = os.path.join(os.environ["VIRTUAL_ENV"], "bin", "pip")
39+
if os.path.exists(venv_pip):
40+
pip_cmd[0] = venv_pip
41+
42+
try:
43+
result = subprocess.run(pip_cmd, capture_output=True, text=True)
44+
if result.returncode != 0:
45+
print(f"Failed to install plugin: {result.stderr}", file=sys.stderr)
46+
return False
47+
print(f"Successfully installed {self.provider.CERTBOT_PACKAGE}")
48+
return True
49+
except Exception as e:
50+
print(f"Error installing plugin: {e}", file=sys.stderr)
51+
return False
52+
53+
def setup_credentials(self) -> bool:
54+
"""Setup credentials file for certbot using provider implementation."""
55+
return self.provider.setup_certbot_credentials()
56+
57+
def _build_certbot_command(self, action: str, domain: str, email: str) -> List[str]:
58+
"""Build certbot command using provider configuration."""
59+
plugin = self.provider.CERTBOT_PLUGIN
60+
if not plugin:
61+
raise ValueError(f"No certbot plugin configured for {self.provider_type}")
62+
63+
propagation_seconds = self.provider.CERTBOT_PROPAGATION_SECONDS
64+
65+
base_cmd = ["certbot", action]
66+
67+
# Add DNS plugin configuration
68+
base_cmd.extend(
69+
[
70+
f"--{plugin}",
71+
f"--{plugin}-propagation-seconds",
72+
str(propagation_seconds),
73+
"--non-interactive",
74+
]
75+
)
76+
77+
# Add credentials file if provider has one configured
78+
if self.provider.CERTBOT_CREDENTIALS_FILE:
79+
credentials_file = os.path.expanduser(
80+
self.provider.CERTBOT_CREDENTIALS_FILE
81+
)
82+
if os.path.exists(credentials_file):
83+
base_cmd.extend([f"--{plugin}-credentials", credentials_file])
84+
85+
if action == "certonly":
86+
base_cmd.extend(
87+
["--email", email, "--agree-tos", "--no-eff-email", "-d", domain]
88+
)
89+
90+
return base_cmd
91+
92+
def obtain_certificate(self, domain: str, email: str) -> bool:
93+
"""Obtain a new certificate for the domain."""
94+
print(f"Obtaining new certificate for {domain} using {self.provider_type}")
95+
96+
cmd = self._build_certbot_command("certonly", domain, email)
97+
98+
try:
99+
result = subprocess.run(cmd, capture_output=True, text=True)
100+
if result.returncode != 0:
101+
print(f"Certificate obtaining failed: {result.stderr}", file=sys.stderr)
102+
return False
103+
104+
print(f"Certificate obtained successfully for {domain}")
105+
return True
106+
107+
except Exception as e:
108+
print(f"Error running certbot: {e}", file=sys.stderr)
109+
return False
110+
111+
def renew_certificate(self, domain: str) -> Tuple[bool, bool]:
112+
"""Renew certificates.
113+
114+
Returns:
115+
(success, renewed): success status and whether renewal was actually performed
116+
"""
117+
print(f"Renewing certificate using {self.provider_type}")
118+
119+
cmd = self._build_certbot_command("renew", domain, "")
120+
121+
try:
122+
result = subprocess.run(cmd, capture_output=True, text=True)
123+
if result.returncode != 0:
124+
print(f"Certificate renewal failed: {result.stderr}", file=sys.stderr)
125+
return False, False
126+
127+
# Check if no renewals were needed
128+
if "No renewals were attempted" in result.stdout:
129+
print("No certificates need renewal")
130+
return True, False
131+
132+
print("Certificate renewed successfully")
133+
return True, True
134+
135+
except Exception as e:
136+
print(f"Error running certbot: {e}", file=sys.stderr)
137+
return False, False
138+
139+
def certificate_exists(self, domain: str) -> bool:
140+
"""Check if certificate already exists for domain."""
141+
cert_path = f"/etc/letsencrypt/live/{domain}/fullchain.pem"
142+
return os.path.isfile(cert_path)
143+
144+
def run_action(
145+
self, domain: str, email: str, action: str = "auto"
146+
) -> Tuple[bool, bool]:
147+
"""High-level certificate management.
148+
149+
Returns:
150+
(success, needs_evidence): success status and whether evidence should be generated
151+
"""
152+
if action == "auto":
153+
if self.certificate_exists(domain):
154+
success, renewed = self.renew_certificate(domain)
155+
return success, renewed # Only generate evidence if actually renewed
156+
else:
157+
success = self.obtain_certificate(domain, email)
158+
return success, success # Always generate evidence for new certificates
159+
elif action == "obtain":
160+
success = self.obtain_certificate(domain, email)
161+
return success, success
162+
elif action == "renew":
163+
success, renewed = self.renew_certificate(domain)
164+
return success, renewed
165+
else:
166+
raise ValueError(f"Invalid action: {action}")
167+
168+
169+
def main():
170+
parser = argparse.ArgumentParser(
171+
description="Manage SSL certificates with certbot using DNS providers"
172+
)
173+
parser.add_argument(
174+
"action", choices=["obtain", "renew", "auto", "setup"], help="Action to perform"
175+
)
176+
parser.add_argument("--domain", help="Domain name")
177+
parser.add_argument("--email", help="Email for Let's Encrypt registration")
178+
parser.add_argument("--provider", help="DNS provider (cloudflare, linode, etc)")
179+
180+
args = parser.parse_args()
181+
182+
try:
183+
manager = CertManager(args.provider)
184+
185+
# Handle setup action
186+
if args.action == "setup":
187+
if not manager.install_plugin():
188+
sys.exit(1)
189+
if not manager.setup_credentials():
190+
sys.exit(1)
191+
print(f"Setup completed for {manager.provider_type} provider")
192+
return
193+
194+
# Domain is required for certificate operations
195+
if not args.domain:
196+
print(
197+
"Error: --domain is required for certificate operations",
198+
file=sys.stderr,
199+
)
200+
sys.exit(1)
201+
202+
# Email is required for obtain and auto actions
203+
if args.action in ["obtain", "auto"] and not args.email:
204+
if not os.environ.get("CERTBOT_EMAIL"):
205+
print(
206+
"Error: --email is required or set CERTBOT_EMAIL environment variable",
207+
file=sys.stderr,
208+
)
209+
sys.exit(1)
210+
args.email = os.environ["CERTBOT_EMAIL"]
211+
212+
success, needs_evidence = manager.run_action(
213+
args.domain, args.email, args.action
214+
)
215+
216+
if not success:
217+
sys.exit(1)
218+
219+
# Exit with code 2 if no evidence generation is needed (no renewal was performed)
220+
if not needs_evidence:
221+
sys.exit(2)
222+
223+
except ValueError as e:
224+
print(f"Error: {e}", file=sys.stderr)
225+
sys.exit(1)
226+
except Exception as e:
227+
print(f"Unexpected error: {e}", file=sys.stderr)
228+
sys.exit(1)
229+
230+
231+
if __name__ == "__main__":
232+
main()

custom-domain/dstack-ingress/scripts/dns_providers/base.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,20 @@ class DNSProvider(ABC):
5050

5151
DETECT_ENV = ""
5252

53+
# Certbot configuration - override in subclasses
54+
CERTBOT_PLUGIN = ""
55+
CERTBOT_PACKAGE = ""
56+
CERTBOT_PROPAGATION_SECONDS = 120
57+
CERTBOT_CREDENTIALS_FILE = "" # Path to credentials file
58+
5359
def __init__(self):
5460
"""Initialize the DNS provider."""
5561
pass
5662

63+
def setup_certbot_credentials(self) -> bool:
64+
"""Setup credentials file for certbot. Override in subclasses if needed."""
65+
return True # Default: no setup needed
66+
5767
@classmethod
5868
def suitable(cls) -> bool:
5969
"""Check if the current environment is suitable for this DNS provider."""
@@ -176,6 +186,10 @@ def set_cname_record(
176186
"""
177187
existing_records = self.get_dns_records(zone_id, name, RecordType.CNAME)
178188
for record in existing_records:
189+
# Check if record already exists with same content
190+
if record.content == content:
191+
print("CNAME record with the same content already exists")
192+
return True
179193
if record.id:
180194
self.delete_dns_record(zone_id, record.id)
181195

@@ -205,6 +219,10 @@ def set_txt_record(
205219
"""
206220
existing_records = self.get_dns_records(zone_id, name, RecordType.TXT)
207221
for record in existing_records:
222+
# Check if record already exists with same content
223+
if record.content == content or record.content == f'"{content}"':
224+
print("TXT record with the same content already exists")
225+
return True
208226
if record.id:
209227
self.delete_dns_record(zone_id, record.id)
210228

custom-domain/dstack-ingress/scripts/dns_providers/cloudflare.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ class CloudflareDNSProvider(DNSProvider):
1313

1414
DETECT_ENV = "CLOUDFLARE_API_TOKEN"
1515

16+
# Certbot configuration
17+
CERTBOT_PLUGIN = "dns-cloudflare"
18+
CERTBOT_PACKAGE = "certbot-dns-cloudflare==4.0.0"
19+
CERTBOT_PROPAGATION_SECONDS = 120
20+
CERTBOT_CREDENTIALS_FILE = "~/.cloudflare/cloudflare.ini"
21+
1622
def __init__(self):
1723
super().__init__()
1824
self.api_token = os.getenv("CLOUDFLARE_API_TOKEN")
@@ -24,6 +30,28 @@ def __init__(self):
2430
"Content-Type": "application/json",
2531
}
2632

33+
def setup_certbot_credentials(self) -> bool:
34+
"""Setup Cloudflare credentials file for certbot."""
35+
credentials_file = os.path.expanduser(self.CERTBOT_CREDENTIALS_FILE)
36+
credentials_dir = os.path.dirname(credentials_file)
37+
38+
try:
39+
# Create credentials directory
40+
os.makedirs(credentials_dir, exist_ok=True)
41+
42+
# Write credentials file
43+
with open(credentials_file, "w") as f:
44+
f.write(f"dns_cloudflare_api_token = {self.api_token}\n")
45+
46+
# Set secure permissions
47+
os.chmod(credentials_file, 0o600)
48+
print(f"Cloudflare credentials file created: {credentials_file}")
49+
return True
50+
51+
except Exception as e:
52+
print(f"Error setting up Cloudflare credentials: {e}", file=sys.stderr)
53+
return False
54+
2755
def _make_request(
2856
self, method: str, endpoint: str, data: Optional[Dict] = None
2957
) -> Dict:

custom-domain/dstack-ingress/scripts/dns_providers/linode.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ class LinodeDNSProvider(DNSProvider):
1414

1515
DETECT_ENV = "LINODE_API_TOKEN"
1616

17+
# Certbot configuration
18+
CERTBOT_PLUGIN = "dns-linode"
19+
CERTBOT_PACKAGE = "certbot-dns-linode"
20+
CERTBOT_PROPAGATION_SECONDS = 300
21+
CERTBOT_CREDENTIALS_FILE = "~/.linode/credentials.ini"
22+
1723
def __init__(self):
1824
super().__init__()
1925
self.api_token = os.getenv("LINODE_API_TOKEN")
@@ -25,6 +31,28 @@ def __init__(self):
2531
"Content-Type": "application/json",
2632
}
2733

34+
def setup_certbot_credentials(self) -> bool:
35+
"""Setup Linode credentials file for certbot."""
36+
credentials_file = os.path.expanduser(self.CERTBOT_CREDENTIALS_FILE)
37+
credentials_dir = os.path.dirname(credentials_file)
38+
39+
try:
40+
# Create credentials directory
41+
os.makedirs(credentials_dir, exist_ok=True)
42+
43+
# Write credentials file
44+
with open(credentials_file, "w") as f:
45+
f.write(f"dns_linode_key = {self.api_token}\n")
46+
47+
# Set secure permissions
48+
os.chmod(credentials_file, 0o600)
49+
print(f"Linode credentials file created: {credentials_file}")
50+
return True
51+
52+
except Exception as e:
53+
print(f"Error setting up Linode credentials: {e}", file=sys.stderr)
54+
return False
55+
2856
def _make_request(
2957
self, method: str, endpoint: str, data: Optional[Dict] = None
3058
) -> Dict:
@@ -227,6 +255,13 @@ def set_alias_record(
227255
if not ip_address:
228256
raise socket.gaierror("Could not resolve any variant of the domain")
229257

258+
# Check if A record already exists with same IP
259+
existing_a_records = self.get_dns_records(zone_id, name, RecordType.A)
260+
for record in existing_a_records:
261+
if record.content == ip_address:
262+
print("A record with the same IP already exists")
263+
return True
264+
230265
# Delete any existing A or CNAME records for this name
231266
for record_type in [RecordType.A, RecordType.CNAME]:
232267
existing_records = self.get_dns_records(zone_id, name, record_type)

0 commit comments

Comments
 (0)