├── .gitignore ├── AUTHORS ├── Caddyfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── caddy_api_client ├── __init__.py └── client.py ├── docker-compose.yaml ├── examples ├── add_domain_with_auto_tls.py ├── add_domain_with_tls.py ├── delete_domain.py ├── manage_domain.py ├── reload.py ├── tls.crt └── tls.key ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | venv/ 25 | ENV/ 26 | env/ 27 | .env/ 28 | .venv/ 29 | 30 | # IDE 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | .DS_Store 36 | 37 | # Caddy specific 38 | certs/ 39 | caddy_data/ 40 | caddy_config/ 41 | 42 | # Logs 43 | *.log 44 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # This is the official list of caddy-api-client authors for copyright purposes. 2 | 3 | # Individual Persons 4 | 5 | Krzysztof Taraszka 6 | 7 | # Organizations 8 | 9 | miget.com 10 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | admin 0.0.0.0:2019 3 | 4 | auto_https disable_certs 5 | email hello@example.com 6 | 7 | on_demand_tls { 8 | ask http://answer-caddy:8080/ask 9 | } 10 | } 11 | 12 | :443 { 13 | tls { 14 | on_demand 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include AUTHORS 4 | include requirements.txt 5 | recursive-include examples *.py 6 | include Caddyfile 7 | include docker-compose.yaml 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Caddy API Client 2 | 3 | A Python client for managing Caddy server configurations through its API. 4 | 5 | ## Installation 6 | 7 | ### Production Installation (Recommended) 8 | 9 | For production workloads, it's recommended to install the package from PyPI: 10 | 11 | ```bash 12 | pip install caddy-api-client 13 | ``` 14 | 15 | ### Development Installation 16 | 17 | For development or if you need to modify the client: 18 | 19 | 1. Clone the repository: 20 | ```bash 21 | git clone https://github.com/migetapp/caddy-api-client.git 22 | cd caddy-api-client 23 | ``` 24 | 25 | 2. Install the package in development mode: 26 | ```bash 27 | pip install -e . 28 | ``` 29 | 30 | ## Usage 31 | 32 | ```python 33 | from caddy_api_client import CaddyAPIClient 34 | 35 | # Initialize the client 36 | client = CaddyAPIClient("http://localhost:2019") # Default Caddy admin endpoint 37 | 38 | # Add a domain with automatic TLS (Let's Encrypt/ZeroSSL) and www redirect 39 | client.add_domain_with_auto_tls( 40 | domain="example.org", 41 | target="nginx", 42 | target_port=80, 43 | redirect_mode="domain_to_www", # Redirects example.org to www.example.org 44 | enable_security_headers=True, # Adds security headers 45 | enable_hsts=True # Enables HSTS 46 | ) 47 | 48 | # Add a domain with www to non-www redirect 49 | client.add_domain_with_auto_tls( 50 | domain="www.example.net", 51 | target="192.168.10.101", 52 | target_port=80, 53 | redirect_mode="www_to_domain" # Redirects www.example.net to example.net 54 | ) 55 | 56 | # Add a domain with custom TLS certificates using PEM data 57 | with open('cert.pem', 'r') as f: 58 | certificate = f.read() 59 | with open('key.pem', 'r') as f: 60 | private_key = f.read() 61 | 62 | client.add_domain_with_tls( 63 | domain="example.net", 64 | target="192.168.10.101", 65 | target_port=80, 66 | certificate=certificate, 67 | private_key=private_key 68 | ) 69 | 70 | # Get domain configuration 71 | config = client.get_domain_config("example.com") 72 | print(config) 73 | 74 | # Update domain configuration 75 | client.update_domain( 76 | domain="example.com", 77 | target="172.16.0.2", 78 | target_port=8080, 79 | redirect_mode="domain_to_www" # Add or update redirect configuration 80 | ) 81 | 82 | # Delete domain 83 | client.delete_domain("example.com") # Removes domain and its redirect configuration 84 | 85 | ## Features 86 | 87 | - Add domains with TLS certificates using PEM data 88 | - Add domains with automatic TLS (Let's Encrypt/ZeroSSL) 89 | - Configure domain redirects: 90 | - www to non-www (`www_to_domain`) 91 | - non-www to www (`domain_to_www`) 92 | - Security features: 93 | - Security headers (Server, X-Frame-Options, etc.) 94 | - HTTP Strict Transport Security (HSTS) 95 | - HTTP to HTTPS redirect 96 | - Path-based security rules 97 | - Delete domain configurations (preserves other domain configurations) 98 | - Get domain configurations 99 | - Update domain configurations 100 | - Support for HTTP/2 and HTTP/3 101 | 102 | ## Error Handling 103 | 104 | The client includes comprehensive error handling for API requests. All methods will raise exceptions with descriptive messages if something goes wrong during the API calls. 105 | 106 | ## License 107 | 108 | This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. 109 | 110 | Copyright 2025 Krzysztof Taraszka and The Miget Authors. 111 | -------------------------------------------------------------------------------- /caddy_api_client/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Krzysztof Taraszka and The Miget Authors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .client import CaddyAPIClient 16 | 17 | __version__ = "0.2.4" 18 | __all__ = ["CaddyAPIClient"] 19 | -------------------------------------------------------------------------------- /caddy_api_client/client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2025 Krzysztof Taraszka and The Miget Authors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import requests 16 | from typing import Dict, Optional, Union 17 | import json 18 | 19 | 20 | class CaddyAPIClient: 21 | def __init__(self, base_url: str = "http://localhost:2019"): 22 | """Initialize the Caddy API client. 23 | 24 | Args: 25 | base_url (str): Base URL for the Caddy API (e.g., http://localhost:2019) 26 | """ 27 | self.base_url = base_url.rstrip('/') # Remove trailing slash if present 28 | 29 | def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None, headers: Optional[Dict] = None) -> requests.Response: 30 | """Make a request to the Caddy API. 31 | 32 | Args: 33 | method (str): HTTP method (GET, POST, etc.) 34 | endpoint (str): API endpoint 35 | data (Optional[Dict], optional): Data to send. Defaults to None. 36 | headers (Optional[Dict], optional): Custom headers. Defaults to None. 37 | 38 | Returns: 39 | requests.Response: Response from the API 40 | """ 41 | url = f"{self.base_url}{endpoint}" 42 | default_headers = {'Content-Type': 'application/json'} 43 | if headers is not None: 44 | default_headers.update(headers) 45 | 46 | try: 47 | response = requests.request( 48 | method=method, 49 | url=url, 50 | headers=default_headers, 51 | json=data if data else None, 52 | timeout=10 # Add timeout to prevent hanging 53 | ) 54 | response.raise_for_status() 55 | return response 56 | except requests.exceptions.RequestException as e: 57 | raise Exception(f"API request failed: {str(e)}") 58 | 59 | def _get_tls_config(self) -> Dict: 60 | """Get current TLS configuration. 61 | 62 | Returns: 63 | Dict: Current TLS configuration 64 | """ 65 | try: 66 | response = self._make_request('GET', '/config/apps/tls') 67 | return response.json() 68 | except Exception: 69 | return {} 70 | 71 | def _update_tls_config(self, new_config: Dict) -> None: 72 | """Update TLS configuration. 73 | 74 | Args: 75 | new_config (Dict): New TLS configuration 76 | """ 77 | try: 78 | # First try to get existing config 79 | response = self._make_request('GET', '/config/apps/tls') 80 | current_config = response.json() 81 | except Exception: 82 | # If no config exists, start with empty one 83 | current_config = {} 84 | 85 | # If automation policies exist, merge them 86 | if 'automation' in new_config: 87 | if 'automation' not in current_config: 88 | current_config['automation'] = {'policies': []} 89 | 90 | # Add new policies 91 | current_policies = current_config['automation']['policies'] 92 | new_policies = new_config['automation']['policies'] 93 | 94 | # Remove any existing policies for the same domains 95 | for new_policy in new_policies: 96 | current_policies = [ 97 | p for p in current_policies 98 | if not any(subject in new_policy['subjects'] for subject in p.get('subjects', [])) 99 | ] 100 | 101 | # Add new policies 102 | current_policies.extend(new_policies) 103 | current_config['automation']['policies'] = current_policies 104 | 105 | # Delete existing config first 106 | try: 107 | self._make_request('DELETE', '/config/apps/tls') 108 | except Exception: 109 | pass 110 | 111 | # Then add the updated config 112 | self._make_request('POST', '/config/apps/tls', data=current_config) 113 | 114 | def add_domain_with_auto_tls(self, domain: str, target: str, target_port: int, 115 | enable_security_headers: bool = False, enable_hsts: bool = False, 116 | frame_options: str = "DENY", enable_compression: bool = False, 117 | redirect_mode: str = None) -> bool: 118 | """Add or update domain with auto TLS configuration. 119 | 120 | Args: 121 | domain (str): Domain name 122 | target (str): Target host (IP or FQDN) for reverse proxy 123 | target_port (int): Target port for reverse proxy 124 | enable_security_headers (bool, optional): Enable security headers. Defaults to False. 125 | enable_hsts (bool, optional): Enable HSTS. Defaults to False. 126 | frame_options (str, optional): X-Frame-Options value. Defaults to "DENY". 127 | enable_compression (bool, optional): Enable compression. Defaults to False. 128 | redirect_mode (str, optional): Redirect mode. Can be "www_to_domain" or "domain_to_www". Defaults to None. 129 | 130 | Returns: 131 | bool: True if successful 132 | """ 133 | try: 134 | # Get current config 135 | response = self._make_request('GET', '/config/') 136 | config = response.json() 137 | 138 | # Initialize HTTP server config if not present 139 | if 'apps' not in config: 140 | config['apps'] = {} 141 | if 'http' not in config['apps']: 142 | config['apps']['http'] = {} 143 | if 'servers' not in config['apps']['http']: 144 | config['apps']['http']['servers'] = {} 145 | if 'srv0' not in config['apps']['http']['servers']: 146 | config['apps']['http']['servers']['srv0'] = {} 147 | if 'routes' not in config['apps']['http']['servers']['srv0']: 148 | config['apps']['http']['servers']['srv0']['routes'] = [] 149 | 150 | # Create route handlers 151 | handlers = [] 152 | 153 | # Add security headers if enabled 154 | if enable_security_headers: 155 | security_headers = self._get_security_headers(enable_hsts, frame_options) 156 | handlers.append({ 157 | "handler": "headers", 158 | "response": { 159 | "set": security_headers 160 | } 161 | }) 162 | 163 | # Add compression if enabled 164 | if enable_compression: 165 | handlers.append({ 166 | "handler": "encode", 167 | "encodings": { 168 | "gzip": {}, 169 | "zstd": {} 170 | } 171 | }) 172 | 173 | # Add reverse proxy handler 174 | handlers.append({ 175 | "handler": "reverse_proxy", 176 | "upstreams": [{ 177 | "dial": f"{target}:{target_port}" 178 | }] 179 | }) 180 | 181 | # Create routes configuration 182 | routes = [] 183 | 184 | # Handle domain names for redirect 185 | base_domain = domain.replace('www.', '') if domain.startswith('www.') else domain 186 | www_domain = f"www.{base_domain}" 187 | 188 | # Add redirect route if redirect_mode is specified 189 | if redirect_mode: 190 | source_domain = www_domain if redirect_mode == "www_to_domain" else base_domain 191 | target_domain = base_domain if redirect_mode == "www_to_domain" else www_domain 192 | 193 | redirect_route = { 194 | "@id": f"{source_domain}-redirect", 195 | "match": [{"host": [source_domain]}], 196 | "handle": [{ 197 | "handler": "static_response", 198 | "headers": { 199 | "Location": [f"https://{target_domain}{{http.request.uri}}"] 200 | }, 201 | "status_code": 308 202 | }] 203 | } 204 | routes.append(redirect_route) 205 | 206 | # Add main route 207 | main_route = { 208 | "@id": domain, 209 | "match": [{"host": [domain]}], 210 | "terminal": True, 211 | "handle": handlers 212 | } 213 | routes.append(main_route) 214 | 215 | # Get current routes 216 | current_routes = config['apps']['http']['servers']['srv0']['routes'] 217 | 218 | # Remove any existing routes for this domain 219 | current_routes = [r for r in current_routes if not ( 220 | r.get('@id') == domain or # Main domain route 221 | r.get('@id') == f"{domain}-redirect" or # Redirect route for this domain 222 | (r.get('match', [{}])[0].get('host', []) == [domain]) # Any route matching this domain 223 | )] 224 | 225 | # Find position after security routes but before domain routes 226 | insert_pos = 0 227 | for i, r in enumerate(current_routes): 228 | if r.get('handle', [{}])[0].get('handler') == 'static_response': 229 | insert_pos = i + 1 230 | elif '@id' in r: 231 | break 232 | 233 | # Insert domain routes 234 | current_routes[insert_pos:insert_pos] = routes 235 | 236 | # Update routes in config 237 | config['apps']['http']['servers']['srv0']['routes'] = current_routes 238 | 239 | # Configure auto TLS 240 | if 'tls' not in config['apps']: 241 | config['apps']['tls'] = {} 242 | if 'automation' not in config['apps']['tls']: 243 | config['apps']['tls']['automation'] = { 244 | 'policies': [] 245 | } 246 | 247 | # Find or create on_demand policy 248 | on_demand_policy = None 249 | for policy in config['apps']['tls']['automation'].get('policies', []): 250 | if policy.get('on_demand'): 251 | on_demand_policy = policy 252 | break 253 | 254 | if on_demand_policy is None: 255 | on_demand_policy = { 256 | 'issuers': [ 257 | { 258 | 'module': 'acme', 259 | 'email': 'auto-tls@miget.com' 260 | }, 261 | { 262 | 'module': 'acme', 263 | 'email': 'auto-tls@miget.com', 264 | 'ca': 'https://acme.zerossl.com/v2/DV90' 265 | } 266 | ], 267 | 'on_demand': True, 268 | 'key_type': 'p384', 269 | 'subjects': [] 270 | } 271 | config['apps']['tls']['automation']['policies'].append(on_demand_policy) 272 | 273 | # Add domain to subjects if not already present 274 | if 'subjects' not in on_demand_policy: 275 | on_demand_policy['subjects'] = [] 276 | if domain not in on_demand_policy['subjects']: 277 | on_demand_policy['subjects'].append(domain) 278 | 279 | # Update configuration 280 | self._make_request('POST', '/config/', data=config) 281 | return True 282 | 283 | except Exception as e: 284 | raise Exception(f"Failed to add domain with auto TLS: {str(e)}") 285 | 286 | def add_domain_with_tls(self, domain: str, target: str, target_port: int, certificate: str, private_key: str, 287 | cert_selection_policy: Optional[Dict] = None, redirect_mode: str = None) -> bool: 288 | """Add domain with TLS certificate. 289 | 290 | Args: 291 | domain (str): Domain name 292 | target (str): Target host (IP or FQDN) for reverse proxy 293 | target_port (int): Target port for reverse proxy 294 | certificate (str): PEM-encoded certificate 295 | private_key (str): PEM-encoded private key 296 | cert_selection_policy (Optional[Dict], optional): Certificate selection policy. Defaults to None. 297 | If not provided, will automatically create one based on the certificate's serial number. 298 | redirect_mode (str, optional): Redirect mode. Can be "www_to_domain" or "domain_to_www". Defaults to None. 299 | 300 | Returns: 301 | bool: True if successful 302 | """ 303 | try: 304 | # Extract certificate serial number for tagging 305 | from cryptography import x509 306 | from cryptography.hazmat.backends import default_backend 307 | import base64 308 | from datetime import datetime 309 | 310 | # Parse certificate to get serial number from the first certificate in the bundle 311 | cert_blocks = [] 312 | current_block = [] 313 | in_cert = False 314 | 315 | # Split into individual certificate blocks 316 | for line in certificate.splitlines(): 317 | if "-----BEGIN CERTIFICATE-----" in line: 318 | in_cert = True 319 | current_block = [line] 320 | elif "-----END CERTIFICATE-----" in line: 321 | in_cert = False 322 | current_block.append(line) 323 | cert_blocks.append("\n".join(current_block)) 324 | elif in_cert: 325 | current_block.append(line) 326 | 327 | if not cert_blocks: 328 | raise Exception("No valid certificates found in the provided certificate data") 329 | 330 | # Use the first certificate (server cert) for the serial number 331 | first_cert = cert_blocks[0] 332 | cert_lines = [] 333 | in_cert = False 334 | for line in first_cert.splitlines(): 335 | if "-----BEGIN CERTIFICATE-----" in line: 336 | in_cert = True 337 | continue 338 | elif "-----END CERTIFICATE-----" in line: 339 | in_cert = False 340 | continue 341 | if in_cert: 342 | cert_lines.append(line) 343 | 344 | cert_der = base64.b64decode("".join(cert_lines)) 345 | cert = x509.load_der_x509_certificate(cert_der, default_backend()) 346 | serial_number = format(cert.serial_number, 'x') # Convert to hex string 347 | 348 | # Create tag in format domain-serial-timestamp 349 | timestamp = datetime.now().strftime('%Y%m%d%H%M%S') 350 | cert_tag = f"{domain}-{serial_number}-{timestamp}" 351 | 352 | # Create routes configuration 353 | routes = [] 354 | 355 | # Handle domain names for redirect 356 | base_domain = domain.replace('www.', '') if domain.startswith('www.') else domain 357 | www_domain = f"www.{base_domain}" 358 | 359 | # Add redirect route if redirect_mode is specified 360 | if redirect_mode: 361 | source_domain = www_domain if redirect_mode == "www_to_domain" else base_domain 362 | target_domain = base_domain if redirect_mode == "www_to_domain" else www_domain 363 | 364 | redirect_route = { 365 | "@id": f"{source_domain}-redirect", 366 | "match": [{"host": [source_domain]}], 367 | "handle": [{ 368 | "handler": "static_response", 369 | "headers": { 370 | "Location": [f"https://{target_domain}{{http.request.uri}}"] 371 | }, 372 | "status_code": 308 373 | }] 374 | } 375 | routes.append(redirect_route) 376 | 377 | # Add main route 378 | main_route = { 379 | "@id": domain, 380 | "match": [{"host": [domain]}], 381 | "handle": [{ 382 | "handler": "subroute", 383 | "routes": [{ 384 | "handle": [{ 385 | "handler": "reverse_proxy", 386 | "upstreams": [{ 387 | "dial": f"{target}:{target_port}" 388 | }] 389 | }] 390 | }] 391 | }] 392 | } 393 | routes.append(main_route) 394 | 395 | # Get current config 396 | response = self._make_request('GET', '/config/') 397 | config = response.json() 398 | 399 | # Add routes to config 400 | if 'apps' not in config: 401 | config['apps'] = {} 402 | if 'http' not in config['apps']: 403 | config['apps']['http'] = {} 404 | if 'servers' not in config['apps']['http']: 405 | config['apps']['http']['servers'] = {} 406 | if 'srv0' not in config['apps']['http']['servers']: 407 | config['apps']['http']['servers']['srv0'] = {} 408 | if 'routes' not in config['apps']['http']['servers']['srv0']: 409 | config['apps']['http']['servers']['srv0']['routes'] = [] 410 | 411 | config['apps']['http']['servers']['srv0']['routes'].extend(routes) 412 | 413 | # Add certificate configuration 414 | if 'tls' not in config['apps']: 415 | config['apps']['tls'] = {} 416 | if 'certificates' not in config['apps']['tls']: 417 | config['apps']['tls']['certificates'] = {} 418 | 419 | cert_config = { 420 | "certificate": certificate, 421 | "key": private_key, 422 | "tags": [cert_tag] # Use domain-serial-timestamp tag 423 | } 424 | 425 | if 'load_pem' not in config['apps']['tls']['certificates']: 426 | config['apps']['tls']['certificates']['load_pem'] = [cert_config] 427 | else: 428 | # Remove any existing certificates with matching domain tag pattern 429 | config['apps']['tls']['certificates']['load_pem'] = [ 430 | cert for cert in config['apps']['tls']['certificates']['load_pem'] 431 | if not (any(tag.startswith(f"{domain}-") for tag in cert.get('tags', [])) or 432 | any(tag.startswith('domain-') for tag in cert.get('tags', [])) or 433 | domain in cert.get('tags', [])) 434 | ] 435 | # Add the new certificate 436 | config['apps']['tls']['certificates']['load_pem'].append(cert_config) 437 | 438 | # Update TLS connection policies 439 | self._update_tls_connection_policies(config, domain, cert_tag=cert_tag) 440 | 441 | # Update the configuration 442 | self._make_request('POST', '/config/', data=config) 443 | return True 444 | 445 | except Exception as e: 446 | raise Exception(f"Failed to add domain: {str(e)}") 447 | 448 | def delete_domain(self, domain: str) -> bool: 449 | """Delete domain configuration. 450 | 451 | Args: 452 | domain (str): Domain name 453 | 454 | Returns: 455 | bool: True if successful 456 | """ 457 | try: 458 | # Get current config 459 | response = self._make_request('GET', '/config/') 460 | config = response.json() 461 | 462 | # Get current routes 463 | routes = config['apps']['http']['servers']['srv0']['routes'] 464 | 465 | # Remove domain routes, redirect routes, and ACME challenge routes 466 | routes = [r for r in routes if not ( 467 | r.get('@id') == domain or # Main domain route 468 | r.get('@id') == f"{domain}-redirect" or # Redirect route for this domain 469 | (r.get('match', [{}])[0].get('host', []) == [domain]) # Any route matching this domain 470 | )] 471 | 472 | # Update routes 473 | config['apps']['http']['servers']['srv0']['routes'] = routes 474 | 475 | # Remove domain from TLS automation subjects if present 476 | if 'tls' in config['apps'] and 'automation' in config['apps']['tls']: 477 | for policy in config['apps']['tls']['automation'].get('policies', []): 478 | if 'subjects' in policy and domain in policy['subjects']: 479 | policy['subjects'].remove(domain) 480 | 481 | # Find certificate tags from TLS connection policies before removing them 482 | cert_tags_to_remove = set() 483 | if 'tls_connection_policies' in config['apps']['http']['servers']['srv0']: 484 | policies = config['apps']['http']['servers']['srv0']['tls_connection_policies'] 485 | for policy in policies: 486 | if ('match' in policy and 'sni' in policy['match'] and 487 | domain in policy['match']['sni'] and 488 | 'certificate_selection' in policy and 489 | 'all_tags' in policy['certificate_selection']): 490 | cert_tags_to_remove.update(policy['certificate_selection']['all_tags']) 491 | 492 | # Remove TLS connection policies for this domain 493 | policies = [p for p in policies if not ( 494 | 'match' in p and 'sni' in p['match'] and domain in p['match']['sni'] 495 | )] 496 | if policies: 497 | config['apps']['http']['servers']['srv0']['tls_connection_policies'] = policies 498 | else: 499 | del config['apps']['http']['servers']['srv0']['tls_connection_policies'] 500 | 501 | # Remove certificates with matching tags from the policy 502 | if cert_tags_to_remove and 'tls' in config['apps'] and 'certificates' in config['apps']['tls']: 503 | if 'load_pem' in config['apps']['tls']['certificates']: 504 | # Filter out certificates with matching tags 505 | config['apps']['tls']['certificates']['load_pem'] = [ 506 | cert for cert in config['apps']['tls']['certificates']['load_pem'] 507 | if not (set(cert.get('tags', [])) & cert_tags_to_remove) 508 | ] 509 | # Remove the certificates section if empty 510 | if not config['apps']['tls']['certificates']['load_pem']: 511 | del config['apps']['tls']['certificates']['load_pem'] 512 | if not config['apps']['tls']['certificates']: 513 | del config['apps']['tls']['certificates'] 514 | 515 | # Update configuration 516 | self._make_request('POST', '/config/', data=config) 517 | return True 518 | 519 | except Exception as e: 520 | raise Exception(f"Failed to delete domain: {str(e)}") 521 | 522 | def get_domain_config(self, domain: str) -> Dict: 523 | """Get domain configuration. 524 | 525 | Args: 526 | domain (str): Domain name 527 | 528 | Returns: 529 | Dict: Domain configuration 530 | """ 531 | try: 532 | # Get current config 533 | response = self._make_request('GET', '/config/') 534 | config = response.json() 535 | 536 | # Find domain route 537 | route = None 538 | if 'apps' in config and 'http' in config['apps']: 539 | servers = config['apps']['http'].get('servers', {}) 540 | for server_name, server in servers.items(): 541 | routes = server.get('routes', []) 542 | for r in routes: 543 | if r.get('@id') == domain: 544 | route = r 545 | break 546 | if route: 547 | break 548 | 549 | if not route: 550 | print(f"Warning: Route for domain {domain} not found") 551 | route = {} 552 | 553 | # Get certificate configuration 554 | cert_config = {} 555 | if 'apps' in config and 'tls' in config['apps']: 556 | tls_config = config['apps']['tls'] 557 | 558 | # Check for auto TLS 559 | if 'automation' in tls_config: 560 | for policy in tls_config['automation'].get('policies', []): 561 | if policy.get('on_demand') and domain in policy.get('subjects', []): 562 | cert_config = { 563 | 'type': 'auto_tls', 564 | 'policy': policy 565 | } 566 | break 567 | 568 | # Check for PEM certificates 569 | if not cert_config and 'certificates' in tls_config: 570 | for cert in tls_config['certificates'].get('load_pem', []): 571 | if domain in cert.get('tags', []): 572 | cert_config = { 573 | 'type': 'pem', 574 | 'certificate': cert 575 | } 576 | break 577 | 578 | return { 579 | 'route': route, 580 | 'certificates': cert_config 581 | } 582 | 583 | except Exception as e: 584 | raise Exception(f"Failed to get domain config: {str(e)}") 585 | 586 | def _is_domain_using_auto_tls(self, config: Dict, domain: str) -> bool: 587 | """Check if a domain is using auto TLS. 588 | 589 | Args: 590 | config (Dict): Current Caddy config 591 | domain (str): Domain to check 592 | 593 | Returns: 594 | bool: True if domain is using auto TLS 595 | """ 596 | if 'tls' in config['apps'] and 'automation' in config['apps']['tls']: 597 | for policy in config['apps']['tls']['automation'].get('policies', []): 598 | if policy.get('on_demand') and domain in policy.get('subjects', []): 599 | return True 600 | return False 601 | 602 | def _get_auto_tls_policy(self, domain: str) -> Dict: 603 | """Get TLS connection policy for auto TLS domain. 604 | 605 | Args: 606 | domain (str): Domain name 607 | 608 | Returns: 609 | Dict: TLS connection policy for auto TLS 610 | """ 611 | return { 612 | "match": {"sni": [domain]}, 613 | "protocol_min": "tls1.2", 614 | "protocol_max": "tls1.3", # Enforce TLS 1.3 as max 615 | "cipher_suites": [ 616 | "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 617 | "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 618 | "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", 619 | "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" 620 | ], 621 | "curves": ["x25519", "secp256r1", "secp384r1"], 622 | "alpn": ["h3", "h2", "h1"] # Enable HTTP/3 (QUIC) 623 | } 624 | 625 | def _get_custom_cert_policy(self, domain: str, cert_tag: str) -> Dict: 626 | """Get TLS connection policy for custom certificate domain. 627 | 628 | Args: 629 | domain (str): Domain name 630 | cert_tag (str): Certificate tag 631 | 632 | Returns: 633 | Dict: TLS connection policy for custom certificate 634 | """ 635 | return { 636 | "match": {"sni": [domain]}, 637 | "protocol_min": "tls1.2", 638 | "protocol_max": "tls1.3", 639 | "cipher_suites": [ 640 | "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 641 | "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 642 | "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", 643 | "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" 644 | ], 645 | "curves": ["x25519", "secp256r1", "secp384r1"], 646 | "alpn": ["h3", "h2", "h1"], 647 | "certificate_selection": {"all_tags": [cert_tag]} 648 | } 649 | 650 | def _get_security_headers(self, enable_hsts: bool = True, frame_options: str = "DENY") -> dict: 651 | """Generate security headers configuration. 652 | 653 | Args: 654 | enable_hsts (bool): Enable HSTS header 655 | frame_options (str): X-Frame-Options value (DENY, SAMEORIGIN) 656 | 657 | Returns: 658 | dict: Security headers configuration 659 | """ 660 | headers = { 661 | "X-Content-Type-Options": ["nosniff"], 662 | "X-Frame-Options": [frame_options], 663 | "Referrer-Policy": ["strict-origin-when-cross-origin"], 664 | } 665 | 666 | if enable_hsts: 667 | headers["Strict-Transport-Security"] = ["max-age=31536000; includeSubDomains"] 668 | 669 | return headers 670 | 671 | def _get_custom_headers(self, custom_headers: Dict[str, str]) -> dict: 672 | """Convert custom headers to Caddy format. 673 | 674 | Args: 675 | custom_headers (Dict[str, str]): Custom headers 676 | 677 | Returns: 678 | dict: Headers in Caddy format 679 | """ 680 | return {k: [v] for k, v in custom_headers.items()} 681 | 682 | def _update_tls_connection_policies(self, config: Dict, domain: str, is_auto_tls: bool = False, cert_tag: str = None): 683 | """Update TLS connection policies for a domain. 684 | 685 | Args: 686 | config (Dict): Current Caddy config 687 | domain (str): Domain name 688 | is_auto_tls (bool): Whether domain uses auto TLS 689 | cert_tag (str): Certificate tag for custom certificate 690 | """ 691 | # Initialize tls_connection_policies if not exists 692 | if 'tls_connection_policies' not in config['apps']['http']['servers']['srv0']: 693 | config['apps']['http']['servers']['srv0']['tls_connection_policies'] = [] 694 | 695 | # Remove existing policy for this domain 696 | policies = config['apps']['http']['servers']['srv0']['tls_connection_policies'] 697 | policies = [p for p in policies if domain not in p.get('match', {}).get('sni', [])] 698 | 699 | # Add policy based on type 700 | if is_auto_tls: 701 | policies.append(self._get_auto_tls_policy(domain)) 702 | elif cert_tag: 703 | policies.append(self._get_custom_cert_policy(domain, cert_tag)) 704 | 705 | # Always update the policies array, even if empty 706 | config['apps']['http']['servers']['srv0']['tls_connection_policies'] = policies 707 | 708 | def _should_remove_tls_connection_policies(self, config: Dict) -> bool: 709 | """Check if tls_connection_policies should be removed. 710 | 711 | Args: 712 | config (Dict): Current Caddy config 713 | 714 | Returns: 715 | bool: True if tls_connection_policies should be removed 716 | """ 717 | # If there are no policies, it's safe to remove 718 | if not config['apps']['http']['servers']['srv0'].get('tls_connection_policies', []): 719 | return True 720 | 721 | # Get all domains from routes 722 | domains = [] 723 | for route in config['apps']['http']['servers']['srv0'].get('routes', []): 724 | if '@id' in route: 725 | domains.append(route['@id']) 726 | 727 | # Check if all domains are using auto TLS 728 | return all(self._is_domain_using_auto_tls(config, domain) for domain in domains) 729 | 730 | def _normalize_domain(self, domain: str) -> str: 731 | """Remove www prefix from domain if present. 732 | 733 | Args: 734 | domain (str): Domain name 735 | 736 | Returns: 737 | str: Domain name without www prefix 738 | """ 739 | return domain[4:] if domain.startswith('www.') else domain 740 | 741 | def update_domain(self, domain: str, target: str = None, target_port: int = None, 742 | certificate: str = None, private_key: str = None, 743 | cert_selection_policy: Optional[Dict] = None, 744 | redirect_mode: str = None) -> bool: 745 | """Update domain configuration. 746 | 747 | Args: 748 | domain (str): Domain name to update 749 | target (str, optional): New target host (IP or FQDN) for reverse proxy. Defaults to None. 750 | target_port (int, optional): New target port for reverse proxy. Defaults to None. 751 | certificate (str, optional): PEM-encoded certificate or "auto" for auto TLS. Defaults to None. 752 | private_key (str, optional): PEM-encoded private key. Required if certificate is PEM. Defaults to None. 753 | cert_selection_policy (Optional[Dict], optional): Certificate selection policy. Defaults to None. 754 | If not provided and certificate is updated, will automatically create one based on the certificate's serial number. 755 | redirect_mode (str, optional): Redirect mode. Can be "www_to_domain" or "domain_to_www". Defaults to None. 756 | 757 | Returns: 758 | bool: True if successful 759 | """ 760 | try: 761 | # Get current config 762 | response = self._make_request('GET', '/config/') 763 | config = response.json() 764 | 765 | # Get current routes 766 | routes = config['apps']['http']['servers']['srv0']['routes'] 767 | 768 | # Find existing route for this domain 769 | domain_route = None 770 | for route in routes: 771 | if route.get('@id') == domain: 772 | domain_route = route 773 | break 774 | 775 | if domain_route is None: 776 | raise Exception(f"Domain {domain} not found in configuration") 777 | 778 | # Update target if provided 779 | if target is not None and target_port is not None: 780 | # Create new handlers list with security headers and reverse proxy 781 | new_handlers = [] 782 | 783 | # Add existing security headers if present 784 | for handler in domain_route['handle']: 785 | if handler['handler'] == 'headers': 786 | new_handlers.append(handler) 787 | elif handler['handler'] == 'encode': 788 | new_handlers.append(handler) 789 | 790 | # Add reverse proxy handler 791 | new_handlers.append({ 792 | "handler": "reverse_proxy", 793 | "upstreams": [{ 794 | "dial": f"{target}:{target_port}" 795 | }] 796 | }) 797 | 798 | # Update domain route with new handlers 799 | domain_route['handle'] = new_handlers 800 | 801 | # Update certificate if provided 802 | if certificate == "auto": 803 | # Remove domain from any existing TLS config 804 | if 'tls' in config['apps']: 805 | # Remove from certificates if present 806 | if 'certificates' in config['apps']['tls']: 807 | if 'load_pem' in config['apps']['tls']['certificates']: 808 | config['apps']['tls']['certificates']['load_pem'] = [ 809 | cert for cert in config['apps']['tls']['certificates']['load_pem'] 810 | if not (any(tag.startswith(f"{domain}-") for tag in cert.get('tags', []))) 811 | ] 812 | elif certificate: 813 | # Handle PEM certificate update 814 | if not private_key: 815 | raise Exception("Private key is required when updating PEM certificate") 816 | 817 | # Extract certificate serial number for tagging 818 | from cryptography import x509 819 | from cryptography.hazmat.backends import default_backend 820 | import base64 821 | from datetime import datetime 822 | 823 | # Parse certificate to get serial number from the first certificate in the bundle 824 | cert_blocks = [] 825 | current_block = [] 826 | in_cert = False 827 | 828 | # Split into individual certificate blocks 829 | for line in certificate.splitlines(): 830 | if "-----BEGIN CERTIFICATE-----" in line: 831 | in_cert = True 832 | current_block = [line] 833 | elif "-----END CERTIFICATE-----" in line: 834 | in_cert = False 835 | current_block.append(line) 836 | cert_blocks.append("\n".join(current_block)) 837 | elif in_cert: 838 | current_block.append(line) 839 | 840 | if not cert_blocks: 841 | raise Exception("No valid certificates found in the provided certificate data") 842 | 843 | # Use the first certificate (server cert) for the serial number 844 | first_cert = cert_blocks[0] 845 | cert_lines = [] 846 | in_cert = False 847 | for line in first_cert.splitlines(): 848 | if "-----BEGIN CERTIFICATE-----" in line: 849 | in_cert = True 850 | continue 851 | elif "-----END CERTIFICATE-----" in line: 852 | in_cert = False 853 | continue 854 | if in_cert: 855 | cert_lines.append(line) 856 | 857 | cert_der = base64.b64decode("".join(cert_lines)) 858 | cert = x509.load_der_x509_certificate(cert_der, default_backend()) 859 | serial_number = format(cert.serial_number, 'x') # Convert to hex string 860 | 861 | # Create tag in format domain-serial-timestamp 862 | timestamp = datetime.now().strftime('%Y%m%d%H%M%S') 863 | cert_tag = f"{domain}-{serial_number}-{timestamp}" 864 | 865 | # Create certificate configuration 866 | cert_config = { 867 | "certificate": certificate, 868 | "key": private_key, 869 | "tags": [cert_tag] # Use domain-serial-timestamp tag 870 | } 871 | 872 | # Add certificate selection policy if provided, otherwise create one based on cert tag 873 | if cert_selection_policy: 874 | cert_config.update(cert_selection_policy) 875 | else: 876 | cert_config["tags"] = [cert_tag] 877 | 878 | # Update certificates configuration 879 | if 'tls' not in config['apps']: 880 | config['apps']['tls'] = {} 881 | if 'certificates' not in config['apps']['tls']: 882 | config['apps']['tls']['certificates'] = {} 883 | if 'load_pem' not in config['apps']['tls']['certificates']: 884 | config['apps']['tls']['certificates']['load_pem'] = [] 885 | 886 | # Remove any existing certificates for this domain 887 | config['apps']['tls']['certificates']['load_pem'] = [ 888 | cert for cert in config['apps']['tls']['certificates']['load_pem'] 889 | if not (any(tag.startswith(f"{domain}-") for tag in cert.get('tags', []))) 890 | ] 891 | 892 | # Add new certificate 893 | config['apps']['tls']['certificates']['load_pem'].append(cert_config) 894 | 895 | # Update TLS connection policies 896 | self._update_tls_connection_policies(config, domain, cert_tag=cert_tag) 897 | 898 | # Update redirect route if redirect_mode is provided 899 | if redirect_mode: 900 | # Find existing redirect route 901 | redirect_route = None 902 | for route in routes: 903 | if route.get('@id') == f"{domain}-redirect": 904 | redirect_route = route 905 | break 906 | 907 | # Create new redirect route if not found 908 | if not redirect_route: 909 | # Normalize domain by removing www if present 910 | base_domain = self._normalize_domain(domain) 911 | source_domain = f"www.{base_domain}" if redirect_mode == "www_to_domain" else base_domain 912 | target_domain = base_domain if redirect_mode == "www_to_domain" else f"www.{base_domain}" 913 | 914 | redirect_route = { 915 | "@id": f"{domain}-redirect", 916 | "match": [{"host": [source_domain]}], 917 | "handle": [{ 918 | "handler": "static_response", 919 | "headers": { 920 | "Location": [f"https://{target_domain}{{http.request.uri}}"] 921 | }, 922 | "status_code": 308 923 | }] 924 | } 925 | routes.append(redirect_route) 926 | else: 927 | # Update existing redirect route 928 | base_domain = self._normalize_domain(domain) 929 | source_domain = f"www.{base_domain}" if redirect_mode == "www_to_domain" else base_domain 930 | target_domain = base_domain if redirect_mode == "www_to_domain" else f"www.{base_domain}" 931 | 932 | redirect_route['match'][0]['host'] = [source_domain] 933 | redirect_route['handle'][0]['headers']['Location'] = [f"https://{target_domain}{{http.request.uri}}"] 934 | 935 | # Update configuration 936 | self._make_request('POST', '/config/', data=config) 937 | return True 938 | 939 | except Exception as e: 940 | raise Exception(f"Failed to update domain: {str(e)}") 941 | 942 | def reload(self) -> bool: 943 | """Force reload of Caddy configuration. 944 | Gets current config and sends it to /load with must-revalidate header. 945 | 946 | Returns: 947 | bool: True if successful 948 | """ 949 | try: 950 | print("Getting current configuration...") 951 | response = self._make_request('GET', '/config/') 952 | config = response.json() 953 | 954 | print("Reloading configuration...") 955 | headers = {'Cache-Control': 'must-revalidate'} 956 | self._make_request('POST', '/load', data=config, headers=headers) 957 | print("Configuration reloaded successfully") 958 | return True 959 | 960 | except Exception as e: 961 | raise Exception(f"Failed to reload configuration: {str(e)}") 962 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy:2.9-alpine 4 | container_name: caddy 5 | platform: linux/amd64 6 | ports: 7 | - "80:80" 8 | - "443:443" 9 | - "2019:2019" 10 | volumes: 11 | - ./Caddyfile:/etc/caddy/Caddyfile 12 | - caddy_data:/data 13 | - caddy_config:/config 14 | - ./certs:/etc/caddy/certs 15 | restart: unless-stopped 16 | entrypoint: ["caddy"] 17 | command: ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile", "--resume"] 18 | 19 | answer-caddy: 20 | image: hashicorp/http-echo:latest 21 | container_name: answer-caddy 22 | ports: 23 | - "8080:8080" 24 | command: ["-text", "true", "-listen", ":8080"] 25 | restart: unless-stopped 26 | 27 | nginx: 28 | image: nginx:alpine 29 | container_name: nginx 30 | restart: unless-stopped 31 | 32 | volumes: 33 | caddy_data: 34 | caddy_config: 35 | -------------------------------------------------------------------------------- /examples/add_domain_with_auto_tls.py: -------------------------------------------------------------------------------- 1 | from caddy_api_client import CaddyAPIClient 2 | 3 | def main(): 4 | client = CaddyAPIClient("http://localhost:2019") 5 | 6 | # Example domain and backend service 7 | domain = "example.com" # Replace with your actual domain 8 | nginx_container_ip = "nginx" # Docker service name will resolve to container IP 9 | nginx_port = 80 10 | 11 | try: 12 | # Add domain with auto TLS and security headers 13 | client.add_domain_with_auto_tls( 14 | domain=domain, 15 | target=nginx_container_ip, 16 | target_port=nginx_port, 17 | # enable_security_headers=True, # Enable security headers 18 | # enable_hsts=True, # Enable HSTS 19 | # frame_options="DENY", # Set X-Frame-Options 20 | # enable_compression=True # Enable compression 21 | ) 22 | print(f"Successfully added domain {domain} with automatic TLS") 23 | 24 | # Get and print the domain configuration 25 | config = client._make_request('GET', '/config/apps/http/servers/srv0/routes').json() 26 | print("\nDomain configuration:") 27 | print(config) 28 | 29 | except Exception as e: 30 | print(f"Error: {str(e)}") 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /examples/add_domain_with_tls.py: -------------------------------------------------------------------------------- 1 | from caddy_api_client import CaddyAPIClient 2 | 3 | def main(): 4 | # Initialize the client 5 | client = CaddyAPIClient("http://localhost:2019") 6 | 7 | # Example domain and backend service 8 | domain = "example.com" # Replace with your actual domain 9 | target = "nginx" # Docker service name will resolve to container IP 10 | target_port = 80 # Replace with your backend service port 11 | 12 | try: 13 | # Read certificate and key PEM files 14 | with open('tls.crt', 'r') as f: 15 | certificate = f.read() 16 | with open('tls.key', 'r') as f: 17 | private_key = f.read() 18 | 19 | # Add domain with PEM certificate 20 | client.add_domain_with_tls( 21 | domain=domain, 22 | target=target, 23 | target_port=target_port, 24 | certificate=certificate, 25 | private_key=private_key 26 | ) 27 | print(f"Successfully added domain {domain} with PEM certificate") 28 | 29 | # Show domain configuration 30 | config = client.get_domain_config(domain) 31 | print("\nDomain configuration:") 32 | print(config) 33 | 34 | except Exception as e: 35 | print(f"Error: {e}") 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /examples/delete_domain.py: -------------------------------------------------------------------------------- 1 | from caddy_api_client import CaddyAPIClient 2 | 3 | def main(): 4 | # Initialize the client 5 | client = CaddyAPIClient("http://localhost:2019") 6 | 7 | # Example domain 8 | domain = "example.com" 9 | 10 | try: 11 | # Delete domain configuration 12 | client.delete_domain(domain) 13 | print(f"Successfully deleted domain {domain}") 14 | 15 | except Exception as e: 16 | print(f"Error: {e}") 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /examples/manage_domain.py: -------------------------------------------------------------------------------- 1 | from caddy_api_client import CaddyAPIClient 2 | 3 | def main(): 4 | # Initialize the client 5 | client = CaddyAPIClient("http://localhost:2019") 6 | 7 | # Domain configuration 8 | domain = "example.com" # Replace with your actual domain 9 | target = "nginx" # Docker service name will resolve to container IP 10 | target_port = 80 # Replace with your backend service port 11 | 12 | try: 13 | # Update domain with auto TLS 14 | print(f"\nUpdating {domain} with auto TLS...") 15 | client.update_domain( 16 | domain=domain, 17 | target=target, 18 | target_port=target_port, 19 | certificate="auto" 20 | ) 21 | print(f"Successfully updated {domain} with auto TLS") 22 | 23 | # Show updated configuration 24 | config = client.get_domain_config(domain) 25 | print("\nUpdated configuration:") 26 | print(config) 27 | 28 | except Exception as e: 29 | print(f"Error: {e}") 30 | 31 | if __name__ == "__main__": 32 | main() -------------------------------------------------------------------------------- /examples/reload.py: -------------------------------------------------------------------------------- 1 | from caddy_api_client import CaddyAPIClient 2 | 3 | def main(): 4 | # Initialize the client 5 | client = CaddyAPIClient("http://localhost:2019") 6 | 7 | try: 8 | # Force reload of configuration 9 | print("\nForcing configuration reload...") 10 | client.reload() 11 | print("Done!") 12 | 13 | except Exception as e: 14 | print(f"Error: {e}") 15 | 16 | if __name__ == "__main__": 17 | main() 18 | -------------------------------------------------------------------------------- /examples/tls.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICljCCAf+gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBoMQswCQYDVQQGEwJwbDEP 3 | MA0GA1UECAwGQ3JhY293MRIwEAYDVQQKDAltaWdldC5jb20xFDASBgNVBAMMC2V4 4 | YW1wbGUuY29tMR4wHAYJKoZIhvcNAQkBFg9oZWxsb0BtaWdldC5jb20wHhcNMjUw 5 | MTAxMjEwMDQ1WhcNMzQxMjMwMjEwMDQ1WjBoMQswCQYDVQQGEwJwbDEPMA0GA1UE 6 | CAwGQ3JhY293MRIwEAYDVQQKDAltaWdldC5jb20xFDASBgNVBAMMC2V4YW1wbGUu 7 | Y29tMR4wHAYJKoZIhvcNAQkBFg9oZWxsb0BtaWdldC5jb20wgZ8wDQYJKoZIhvcN 8 | AQEBBQADgY0AMIGJAoGBALOxBCJy+i/FaB427fpxe7wjNkrPhIcE3gEo2ppIlCE5 9 | jlhJHIdz7dLH1n+JuVtEJ0wrwo/iHc14kkdp06DUZZ9UBEWp2etxvTZRCo6GE+Pz 10 | 5ITpeDA4Gok7vQIp6QYgYWe/ql+Rn8pDExQqB1v4zcR3kEanUuQoPsppG6gaYBDr 11 | AgMBAAGjUDBOMB0GA1UdDgQWBBRatpITKaZ67AQsb1fTL3iDhaeidTAfBgNVHSME 12 | GDAWgBRatpITKaZ67AQsb1fTL3iDhaeidTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3 13 | DQEBDQUAA4GBAGDAGF7UZ3Wfb5ZHl9AEwDAPVYGuZ30Oar0oooe43x2Dj14LWdSk 14 | Lt25NGS/qv1lVBVv5IlIHNY5T25BsBNUaCM+AF8qJagOLR/k8WOTzeoDzexwY00N 15 | m+pqGlTONTYNpzP/KG184gIs6WoZOp567pSKC4DwhQRswYeZ0VLHX2B/ 16 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /examples/tls.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBALOxBCJy+i/FaB42 3 | 7fpxe7wjNkrPhIcE3gEo2ppIlCE5jlhJHIdz7dLH1n+JuVtEJ0wrwo/iHc14kkdp 4 | 06DUZZ9UBEWp2etxvTZRCo6GE+Pz5ITpeDA4Gok7vQIp6QYgYWe/ql+Rn8pDExQq 5 | B1v4zcR3kEanUuQoPsppG6gaYBDrAgMBAAECgYBuLYPTe5xb3jbRD+0rOpob6ZtI 6 | k6U3BIz7OQmQwB/Tn27gQzPy+epA67dhzBiTFV7jHZHWl1aeveczUngojRMhbdG8 7 | Hjp0pmxscwflhaJwiZwkHsEkEppoBebF+nyPgtWzoc2C74G4f8wRnARgFcBtSehm 8 | xPrCqKYVWCe0ybJNwQJBAO9LoJ7ol5UyLXRYpdl0yPipR5ZxA79DJwAHS0igx/sz 9 | H+4+0Gj7cK4WM3BOT4vqdhqRpWKHO+mV4Qg/Fdd8N60CQQDAPDm1oqtdH1N6qRac 10 | KO2LlkdLjbRsnopXV0AfSoYCseIlGSsu7ptRYN0BI3BA2bSIye1ggtRpAekIZ3jS 11 | Dt33AkEAnGDD2pUNWkasRbgYyf7zxux5B+tbE4aC2hXqMNBBX/uNBliuQazvehSw 12 | ENhcS4cxHzPG7JiLop57rLPtRiC7EQJAK3tz8knhSScU0uH8Y0Y+tRxA9C4RaaBS 13 | 2n652loZBfMGnC2dPvhp2XZ3hxJlCcY0t9w0/WeadEYiV+xPv/Ps8QJBAIi6q3zY 14 | /8CLru2M6hHz671TjjU3AwhK8iSRVaxnW6i3ynJVa60OZ+Q1WDKYNCmSwrdCqPdg 15 | 0grlA1vZ+KJBmQE= 16 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.31.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | 4 | # read the contents of README file 5 | with open(os.path.join(os.path.dirname(__file__), 'README.md'), encoding='utf-8') as f: 6 | long_description = f.read() 7 | 8 | setup( 9 | name="caddy-api-client", 10 | version="0.2.4", 11 | packages=find_packages(), 12 | install_requires=[ 13 | "requests>=2.31.0", 14 | ], 15 | author="Krzysztof Taraszka", 16 | author_email="chris@miget.com", 17 | description="A Python client for the Caddy API", 18 | long_description=long_description, 19 | long_description_content_type="text/markdown", 20 | url="https://github.com/migetapp/caddy-api-client", 21 | classifiers=[ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 30 | "Topic :: Software Development :: Libraries :: Python Modules", 31 | ], 32 | python_requires=">=3.7", 33 | ) 34 | --------------------------------------------------------------------------------