├── .github └── workflows │ ├── deploy.yaml │ └── test.yaml.off ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── fastcaddy ├── __init__.py ├── _modidx.py └── core.py ├── nbs ├── 00_core.ipynb ├── _quarto.yml ├── index.ipynb ├── nbdev.yml ├── styles.css └── test_add_sub_reverse_proxy.ipynb ├── pyproject.toml ├── settings.ini ├── setup.py └── setup_service.sh /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | permissions: 4 | contents: write 5 | pages: write 6 | 7 | on: 8 | push: 9 | branches: [ "main", "master" ] 10 | workflow_dispatch: 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | steps: [uses: fastai/workflows/quarto-ghp@master] 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml.off: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [workflow_dispatch, pull_request, push] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: [uses: fastai/workflows/nbdev-ci@master] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | posts/ 2 | .quarto 3 | .sesskey 4 | *.db-* 5 | *.db 6 | .gitattributes 7 | _proc/ 8 | sidebar.yml 9 | Gemfile.lock 10 | token 11 | _docs/ 12 | conda/ 13 | .last_checked 14 | .gitconfig 15 | *.bak 16 | *.log 17 | *~ 18 | ~* 19 | _tmp* 20 | tmp* 21 | tags 22 | 23 | # Byte-compiled / optimized / DLL files 24 | __pycache__/ 25 | *.py[cod] 26 | *$py.class 27 | 28 | # C extensions 29 | *.so 30 | 31 | # Distribution / packaging 32 | .Python 33 | env/ 34 | build/ 35 | develop-eggs/ 36 | dist/ 37 | downloads/ 38 | eggs/ 39 | .eggs/ 40 | lib/ 41 | lib64/ 42 | parts/ 43 | sdist/ 44 | var/ 45 | wheels/ 46 | *.egg-info/ 47 | .installed.cfg 48 | *.egg 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | .hypothesis/ 70 | 71 | # Translations 72 | *.mo 73 | *.pot 74 | 75 | # Django stuff: 76 | *.log 77 | local_settings.py 78 | 79 | # Flask stuff: 80 | instance/ 81 | .webassets-cache 82 | 83 | # Scrapy stuff: 84 | .scrapy 85 | 86 | # Sphinx documentation 87 | docs/_build/ 88 | 89 | # PyBuilder 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # celery beat schedule file 99 | celerybeat-schedule 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # dotenv 105 | .env 106 | 107 | # virtualenv 108 | .venv 109 | venv/ 110 | ENV/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | 125 | .vscode 126 | *.swp 127 | 128 | # osx generated files 129 | .DS_Store 130 | .DS_Store? 131 | .Trashes 132 | ehthumbs.db 133 | Thumbs.db 134 | .idea 135 | 136 | # pytest 137 | .pytest_cache 138 | 139 | # tools/trust-doc-nbs 140 | docs_src/.last_checked 141 | 142 | # symlinks to fastai 143 | docs_src/fastai 144 | tools/fastai 145 | 146 | # link checker 147 | checklink/cookies.txt 148 | 149 | # .gitconfig is now autogenerated 150 | .gitconfig 151 | 152 | _docs 153 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | 4 | 5 | ## 0.0.8 6 | 7 | ### Bugs Squashed 8 | 9 | - update acme_path var name ([#11](https://github.com/AnswerDotAI/fastcaddy/pull/11)), thanks to [@RensDimmendaal](https://github.com/RensDimmendaal) 10 | 11 | 12 | ## 0.0.7 13 | 14 | ### New Features 15 | 16 | - Add `skip_install_trust` ([#10](https://github.com/AnswerDotAI/fastcaddy/issues/10)) 17 | 18 | 19 | ## 0.0.5 20 | 21 | ### New Features 22 | 23 | - Multiport and internal tls support ([#9](https://github.com/AnswerDotAI/fastcaddy/issues/9)), thanks to [@pydanny](https://github.com/pydanny) 24 | - Improved pcfg error ([#8](https://github.com/AnswerDotAI/fastcaddy/pull/8)), thanks to [@pydanny](https://github.com/pydanny) 25 | 26 | 27 | ## 0.0.4 28 | 29 | ### New Features 30 | 31 | - Work around caddy+chrome http 3 bug by disabling http 3 ([#7](https://github.com/AnswerDotAI/fastcaddy/issues/7)) 32 | - Modify `add_sub_reverse_proxy` to be multi-port ([#3](https://github.com/AnswerDotAI/fastcaddy/pull/3)), thanks to [@audreyfeldroy](https://github.com/audreyfeldroy) 33 | 34 | 35 | ## 0.0.3 36 | 37 | ### New Features 38 | 39 | - Add wildcard domain support ([#2](https://github.com/AnswerDotAI/fastcaddy/issues/2)) 40 | 41 | 42 | ## 0.0.2 43 | 44 | - Initial release 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /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 settings.ini 2 | include LICENSE 3 | include CONTRIBUTING.md 4 | include README.md 5 | recursive-exclude * __pycache__ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastcaddy 2 | 3 | 4 | 5 | 6 | ## Usage 7 | 8 | ### Installation 9 | 10 | Install from [pypi](https://pypi.org/project/fastcaddy/) 11 | 12 | ``` sh 13 | $ pip install fastcaddy 14 | ``` 15 | 16 | ## Installing Caddy 17 | 18 | This project is to help you use the caddy API, rather than a Caddyfile, 19 | to use caddy. To use the API, you need to install a plugin for your 20 | domain management service. We use Cloudflare, so we’ll document that 21 | here. For other domain services, see the Caddy docs for other plugins. 22 | 23 | ### Cloudflare setup 24 | 25 | ``` python 26 | from fastcore.utils import * 27 | ``` 28 | 29 | You’ll need a token from Cloudflare with access to modify the necessary 30 | settings. Here’s the steps to create a token with the minimal 31 | privileges. You’ll need to install the cloudflare pip package, then 32 | import: 33 | 34 | ``` python 35 | from cloudflare import Cloudflare 36 | ``` 37 | 38 | Then you’ll need create a Cloudflare API token for your user, which 39 | we’ll then use to create the less privileged token. 40 | 41 | ``` python 42 | cf_token = os.environ['CLOUDFLARE_API_TOKEN'] 43 | ``` 44 | 45 | We can now check that works OK: 46 | 47 | ``` python 48 | cf = Cloudflare(api_token=cf_token) 49 | zones = cf.zones.list() 50 | len(zones.result) 51 | ``` 52 | 53 | 8 54 | 55 | Replace this with your domain name: 56 | 57 | ``` python 58 | domain = 'answer.ai' 59 | zones = cf.zones.list(name=domain) 60 | assert len(zones.result)==1 61 | ``` 62 | 63 | ``` python 64 | zone_id = zones.result[0].id 65 | ``` 66 | 67 | Here’s the methods available for modifying DNS records: 68 | 69 | - `client.dns.records.create(*, zone_id, **params) -> Optional` 70 | - `client.dns.records.update(dns_record_id, *, zone_id, **params) -> Optional` 71 | - `client.dns.records.list(*, zone_id, **params) -> SyncV4PagePaginationArray[Record]` 72 | - `client.dns.records.delete(dns_record_id, *, zone_id) -> Optional` 73 | - `client.dns.records.edit(dns_record_id, *, zone_id, **params) -> Optional` 74 | - `client.dns.records.export(*, zone_id) -> str` 75 | - `client.dns.records.get(dns_record_id, *, zone_id) -> Optional` 76 | - `client.dns.records.import\_(*, zone_id, **params) -> Optional` 77 | - `client.dns.records.scan(*, zone_id, **params) -> Optional` 78 | 79 | …and here’s the methods for tokens: 80 | 81 | ``` python 82 | from cloudflare.types.user import (CIDRList, Policy, Token, TokenCreateResponse, TokenUpdateResponse, TokenListResponse, 83 | TokenDeleteResponse, TokenGetResponse, TokenVerifyResponse) 84 | ``` 85 | 86 | - `client.user.tokens.create(**params) -> Optional` 87 | - `client.user.tokens.update(token_id, **params) -> object` 88 | - `client.user.tokens.list(**params) -> SyncV4PagePaginationArray[object]` 89 | - `client.user.tokens.delete(token_id) -> Optional` 90 | - `client.user.tokens.get(token_id) -> object` 91 | - `client.user.tokens.verify() -> Optional` 92 | 93 | ``` python 94 | from cloudflare.types.user.tokens import PermissionGroupListResponse 95 | ``` 96 | 97 | - client.user.tokens.permission_groups.list() -\> 98 | SyncSinglePage\[object\] 99 | 100 | ``` python 101 | from cloudflare.types.user.tokens import Value 102 | ``` 103 | 104 | - client.user.tokens.value.update(token_id, \*\*params) -\> str 105 | 106 | We need these two permissions in our token: 107 | 108 | ``` python 109 | permission_groups = cf.user.tokens.permission_groups.list() 110 | 111 | dns_write = next(group for group in permission_groups if group['name'] == 'DNS Write') 112 | zone_read = next(group for group in permission_groups if group['name'] == 'Zone Read') 113 | ``` 114 | 115 | Now we can create it: 116 | 117 | ``` python 118 | new_token = cf.user.tokens.create( 119 | name='caddy_dns', 120 | policies=[{ 121 | "effect": "allow", 122 | "resources": { f"com.cloudflare.api.account.zone.{zone_id}": "*" }, 123 | "permission_groups": [ 124 | {"id": zone_read['id'], "name": "Zone Read"}, 125 | {"id": dns_write['id'], "name": "DNS Write"} 126 | ] 127 | }] 128 | ) 129 | 130 | print(new_token.value) 131 | ``` 132 | 133 | Make a copy of this value, which we’ll need for setting up caddy. 134 | 135 | ### Installing caddy 136 | 137 | To install caddy, we’ll use a tool called `xcaddy`. This is written in 138 | go. So first install go: 139 | 140 | - Mac: `brew install go` 141 | - Linux: `sudo apt install golang` 142 | 143 | Note that if you are not on the latest Ubuntu, you’ll need to setup the 144 | backport repo before installing go: 145 | 146 | ``` sh 147 | sudo add-apt-repository -y ppa:longsleep/golang-backports 148 | sudo apt update 149 | ``` 150 | 151 | Now we can install xcaddy: 152 | 153 | ``` sh 154 | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest 155 | ``` 156 | 157 | Alternatively, you can download the latest xcaddy directly, e.g: 158 | 159 | ``` sh 160 | # Change the OS and arch as needed, or remove them to view all options 161 | wget -qO- https://latest.fast.ai/latest/caddyserver/xcaddy/linux_amd64.tar.gz 162 | ``` 163 | 164 | Then we use that to compile caddy with our desired domain plugin 165 | (cloudflare, in this case): 166 | 167 | ``` sh 168 | mkdir -p ~/go/bin 169 | cd ~/go/bin 170 | ./xcaddy build --with github.com/caddy-dns/cloudflare 171 | ``` 172 | 173 | This gives us a `~/go/bin/caddy` binary we can run: 174 | 175 | ``` sh 176 | ./caddy version 177 | ./caddy run 178 | ``` 179 | 180 | ### Securely run caddy on start 181 | 182 | If you’re using a server or running caddy a lot, you’ll want it to run 183 | on start. And if you’re making it publicly accessible, you’ll want it to 184 | be secure. This isn’t needed otherwise – you can just 185 | `~/go/bin/caddy run` to run it manually (you may want to add `~/go/bin` 186 | to your `PATH` env var). 187 | 188 | To set this up, run from this repo root: 189 | 190 | ``` sh 191 | ./setup_service.sh 192 | ``` 193 | 194 | If all went well, you should see output like this: 195 | 196 | ``` sh 197 | ● caddy.service - Caddy 198 | Loaded: loaded (/etc/systemd/system/caddy.service; enabled; preset: enabled) 199 | Active: active (running) since Sat 2024-11-09 05:06:47 UTC; 2 days ago 200 | Docs: https://caddyserver.com/docs/ 201 | Main PID: 138140 (caddy) 202 | Tasks: 29 (limit: 154166) 203 | Memory: 19.3M (peak: 28.8M) 204 | CPU: 3min 37.216s 205 | CGroup: /system.slice/caddy.service 206 | └─138140 /usr/bin/caddy run --environ 207 | ``` 208 | 209 | ## How to use 210 | 211 | We will now show how to set up caddy as a reverse proxy for hosts added 212 | dynamically. We’ll grab our token from the previous step (assuming here 213 | that it’s stored in an env var): 214 | 215 | ``` python 216 | cf_token = os.environ.get('CADDY_CF_TOKEN', 'XXX') 217 | ``` 218 | 219 | We can now setup the basic routes needed for caddy: 220 | 221 | ``` python 222 | setup_caddy(cf_token) 223 | ``` 224 | 225 | To view the configuration created, use 226 | [`gcfg`](https://AnswerDotAI.github.io/fastcaddy/core.html#gcfg): 227 | 228 | ``` python 229 | gcfg() 230 | ``` 231 | 232 | ``` json 233 | { 'apps': { 'http': { 'servers': { 'srv0': { 'listen': [':80', ':443'], 234 | 'routes': []}}}, 235 | 'tls': { 'automation': { 'policies': [{'issuers': [{'challenges': {'dns': {'provider': {'api_token': 'XXX', 'name': 'cloudflare'}}}, 'module': 'acme'}]}]}}}} 236 | ``` 237 | 238 | You can also view a sub-path of the configuration: 239 | 240 | ``` python 241 | gcfg('/apps/http/servers') 242 | ``` 243 | 244 | ``` json 245 | {'srv0': {'listen': [':80', ':443'], 'routes': []}} 246 | ``` 247 | 248 | To add a reverse proxy, use 249 | [`add_reverse_proxy`](https://AnswerDotAI.github.io/fastcaddy/core.html#add_reverse_proxy): 250 | 251 | ``` python 252 | host = 'jph.answer.ai' 253 | add_reverse_proxy(host, 'localhost:5001') 254 | ``` 255 | 256 | This is automatically added with an id matching the host, which you can 257 | view with 258 | [`gid`](https://AnswerDotAI.github.io/fastcaddy/core.html#gid): 259 | 260 | ``` python 261 | gid('jph.answer.ai') 262 | ``` 263 | 264 | ``` json 265 | { '@id': 'jph.answer.ai', 266 | 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:5001'}]}], 267 | 'match': [{'host': ['jph.answer.ai']}], 268 | 'terminal': True} 269 | ``` 270 | 271 | If you call this again with the same host, it will be replaced: 272 | 273 | ``` python 274 | add_reverse_proxy(host, 'localhost:8000') 275 | gid('jph.answer.ai').handle[0] 276 | ``` 277 | 278 | ``` json 279 | {'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:8000'}]} 280 | ``` 281 | 282 | To remove a host, delete its id: 283 | 284 | ``` python 285 | del_id(host) 286 | ``` 287 | -------------------------------------------------------------------------------- /fastcaddy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.9" 2 | from .core import * 3 | 4 | -------------------------------------------------------------------------------- /fastcaddy/_modidx.py: -------------------------------------------------------------------------------- 1 | # Autogenerated by nbdev 2 | 3 | d = { 'settings': { 'branch': 'main', 4 | 'doc_baseurl': '/fastcaddy', 5 | 'doc_host': 'https://AnswerDotAI.github.io', 6 | 'git_url': 'https://github.com/AnswerDotAI/fastcaddy', 7 | 'lib_path': 'fastcaddy'}, 8 | 'syms': { 'fastcaddy.core': { 'fastcaddy.core.add_acme_config': ('core.html#add_acme_config', 'fastcaddy/core.py'), 9 | 'fastcaddy.core.add_reverse_proxy': ('core.html#add_reverse_proxy', 'fastcaddy/core.py'), 10 | 'fastcaddy.core.add_route': ('core.html#add_route', 'fastcaddy/core.py'), 11 | 'fastcaddy.core.add_sub_reverse_proxy': ('core.html#add_sub_reverse_proxy', 'fastcaddy/core.py'), 12 | 'fastcaddy.core.add_tls_internal_config': ('core.html#add_tls_internal_config', 'fastcaddy/core.py'), 13 | 'fastcaddy.core.add_wildcard_route': ('core.html#add_wildcard_route', 'fastcaddy/core.py'), 14 | 'fastcaddy.core.del_id': ('core.html#del_id', 'fastcaddy/core.py'), 15 | 'fastcaddy.core.gcfg': ('core.html#gcfg', 'fastcaddy/core.py'), 16 | 'fastcaddy.core.get_acme_config': ('core.html#get_acme_config', 'fastcaddy/core.py'), 17 | 'fastcaddy.core.get_id': ('core.html#get_id', 'fastcaddy/core.py'), 18 | 'fastcaddy.core.get_path': ('core.html#get_path', 'fastcaddy/core.py'), 19 | 'fastcaddy.core.gid': ('core.html#gid', 'fastcaddy/core.py'), 20 | 'fastcaddy.core.has_id': ('core.html#has_id', 'fastcaddy/core.py'), 21 | 'fastcaddy.core.has_path': ('core.html#has_path', 'fastcaddy/core.py'), 22 | 'fastcaddy.core.init_path': ('core.html#init_path', 'fastcaddy/core.py'), 23 | 'fastcaddy.core.init_routes': ('core.html#init_routes', 'fastcaddy/core.py'), 24 | 'fastcaddy.core.keys2path': ('core.html#keys2path', 'fastcaddy/core.py'), 25 | 'fastcaddy.core.nested_setcfg': ('core.html#nested_setcfg', 'fastcaddy/core.py'), 26 | 'fastcaddy.core.nested_setdict': ('core.html#nested_setdict', 'fastcaddy/core.py'), 27 | 'fastcaddy.core.path2keys': ('core.html#path2keys', 'fastcaddy/core.py'), 28 | 'fastcaddy.core.pcfg': ('core.html#pcfg', 'fastcaddy/core.py'), 29 | 'fastcaddy.core.pid': ('core.html#pid', 'fastcaddy/core.py'), 30 | 'fastcaddy.core.setup_caddy': ('core.html#setup_caddy', 'fastcaddy/core.py'), 31 | 'fastcaddy.core.setup_pki_trust': ('core.html#setup_pki_trust', 'fastcaddy/core.py')}}} 32 | -------------------------------------------------------------------------------- /fastcaddy/core.py: -------------------------------------------------------------------------------- 1 | """Basic API for Caddy""" 2 | 3 | # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb. 4 | 5 | # %% auto 0 6 | __all__ = ['automation_path', 'srvs_path', 'rts_path', 'get_id', 'get_path', 'gid', 'has_id', 'gcfg', 'has_path', 'pid', 'pcfg', 7 | 'nested_setdict', 'path2keys', 'keys2path', 'nested_setcfg', 'init_path', 'get_acme_config', 8 | 'add_tls_internal_config', 'add_acme_config', 'init_routes', 'setup_pki_trust', 'setup_caddy', 'add_route', 9 | 'del_id', 'add_reverse_proxy', 'add_wildcard_route', 'add_sub_reverse_proxy'] 10 | 11 | # %% ../nbs/00_core.ipynb 3 12 | import os, subprocess, httpx, json 13 | from fastcore.utils import * 14 | from httpx import HTTPStatusError, get as xget, post as xpost, patch as xpatch, put as xput, delete as xdelete, head as xhead 15 | from typing import Sequence 16 | 17 | # %% ../nbs/00_core.ipynb 5 18 | def get_id(path): 19 | "Get a ID full URL from a path" 20 | if path[0 ]!='/': path = '/'+path 21 | if path[-1]!='/': path = path+'/' 22 | return f'http://localhost:2019/id{path}' 23 | 24 | # %% ../nbs/00_core.ipynb 8 25 | def get_path(path): 26 | "Get a config full URL from a path" 27 | if path[0 ]!='/': path = '/'+path 28 | if path[-1]!='/': path = path+'/' 29 | return f'http://localhost:2019/config{path}' 30 | 31 | # %% ../nbs/00_core.ipynb 10 32 | def gid(path='/'): 33 | "Gets the id at `path`" 34 | response = xget(get_id(path)) 35 | response.raise_for_status() 36 | return dict2obj(response.json()) 37 | 38 | # %% ../nbs/00_core.ipynb 11 39 | def has_id(id): 40 | "Check if `id` is set up" 41 | try: gid(id) 42 | except HTTPStatusError: return False 43 | return True 44 | 45 | # %% ../nbs/00_core.ipynb 12 46 | def gcfg(path='/', method='get'): 47 | "Gets the config at `path`" 48 | f = getattr(httpx, method) 49 | response = f(get_path(path)) 50 | response.raise_for_status() 51 | return dict2obj(response.json()) 52 | 53 | # %% ../nbs/00_core.ipynb 13 54 | def has_path(path): 55 | "Check if `path` is set up" 56 | try: gcfg(path) 57 | except HTTPStatusError: return False 58 | return True 59 | 60 | # %% ../nbs/00_core.ipynb 15 61 | def pid(d, path='/', method='post'): 62 | "Puts the config `d` into `path`" 63 | f = getattr(httpx, method) 64 | response = f(get_id(path), json=obj2dict(d)) 65 | response.raise_for_status() 66 | return response.text or None 67 | 68 | # %% ../nbs/00_core.ipynb 16 69 | def pcfg(d, path='/', method='post'): 70 | "Puts the config `d` into `path`" 71 | f = getattr(httpx, method) 72 | response = f(get_path(path), json=obj2dict(d)) 73 | try: response.raise_for_status() 74 | except Exception as e: 75 | e.add_note(f"Error: '{json.loads(response.text)['error']}'") 76 | raise 77 | return response.text or None 78 | 79 | # %% ../nbs/00_core.ipynb 18 80 | def nested_setdict(sd, value, *keys): 81 | "Returns `sd` updated to set `value` at the path `keys`" 82 | d = sd 83 | for key in keys[:-1]: d = d.setdefault(key, {}) 84 | d[keys[-1]] = value 85 | return sd 86 | 87 | # %% ../nbs/00_core.ipynb 20 88 | def path2keys(path): 89 | "Split `path` by '/' into a list" 90 | return path.strip('/').split('/') 91 | 92 | # %% ../nbs/00_core.ipynb 22 93 | def keys2path(*keys): 94 | "Join `keys` into a '/' separated path" 95 | return '/'+'/'.join(keys) 96 | 97 | # %% ../nbs/00_core.ipynb 24 98 | def nested_setcfg(value, *keys): 99 | d = nested_setdict(gcfg(), value, *keys) 100 | return pcfg(d) 101 | 102 | # %% ../nbs/00_core.ipynb 25 103 | def init_path(path, skip=0): 104 | sp = [] 105 | for i,p in enumerate(path2keys(path)): 106 | sp.append(p) 107 | if i Basic API for Caddy" 19 | ] 20 | }, 21 | { 22 | "cell_type": "markdown", 23 | "metadata": {}, 24 | "source": [ 25 | "# Caddy admin" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": null, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [ 34 | "#| export\n", 35 | "import os, subprocess, httpx, json\n", 36 | "from fastcore.utils import *\n", 37 | "from httpx import HTTPStatusError, get as xget, post as xpost, patch as xpatch, put as xput, delete as xdelete, head as xhead\n", 38 | "from typing import Sequence" 39 | ] 40 | }, 41 | { 42 | "cell_type": "markdown", 43 | "metadata": {}, 44 | "source": [ 45 | "## Initial functions" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "#| export\n", 55 | "def get_id(path):\n", 56 | " \"Get a ID full URL from a path\"\n", 57 | " if path[0 ]!='/': path = '/'+path\n", 58 | " if path[-1]!='/': path = path+'/'\n", 59 | " return f'http://localhost:2019/id{path}'" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": null, 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "host = 'jph.answer.ai'" 69 | ] 70 | }, 71 | { 72 | "cell_type": "code", 73 | "execution_count": null, 74 | "metadata": {}, 75 | "outputs": [ 76 | { 77 | "data": { 78 | "text/plain": [ 79 | "'http://localhost:2019/id/jph.answer.ai/'" 80 | ] 81 | }, 82 | "execution_count": null, 83 | "metadata": {}, 84 | "output_type": "execute_result" 85 | } 86 | ], 87 | "source": [ 88 | "get_id('jph.answer.ai')" 89 | ] 90 | }, 91 | { 92 | "cell_type": "code", 93 | "execution_count": null, 94 | "metadata": {}, 95 | "outputs": [], 96 | "source": [ 97 | "#| export\n", 98 | "def get_path(path):\n", 99 | " \"Get a config full URL from a path\"\n", 100 | " if path[0 ]!='/': path = '/'+path\n", 101 | " if path[-1]!='/': path = path+'/'\n", 102 | " return f'http://localhost:2019/config{path}'" 103 | ] 104 | }, 105 | { 106 | "cell_type": "code", 107 | "execution_count": null, 108 | "metadata": {}, 109 | "outputs": [ 110 | { 111 | "data": { 112 | "text/plain": [ 113 | "'http://localhost:2019/config/apps/tls/automation/policies/'" 114 | ] 115 | }, 116 | "execution_count": null, 117 | "metadata": {}, 118 | "output_type": "execute_result" 119 | } 120 | ], 121 | "source": [ 122 | "get_path('/apps/tls/automation/policies')" 123 | ] 124 | }, 125 | { 126 | "cell_type": "code", 127 | "execution_count": null, 128 | "metadata": {}, 129 | "outputs": [], 130 | "source": [ 131 | "#| export\n", 132 | "def gid(path='/'):\n", 133 | " \"Gets the id at `path`\"\n", 134 | " response = xget(get_id(path))\n", 135 | " response.raise_for_status()\n", 136 | " return dict2obj(response.json())" 137 | ] 138 | }, 139 | { 140 | "cell_type": "code", 141 | "execution_count": null, 142 | "metadata": {}, 143 | "outputs": [], 144 | "source": [ 145 | "#| export\n", 146 | "def has_id(id):\n", 147 | " \"Check if `id` is set up\"\n", 148 | " try: gid(id)\n", 149 | " except HTTPStatusError: return False\n", 150 | " return True" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": null, 156 | "metadata": {}, 157 | "outputs": [], 158 | "source": [ 159 | "#| export\n", 160 | "def gcfg(path='/', method='get'):\n", 161 | " \"Gets the config at `path`\"\n", 162 | " f = getattr(httpx, method)\n", 163 | " response = f(get_path(path))\n", 164 | " response.raise_for_status()\n", 165 | " return dict2obj(response.json())" 166 | ] 167 | }, 168 | { 169 | "cell_type": "code", 170 | "execution_count": null, 171 | "metadata": {}, 172 | "outputs": [], 173 | "source": [ 174 | "#| export\n", 175 | "def has_path(path):\n", 176 | " \"Check if `path` is set up\"\n", 177 | " try: gcfg(path)\n", 178 | " except HTTPStatusError: return False\n", 179 | " return True" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": null, 185 | "metadata": {}, 186 | "outputs": [], 187 | "source": [ 188 | "gcfg()" 189 | ] 190 | }, 191 | { 192 | "cell_type": "code", 193 | "execution_count": null, 194 | "metadata": {}, 195 | "outputs": [], 196 | "source": [ 197 | "#| export\n", 198 | "def pid(d, path='/', method='post'):\n", 199 | " \"Puts the config `d` into `path`\"\n", 200 | " f = getattr(httpx, method)\n", 201 | " response = f(get_id(path), json=obj2dict(d))\n", 202 | " response.raise_for_status()\n", 203 | " return response.text or None" 204 | ] 205 | }, 206 | { 207 | "cell_type": "code", 208 | "execution_count": null, 209 | "metadata": {}, 210 | "outputs": [], 211 | "source": [ 212 | "#| export\n", 213 | "def pcfg(d, path='/', method='post'):\n", 214 | " \"Puts the config `d` into `path`\"\n", 215 | " f = getattr(httpx, method)\n", 216 | " response = f(get_path(path), json=obj2dict(d))\n", 217 | " try: response.raise_for_status()\n", 218 | " except Exception as e:\n", 219 | " e.add_note(f\"Error: '{json.loads(response.text)['error']}'\")\n", 220 | " raise\n", 221 | " return response.text or None" 222 | ] 223 | }, 224 | { 225 | "cell_type": "code", 226 | "execution_count": null, 227 | "metadata": {}, 228 | "outputs": [], 229 | "source": [ 230 | "# pcfg({})" 231 | ] 232 | }, 233 | { 234 | "cell_type": "code", 235 | "execution_count": null, 236 | "metadata": {}, 237 | "outputs": [], 238 | "source": [ 239 | "#| export\n", 240 | "def nested_setdict(sd, value, *keys):\n", 241 | " \"Returns `sd` updated to set `value` at the path `keys`\"\n", 242 | " d = sd\n", 243 | " for key in keys[:-1]: d = d.setdefault(key, {})\n", 244 | " d[keys[-1]] = value\n", 245 | " return sd" 246 | ] 247 | }, 248 | { 249 | "cell_type": "code", 250 | "execution_count": null, 251 | "metadata": {}, 252 | "outputs": [ 253 | { 254 | "data": { 255 | "text/plain": [ 256 | "{'a': 'b', 'apps': {'http': {'servers': {'srv0': {'c': 'd'}}}}}" 257 | ] 258 | }, 259 | "execution_count": null, 260 | "metadata": {}, 261 | "output_type": "execute_result" 262 | } 263 | ], 264 | "source": [ 265 | "nested_setdict({'a':'b'}, {'c':'d'}, 'apps', 'http', 'servers', 'srv0')" 266 | ] 267 | }, 268 | { 269 | "cell_type": "code", 270 | "execution_count": null, 271 | "metadata": {}, 272 | "outputs": [], 273 | "source": [ 274 | "#| export\n", 275 | "def path2keys(path):\n", 276 | " \"Split `path` by '/' into a list\"\n", 277 | " return path.strip('/').split('/')" 278 | ] 279 | }, 280 | { 281 | "cell_type": "code", 282 | "execution_count": null, 283 | "metadata": {}, 284 | "outputs": [ 285 | { 286 | "data": { 287 | "text/plain": [ 288 | "['apps', 'tls', 'automation', 'policies']" 289 | ] 290 | }, 291 | "execution_count": null, 292 | "metadata": {}, 293 | "output_type": "execute_result" 294 | } 295 | ], 296 | "source": [ 297 | "path2keys('/apps/tls/automation/policies')" 298 | ] 299 | }, 300 | { 301 | "cell_type": "code", 302 | "execution_count": null, 303 | "metadata": {}, 304 | "outputs": [], 305 | "source": [ 306 | "#| export\n", 307 | "def keys2path(*keys):\n", 308 | " \"Join `keys` into a '/' separated path\"\n", 309 | " return '/'+'/'.join(keys)" 310 | ] 311 | }, 312 | { 313 | "cell_type": "code", 314 | "execution_count": null, 315 | "metadata": {}, 316 | "outputs": [ 317 | { 318 | "data": { 319 | "text/plain": [ 320 | "'/apps/tls/automation/policies'" 321 | ] 322 | }, 323 | "execution_count": null, 324 | "metadata": {}, 325 | "output_type": "execute_result" 326 | } 327 | ], 328 | "source": [ 329 | "keys2path('apps', 'tls', 'automation', 'policies')" 330 | ] 331 | }, 332 | { 333 | "cell_type": "code", 334 | "execution_count": null, 335 | "metadata": {}, 336 | "outputs": [], 337 | "source": [ 338 | "#| export\n", 339 | "def nested_setcfg(value, *keys):\n", 340 | " d = nested_setdict(gcfg(), value, *keys)\n", 341 | " return pcfg(d)" 342 | ] 343 | }, 344 | { 345 | "cell_type": "code", 346 | "execution_count": null, 347 | "metadata": {}, 348 | "outputs": [], 349 | "source": [ 350 | "#| export\n", 351 | "def init_path(path, skip=0):\n", 352 | " sp = []\n", 353 | " for i,p in enumerate(path2keys(path)):\n", 354 | " sp.append(p)\n", 355 | " if i A simple python wrapper for using the caddy API\n", 21 | "\n", 22 | "- skip_showdoc: true\n", 23 | "- skip_exec: true" 24 | ] 25 | }, 26 | { 27 | "cell_type": "markdown", 28 | "metadata": {}, 29 | "source": [ 30 | "## Usage" 31 | ] 32 | }, 33 | { 34 | "cell_type": "markdown", 35 | "metadata": {}, 36 | "source": [ 37 | "### Installation" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "metadata": {}, 43 | "source": [ 44 | "Install from [pypi][pypi]\n", 45 | "\n", 46 | "\n", 47 | "```sh\n", 48 | "$ pip install fastcaddy\n", 49 | "```\n", 50 | "\n", 51 | "[pypi]: https://pypi.org/project/fastcaddy/" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "## Installing Caddy" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "metadata": {}, 65 | "outputs": [], 66 | "source": [ 67 | "from fastcore.utils import *" 68 | ] 69 | }, 70 | { 71 | "cell_type": "markdown", 72 | "metadata": {}, 73 | "source": [ 74 | "This project is to help you use the caddy API, rather than a Caddyfile, to use caddy. To use the API, you need to install a plugin for your domain management service. We use Cloudflare, so we'll document that here. For other domain services, see the Caddy docs for other plugins." 75 | ] 76 | }, 77 | { 78 | "cell_type": "markdown", 79 | "metadata": {}, 80 | "source": [ 81 | "### Cloudflare setup" 82 | ] 83 | }, 84 | { 85 | "cell_type": "markdown", 86 | "metadata": {}, 87 | "source": [ 88 | "You'll need a token from Cloudflare with access to modify the necessary settings. Here's the steps to create a token with the minimal privileges. You'll need to install the cloudflare pip package, then import:" 89 | ] 90 | }, 91 | { 92 | "cell_type": "code", 93 | "execution_count": null, 94 | "metadata": {}, 95 | "outputs": [], 96 | "source": [ 97 | "from cloudflare import Cloudflare" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "Then you'll need create a Cloudflare API token for your user, which we'll then use to create the less privileged token." 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": null, 110 | "metadata": {}, 111 | "outputs": [], 112 | "source": [ 113 | "cf_token = os.environ['CLOUDFLARE_API_TOKEN']" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "We can now check that works OK:" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": {}, 127 | "outputs": [ 128 | { 129 | "data": { 130 | "text/plain": [ 131 | "8" 132 | ] 133 | }, 134 | "execution_count": null, 135 | "metadata": {}, 136 | "output_type": "execute_result" 137 | } 138 | ], 139 | "source": [ 140 | "cf = Cloudflare(api_token=cf_token)\n", 141 | "zones = cf.zones.list()\n", 142 | "len(zones.result)" 143 | ] 144 | }, 145 | { 146 | "cell_type": "markdown", 147 | "metadata": {}, 148 | "source": [ 149 | "Replace this with your domain name:" 150 | ] 151 | }, 152 | { 153 | "cell_type": "code", 154 | "execution_count": null, 155 | "metadata": {}, 156 | "outputs": [], 157 | "source": [ 158 | "domain = 'answer.ai'\n", 159 | "zones = cf.zones.list(name=domain)\n", 160 | "assert len(zones.result)==1" 161 | ] 162 | }, 163 | { 164 | "cell_type": "code", 165 | "execution_count": null, 166 | "metadata": {}, 167 | "outputs": [], 168 | "source": [ 169 | "zone_id = zones.result[0].id" 170 | ] 171 | }, 172 | { 173 | "cell_type": "markdown", 174 | "metadata": {}, 175 | "source": [ 176 | "Here's the methods available for modifying DNS records:\n", 177 | "\n", 178 | "- `client.dns.records.create(*, zone_id, **params) -> Optional`\n", 179 | "- `client.dns.records.update(dns_record_id, *, zone_id, **params) -> Optional`\n", 180 | "- `client.dns.records.list(*, zone_id, **params) -> SyncV4PagePaginationArray[Record]`\n", 181 | "- `client.dns.records.delete(dns_record_id, *, zone_id) -> Optional`\n", 182 | "- `client.dns.records.edit(dns_record_id, *, zone_id, **params) -> Optional`\n", 183 | "- `client.dns.records.export(*, zone_id) -> str`\n", 184 | "- `client.dns.records.get(dns_record_id, *, zone_id) -> Optional`\n", 185 | "- `client.dns.records.import\\_(*, zone_id, **params) -> Optional`\n", 186 | "- `client.dns.records.scan(*, zone_id, **params) -> Optional`" 187 | ] 188 | }, 189 | { 190 | "cell_type": "markdown", 191 | "metadata": {}, 192 | "source": [ 193 | "…and here's the methods for tokens:\n", 194 | "\n", 195 | "```python\n", 196 | "from cloudflare.types.user import (CIDRList, Policy, Token, TokenCreateResponse, TokenUpdateResponse, TokenListResponse,\n", 197 | " TokenDeleteResponse, TokenGetResponse, TokenVerifyResponse)\n", 198 | "```\n", 199 | "\n", 200 | "- `client.user.tokens.create(**params) -> Optional`\n", 201 | "- `client.user.tokens.update(token_id, **params) -> object`\n", 202 | "- `client.user.tokens.list(**params) -> SyncV4PagePaginationArray[object]`\n", 203 | "- `client.user.tokens.delete(token_id) -> Optional`\n", 204 | "- `client.user.tokens.get(token_id) -> object`\n", 205 | "- `client.user.tokens.verify() -> Optional`\n", 206 | "\n", 207 | "```python\n", 208 | "from cloudflare.types.user.tokens import PermissionGroupListResponse\n", 209 | "```\n", 210 | "\n", 211 | "- client.user.tokens.permission_groups.list() -> SyncSinglePage[object]\n", 212 | "\n", 213 | "```python\n", 214 | "from cloudflare.types.user.tokens import Value\n", 215 | "```\n", 216 | "\n", 217 | "- client.user.tokens.value.update(token_id, **params) -> str" 218 | ] 219 | }, 220 | { 221 | "cell_type": "markdown", 222 | "metadata": {}, 223 | "source": [ 224 | "We need these two permissions in our token:" 225 | ] 226 | }, 227 | { 228 | "cell_type": "code", 229 | "execution_count": null, 230 | "metadata": {}, 231 | "outputs": [], 232 | "source": [ 233 | "permission_groups = cf.user.tokens.permission_groups.list()\n", 234 | "\n", 235 | "dns_write = next(group for group in permission_groups if group['name'] == 'DNS Write')\n", 236 | "zone_read = next(group for group in permission_groups if group['name'] == 'Zone Read')" 237 | ] 238 | }, 239 | { 240 | "cell_type": "markdown", 241 | "metadata": {}, 242 | "source": [ 243 | "Now we can create it:\n", 244 | "\n", 245 | "```python\n", 246 | "new_token = cf.user.tokens.create(\n", 247 | " name='caddy_dns',\n", 248 | " policies=[{\n", 249 | " \"effect\": \"allow\",\n", 250 | " \"resources\": { f\"com.cloudflare.api.account.zone.{zone_id}\": \"*\" },\n", 251 | " \"permission_groups\": [\n", 252 | " {\"id\": zone_read['id'], \"name\": \"Zone Read\"},\n", 253 | " {\"id\": dns_write['id'], \"name\": \"DNS Write\"}\n", 254 | " ]\n", 255 | " }]\n", 256 | ")\n", 257 | "\n", 258 | "print(new_token.value)\n", 259 | "```" 260 | ] 261 | }, 262 | { 263 | "cell_type": "markdown", 264 | "metadata": {}, 265 | "source": [ 266 | "Make a copy of this value, which we'll need for setting up caddy." 267 | ] 268 | }, 269 | { 270 | "cell_type": "markdown", 271 | "metadata": {}, 272 | "source": [ 273 | "### Installing caddy" 274 | ] 275 | }, 276 | { 277 | "cell_type": "markdown", 278 | "metadata": {}, 279 | "source": [ 280 | "To install caddy, we'll use a tool called `xcaddy`. This is written in go. So first install go:\n", 281 | "\n", 282 | "- Mac: `brew install go`\n", 283 | "- Linux: `sudo apt install golang`\n", 284 | "\n", 285 | "Note that if you are not on the latest Ubuntu, you'll need to setup the backport repo before installing go:\n", 286 | "\n", 287 | "\n", 288 | "```sh\n", 289 | "sudo add-apt-repository -y ppa:longsleep/golang-backports\n", 290 | "sudo apt update\n", 291 | "```\n", 292 | "\n", 293 | "Now we can install xcaddy:\n", 294 | "\n", 295 | "```sh\n", 296 | "go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest\n", 297 | "```\n", 298 | "\n", 299 | "Alternatively, you can download the latest xcaddy directly, e.g:\n", 300 | "\n", 301 | "```sh\n", 302 | "# Change the OS and arch as needed, or remove them to view all options\n", 303 | "wget -qO- https://latest.fast.ai/latest/caddyserver/xcaddy/linux_amd64.tar.gz\n", 304 | "```\n", 305 | "\n", 306 | "Then we use that to compile caddy with our desired domain plugin (cloudflare, in this case):\n", 307 | "\n", 308 | "```sh\n", 309 | "mkdir -p ~/go/bin\n", 310 | "cd ~/go/bin\n", 311 | "./xcaddy build --with github.com/caddy-dns/cloudflare\n", 312 | "```\n", 313 | "\n", 314 | "This gives us a `~/go/bin/caddy` binary we can run:\n", 315 | "\n", 316 | "```sh\n", 317 | "./caddy version\n", 318 | "./caddy run\n", 319 | "```" 320 | ] 321 | }, 322 | { 323 | "cell_type": "markdown", 324 | "metadata": {}, 325 | "source": [ 326 | "### Securely run caddy on start" 327 | ] 328 | }, 329 | { 330 | "cell_type": "markdown", 331 | "metadata": {}, 332 | "source": [ 333 | "If you're using a server or running caddy a lot, you'll want it to run on start. And if you're making it publicly accessible, you'll want it to be secure. This isn't needed otherwise -- you can just `~/go/bin/caddy run` to run it manually (you may want to add `~/go/bin` to your `PATH` env var).\n", 334 | "\n", 335 | "To set this up, run from this repo root:\n", 336 | "\n", 337 | "```sh\n", 338 | "./setup_service.sh\n", 339 | "```\n", 340 | "\n", 341 | "If all went well, you should see output like this:\n", 342 | "\n", 343 | "```sh\n", 344 | "● caddy.service - Caddy\n", 345 | " Loaded: loaded (/etc/systemd/system/caddy.service; enabled; preset: enabled)\n", 346 | " Active: active (running) since Sat 2024-11-09 05:06:47 UTC; 2 days ago\n", 347 | " Docs: https://caddyserver.com/docs/\n", 348 | " Main PID: 138140 (caddy)\n", 349 | " Tasks: 29 (limit: 154166)\n", 350 | " Memory: 19.3M (peak: 28.8M)\n", 351 | " CPU: 3min 37.216s\n", 352 | " CGroup: /system.slice/caddy.service\n", 353 | " └─138140 /usr/bin/caddy run --environ\n", 354 | "```" 355 | ] 356 | }, 357 | { 358 | "cell_type": "markdown", 359 | "metadata": {}, 360 | "source": [ 361 | "## How to use" 362 | ] 363 | }, 364 | { 365 | "cell_type": "markdown", 366 | "metadata": {}, 367 | "source": [ 368 | "We will now show how to set up caddy as a reverse proxy for hosts added dynamically." 369 | ] 370 | }, 371 | { 372 | "cell_type": "markdown", 373 | "metadata": {}, 374 | "source": [ 375 | "### Initial setup" 376 | ] 377 | }, 378 | { 379 | "cell_type": "markdown", 380 | "metadata": {}, 381 | "source": [ 382 | "We'll grab our token from the previous step (assuming here that it's stored in an env var):" 383 | ] 384 | }, 385 | { 386 | "cell_type": "code", 387 | "execution_count": null, 388 | "metadata": {}, 389 | "outputs": [], 390 | "source": [ 391 | "cf_token = os.environ.get('AAI_CF_TOKEN', 'XXX')" 392 | ] 393 | }, 394 | { 395 | "cell_type": "code", 396 | "execution_count": null, 397 | "metadata": {}, 398 | "outputs": [ 399 | { 400 | "data": { 401 | "text/markdown": [ 402 | "---\n", 403 | "\n", 404 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 405 | "\n", 406 | "#### setup_caddy\n", 407 | "\n", 408 | "> setup_caddy (cf_token, srv_name='srv0')\n", 409 | "\n", 410 | "*Create SSL config and HTTP app skeleton*" 411 | ], 412 | "text/plain": [ 413 | "---\n", 414 | "\n", 415 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 416 | "\n", 417 | "#### setup_caddy\n", 418 | "\n", 419 | "> setup_caddy (cf_token, srv_name='srv0')\n", 420 | "\n", 421 | "*Create SSL config and HTTP app skeleton*" 422 | ] 423 | }, 424 | "execution_count": null, 425 | "metadata": {}, 426 | "output_type": "execute_result" 427 | } 428 | ], 429 | "source": [ 430 | "show_doc(setup_caddy, title_level=4)" 431 | ] 432 | }, 433 | { 434 | "cell_type": "markdown", 435 | "metadata": {}, 436 | "source": [ 437 | "We can now setup the basic routes needed for caddy:" 438 | ] 439 | }, 440 | { 441 | "cell_type": "code", 442 | "execution_count": null, 443 | "metadata": {}, 444 | "outputs": [], 445 | "source": [ 446 | "setup_caddy(cf_token)" 447 | ] 448 | }, 449 | { 450 | "cell_type": "code", 451 | "execution_count": null, 452 | "metadata": {}, 453 | "outputs": [ 454 | { 455 | "data": { 456 | "text/markdown": [ 457 | "---\n", 458 | "\n", 459 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 460 | "\n", 461 | "#### gcfg\n", 462 | "\n", 463 | "> gcfg (path='/', method='get')\n", 464 | "\n", 465 | "*Gets the config at `path`*" 466 | ], 467 | "text/plain": [ 468 | "---\n", 469 | "\n", 470 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 471 | "\n", 472 | "#### gcfg\n", 473 | "\n", 474 | "> gcfg (path='/', method='get')\n", 475 | "\n", 476 | "*Gets the config at `path`*" 477 | ] 478 | }, 479 | "execution_count": null, 480 | "metadata": {}, 481 | "output_type": "execute_result" 482 | } 483 | ], 484 | "source": [ 485 | "show_doc(gcfg, title_level=4)" 486 | ] 487 | }, 488 | { 489 | "cell_type": "markdown", 490 | "metadata": {}, 491 | "source": [ 492 | "To view the configuration created, use `gcfg`:" 493 | ] 494 | }, 495 | { 496 | "cell_type": "code", 497 | "execution_count": null, 498 | "metadata": {}, 499 | "outputs": [], 500 | "source": [ 501 | "# gcfg()" 502 | ] 503 | }, 504 | { 505 | "cell_type": "markdown", 506 | "metadata": {}, 507 | "source": [ 508 | "You can also view a sub-path of the configuration:" 509 | ] 510 | }, 511 | { 512 | "cell_type": "code", 513 | "execution_count": null, 514 | "metadata": {}, 515 | "outputs": [ 516 | { 517 | "data": { 518 | "text/markdown": [ 519 | "```json\n", 520 | "{'srv0': {'listen': [':80', ':443'], 'routes': []}}\n", 521 | "```" 522 | ], 523 | "text/plain": [ 524 | "{'srv0': {'listen': (#2) [':80',':443'], 'routes': (#0) []}}" 525 | ] 526 | }, 527 | "execution_count": null, 528 | "metadata": {}, 529 | "output_type": "execute_result" 530 | } 531 | ], 532 | "source": [ 533 | "gcfg('/apps/http/servers')" 534 | ] 535 | }, 536 | { 537 | "cell_type": "markdown", 538 | "metadata": {}, 539 | "source": [ 540 | "### Reverse proxies" 541 | ] 542 | }, 543 | { 544 | "cell_type": "code", 545 | "execution_count": null, 546 | "metadata": {}, 547 | "outputs": [ 548 | { 549 | "data": { 550 | "text/markdown": [ 551 | "---\n", 552 | "\n", 553 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 554 | "\n", 555 | "#### add_reverse_proxy\n", 556 | "\n", 557 | "> add_reverse_proxy (from_host, to_url)\n", 558 | "\n", 559 | "*Create a reverse proxy handler*" 560 | ], 561 | "text/plain": [ 562 | "---\n", 563 | "\n", 564 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 565 | "\n", 566 | "#### add_reverse_proxy\n", 567 | "\n", 568 | "> add_reverse_proxy (from_host, to_url)\n", 569 | "\n", 570 | "*Create a reverse proxy handler*" 571 | ] 572 | }, 573 | "execution_count": null, 574 | "metadata": {}, 575 | "output_type": "execute_result" 576 | } 577 | ], 578 | "source": [ 579 | "show_doc(add_reverse_proxy, title_level=4)" 580 | ] 581 | }, 582 | { 583 | "cell_type": "markdown", 584 | "metadata": {}, 585 | "source": [ 586 | "To add a reverse proxy, use `add_reverse_proxy`:" 587 | ] 588 | }, 589 | { 590 | "cell_type": "code", 591 | "execution_count": null, 592 | "metadata": {}, 593 | "outputs": [], 594 | "source": [ 595 | "host = 'jph.answer.ai'\n", 596 | "add_reverse_proxy(host, 'localhost:5001')" 597 | ] 598 | }, 599 | { 600 | "cell_type": "code", 601 | "execution_count": null, 602 | "metadata": {}, 603 | "outputs": [ 604 | { 605 | "data": { 606 | "text/markdown": [ 607 | "---\n", 608 | "\n", 609 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 610 | "\n", 611 | "#### gid\n", 612 | "\n", 613 | "> gid (path='/')\n", 614 | "\n", 615 | "*Gets the id at `path`*" 616 | ], 617 | "text/plain": [ 618 | "---\n", 619 | "\n", 620 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 621 | "\n", 622 | "#### gid\n", 623 | "\n", 624 | "> gid (path='/')\n", 625 | "\n", 626 | "*Gets the id at `path`*" 627 | ] 628 | }, 629 | "execution_count": null, 630 | "metadata": {}, 631 | "output_type": "execute_result" 632 | } 633 | ], 634 | "source": [ 635 | "show_doc(gid, title_level=4)" 636 | ] 637 | }, 638 | { 639 | "cell_type": "markdown", 640 | "metadata": {}, 641 | "source": [ 642 | "This is automatically added with an id matching the host, which you can view with `gid`:" 643 | ] 644 | }, 645 | { 646 | "cell_type": "code", 647 | "execution_count": null, 648 | "metadata": {}, 649 | "outputs": [ 650 | { 651 | "data": { 652 | "text/markdown": [ 653 | "```json\n", 654 | "{ '@id': 'jph.answer.ai',\n", 655 | " 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:5001'}]}],\n", 656 | " 'match': [{'host': ['jph.answer.ai']}],\n", 657 | " 'terminal': True}\n", 658 | "```" 659 | ], 660 | "text/plain": [ 661 | "{'@id': 'jph.answer.ai',\n", 662 | " 'handle': (#1) [{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:5001'}]}],\n", 663 | " 'match': (#1) [{'host': ['jph.answer.ai']}],\n", 664 | " 'terminal': True}" 665 | ] 666 | }, 667 | "execution_count": null, 668 | "metadata": {}, 669 | "output_type": "execute_result" 670 | } 671 | ], 672 | "source": [ 673 | "gid('jph.answer.ai')" 674 | ] 675 | }, 676 | { 677 | "cell_type": "markdown", 678 | "metadata": {}, 679 | "source": [ 680 | "If you call this again with the same host, it will be replaced:" 681 | ] 682 | }, 683 | { 684 | "cell_type": "code", 685 | "execution_count": null, 686 | "metadata": {}, 687 | "outputs": [ 688 | { 689 | "data": { 690 | "text/markdown": [ 691 | "```json\n", 692 | "{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:8000'}]}\n", 693 | "```" 694 | ], 695 | "text/plain": [ 696 | "{'handler': 'reverse_proxy', 'upstreams': (#1) [{'dial': 'localhost:8000'}]}" 697 | ] 698 | }, 699 | "execution_count": null, 700 | "metadata": {}, 701 | "output_type": "execute_result" 702 | } 703 | ], 704 | "source": [ 705 | "add_reverse_proxy(host, 'localhost:8000')\n", 706 | "gid('jph.answer.ai').handle[0]" 707 | ] 708 | }, 709 | { 710 | "cell_type": "code", 711 | "execution_count": null, 712 | "metadata": {}, 713 | "outputs": [ 714 | { 715 | "data": { 716 | "text/markdown": [ 717 | "---\n", 718 | "\n", 719 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 720 | "\n", 721 | "#### del_id\n", 722 | "\n", 723 | "> del_id (id)\n", 724 | "\n", 725 | "*Delete route for `id` (e.g. a host)*" 726 | ], 727 | "text/plain": [ 728 | "---\n", 729 | "\n", 730 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 731 | "\n", 732 | "#### del_id\n", 733 | "\n", 734 | "> del_id (id)\n", 735 | "\n", 736 | "*Delete route for `id` (e.g. a host)*" 737 | ] 738 | }, 739 | "execution_count": null, 740 | "metadata": {}, 741 | "output_type": "execute_result" 742 | } 743 | ], 744 | "source": [ 745 | "show_doc(del_id, title_level=4)" 746 | ] 747 | }, 748 | { 749 | "cell_type": "markdown", 750 | "metadata": {}, 751 | "source": [ 752 | "To remove a host, delete its id:" 753 | ] 754 | }, 755 | { 756 | "cell_type": "code", 757 | "execution_count": null, 758 | "metadata": {}, 759 | "outputs": [], 760 | "source": [ 761 | "del_id(host)" 762 | ] 763 | }, 764 | { 765 | "cell_type": "markdown", 766 | "metadata": {}, 767 | "source": [ 768 | "### Wildcard subdomains" 769 | ] 770 | }, 771 | { 772 | "cell_type": "markdown", 773 | "metadata": {}, 774 | "source": [ 775 | "Caddy can create a wildcard SSL cert. To do so, add a wildcard route:" 776 | ] 777 | }, 778 | { 779 | "cell_type": "code", 780 | "execution_count": null, 781 | "metadata": {}, 782 | "outputs": [ 783 | { 784 | "data": { 785 | "text/markdown": [ 786 | "---\n", 787 | "\n", 788 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 789 | "\n", 790 | "#### add_wildcard_route\n", 791 | "\n", 792 | "> add_wildcard_route (domain)\n", 793 | "\n", 794 | "*Add a wildcard subdomain*" 795 | ], 796 | "text/plain": [ 797 | "---\n", 798 | "\n", 799 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 800 | "\n", 801 | "#### add_wildcard_route\n", 802 | "\n", 803 | "> add_wildcard_route (domain)\n", 804 | "\n", 805 | "*Add a wildcard subdomain*" 806 | ] 807 | }, 808 | "execution_count": null, 809 | "metadata": {}, 810 | "output_type": "execute_result" 811 | } 812 | ], 813 | "source": [ 814 | "show_doc(add_wildcard_route, title_level=4)" 815 | ] 816 | }, 817 | { 818 | "cell_type": "code", 819 | "execution_count": null, 820 | "metadata": {}, 821 | "outputs": [], 822 | "source": [ 823 | "add_wildcard_route('something.fast.ai')" 824 | ] 825 | }, 826 | { 827 | "cell_type": "markdown", 828 | "metadata": {}, 829 | "source": [ 830 | "Create reverse proxies in a wildcard domain requires using a special function:" 831 | ] 832 | }, 833 | { 834 | "cell_type": "code", 835 | "execution_count": null, 836 | "metadata": {}, 837 | "outputs": [ 838 | { 839 | "data": { 840 | "text/markdown": [ 841 | "---\n", 842 | "\n", 843 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 844 | "\n", 845 | "#### add_sub_reverse_proxy\n", 846 | "\n", 847 | "> add_sub_reverse_proxy (domain, subdomain, port)\n", 848 | "\n", 849 | "*Add a reverse proxy to a wildcard subdomain*" 850 | ], 851 | "text/plain": [ 852 | "---\n", 853 | "\n", 854 | "[source](https://github.com/AnswerDotAI/fastcaddy/blob/main/fastcaddy/core.py#LNone){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", 855 | "\n", 856 | "#### add_sub_reverse_proxy\n", 857 | "\n", 858 | "> add_sub_reverse_proxy (domain, subdomain, port)\n", 859 | "\n", 860 | "*Add a reverse proxy to a wildcard subdomain*" 861 | ] 862 | }, 863 | "execution_count": null, 864 | "metadata": {}, 865 | "output_type": "execute_result" 866 | } 867 | ], 868 | "source": [ 869 | "show_doc(add_sub_reverse_proxy, title_level=4)" 870 | ] 871 | }, 872 | { 873 | "cell_type": "code", 874 | "execution_count": null, 875 | "metadata": {}, 876 | "outputs": [], 877 | "source": [ 878 | "add_sub_reverse_proxy('something.fast.ai', 'foo', 5001)" 879 | ] 880 | }, 881 | { 882 | "cell_type": "markdown", 883 | "metadata": {}, 884 | "source": [ 885 | "These subdomains can be deleted in the usual way:" 886 | ] 887 | }, 888 | { 889 | "cell_type": "code", 890 | "execution_count": null, 891 | "metadata": {}, 892 | "outputs": [], 893 | "source": [ 894 | "del_id('foo.something.fast.ai')" 895 | ] 896 | }, 897 | { 898 | "cell_type": "code", 899 | "execution_count": null, 900 | "metadata": {}, 901 | "outputs": [], 902 | "source": [] 903 | } 904 | ], 905 | "metadata": { 906 | "kernelspec": { 907 | "display_name": "python3", 908 | "language": "python", 909 | "name": "python3" 910 | } 911 | }, 912 | "nbformat": 4, 913 | "nbformat_minor": 4 914 | } 915 | -------------------------------------------------------------------------------- /nbs/nbdev.yml: -------------------------------------------------------------------------------- 1 | project: 2 | output-dir: _docs 3 | 4 | website: 5 | title: "fastcaddy" 6 | site-url: "https://AnswerDotAI.github.io/fastcaddy" 7 | description: "A simple python wrapper for using the Caddy API" 8 | repo-branch: main 9 | repo-url: "https://github.com/AnswerDotAI/fastcaddy" 10 | -------------------------------------------------------------------------------- /nbs/styles.css: -------------------------------------------------------------------------------- 1 | .cell { 2 | margin-bottom: 1rem; 3 | } 4 | 5 | .cell > .sourceCode { 6 | margin-bottom: 0; 7 | } 8 | 9 | .cell-output > pre { 10 | margin-bottom: 0; 11 | } 12 | 13 | .cell-output > pre, .cell-output > .sourceCode > pre, .cell-output-stdout > pre { 14 | margin-left: 0.8rem; 15 | margin-top: 0; 16 | background: none; 17 | border-left: 2px solid lightsalmon; 18 | border-top-left-radius: 0; 19 | border-top-right-radius: 0; 20 | } 21 | 22 | .cell-output > .sourceCode { 23 | border: none; 24 | } 25 | 26 | .cell-output > .sourceCode { 27 | background: none; 28 | margin-top: 0; 29 | } 30 | 31 | div.description { 32 | padding-left: 2px; 33 | padding-top: 5px; 34 | font-style: italic; 35 | font-size: 135%; 36 | opacity: 70%; 37 | } 38 | -------------------------------------------------------------------------------- /nbs/test_add_sub_reverse_proxy.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "33dfcb6a", 6 | "metadata": {}, 7 | "source": [ 8 | "# Test add_sub_reverse_proxy\n", 9 | "\n", 10 | "- skip_showdoc: true\n", 11 | "- skip_exec: true" 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": null, 17 | "id": "eb5677b5", 18 | "metadata": {}, 19 | "outputs": [], 20 | "source": [ 21 | "from fastcaddy.core import *\n", 22 | "from fastcore.test import *" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": null, 28 | "id": "c5b916a7", 29 | "metadata": {}, 30 | "outputs": [], 31 | "source": [ 32 | "import json" 33 | ] 34 | }, 35 | { 36 | "cell_type": "code", 37 | "execution_count": null, 38 | "id": "5bc073de", 39 | "metadata": {}, 40 | "outputs": [], 41 | "source": [ 42 | "def print_json(j): print(json.dumps(j))" 43 | ] 44 | }, 45 | { 46 | "cell_type": "code", 47 | "execution_count": null, 48 | "id": "546b17eb", 49 | "metadata": {}, 50 | "outputs": [], 51 | "source": [ 52 | "pcfg({})" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": null, 58 | "id": "e46580f3", 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "cf_token = 'DUMMY_TOKEN'" 63 | ] 64 | }, 65 | { 66 | "cell_type": "code", 67 | "execution_count": null, 68 | "id": "7b2c437f", 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "setup_caddy(cf_token)" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "id": "42fbd6fa", 78 | "metadata": {}, 79 | "source": [ 80 | "At this point our Caddy config is:" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "id": "4ac510a2", 87 | "metadata": {}, 88 | "outputs": [ 89 | { 90 | "data": { 91 | "text/html": [ 92 | "
{\n",
 93 |        "    'apps': {\n",
 94 |        "        'http': {'servers': {'srv0': {'listen': [':80', ':443'], 'routes': []}}},\n",
 95 |        "        'tls': {\n",
 96 |        "            'automation': {\n",
 97 |        "                'policies': [{'issuers': [{'challenges': {'dns': {'provider': {'api_token': 'DUMMY_TOKEN', 'name': \n",
 98 |        "'cloudflare'}}}, 'module': 'acme'}]}]\n",
 99 |        "            }\n",
100 |        "        }\n",
101 |        "    }\n",
102 |        "}\n",
103 |        "
\n" 104 | ], 105 | "text/plain": [ 106 | "\u001b[1m{\u001b[0m\n", 107 | " \u001b[32m'apps'\u001b[0m: \u001b[1m{\u001b[0m\n", 108 | " \u001b[32m'http'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'servers'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'srv0'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'listen'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m':80'\u001b[0m, \u001b[32m':443'\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'routes'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m,\n", 109 | " \u001b[32m'tls'\u001b[0m: \u001b[1m{\u001b[0m\n", 110 | " \u001b[32m'automation'\u001b[0m: \u001b[1m{\u001b[0m\n", 111 | " \u001b[32m'policies'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'issuers'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'challenges'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'dns'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'provider'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'api_token'\u001b[0m: \u001b[32m'DUMMY_TOKEN'\u001b[0m, \u001b[32m'name'\u001b[0m: \n", 112 | "\u001b[32m'cloudflare'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m, \u001b[32m'module'\u001b[0m: \u001b[32m'acme'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n", 113 | " \u001b[1m}\u001b[0m\n", 114 | " \u001b[1m}\u001b[0m\n", 115 | " \u001b[1m}\u001b[0m\n", 116 | "\u001b[1m}\u001b[0m\n" 117 | ] 118 | }, 119 | "metadata": {}, 120 | "output_type": "display_data" 121 | } 122 | ], 123 | "source": [ 124 | "print(gcfg())" 125 | ] 126 | }, 127 | { 128 | "cell_type": "markdown", 129 | "id": "cf709202", 130 | "metadata": {}, 131 | "source": [ 132 | "We can confirm that by going to [http://localhost:2019/config/](http://localhost:2019/config/)" 133 | ] 134 | }, 135 | { 136 | "cell_type": "markdown", 137 | "id": "acb47c61", 138 | "metadata": {}, 139 | "source": [ 140 | "## Setup: add wildcard route *.something.example.com. " 141 | ] 142 | }, 143 | { 144 | "cell_type": "markdown", 145 | "id": "f2891fe8", 146 | "metadata": {}, 147 | "source": [ 148 | "This is needed in order to add subroutes to it. " 149 | ] 150 | }, 151 | { 152 | "cell_type": "code", 153 | "execution_count": null, 154 | "id": "014bff50", 155 | "metadata": {}, 156 | "outputs": [], 157 | "source": [ 158 | "add_wildcard_route('something.example.com')" 159 | ] 160 | }, 161 | { 162 | "cell_type": "markdown", 163 | "id": "c0f23530", 164 | "metadata": {}, 165 | "source": [ 166 | "Now our config should include the wildcard route:" 167 | ] 168 | }, 169 | { 170 | "cell_type": "code", 171 | "execution_count": null, 172 | "id": "f6e33e6c", 173 | "metadata": {}, 174 | "outputs": [ 175 | { 176 | "data": { 177 | "text/html": [ 178 | "
{\n",
179 |        "    'apps': {\n",
180 |        "        'http': {\n",
181 |        "            'servers': {\n",
182 |        "                'srv0': {\n",
183 |        "                    'listen': [':80', ':443'],\n",
184 |        "                    'routes': [{'@id': 'wildcard-something.example.com', 'handle': [{'handler': 'subroute', \n",
185 |        "'routes': []}], 'match': [{'host': ['*.something.example.com']}], 'terminal': True}]\n",
186 |        "                }\n",
187 |        "            }\n",
188 |        "        },\n",
189 |        "        'tls': {\n",
190 |        "            'automation': {\n",
191 |        "                'policies': [{'issuers': [{'challenges': {'dns': {'provider': {'api_token': 'DUMMY_TOKEN', 'name': \n",
192 |        "'cloudflare'}}}, 'module': 'acme'}]}]\n",
193 |        "            }\n",
194 |        "        }\n",
195 |        "    }\n",
196 |        "}\n",
197 |        "
\n" 198 | ], 199 | "text/plain": [ 200 | "\u001b[1m{\u001b[0m\n", 201 | " \u001b[32m'apps'\u001b[0m: \u001b[1m{\u001b[0m\n", 202 | " \u001b[32m'http'\u001b[0m: \u001b[1m{\u001b[0m\n", 203 | " \u001b[32m'servers'\u001b[0m: \u001b[1m{\u001b[0m\n", 204 | " \u001b[32m'srv0'\u001b[0m: \u001b[1m{\u001b[0m\n", 205 | " \u001b[32m'listen'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m':80'\u001b[0m, \u001b[32m':443'\u001b[0m\u001b[1m]\u001b[0m,\n", 206 | " \u001b[32m'routes'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'wildcard-something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'subroute'\u001b[0m, \n", 207 | "\u001b[32m'routes'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'*.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'terminal'\u001b[0m: \u001b[3;92mTrue\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n", 208 | " \u001b[1m}\u001b[0m\n", 209 | " \u001b[1m}\u001b[0m\n", 210 | " \u001b[1m}\u001b[0m,\n", 211 | " \u001b[32m'tls'\u001b[0m: \u001b[1m{\u001b[0m\n", 212 | " \u001b[32m'automation'\u001b[0m: \u001b[1m{\u001b[0m\n", 213 | " \u001b[32m'policies'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'issuers'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'challenges'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'dns'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'provider'\u001b[0m: \u001b[1m{\u001b[0m\u001b[32m'api_token'\u001b[0m: \u001b[32m'DUMMY_TOKEN'\u001b[0m, \u001b[32m'name'\u001b[0m: \n", 214 | "\u001b[32m'cloudflare'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m\u001b[1m}\u001b[0m, \u001b[32m'module'\u001b[0m: \u001b[32m'acme'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n", 215 | " \u001b[1m}\u001b[0m\n", 216 | " \u001b[1m}\u001b[0m\n", 217 | " \u001b[1m}\u001b[0m\n", 218 | "\u001b[1m}\u001b[0m\n" 219 | ] 220 | }, 221 | "metadata": {}, 222 | "output_type": "display_data" 223 | } 224 | ], 225 | "source": [ 226 | "print(gcfg())" 227 | ] 228 | }, 229 | { 230 | "cell_type": "markdown", 231 | "id": "1974ace0", 232 | "metadata": {}, 233 | "source": [ 234 | "At this point there are no subroutes associated with that `*.something.example.com` wildcard. When that is matched, the handlers list is empty until..." 235 | ] 236 | }, 237 | { 238 | "cell_type": "markdown", 239 | "id": "51b07f9a", 240 | "metadata": {}, 241 | "source": [ 242 | "## Add subroute 1: foo.something.example.com " 243 | ] 244 | }, 245 | { 246 | "cell_type": "code", 247 | "execution_count": null, 248 | "id": "ecb669fd", 249 | "metadata": {}, 250 | "outputs": [], 251 | "source": [ 252 | "add_sub_reverse_proxy('something.example.com', 'foo', 5001)" 253 | ] 254 | }, 255 | { 256 | "cell_type": "code", 257 | "execution_count": null, 258 | "id": "45436d55", 259 | "metadata": {}, 260 | "outputs": [ 261 | { 262 | "data": { 263 | "text/html": [ 264 | "
[{'handler': 'subroute', 'routes': [{'@id': 'foo.something.example.com', 'handle': [{'handler': 'reverse_proxy', \n",
265 |        "'upstreams': [{'dial': 'localhost:5001'}]}], 'match': [{'host': ['foo.something.example.com']}]}]}]\n",
266 |        "
\n" 267 | ], 268 | "text/plain": [ 269 | "\u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'subroute'\u001b[0m, \u001b[32m'routes'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'foo.something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \n", 270 | "\u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5001'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'foo.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n" 271 | ] 272 | }, 273 | "metadata": {}, 274 | "output_type": "display_data" 275 | } 276 | ], 277 | "source": [ 278 | "print(gcfg('/apps/http/servers/srv0/routes/0/handle'))" 279 | ] 280 | }, 281 | { 282 | "cell_type": "markdown", 283 | "id": "c493eee5", 284 | "metadata": {}, 285 | "source": [ 286 | "Now we can see the `handle` config with the list of sub-routes and the sub-route handler. " 287 | ] 288 | }, 289 | { 290 | "cell_type": "code", 291 | "execution_count": null, 292 | "id": "84602288", 293 | "metadata": {}, 294 | "outputs": [ 295 | { 296 | "data": { 297 | "text/html": [ 298 | "
[{'@id': 'foo.something.example.com', 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': \n",
299 |        "'localhost:5001'}]}], 'match': [{'host': ['foo.something.example.com']}]}]\n",
300 |        "
\n" 301 | ], 302 | "text/plain": [ 303 | "\u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'foo.something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \n", 304 | "\u001b[32m'localhost:5001'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'foo.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n" 305 | ] 306 | }, 307 | "metadata": {}, 308 | "output_type": "display_data" 309 | } 310 | ], 311 | "source": [ 312 | "print(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes'))" 313 | ] 314 | }, 315 | { 316 | "cell_type": "markdown", 317 | "id": "fb584a6e", 318 | "metadata": {}, 319 | "source": [ 320 | "We can see that:\n" 321 | ] 322 | }, 323 | { 324 | "cell_type": "markdown", 325 | "id": "1f9e0373", 326 | "metadata": {}, 327 | "source": [ 328 | "1. A route with id `foo.something.example.com` was created. " 329 | ] 330 | }, 331 | { 332 | "cell_type": "code", 333 | "execution_count": null, 334 | "id": "d41878c5", 335 | "metadata": {}, 336 | "outputs": [], 337 | "source": [ 338 | "test_eq(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes/0/@id'), 'foo.something.example.com')" 339 | ] 340 | }, 341 | { 342 | "cell_type": "markdown", 343 | "id": "b8877153", 344 | "metadata": {}, 345 | "source": [ 346 | "2. It matches requests for `foo.something.example.com`" 347 | ] 348 | }, 349 | { 350 | "cell_type": "code", 351 | "execution_count": null, 352 | "id": "6c2363c1", 353 | "metadata": {}, 354 | "outputs": [], 355 | "source": [ 356 | "test_eq(gcfg('apps/http/servers/srv0/routes/0/handle/0/routes/0/match/0/host/0'), 'foo.something.example.com')" 357 | ] 358 | }, 359 | { 360 | "cell_type": "markdown", 361 | "id": "5e1df309", 362 | "metadata": {}, 363 | "source": [ 364 | "3. When a request for `foo.something.example.com` is matched, its handler is a reverse proxy to `localhost:5001`. " 365 | ] 366 | }, 367 | { 368 | "cell_type": "code", 369 | "execution_count": null, 370 | "id": "da927c2b", 371 | "metadata": {}, 372 | "outputs": [], 373 | "source": [ 374 | "test_eq(gcfg('apps/http/servers/srv0/routes/0/handle/0/routes/0/handle/0/upstreams/0/dial'), 'localhost:5001')" 375 | ] 376 | }, 377 | { 378 | "cell_type": "markdown", 379 | "id": "6d85b447", 380 | "metadata": {}, 381 | "source": [ 382 | "## Add subroute 2: bar.something.example.com " 383 | ] 384 | }, 385 | { 386 | "cell_type": "code", 387 | "execution_count": null, 388 | "id": "ded8cc12", 389 | "metadata": {}, 390 | "outputs": [], 391 | "source": [ 392 | "add_sub_reverse_proxy('something.example.com', 'bar', 5002)" 393 | ] 394 | }, 395 | { 396 | "cell_type": "code", 397 | "execution_count": null, 398 | "id": "8e795754", 399 | "metadata": {}, 400 | "outputs": [ 401 | { 402 | "data": { 403 | "text/html": [ 404 | "
[{'handler': 'subroute', 'routes': [{'@id': 'foo.something.example.com', 'handle': [{'handler': 'reverse_proxy', \n",
405 |        "'upstreams': [{'dial': 'localhost:5001'}]}], 'match': [{'host': ['foo.something.example.com']}]}, {'@id': \n",
406 |        "'bar.something.example.com', 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:5002'}]}], \n",
407 |        "'match': [{'host': ['bar.something.example.com']}]}]}]\n",
408 |        "
\n" 409 | ], 410 | "text/plain": [ 411 | "\u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'subroute'\u001b[0m, \u001b[32m'routes'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'foo.something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \n", 412 | "\u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5001'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'foo.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m, \u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \n", 413 | "\u001b[32m'bar.something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5002'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \n", 414 | "\u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'bar.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n" 415 | ] 416 | }, 417 | "metadata": {}, 418 | "output_type": "display_data" 419 | } 420 | ], 421 | "source": [ 422 | "print(gcfg('/apps/http/servers/srv0/routes/0/handle'))" 423 | ] 424 | }, 425 | { 426 | "cell_type": "markdown", 427 | "id": "1c8ca1f8", 428 | "metadata": {}, 429 | "source": [ 430 | "Now we see a single sub-route handler containing two routes. " 431 | ] 432 | }, 433 | { 434 | "cell_type": "code", 435 | "execution_count": null, 436 | "id": "42911a70", 437 | "metadata": {}, 438 | "outputs": [ 439 | { 440 | "data": { 441 | "text/html": [ 442 | "
[{'@id': 'foo.something.example.com', 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': \n",
443 |        "'localhost:5001'}]}], 'match': [{'host': ['foo.something.example.com']}]}, {'@id': 'bar.something.example.com', \n",
444 |        "'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': 'localhost:5002'}]}], 'match': [{'host': \n",
445 |        "['bar.something.example.com']}]}]\n",
446 |        "
\n" 447 | ], 448 | "text/plain": [ 449 | "\u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'foo.something.example.com'\u001b[0m, \u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \n", 450 | "\u001b[32m'localhost:5001'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \u001b[1m[\u001b[0m\u001b[32m'foo.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m, \u001b[1m{\u001b[0m\u001b[32m'@id'\u001b[0m: \u001b[32m'bar.something.example.com'\u001b[0m, \n", 451 | "\u001b[32m'handle'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'handler'\u001b[0m: \u001b[32m'reverse_proxy'\u001b[0m, \u001b[32m'upstreams'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5002'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m, \u001b[32m'match'\u001b[0m: \u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'host'\u001b[0m: \n", 452 | "\u001b[1m[\u001b[0m\u001b[32m'bar.something.example.com'\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n" 453 | ] 454 | }, 455 | "metadata": {}, 456 | "output_type": "display_data" 457 | } 458 | ], 459 | "source": [ 460 | "print(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes'))" 461 | ] 462 | }, 463 | { 464 | "cell_type": "markdown", 465 | "id": "77b83aaf", 466 | "metadata": {}, 467 | "source": [ 468 | "Here we see that:\n", 469 | "\n", 470 | "1. The `foo.something.example.com` subroute is still present. " 471 | ] 472 | }, 473 | { 474 | "cell_type": "code", 475 | "execution_count": null, 476 | "id": "5ea4b8d6", 477 | "metadata": {}, 478 | "outputs": [], 479 | "source": [ 480 | "test_eq(gcfg('/apps/http/servers/srv0/routes/0/handle/0/handler'), 'subroute')" 481 | ] 482 | }, 483 | { 484 | "cell_type": "markdown", 485 | "id": "a26750d4", 486 | "metadata": {}, 487 | "source": [ 488 | "2. A route with id `bar.something.example.com` was created. " 489 | ] 490 | }, 491 | { 492 | "cell_type": "code", 493 | "execution_count": null, 494 | "id": "5043b0d4", 495 | "metadata": {}, 496 | "outputs": [], 497 | "source": [ 498 | "test_eq(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes/1/@id'), 'bar.something.example.com')" 499 | ] 500 | }, 501 | { 502 | "cell_type": "markdown", 503 | "id": "793d2231", 504 | "metadata": {}, 505 | "source": [ 506 | "3. It matches requests for `bar.something.example.com`" 507 | ] 508 | }, 509 | { 510 | "cell_type": "code", 511 | "execution_count": null, 512 | "id": "81db9404", 513 | "metadata": {}, 514 | "outputs": [], 515 | "source": [ 516 | "test_eq(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes/1/match/0/host/0'),'bar.something.example.com')" 517 | ] 518 | }, 519 | { 520 | "cell_type": "markdown", 521 | "id": "9cc425cb", 522 | "metadata": {}, 523 | "source": [ 524 | "4. When a request for `bar.something.example.com` is matched, its handler is a reverse proxy to `localhost:5002`. " 525 | ] 526 | }, 527 | { 528 | "cell_type": "code", 529 | "execution_count": null, 530 | "id": "125cda1a", 531 | "metadata": {}, 532 | "outputs": [], 533 | "source": [ 534 | "test_eq(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes/1/handle/0/upstreams/0/dial'), 'localhost:5002')" 535 | ] 536 | }, 537 | { 538 | "cell_type": "markdown", 539 | "id": "9300a353", 540 | "metadata": {}, 541 | "source": [ 542 | "## Add multi-port subroute" 543 | ] 544 | }, 545 | { 546 | "cell_type": "code", 547 | "execution_count": null, 548 | "id": "67e1ed98", 549 | "metadata": {}, 550 | "outputs": [], 551 | "source": [ 552 | "add_sub_reverse_proxy('something.example.com', 'multiport', [5003, 5004])" 553 | ] 554 | }, 555 | { 556 | "cell_type": "code", 557 | "execution_count": null, 558 | "id": "7d8607d9", 559 | "metadata": {}, 560 | "outputs": [ 561 | { 562 | "data": { 563 | "text/html": [ 564 | "
[{'dial': 'localhost:5003'}, {'dial': 'localhost:5004'}]\n",
565 |        "
\n" 566 | ], 567 | "text/plain": [ 568 | "\u001b[1m[\u001b[0m\u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5003'\u001b[0m\u001b[1m}\u001b[0m, \u001b[1m{\u001b[0m\u001b[32m'dial'\u001b[0m: \u001b[32m'localhost:5004'\u001b[0m\u001b[1m}\u001b[0m\u001b[1m]\u001b[0m\n" 569 | ] 570 | }, 571 | "metadata": {}, 572 | "output_type": "display_data" 573 | } 574 | ], 575 | "source": [ 576 | "print(gcfg('/apps/http/servers/srv0/routes/0/handle/0/routes/2/handle/0/upstreams'))" 577 | ] 578 | }, 579 | { 580 | "cell_type": "code", 581 | "execution_count": null, 582 | "id": "8a30320b", 583 | "metadata": {}, 584 | "outputs": [], 585 | "source": [ 586 | "test_eq[{'dial': 'localhost:5003'}, {'dial': 'localhost:5004'}]" 587 | ] 588 | } 589 | ], 590 | "metadata": { 591 | "kernelspec": { 592 | "display_name": "python3", 593 | "language": "python", 594 | "name": "python3" 595 | } 596 | }, 597 | "nbformat": 4, 598 | "nbformat_minor": 5 599 | } 600 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name="fastcaddy" 7 | requires-python=">=3.7" 8 | dynamic = [ "keywords", "description", "version", "dependencies", "optional-dependencies", "readme", "license", "authors", "classifiers", "entry-points", "scripts", "urls"] 9 | 10 | [tool.uv] 11 | cache-keys = [{ file = "pyproject.toml" }, { file = "settings.ini" }, { file = "setup.py" }] 12 | -------------------------------------------------------------------------------- /settings.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | repo = fastcaddy 3 | lib_name = fastcaddy 4 | version = 0.0.9 5 | min_python = 3.7 6 | license = apache2 7 | black_formatting = False 8 | requirements = fastcore httpx 9 | doc_path = _docs 10 | lib_path = fastcaddy 11 | nbs_path = nbs 12 | recursive = True 13 | tst_flags = notest 14 | put_version_in_init = True 15 | branch = main 16 | custom_sidebar = False 17 | doc_host = https://AnswerDotAI.github.io 18 | doc_baseurl = /fastcaddy 19 | git_url = https://github.com/AnswerDotAI/fastcaddy 20 | title = fastcaddy 21 | audience = Developers 22 | author = Jeremy Howard 23 | author_email = github@jhoward.fastmail.fm 24 | copyright = 2024 onwards, Jeremy Howard 25 | description = A simple python wrapper for using the Caddy API 26 | keywords = nbdev jupyter notebook python 27 | language = English 28 | status = 3 29 | user = AnswerDotAI 30 | conda_user = fastai 31 | readme_nb = index.ipynb 32 | allowed_metadata_keys = 33 | allowed_cell_metadata_keys = 34 | jupyter_hooks = False 35 | clean_ids = True 36 | clear_all = False 37 | cell_number = True 38 | skip_procs = 39 | update_pyproject = True 40 | 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import parse_version 2 | from configparser import ConfigParser 3 | import setuptools, shlex 4 | assert parse_version(setuptools.__version__)>=parse_version('36.2') 5 | 6 | # note: all settings are in settings.ini; edit there, not here 7 | config = ConfigParser(delimiters=['=']) 8 | config.read('settings.ini', encoding='utf-8') 9 | cfg = config['DEFAULT'] 10 | 11 | cfg_keys = 'version description keywords author author_email'.split() 12 | expected = cfg_keys + "lib_name user branch license status min_python audience language".split() 13 | for o in expected: assert o in cfg, "missing expected setting: {}".format(o) 14 | setup_cfg = {o:cfg[o] for o in cfg_keys} 15 | 16 | licenses = { 17 | 'apache2': ('Apache Software License 2.0','OSI Approved :: Apache Software License'), 18 | 'mit': ('MIT License', 'OSI Approved :: MIT License'), 19 | 'gpl2': ('GNU General Public License v2', 'OSI Approved :: GNU General Public License v2 (GPLv2)'), 20 | 'gpl3': ('GNU General Public License v3', 'OSI Approved :: GNU General Public License v3 (GPLv3)'), 21 | 'bsd3': ('BSD License', 'OSI Approved :: BSD License'), 22 | } 23 | statuses = [ '1 - Planning', '2 - Pre-Alpha', '3 - Alpha', 24 | '4 - Beta', '5 - Production/Stable', '6 - Mature', '7 - Inactive' ] 25 | py_versions = '3.6 3.7 3.8 3.9 3.10 3.11 3.12'.split() 26 | 27 | requirements = shlex.split(cfg.get('requirements', '')) 28 | if cfg.get('pip_requirements'): requirements += shlex.split(cfg.get('pip_requirements', '')) 29 | min_python = cfg['min_python'] 30 | lic = licenses.get(cfg['license'].lower(), (cfg['license'], None)) 31 | dev_requirements = (cfg.get('dev_requirements') or '').split() 32 | 33 | package_data = dict() 34 | pkg_data = cfg.get('package_data', None) 35 | if pkg_data: 36 | package_data[cfg['lib_name']] = pkg_data.split() # split as multiple files might be listed 37 | # Add package data to setup_cfg for setuptools.setup(..., **setup_cfg) 38 | setup_cfg['package_data'] = package_data 39 | 40 | setuptools.setup( 41 | name = cfg['lib_name'], 42 | license = lic[0], 43 | classifiers = [ 44 | 'Development Status :: ' + statuses[int(cfg['status'])], 45 | 'Intended Audience :: ' + cfg['audience'].title(), 46 | 'Natural Language :: ' + cfg['language'].title(), 47 | ] + ['Programming Language :: Python :: '+o for o in py_versions[py_versions.index(min_python):]] + (['License :: ' + lic[1] ] if lic[1] else []), 48 | url = cfg['git_url'], 49 | packages = setuptools.find_packages(), 50 | include_package_data = True, 51 | install_requires = requirements, 52 | extras_require={ 'dev': dev_requirements }, 53 | dependency_links = cfg.get('dep_links','').split(), 54 | python_requires = '>=' + cfg['min_python'], 55 | long_description = open('README.md', encoding='utf-8').read(), 56 | long_description_content_type = 'text/markdown', 57 | zip_safe = False, 58 | entry_points = { 59 | 'console_scripts': cfg.get('console_scripts','').split(), 60 | 'nbdev': [f'{cfg.get("lib_path")}={cfg.get("lib_path")}._modidx:d'] 61 | }, 62 | **setup_cfg) 63 | 64 | 65 | -------------------------------------------------------------------------------- /setup_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Create caddy group and user 3 | groupadd --system caddy 4 | useradd --system \ 5 | --gid caddy \ 6 | --create-home \ 7 | --home-dir /var/lib/caddy \ 8 | --shell /usr/sbin/nologin \ 9 | --comment "Caddy web server" \ 10 | caddy 11 | 12 | cp "/home/$SUDO_USER/go/bin/caddy" /usr/bin/ 13 | chmod a+x /usr/bin/caddy 14 | 15 | # Write caddy.service file 16 | cat > /etc/systemd/system/caddy.service << EOF 17 | # See https://caddyserver.com/docs/install for instructions. 18 | [Unit] 19 | Description=Caddy 20 | Documentation=https://caddyserver.com/docs/ 21 | After=network.target network-online.target 22 | Requires=network-online.target 23 | 24 | [Service] 25 | Type=notify 26 | User=caddy 27 | Group=caddy 28 | ExecStart=/usr/bin/caddy run --environ 29 | TimeoutStopSec=5s 30 | LimitNOFILE=1048576 31 | PrivateTmp=true 32 | ProtectSystem=full 33 | AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE 34 | 35 | [Install] 36 | WantedBy=multi-user.target 37 | EOF 38 | 39 | # Set correct permissions for the service file 40 | chmod 644 /etc/systemd/system/caddy.service 41 | 42 | systemctl daemon-reload 43 | systemctl enable --now caddy 44 | systemctl status caddy 45 | 46 | --------------------------------------------------------------------------------