├── .air.toml ├── .dockerignore ├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── docker-release.yaml │ ├── docker-unstable.yaml │ ├── linux-binary-release.yaml │ └── pull-request-build-status-check.yaml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd └── main │ └── service.go ├── config └── zones │ └── default.yaml ├── docs └── providers │ ├── CPanel.md │ ├── Cloudflare.md │ ├── WebSupport.md │ └── assets │ └── cloudflare-api-token-required-permissions.png ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── internal ├── acme │ ├── constants │ │ └── provider_constants │ │ │ └── service.go │ ├── providers │ │ ├── cloudflare │ │ │ └── service.go │ │ ├── cpanel │ │ │ └── service.go │ │ ├── provider_utils │ │ │ ├── client.go │ │ │ ├── environment.go │ │ │ ├── service.go │ │ │ └── user.go │ │ ├── service.go │ │ └── websupport │ │ │ └── service.go │ ├── service.go │ └── zone_configuration │ │ └── service.go ├── certificates │ └── service.go └── configuration │ └── service.go └── logs.txt /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ./cmd/main" 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", ".direnv", ".idea", "config"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [proxy] 45 | app_port = 0 46 | enabled = false 47 | proxy_port = 0 48 | 49 | [screen] 50 | clear_on_rebuild = false 51 | keep_scroll = true 52 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea/**/* 2 | .direnv/**/* 3 | tmp/**/* 4 | config/**/* 5 | docs/**/* 6 | 7 | .air.toml 8 | .envrc 9 | flake.nix 10 | flake.lock 11 | .gitignore 12 | Dockerfile 13 | LICENSE 14 | README.md -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug for Low-Stack Certify 4 | title: "[Bug] " 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | ## Description 10 | 11 | 12 | 13 | ## Steps to Reproduce 14 | 15 | 16 | 1. 17 | 2. 18 | 3. 19 | 20 | ## Expected Behavior 21 | 22 | 23 | 24 | ## Actual Behavior 25 | 26 | 27 | 28 | ## Configuration and Logs 29 | 30 | 32 | - **Configuration file:** 33 | ```yaml 34 | # Your config here 35 | ``` 36 | - **Environment:** 37 | - OS: [e.g., Ubuntu 22.04] 38 | - Architecture: [e.g., x86_64 or arm64] 39 | - Docker version: [e.g., 20.10.24, if applicable] 40 | - Docker Compose version: [e.g., v2.17.3, if applicable] 41 | - Low-Stack Certify version: [e.g., v1.0.0] 42 | - **Logs:** 43 | ``` 44 | # Your logs here 45 | ``` 46 | 47 | ## Additional Context 48 | 49 | 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature or enhancement for Low-Stack Certify 4 | title: '[Feature Request] ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ## Feature Description 10 | 11 | 12 | 13 | ## Proposed Solution 14 | 15 | 16 | 17 | ## Use Case 18 | 19 | 20 | 21 | ## Alternatives Considered 22 | 23 | 24 | 25 | ## Additional Context 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | - Summary of the changes introduced 5 | - Relevant details or context 6 | - Related issue(s), if applicable 7 | 8 | ## Checklist 9 | 10 | - [ ] I have read and understood the [CONTRIBUTING.md](CONTRIBUTING.md) guidelines 11 | - [ ] Code adheres to the repository's style guides (e.g., formatting, naming conventions) 12 | - [ ] Changes include relevant documentation updates (README, Configuration, etc.) 13 | - [ ] All new and existing tests pass locally 14 | - [ ] Tested on all supported platforms/environments 15 | - [ ] Appropriate logs or output were checked for errors and corrected where necessary 16 | 17 | ## Types of changes 18 | 19 | 24 | 25 | ## Related Issues 26 | 27 | 28 | 29 | ## Further Comments 30 | 31 | 32 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Release 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | attestations: write 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up QEMU 21 | uses: docker/setup-qemu-action@v2 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v2 25 | 26 | - name: Login to GitHub Container Registry 27 | uses: docker/login-action@v2 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.repository_owner }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Build and push 34 | uses: docker/build-push-action@v6 35 | with: 36 | context: . 37 | platforms: linux/amd64,linux/arm64 38 | push: true 39 | tags: | 40 | ghcr.io/low-stack-technologies/lowstack-certify:latest 41 | ghcr.io/low-stack-technologies/lowstack-certify:${{ github.event.release.tag_name }} -------------------------------------------------------------------------------- /.github/workflows/docker-unstable.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Unstable 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v2 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v2 24 | 25 | - name: Login to GitHub Container Registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Build and push 33 | uses: docker/build-push-action@v4.0.0 34 | with: 35 | context: . 36 | platforms: linux/amd64,linux/arm64 37 | push: true 38 | tags: ghcr.io/low-stack-technologies/lowstack-certify:unstable -------------------------------------------------------------------------------- /.github/workflows/linux-binary-release.yaml: -------------------------------------------------------------------------------- 1 | name: Linux Binary Release 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: write 13 | packages: write 14 | attestations: write 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '^1.23.2' 24 | 25 | - name: Build Linux binary for x86_64 26 | run: | 27 | export GOOS=linux 28 | export GOARCH=amd64 29 | go build -o certify-x86_64 ./cmd/main 30 | 31 | - name: Build Linux binary for arm64 32 | run: | 33 | export GOOS=linux 34 | export GOARCH=arm64 35 | go build -o certify-arm64 ./cmd/main 36 | 37 | - name: Upload Linux binary for x86_64 to release 38 | uses: svenstaro/upload-release-action@v2 39 | with: 40 | repo_token: ${{ secrets.GITHUB_TOKEN }} 41 | file: certify-x86_64 42 | asset_name: certify-linux-amd64 43 | tag: ${{ github.ref }} 44 | 45 | - name: Upload Linux binary for arm64 to release 46 | uses: svenstaro/upload-release-action@v2 47 | with: 48 | repo_token: ${{ secrets.GITHUB_TOKEN }} 49 | file: certify-arm64 50 | asset_name: certify-linux-arm64 51 | tag: ${{ github.ref }} -------------------------------------------------------------------------------- /.github/workflows/pull-request-build-status-check.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request Build Status Check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '^1.23.2' 22 | 23 | - name: Build Linux binary for x86_64 24 | run: | 25 | export GOOS=linux 26 | export GOARCH=amd64 27 | go build -o certify-x86_64 ./cmd/main 28 | 29 | - name: Build Linux binary for arm64 30 | run: | 31 | export GOOS=linux 32 | export GOARCH=arm64 33 | go build -o certify-arm64 ./cmd/main -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/**/* 2 | .direnv/**/* 3 | tmp/**/* 4 | 5 | config/**/* 6 | !config/zones/default.yaml -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Low-Stack Certify 2 | 3 | Thank you for your interest in contributing to Low-Stack Certify! We appreciate the community's efforts to improve the project. This document outlines the guidelines for contributing and includes instructions on using our issue and pull request templates. 4 | 5 | ## Getting Started 6 | 7 | To start contributing to Low-Stack Certify, you can follow these steps: 8 | 9 | 1. **Fork the Repository**: Begin by forking the Low-Stack Certify repository on GitHub to create your own version of the repository. 10 | 11 | 2. **Clone Locally**: Clone your forked repository to your local machine using `git clone`. 12 | 13 | 3. **Create a Branch**: Before making changes, create a new branch specific to the feature or fix you want to contribute, e.g., `feature/new-feature` or `bugfix/fix-issue`. 14 | 15 | 4. **Develop Your Changes**: Make and test your changes locally. Ensure your code adheres to the project's coding standards and includes necessary documentation. 16 | 17 | 5. **Push Changes**: Once your changes are ready, push them to your forked repository. 18 | 19 | 6. **Submit a Pull Request**: Open a pull request to the main repository from your branch. Ensure you fill in all the necessary details in the pull request template provided. 20 | 21 | ## Code Style Guidelines 22 | 23 | When contributing to this project, please adhere to the following code style guidelines: 24 | 25 | - Use **camelCase** for long variable names (e.g., `longVariableName`). 26 | - Use **snake_case** for service names (e.g., `user_service`). 27 | - Ensure that your code is properly formatted and adheres to Go best practices unless specified otherwise. 28 | - Document your code well and include comments where necessary for clarity. 29 | 30 | ## Pull Request Guidelines 31 | 32 | When creating a pull request, please adhere to the following guidelines: 33 | 34 | - Ensure that your code follows the repository's style guides (e.g., formatting, naming conventions). 35 | - Update relevant documentation (README, configuration files) with your changes. 36 | - Confirm that all new and existing tests pass locally. 37 | - Test your changes across all supported platforms/environments. 38 | - Utilize the pull request template to provide a clear description and checklist for your contribution. 39 | 40 | ## Issue Guidelines 41 | 42 | ### Bug Reports 43 | 44 | If you encounter a bug, please use the bug report template to submit a detailed report. Include the following: 45 | 46 | - **Description**: A clear and concise description of the bug. 47 | - **Steps to Reproduce**: Detailed steps to reproduce the issue. 48 | - **Expected vs. Actual Behavior**: What you expected to happen vs. what actually happened. 49 | - **Configuration and Logs**: Relevant configuration details, environment settings, and logs. 50 | - **Additional Context**: Any additional information that might help diagnose the issue. 51 | 52 | ### Feature Requests 53 | 54 | For proposing new features or enhancements, use the feature request template. Provide the following information: 55 | 56 | - **Feature Description**: A clear outline of the feature or enhancement you propose, explaining the problem it solves or the functionality it adds. 57 | - **Proposed Solution**: If applicable, describe your proposed solution or design, including diagrams or examples. 58 | - **Use Case**: Why this feature would be useful, including any real-world examples or scenarios. 59 | - **Alternatives Considered**: Any alternative solutions or workarounds you have considered. 60 | - **Additional Context**: Extra context, screenshots, or details to support your request. 61 | 62 | Thank you for helping us improve Low-Stack Certify. We look forward to your contributions! 63 | - Ensure that your code is properly formatted. 64 | - Ensure that your code is well-documented and includes comments where necessary. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/certify ./cmd/main 10 | 11 | FROM alpine:latest 12 | 13 | WORKDIR /app 14 | 15 | COPY --from=builder /app/certify /app/certify 16 | 17 | ENTRYPOINT ["/app/certify"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2017 Sebastian Erhart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Low-Stack Certify 2 | 3 | [![GitHub last commit (branch)](https://img.shields.io/github/last-commit/Low-Stack-Technologies/lowstack-certify/main)](https://github.com/Low-Stack-Technologies/lowstack-certify) 4 | 5 | ## Introduction 6 | 7 | Low-Stack Certify is a tool that automates the process of obtaining and renewing SSL/TLS certificates using the ACME protocol. It is designed to work with different DNS providers, and be easily expanded upon. The tool can be deployed using Docker Compose, allowing users to configure and run it with custom settings for certificate storage, zone management, and periodic execution. The configuration is highly customizable, supporting various key types and file permissions, and is intended to simplify the management of SSL/TLS certificates for multiple domains. 8 | 9 | ## Table of Contents 10 | 11 | - [Introduction](#introduction) 12 | - [Getting Started](#getting-started) 13 | - [Using Docker](#using-docker) 14 | - [Using Docker Compose](#using-docker-compose) 15 | - [Running the binary (TBD)](#running-the-binary) 16 | - [Configuration](#configuration) 17 | - [Providers](#providers) 18 | 19 | ## Getting Started 20 | 21 | ### Using Docker 22 | 23 | To get started using Docker, you can use the following command: 24 | 25 | ```bash 26 | $ docker run -d --name certify \ 27 | -v /path/to/config:/config \ 28 | -v /path/to/zones:/zones \ 29 | -v /path/to/certificates:/certificates \ 30 | ghcr.io/low-stack-technologies/lowstack-certify:latest 31 | ``` 32 | 33 | This will start the application and mount the configuration, zones, and certificates directories to the container. 34 | 35 | Then you can continue to the [Configuration](#configuration) section to configure the application. 36 | 37 | ### Using Docker Compose 38 | 39 | To get started using Docker Compose, you can use the following configuration: 40 | 41 | ```yaml 42 | services: 43 | certify: 44 | image: ghcr.io/low-stack-technologies/lowstack-certify:latest 45 | container_name: certify 46 | user: "1000:1000" # This has to be set to 0:0 if you want to set the file permissions 47 | volumes: 48 | - ./config:/config 49 | - ./zones:/zones 50 | - ./certificates:/certificates 51 | environment: 52 | - CUSTOM_CONFIGURATION_PATH=/config/config.yaml 53 | restart: unless-stopped 54 | ``` 55 | 56 | This will start the application and mount the configuration, zones, and certificates directories to the container. 57 | 58 | Then you can continue to the [Configuration](#configuration) section to configure the application. 59 | 60 | ### Running the binary 61 | 62 | To be written 63 | 64 | ## Configuration 65 | 66 | Configuration is split into two parts: [application config](#application-config) and [zone configuration](#zone-configuration). 67 | 68 | ### Application Config 69 | 70 | The application config is located at the path specified by the `CUSTOM_CONFIGURATION_PATH` environment variable, or wherever you mounted the configuration directory. 71 | 72 | #### Runtime 73 | 74 | The runtime configuration is used to control the behavior of the application. It is used to enable or disable the periodic execution of the application, and to set the interval between executions. 75 | 76 | ```yaml 77 | runtime: 78 | run_periodically: true 79 | period_in_minutes: 15 80 | ``` 81 | 82 | `run_periodically` is a boolean value that determines whether the application should run periodically or not. If set to `false`, the application will only run once and exit. This is useful for testing purposes, running the application manually, or if you wish to set up a cron job to run the application periodically. 83 | 84 | `period_in_minutes` is an integer value that determines the interval between executions of the application in minutes. This is the interval that will be used if `run_periodically` is set to `true`. If `run_periodably` is set to `false`, this value will be ignored. 85 | 86 | #### Paths 87 | 88 | The paths are used to specify the paths where the zones and certificates directories are located. These paths can be absolute or relative. 89 | 90 | ```yaml 91 | zones_path: "/zones" 92 | certificates_path: "/certificates" 93 | ``` 94 | 95 | `zones_path` is the path where the zones directory is located. This is the directory where the zone configuration files are stored. See the [Zone Configuration](#zone-configuration) section for more information. 96 | 97 | `certificates_path` is the path where the certificates directory is located. Each zone will have its own directory within this directory, where the certificates will be stored. The zone `unique_identifier` will be used as the directory name, see the [Zone Configuration](#zone-configuration) section for more information. 98 | 99 | #### CA URL 100 | 101 | The CA URL is used to specify the URL of the CA directory that will be used to sign certificates. This is used by the ACME client to fetch the CA certificate. This is by default set to Let's Encrypt's production CA URL, but can be changed to use a different CA. 102 | 103 | ```yaml 104 | ca_url: "https://acme-v02.api.letsencrypt.org/directory" 105 | ``` 106 | 107 | `ca_url` is the URL of the CA directory that will be used to sign certificates. This is used by the ACME client to fetch the CA certificate. Self-hosted CAs can be used by setting this to the URL of the CA directory. 108 | 109 | #### Zones 110 | 111 | Zones are the configuration files that define the domains that will be managed by the application. 112 | 113 | ### Zone Configuration 114 | 115 | A zone is a group of subdomains for a domain. This can include specific subdomains, wildcard subdomains, or a combination of both. Zones are defined in YAML files, and are located in the `zones` directory specified by the `zones_path` in the [Application Config](#application-config). 116 | 117 | A minimal zone configuration file for Cloudflare looks like this: 118 | 119 | ```yaml 120 | unique_identifier: example.com 121 | 122 | hostnames: 123 | - example.com 124 | - "*.example.com" 125 | 126 | identity_email: example@example.com 127 | 128 | renewal_days: 15 129 | 130 | provider: cloudflare 131 | provider_options: 132 | api_token: YOUR_API_TOKEN 133 | 134 | key_type: 2048 135 | 136 | file_permissions: 137 | enabled: false 138 | uid: 0 139 | gid: 0 140 | private_key_mode: 0600 141 | full_chain_mode: 0644 142 | ``` 143 | 144 | #### Unique Identifier 145 | 146 | The unique identifier is used to identify the zone. This is used to create the directory where the certificates will be stored. This can be anything, but it is recommended to use a domain name to make it easier to identify the zone. 147 | 148 | #### Hostnames 149 | 150 | The hostnames are the hostnames that will be managed by the application. This can be a root domain, a subdomain, or a wildcard subdomain. If a wildcard subdomain is used, it has to be enclosed in quotes. 151 | 152 | #### Identity Email 153 | 154 | This is the email address that will be used to register the user with the ACME server. 155 | 156 | #### Renewal Days 157 | 158 | This is the number of days before the certificate expires on which to renew the certificate. Normally, a new certificate expires every 90 days. 159 | 160 | #### Provider and Provider Options 161 | 162 | The provider is the DNS provider that will be used to obtain and renew the certificate. The provider options are the options that will be passed to the provider when requesting a certificate. These options are provider-specific, and can be found in the [Providers](#providers) section. 163 | 164 | #### Key Type 165 | 166 | The key type is the type of key that will be generated for the certificate. This can be a RSA key (2048, 3072, or 4096), or an EC key (P256, P384, or P521). 167 | 168 | #### File Permissions 169 | 170 | To automatically set the permissions of the certificate files you can enable this feature. This will update the file permissions to match the specified permissions. Otherwise, the certificate files will be left as-is. **Important!** This does require root permissions to update the file permissions! 171 | 172 | ```yaml 173 | file_permissions: 174 | enabled: false 175 | uid: 0 176 | gid: 0 177 | private_key_mode: 0600 178 | full_chain_mode: 0644 179 | ``` 180 | 181 | `enabled` is a boolean value that determines whether the file permissions should be updated or not. 182 | 183 | `uid` is the user ID that will be used to update the file permissions. 184 | 185 | `gid` is the group ID that will be used to update the file permissions. 186 | 187 | `private_key_mode` is the file mode that will be used to update the private key file permissions. 188 | 189 | `full_chain_mode` is the file mode that will be used to update the full chain file permissions. 190 | 191 | ## Providers 192 | 193 | The following providers are currently supported: 194 | 195 | - [Cloudflare](docs/providers/Cloudflare.md) 196 | - [WebSupport](docs/providers/WebSupport.md) 197 | - [CPanel / WHM](docs/providers/CPanel.md) 198 | 199 | ## Contributing 200 | 201 | If you would like to contribute to Low-Stack Certify, please read the [CONTRIBUTING.md](CONTRIBUTING.md) file for more information. 202 | 203 | ## Acknowledgements 204 | 205 | This project is based on the following projects: 206 | 207 | - [github.com/go-acme/lego](https://github.com/go-acme/lego) 208 | 209 | ## License 210 | 211 | Low-Stack Certify is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. -------------------------------------------------------------------------------- /cmd/main/service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "certify/internal/acme" 5 | "certify/internal/configuration" 6 | "log" 7 | "os" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | log.Println("Low-Stack Certify is running!") 13 | 14 | // Load the configuration 15 | config := configuration.GetConfiguration() 16 | 17 | for { 18 | // Re-load the configuration 19 | config = configuration.GetConfiguration() 20 | 21 | // Get all zones and handle them 22 | zones := acme.GetZones(config.ZonesPath) 23 | for _, zone := range zones { 24 | if err := acme.HandleZone(config, zone); err != nil { 25 | log.Printf("Failed to handle zone: %s\n%s", zone.UniqueIdentifier, err) 26 | } 27 | } 28 | 29 | // Break if the application should not run periodically 30 | if !config.RuntimeConfiguration.RunPeriodically { 31 | break 32 | } 33 | 34 | // Sleep for the specified amount of time 35 | time.Sleep(time.Duration(config.RuntimeConfiguration.PeriodMinutes) * time.Minute) 36 | } 37 | 38 | log.Println("Low-Stack Certify is exiting!") 39 | os.Exit(0) 40 | } 41 | -------------------------------------------------------------------------------- /config/zones/default.yaml: -------------------------------------------------------------------------------- 1 | # This is an example zone configuration file, 2 | # you can copy this file to zones/.yaml and edit it. 3 | 4 | # This is a unique identifier for the zone. 5 | # 6 | # IMPORTANT! This has to be safe to use as a directory 7 | # name, because it will be used as the directory name 8 | # for storing the certificates! 9 | unique_identifier: example.com 10 | 11 | # The hostnames that this zone will be responsible for. 12 | # You can specify multiple hostnames, including wildcard 13 | # hostnames using *.example.com. 14 | hostnames: 15 | - example.com 16 | - www.example.com 17 | 18 | # This is the email used when registering with the ACME 19 | # server. This can be shared between multiple zones. 20 | # 21 | # You might get notifications from the ACME server 22 | # when certificates are about to expire, so it's 23 | # important to keep this email up to date. 24 | identity_email: example@example.com 25 | 26 | # This is the number of days before the certificate 27 | # expires on which to renew the certificate. 28 | # 29 | # Normally, a new certificate expires every 90 days. 30 | renewal_days: 15 31 | 32 | # This is the provider that will be used to challenge 33 | # the certificate. 34 | # 35 | # Available providers: cloudflare, websupport & 36 | # cpanel 37 | provider: cloudflare 38 | 39 | # This is the options that will be passed to the 40 | # provider when requesting a certificate. 41 | # These options are provider-specific. 42 | provider_options: 43 | # Cloudflare 44 | api_token: YOUR_API_TOKEN # Requires DNS:Edit & ZONE:Read permissions 45 | 46 | # WebSupport 47 | #api_key: YOUR_API_KEY 48 | #api_secret: YOUR_API_SECRET 49 | 50 | # CPanel / WHM 51 | #username: YOUR_USERNAME 52 | #token: YOUR_TOKEN 53 | #base_url: https://cpanel.example.com 54 | #mode: whm # Optional, defaults to cpanel 55 | 56 | # This is the type of key that will be generated for the 57 | # certificate. 58 | # 59 | # Available key types: EC256 (P256), EC384 (P384), 60 | # RSA2048 (2048), RSA3072 (3072), RSA4096 (4096), 61 | # RSA8192 (8192) 62 | key_type: 2048 63 | 64 | # This is the file permissions that will be used for the 65 | # certificate files. 66 | # 67 | # If enabled, the certificate files will be updated to 68 | # match the specified permissions. Otherwise, the 69 | # certificate files will be left as-is. 70 | # 71 | # IMPORTANT! This does require root permissions to 72 | # update the file permissions! 73 | file_permissions: 74 | enabled: false 75 | uid: 0 76 | gid: 0 77 | private_key_mode: 0600 78 | full_chain_mode: 0644 -------------------------------------------------------------------------------- /docs/providers/CPanel.md: -------------------------------------------------------------------------------- 1 | # CPanel / WHM Provider 2 | 3 | If your hosting service is using [CPanel / WHM](https://www.cpanel.net/) as their interface, you are very likely to be able to use this provider to obtain and renew certificates. 4 | 5 | ## Required Provider Options 6 | 7 | ```yaml 8 | provider: cpanel 9 | 10 | provider_options: 11 | username: YOUR_USERNAME # This is the CPanel username 12 | token: YOUR_API_TOKEN 13 | base_url: https://cpanel.example.com # This is the base URL of your CPanel installation 14 | # mode: whm # Optional, defaults to cpanel 15 | ``` 16 | 17 | - `provider: cpanel` - This has to be set to `cpanel` to use the CPanel / WHM provider. 18 | - `provider_options.username` - This is the CPanel username that will be used to authenticate with the CPanel API. 19 | - `provider_options.token` - This is the API token that will be used to authenticate with the CPanel API. 20 | - `provider_options.base_url` - This is the base URL of your CPanel installation. 21 | - `provider_options.mode` - This is the mode that will be used to authenticate with the CPanel API. 22 | 23 | ## How to Obtain the API Token 24 | 25 | Follow the instructions in their documentation called [How to Use cPanel API Tokens](https://docs.cpanel.net/knowledge-base/security/how-to-use-cpanel-api-tokens/) to obtain the API token. -------------------------------------------------------------------------------- /docs/providers/Cloudflare.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Provider 2 | 3 | If you are using [Cloudflare](https://www.cloudflare.com/) as your DNS provider, you can use the Cloudflare provider to obtain and renew certificates. 4 | 5 | ## Required Provider Options 6 | 7 | ```yaml 8 | provider: cloudflare 9 | 10 | provider_options: 11 | api_token: YOUR_API_TOKEN 12 | ``` 13 | 14 | - `provider: cloudflare` - This has to be set to `cloudflare` to use the Cloudflare provider. 15 | - `provider_options.api_token` - This is the API token that will be used to authenticate with the Cloudflare API. 16 | 17 | ## How to Obtain the API Token 18 | 19 | Head over to the [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) page and create a new API token. 20 | 21 | 1. Click on the "Create Token" button. 22 | 2. Go to the bottom and click "Get started" next to "Create Custom Token". 23 | 3. Give the token a suitable name. 24 | 4. Give the following permissions: 25 | ![Cloudflare API Token Permissions](assets/cloudflare-api-token-required-permissions.png) 26 | 5. Under "Zone Resources", select the zones that you want to use with the application. All domains that are managed by the application have to be included in the zones. 27 | 6. Set a reasonable expiration date for the token under "TTL". -------------------------------------------------------------------------------- /docs/providers/WebSupport.md: -------------------------------------------------------------------------------- 1 | # WebSupport Provider 2 | 3 | If you are using [WebSupport](https://www.websupport.se/) as your DNS provider, you can use the WebSupport provider to obtain and renew certificates. 4 | 5 | ## Required Provider Options 6 | 7 | ```yaml 8 | provider: websupport 9 | 10 | provider_options: 11 | api_key: YOUR_API_KEY 12 | api_secret: YOUR_API_SECRET 13 | ``` 14 | 15 | - `provider: websupport` - This has to be set to `websupport` to use the WebSupport provider. 16 | - `provider_options.api_key` - This is the API key that will be used to authenticate with the WebSupport API. 17 | - `provider_options.api_secret` - This is the API secret that will be used to authenticate with the WebSupport API. 18 | 19 | ## How to Obtain the API Key and Secret 20 | 21 | Head over to the [Security and login](https://admin.websupport.se/en/auth/security-settings) page and create a new API key. 22 | 23 | 1. Scroll down to "API Authentication & Dynamic DNS". 24 | 2. Click on "+ Generate new API access". 25 | 3. Choose "Standard" and click "+ Generate new API access". 26 | 4. (Optional) Give the API key a suitable name. 27 | 5. Copy the API key (Identifier) and API secret (Secret). -------------------------------------------------------------------------------- /docs/providers/assets/cloudflare-api-token-required-permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Low-Stack-Technologies/lowstack-certify/5760257096ddcab390c796d522efe6e7c4ec9ce4/docs/providers/assets/cloudflare-api-token-required-permissions.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1726560853, 9 | "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1717179513, 24 | "narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "24.05", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Go"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/24.05"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = import nixpkgs { inherit system; }; 13 | in 14 | { 15 | devShell = pkgs.mkShell { 16 | buildInputs = [ 17 | pkgs.go 18 | pkgs.air 19 | ]; 20 | 21 | shellHook = '' 22 | echo "Go environment loaded. Go version: $(go version)" 23 | ''; 24 | }; 25 | } 26 | ); 27 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module certify 2 | 3 | go 1.22.5 4 | 5 | require ( 6 | github.com/go-acme/lego/v4 v4.19.2 7 | gopkg.in/yaml.v3 v3.0.1 8 | ) 9 | 10 | require ( 11 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 12 | github.com/cloudflare/cloudflare-go v0.104.0 // indirect 13 | github.com/go-jose/go-jose/v4 v4.0.4 // indirect 14 | github.com/goccy/go-json v0.10.3 // indirect 15 | github.com/google/go-querystring v1.1.0 // indirect 16 | github.com/kr/text v0.2.0 // indirect 17 | github.com/miekg/dns v1.1.62 // indirect 18 | golang.org/x/crypto v0.28.0 // indirect 19 | golang.org/x/mod v0.21.0 // indirect 20 | golang.org/x/net v0.30.0 // indirect 21 | golang.org/x/sync v0.8.0 // indirect 22 | golang.org/x/sys v0.26.0 // indirect 23 | golang.org/x/text v0.19.0 // indirect 24 | golang.org/x/time v0.6.0 // indirect 25 | golang.org/x/tools v0.25.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 2 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 3 | github.com/cloudflare/cloudflare-go v0.104.0 h1:R/lB0dZupaZbOgibAH/BRrkFbZ6Acn/WsKg2iX2xXuY= 4 | github.com/cloudflare/cloudflare-go v0.104.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM= 5 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/go-acme/lego/v4 v4.19.2 h1:Y8hrmMvWETdqzzkRly7m98xtPJJivWFsgWi8fcvZo+Y= 9 | github.com/go-acme/lego/v4 v4.19.2/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ= 10 | github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= 11 | github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 12 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 13 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 14 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 16 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 17 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 18 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 19 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 20 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ= 24 | github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ= 25 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 26 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 28 | github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 29 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 30 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 31 | golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= 32 | golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= 33 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 34 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 35 | golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= 36 | golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= 37 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 38 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 39 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 40 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 41 | golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= 42 | golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 43 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 44 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 45 | golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= 46 | golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= 47 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 48 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 49 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 50 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 51 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 52 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 53 | -------------------------------------------------------------------------------- /internal/acme/constants/provider_constants/service.go: -------------------------------------------------------------------------------- 1 | package provider_constants 2 | 3 | const ( 4 | ProviderCloudflare = Provider("cloudflare") 5 | ProviderWebsupport = Provider("websupport") 6 | ProviderCPanel = Provider("cpanel") 7 | ) 8 | 9 | type Provider string 10 | -------------------------------------------------------------------------------- /internal/acme/providers/cloudflare/service.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "certify/internal/acme/providers/provider_utils" 5 | "certify/internal/acme/zone_configuration" 6 | "certify/internal/configuration" 7 | "fmt" 8 | legoCertificate "github.com/go-acme/lego/v4/certificate" 9 | cloudflareChallenge "github.com/go-acme/lego/v4/providers/dns/cloudflare" 10 | ) 11 | 12 | type Provider struct{} 13 | 14 | func NewProvider() Provider { 15 | return Provider{} 16 | } 17 | 18 | func (p Provider) ObtainCertificate(configuration *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) (*legoCertificate.Resource, error) { 19 | acmeUser, exists, err := provider_utils.GetACMEUser(configuration, zoneConfiguration.IdentityEmail) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to get user: %w", err) 22 | } 23 | 24 | acmeClient, err := provider_utils.GetACMEClient(acmeUser, configuration, zoneConfiguration) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to get ACME client: %w", err) 27 | } 28 | 29 | defer provider_utils.UnsetEnvironmentVariable("CLOUDFLARE_DNS_API_TOKEN") 30 | if err = provider_utils.SetEnvironmentVariable("CLOUDFLARE_DNS_API_TOKEN", zoneConfiguration, "api_token"); err != nil { 31 | return nil, fmt.Errorf("failed to set Cloudflare API token: %w", err) 32 | } 33 | 34 | dnsProvider, err := cloudflareChallenge.NewDNSProvider() 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to create DNS provider: %w", err) 37 | } 38 | 39 | if err := acmeClient.Challenge.SetDNS01Provider(dnsProvider); err != nil { 40 | return nil, fmt.Errorf("failed to set DNS provider: %w", err) 41 | } 42 | 43 | // If the user does not exist, register them 44 | if !exists { 45 | if err := provider_utils.RegisterACMEUser(acmeClient, acmeUser); err != nil { 46 | return nil, fmt.Errorf("failed to register user: %w", err) 47 | } 48 | } 49 | 50 | return provider_utils.ObtainACMECertificate(acmeClient, zoneConfiguration) 51 | } 52 | -------------------------------------------------------------------------------- /internal/acme/providers/cpanel/service.go: -------------------------------------------------------------------------------- 1 | package cpanel 2 | 3 | import ( 4 | "certify/internal/acme/providers/provider_utils" 5 | "certify/internal/acme/zone_configuration" 6 | "certify/internal/configuration" 7 | "fmt" 8 | legoCertificate "github.com/go-acme/lego/v4/certificate" 9 | cpanelChallenge "github.com/go-acme/lego/v4/providers/dns/cpanel" 10 | ) 11 | 12 | type Provider struct{} 13 | 14 | func NewProvider() Provider { 15 | return Provider{} 16 | } 17 | 18 | func (p Provider) ObtainCertificate(configuration *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) (*legoCertificate.Resource, error) { 19 | acmeUser, exists, err := provider_utils.GetACMEUser(configuration, zoneConfiguration.IdentityEmail) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to get user: %w", err) 22 | } 23 | 24 | acmeClient, err := provider_utils.GetACMEClient(acmeUser, configuration, zoneConfiguration) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to get ACME client: %w", err) 27 | } 28 | 29 | defer provider_utils.UnsetEnvironmentVariable("CPANEL_USERNAME") 30 | if err = provider_utils.SetEnvironmentVariable("CPANEL_USERNAME", zoneConfiguration, "username"); err != nil { 31 | return nil, fmt.Errorf("failed to set CPanel username: %w", err) 32 | } 33 | 34 | defer provider_utils.UnsetEnvironmentVariable("CPANEL_TOKEN") 35 | if err = provider_utils.SetEnvironmentVariable("CPANEL_TOKEN", zoneConfiguration, "token"); err != nil { 36 | return nil, fmt.Errorf("failed to set CPanel API token: %w", err) 37 | } 38 | 39 | defer provider_utils.UnsetEnvironmentVariable("CPANEL_BASE_URL") 40 | if err = provider_utils.SetEnvironmentVariable("CPANEL_BASE_URL", zoneConfiguration, "base_url"); err != nil { 41 | return nil, fmt.Errorf("failed to set CPanel base URL: %w", err) 42 | } 43 | 44 | // Optional set CPanel mode 45 | if zoneConfiguration.ProviderOptions["mode"] != "" { 46 | defer provider_utils.UnsetEnvironmentVariable("CPANEL_MODE") 47 | if err = provider_utils.SetEnvironmentVariable("CPANEL_MODE", zoneConfiguration, "mode"); err != nil { 48 | return nil, fmt.Errorf("failed to set CPanel mode: %w", err) 49 | } 50 | } 51 | 52 | dnsProvider, err := cpanelChallenge.NewDNSProvider() 53 | if err != nil { 54 | return nil, fmt.Errorf("failed to create DNS provider: %w", err) 55 | } 56 | 57 | if err := acmeClient.Challenge.SetDNS01Provider(dnsProvider); err != nil { 58 | return nil, fmt.Errorf("failed to set DNS provider: %w", err) 59 | } 60 | 61 | // If the user does not exist, register them 62 | if !exists { 63 | if err := provider_utils.RegisterACMEUser(acmeClient, acmeUser); err != nil { 64 | return nil, fmt.Errorf("failed to register user: %w", err) 65 | } 66 | } 67 | 68 | return provider_utils.ObtainACMECertificate(acmeClient, zoneConfiguration) 69 | } 70 | -------------------------------------------------------------------------------- /internal/acme/providers/provider_utils/client.go: -------------------------------------------------------------------------------- 1 | package provider_utils 2 | 3 | import ( 4 | "certify/internal/acme/zone_configuration" 5 | "certify/internal/configuration" 6 | "fmt" 7 | legoCertificate "github.com/go-acme/lego/v4/certificate" 8 | "github.com/go-acme/lego/v4/lego" 9 | ) 10 | 11 | func GetACMEClient(acmeUser *User, configuration *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) (*lego.Client, error) { 12 | // Create a new ACME config 13 | acmeConfig := lego.NewConfig(acmeUser) 14 | acmeConfig.CADirURL = configuration.CAURL 15 | acmeConfig.Certificate.KeyType = zoneConfiguration.KeyType 16 | 17 | // Create ACME client 18 | acmeClient, err := lego.NewClient(acmeConfig) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to create ACME client: %w", err) 21 | } 22 | 23 | return acmeClient, nil 24 | } 25 | 26 | func ObtainACMECertificate(acmeClient *lego.Client, zoneConfiguration *zone_configuration.ZoneConfiguration) (*legoCertificate.Resource, error) { 27 | request := legoCertificate.ObtainRequest{ 28 | Domains: zoneConfiguration.Hostnames, 29 | Bundle: true, 30 | } 31 | certificate, err := acmeClient.Certificate.Obtain(request) 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to obtain certificate: %w", err) 34 | } 35 | 36 | return certificate, nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/acme/providers/provider_utils/environment.go: -------------------------------------------------------------------------------- 1 | package provider_utils 2 | 3 | import ( 4 | "certify/internal/acme/zone_configuration" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func SetEnvironmentVariable(key string, zoneConfiguration *zone_configuration.ZoneConfiguration, optionKey string) error { 10 | value, ok := zoneConfiguration.ProviderOptions[optionKey] 11 | if !ok { 12 | return fmt.Errorf("no %s provided in configuration", optionKey) 13 | } 14 | 15 | err := os.Setenv(key, value) 16 | if err != nil { 17 | return fmt.Errorf("failed to set environment variable (%s): %w", key, err) 18 | } 19 | 20 | return nil 21 | } 22 | 23 | func UnsetEnvironmentVariable(key string) { 24 | err := os.Unsetenv(key) 25 | if err != nil { 26 | panic(fmt.Errorf("failed to unset environment variable (%s): %w", key, err)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /internal/acme/providers/provider_utils/service.go: -------------------------------------------------------------------------------- 1 | package provider_utils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func makeDirectoryIfNotExists(path string) error { 9 | if _, err := os.Stat(path); os.IsNotExist(err) { 10 | if err := os.MkdirAll(path, 0600); err != nil { 11 | return fmt.Errorf("failed to create directory: %w", err) 12 | } 13 | } 14 | 15 | // Check if directory is writable 16 | if err := os.Chmod(path, 0700); err != nil { 17 | return fmt.Errorf("failed to set directory permissions: %w", err) 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /internal/acme/providers/provider_utils/user.go: -------------------------------------------------------------------------------- 1 | package provider_utils 2 | 3 | import ( 4 | "certify/internal/configuration" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/x509" 10 | "encoding/json" 11 | "encoding/pem" 12 | "fmt" 13 | "github.com/go-acme/lego/v4/lego" 14 | "github.com/go-acme/lego/v4/registration" 15 | "log" 16 | "os" 17 | "path" 18 | "strings" 19 | ) 20 | 21 | type AcmeUserFile struct { 22 | Email string `json:"email"` 23 | Registration *registration.Resource `json:"registration"` 24 | KeyPem string `json:"key_pem"` 25 | } 26 | 27 | type User struct { 28 | Email string `json:"email"` 29 | Registration *registration.Resource `json:"registration"` 30 | key crypto.PrivateKey 31 | } 32 | 33 | func (u *User) GetEmail() string { 34 | return u.Email 35 | } 36 | 37 | func (u *User) GetRegistration() *registration.Resource { 38 | return u.Registration 39 | } 40 | 41 | func (u *User) GetPrivateKey() crypto.PrivateKey { 42 | return u.key 43 | } 44 | 45 | func (u *User) SaveToFile() error { 46 | usersPath := getACMEUsersPath() 47 | if err := makeDirectoryIfNotExists(usersPath); err != nil { 48 | return fmt.Errorf("failed to create users directory: %w", err) 49 | } 50 | 51 | filePath := path.Join(usersPath, emailToFileSafeString(u.Email)) 52 | file, err := os.Create(filePath) 53 | if err != nil { 54 | return fmt.Errorf("failed to create user file: %w", err) 55 | } 56 | defer file.Close() 57 | 58 | derKey, err := x509.MarshalECPrivateKey(u.key.(*ecdsa.PrivateKey)) 59 | if err != nil { 60 | return fmt.Errorf("failed to marshal private key: %w", err) 61 | } 62 | 63 | pemKey := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: derKey}) 64 | 65 | acmeUserFileContent := AcmeUserFile{ 66 | Email: u.Email, 67 | Registration: u.Registration, 68 | KeyPem: string(pemKey), 69 | } 70 | 71 | if err := json.NewEncoder(file).Encode(acmeUserFileContent); err != nil { 72 | return fmt.Errorf("failed to encode user to JSON: %w", err) 73 | } 74 | 75 | if err := os.Chmod(filePath, 0600); err != nil { 76 | return fmt.Errorf("failed to set user file permissions: %w", err) 77 | } 78 | 79 | return nil 80 | } 81 | 82 | func LoadACMEUser(configuration *configuration.Configuration, email string) (user *User, err error) { 83 | usersPath := getACMEUsersPath() 84 | filePath := path.Join(usersPath, emailToFileSafeString(email)) 85 | 86 | file, err := os.Open(filePath) 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to open user file: %w", err) 89 | } 90 | defer file.Close() 91 | 92 | var acmeUserFile AcmeUserFile 93 | decoder := json.NewDecoder(file) 94 | if err := decoder.Decode(&acmeUserFile); err != nil { 95 | return nil, fmt.Errorf("failed to decode user file: %w", err) 96 | } 97 | 98 | block, _ := pem.Decode([]byte(acmeUserFile.KeyPem)) 99 | if block == nil { 100 | return nil, fmt.Errorf("failed to decode private key PEM block: %w", err) 101 | } 102 | 103 | privateKey, err := x509.ParseECPrivateKey(block.Bytes) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse private key: %w", err) 106 | } 107 | 108 | user = &User{ 109 | Email: acmeUserFile.Email, 110 | Registration: acmeUserFile.Registration, 111 | key: privateKey, 112 | } 113 | 114 | return user, nil 115 | } 116 | 117 | func GetACMEUser(configuration *configuration.Configuration, email string) (user *User, exists bool, err error) { 118 | // Try to load user from file 119 | user, err = LoadACMEUser(configuration, email) 120 | if err != nil { 121 | log.Printf("Failed to load user from file: %s", err) 122 | } 123 | 124 | // Return if user was loaded 125 | if user != nil { 126 | log.Printf("User %s was loaded from file", email) 127 | return user, true, nil 128 | } 129 | 130 | // Generate private key for user 131 | privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 132 | if err != nil { 133 | exists = false 134 | err = fmt.Errorf("failed to generate private key: %w", err) 135 | return 136 | } 137 | 138 | exists = false 139 | user = &User{ 140 | Email: email, 141 | key: privateKey, 142 | } 143 | 144 | return 145 | } 146 | 147 | func RegisterACMEUser(acmeClient *lego.Client, user *User) error { 148 | registrationResponse, err := acmeClient.Registration.Register(registration.RegisterOptions{ 149 | TermsOfServiceAgreed: true, 150 | }) 151 | if err != nil { 152 | return fmt.Errorf("failed to register user: %w", err) 153 | } 154 | 155 | user.Registration = registrationResponse 156 | 157 | if err := user.SaveToFile(); err != nil { 158 | log.Printf("Failed to save user to file: %s", err) 159 | } 160 | 161 | return nil 162 | } 163 | 164 | func getACMEUsersPath() string { 165 | configurationDirectory := path.Dir(configuration.GetConfigurationPath()) 166 | return path.Join(configurationDirectory, "acme_users") 167 | } 168 | 169 | func emailToFileSafeString(email string) string { 170 | return strings.ReplaceAll(email, "@", "_") 171 | } 172 | -------------------------------------------------------------------------------- /internal/acme/providers/service.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "certify/internal/acme/constants/provider_constants" 5 | "certify/internal/acme/providers/cloudflare" 6 | "certify/internal/acme/providers/cpanel" 7 | "certify/internal/acme/providers/websupport" 8 | "certify/internal/acme/zone_configuration" 9 | "certify/internal/configuration" 10 | "fmt" 11 | legoCertificate "github.com/go-acme/lego/v4/certificate" 12 | ) 13 | 14 | type Provider interface { 15 | ObtainCertificate(configuration *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) (*legoCertificate.Resource, error) 16 | } 17 | 18 | func GetProvider(provider provider_constants.Provider) Provider { 19 | switch provider { 20 | case provider_constants.ProviderCloudflare: 21 | return cloudflare.NewProvider() 22 | case provider_constants.ProviderWebsupport: 23 | return websupport.NewProvider() 24 | case provider_constants.ProviderCPanel: 25 | return cpanel.NewProvider() 26 | default: 27 | panic(fmt.Errorf("unknown provider: %s", provider)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/acme/providers/websupport/service.go: -------------------------------------------------------------------------------- 1 | package websupport 2 | 3 | import ( 4 | "certify/internal/acme/providers/provider_utils" 5 | "certify/internal/acme/zone_configuration" 6 | "certify/internal/configuration" 7 | "fmt" 8 | legoCertificate "github.com/go-acme/lego/v4/certificate" 9 | websupportChallenge "github.com/go-acme/lego/v4/providers/dns/websupport" 10 | ) 11 | 12 | type Provider struct{} 13 | 14 | func NewProvider() Provider { 15 | return Provider{} 16 | } 17 | 18 | func (p Provider) ObtainCertificate(configuration *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) (*legoCertificate.Resource, error) { 19 | acmeUser, exists, err := provider_utils.GetACMEUser(configuration, zoneConfiguration.IdentityEmail) 20 | if err != nil { 21 | return nil, fmt.Errorf("failed to get user: %w", err) 22 | } 23 | 24 | acmeClient, err := provider_utils.GetACMEClient(acmeUser, configuration, zoneConfiguration) 25 | if err != nil { 26 | return nil, fmt.Errorf("failed to get ACME client: %w", err) 27 | } 28 | 29 | defer provider_utils.UnsetEnvironmentVariable("WEBSUPPORT_API_KEY") 30 | if err = provider_utils.SetEnvironmentVariable("WEBSUPPORT_API_KEY", zoneConfiguration, "api_key"); err != nil { 31 | return nil, fmt.Errorf("failed to set Websupport API key: %w", err) 32 | } 33 | 34 | defer provider_utils.UnsetEnvironmentVariable("WEBSUPPORT_SECRET") 35 | if err = provider_utils.SetEnvironmentVariable("WEBSUPPORT_SECRET", zoneConfiguration, "api_secret"); err != nil { 36 | return nil, fmt.Errorf("failed to set Websupport API secret: %w", err) 37 | } 38 | 39 | dnsProvider, err := websupportChallenge.NewDNSProvider() 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to create DNS provider: %w", err) 42 | } 43 | 44 | if err := acmeClient.Challenge.SetDNS01Provider(dnsProvider); err != nil { 45 | return nil, fmt.Errorf("failed to set DNS provider: %w", err) 46 | } 47 | 48 | // If the user does not exist, register them 49 | if !exists { 50 | if err := provider_utils.RegisterACMEUser(acmeClient, acmeUser); err != nil { 51 | return nil, fmt.Errorf("failed to register user: %w", err) 52 | } 53 | } 54 | 55 | return provider_utils.ObtainACMECertificate(acmeClient, zoneConfiguration) 56 | } 57 | -------------------------------------------------------------------------------- /internal/acme/service.go: -------------------------------------------------------------------------------- 1 | package acme 2 | 3 | import ( 4 | "certify/internal/acme/providers" 5 | "certify/internal/acme/zone_configuration" 6 | "certify/internal/certificates" 7 | "certify/internal/configuration" 8 | "fmt" 9 | "log" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | ) 14 | 15 | func HandleZone(config *configuration.Configuration, zoneConfiguration *zone_configuration.ZoneConfiguration) error { 16 | log.Printf("Handling zone: %s", zoneConfiguration.UniqueIdentifier) 17 | 18 | certificateDirectoryPath := path.Join(config.CertificatesPath, zoneConfiguration.UniqueIdentifier) 19 | if err := os.MkdirAll(certificateDirectoryPath, 0755); err != nil { 20 | return fmt.Errorf("failed to create certificate directory: %w", err) 21 | } 22 | 23 | certificateExpirationDays, err := certificates.GetExpirationDays(certificateDirectoryPath) 24 | log.Printf("Certificate expiration is %d days from now", certificateExpirationDays) 25 | if err != nil { 26 | return fmt.Errorf("failed to get certificate expiration days: %w", err) 27 | } 28 | 29 | if certificateExpirationDays > zoneConfiguration.RenewalDays { 30 | log.Printf("Certificate expiration is more than %d days from now, skipping", zoneConfiguration.RenewalDays) 31 | return nil 32 | } 33 | 34 | log.Printf("Certificate expiration is less than %d days from now, renewing", zoneConfiguration.RenewalDays) 35 | 36 | provider := providers.GetProvider(zoneConfiguration.Provider) 37 | certificate, err := provider.ObtainCertificate(config, zoneConfiguration) 38 | if err != nil { 39 | return fmt.Errorf("failed to obtain certificate: %w", err) 40 | } 41 | 42 | if err = certificates.SaveCertificate(certificateDirectoryPath, certificate, zoneConfiguration); err != nil { 43 | return fmt.Errorf("failed to save certificate: %w", err) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | func GetZones(path string) []*zone_configuration.ZoneConfiguration { 50 | zoneConfigurationPaths, err := getZoneConfigurationsInDirectory(path) 51 | if err != nil { 52 | log.Fatal(fmt.Errorf("failed to get zone configuration files: %w", err)) 53 | } 54 | 55 | var zoneConfigurations []*zone_configuration.ZoneConfiguration 56 | for _, zoneConfigurationPath := range zoneConfigurationPaths { 57 | zoneConfiguration, err := zone_configuration.ReadZoneConfiguration(zoneConfigurationPath) 58 | if err != nil { 59 | log.Fatal(fmt.Errorf("failed to read zone configuration: %w", err)) 60 | } 61 | 62 | zoneConfigurations = append(zoneConfigurations, zoneConfiguration) 63 | } 64 | 65 | if err := areZoneConfigurationsIdentifiersUnique(zoneConfigurations); err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | return zoneConfigurations 70 | } 71 | 72 | func areZoneConfigurationsIdentifiersUnique(zoneConfigurations []*zone_configuration.ZoneConfiguration) error { 73 | uniqueIdentifiers := make(map[string]bool) 74 | for _, zoneConfiguration := range zoneConfigurations { 75 | if _, ok := uniqueIdentifiers[zoneConfiguration.UniqueIdentifier]; ok { 76 | return fmt.Errorf("there are multiple zone configurations with the same unique identifier: %s", zoneConfiguration.UniqueIdentifier) 77 | } 78 | 79 | uniqueIdentifiers[zoneConfiguration.UniqueIdentifier] = true 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func getZoneConfigurationsInDirectory(zoneDirectoryPath string) ([]string, error) { 86 | stat, err := os.Stat(zoneDirectoryPath) 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to get zones directory: %w", err) 89 | } 90 | 91 | if stat.IsDir() == false { 92 | return nil, fmt.Errorf("zones path is not a directory: %s", zoneDirectoryPath) 93 | } 94 | 95 | files, err := os.ReadDir(zoneDirectoryPath) 96 | if err != nil { 97 | return nil, fmt.Errorf("failed to read zones directory: %w", err) 98 | } 99 | 100 | var zoneConfigurationPaths []string 101 | for _, file := range files { 102 | if file.IsDir() { 103 | continue 104 | } 105 | 106 | // Ignore files that are not .yaml or .yml 107 | extension := filepath.Ext(file.Name()) 108 | if extension != ".yaml" && extension != ".yml" { 109 | continue 110 | } 111 | 112 | zoneConfigurationPaths = append(zoneConfigurationPaths, path.Join(zoneDirectoryPath, file.Name())) 113 | } 114 | 115 | return zoneConfigurationPaths, nil 116 | } 117 | -------------------------------------------------------------------------------- /internal/acme/zone_configuration/service.go: -------------------------------------------------------------------------------- 1 | package zone_configuration 2 | 3 | import ( 4 | "certify/internal/acme/constants/provider_constants" 5 | "fmt" 6 | "github.com/go-acme/lego/v4/certcrypto" 7 | "gopkg.in/yaml.v3" 8 | "os" 9 | ) 10 | 11 | type FilePermissions struct { 12 | Enabled bool `yaml:"enabled"` 13 | UID int `yaml:"uid"` 14 | GID int `yaml:"gid"` 15 | PrivateKeyMode os.FileMode `yaml:"private_key_mode"` 16 | FullChainMode os.FileMode `yaml:"full_chain_mode"` 17 | } 18 | 19 | type ZoneConfiguration struct { 20 | UniqueIdentifier string `yaml:"unique_identifier"` 21 | Hostnames []string `yaml:"hostnames"` 22 | IdentityEmail string `yaml:"identity_email"` 23 | RenewalDays int `yaml:"renewal_days"` 24 | Provider provider_constants.Provider `yaml:"provider"` 25 | ProviderOptions map[string]string `yaml:"provider_options"` 26 | KeyType certcrypto.KeyType `yaml:"key_type"` 27 | FilePermissions FilePermissions `yaml:"file_permissions"` 28 | } 29 | 30 | func ReadZoneConfiguration(path string) (*ZoneConfiguration, error) { 31 | contents, err := os.ReadFile(path) 32 | if err != nil { 33 | return nil, fmt.Errorf("failed to read zone configuration @ %s: %w", path, err) 34 | } 35 | 36 | var zone ZoneConfiguration 37 | err = yaml.Unmarshal(contents, &zone) 38 | if err != nil { 39 | return nil, fmt.Errorf("failed to unmarshal zone configuration: %w", err) 40 | } 41 | 42 | return &zone, nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/certificates/service.go: -------------------------------------------------------------------------------- 1 | package certificates 2 | 3 | import ( 4 | "certify/internal/acme/zone_configuration" 5 | "crypto/x509" 6 | "encoding/pem" 7 | "fmt" 8 | legoCertificate "github.com/go-acme/lego/v4/certificate" 9 | "os" 10 | "path" 11 | "time" 12 | ) 13 | 14 | func GetExpirationDays(certificateDirectoryPath string) (int, error) { 15 | certificatePath := path.Join(certificateDirectoryPath, "fullchain.pem") 16 | 17 | file, err := os.Open(certificatePath) 18 | if err != nil { 19 | if os.IsNotExist(err) { 20 | return 0, nil 21 | } 22 | 23 | return 0, fmt.Errorf("failed to open certificate file: %w", err) 24 | } 25 | defer file.Close() 26 | 27 | certificateBytes, err := os.ReadFile(certificatePath) 28 | if err != nil { 29 | return 0, fmt.Errorf("failed to read certificate file: %w", err) 30 | } 31 | 32 | block, _ := pem.Decode(certificateBytes) 33 | if block == nil { 34 | return 0, fmt.Errorf("failed to decode certificate file") 35 | } 36 | 37 | if block.Type != "CERTIFICATE" { 38 | return 0, fmt.Errorf("certificate file is not a certificate") 39 | } 40 | 41 | certificate, err := x509.ParseCertificate(block.Bytes) 42 | if err != nil { 43 | return 0, fmt.Errorf("failed to parse certificate: %w", err) 44 | } 45 | 46 | return int(certificate.NotAfter.Sub(time.Now()).Hours() / 24), nil 47 | } 48 | 49 | func SaveCertificate(certificateDirectoryPath string, certificateResource *legoCertificate.Resource, zoneConfiguration *zone_configuration.ZoneConfiguration) error { 50 | fullChainPath := path.Join(certificateDirectoryPath, "fullchain.pem") 51 | if err := writeFileAndOverrideIfExists(fullChainPath, certificateResource.Certificate); err != nil { 52 | return fmt.Errorf("failed to write fullchain.pem: %w", err) 53 | } 54 | 55 | privateKeyPath := path.Join(certificateDirectoryPath, "privkey.pem") 56 | if err := writeFileAndOverrideIfExists(privateKeyPath, certificateResource.PrivateKey); err != nil { 57 | return fmt.Errorf("failed to write privkey.pem: %w", err) 58 | } 59 | 60 | // If file permissions are not enabled, skip 61 | if !zoneConfiguration.FilePermissions.Enabled { 62 | return nil 63 | } 64 | 65 | if err := updateFilePermissions(fullChainPath, zoneConfiguration.FilePermissions.UID, zoneConfiguration.FilePermissions.GID, zoneConfiguration.FilePermissions.FullChainMode); err != nil { 66 | return fmt.Errorf("failed to update fullchain.pem file permissions: %w", err) 67 | } 68 | 69 | if err := updateFilePermissions(privateKeyPath, zoneConfiguration.FilePermissions.UID, zoneConfiguration.FilePermissions.GID, zoneConfiguration.FilePermissions.PrivateKeyMode); err != nil { 70 | return fmt.Errorf("failed to update privkey.pem file permissions: %w", err) 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func updateFilePermissions(path string, uid int, gid int, mode os.FileMode) error { 77 | if err := os.Chmod(path, mode); err != nil { 78 | return fmt.Errorf("failed to update file permissions: %w", err) 79 | } 80 | 81 | if err := os.Chown(path, uid, gid); err != nil { 82 | return fmt.Errorf("failed to update file ownership: %w", err) 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func writeFileAndOverrideIfExists(path string, data []byte) error { 89 | if err := deleteFileIfExists(path); err != nil { 90 | return fmt.Errorf("failed to delete file: %w", err) 91 | } 92 | 93 | file, err := os.Create(path) 94 | if err != nil { 95 | return fmt.Errorf("failed to create file: %w", err) 96 | } 97 | defer file.Close() 98 | 99 | _, err = file.Write(data) 100 | if err != nil { 101 | return fmt.Errorf("failed to write to file: %w", err) 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func deleteFileIfExists(path string) error { 108 | if _, err := os.Stat(path); os.IsNotExist(err) { 109 | return nil 110 | } 111 | 112 | return os.Remove(path) 113 | } 114 | -------------------------------------------------------------------------------- /internal/configuration/service.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "gopkg.in/yaml.v3" 6 | "log" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type RuntimeConfiguration struct { 12 | RunPeriodically bool `yaml:"run_periodically"` 13 | PeriodMinutes int `yaml:"period_in_minutes"` 14 | } 15 | 16 | type Configuration struct { 17 | RuntimeConfiguration RuntimeConfiguration `yaml:"runtime"` 18 | ZonesPath string `yaml:"zones_path"` 19 | CertificatesPath string `yaml:"certificates_path"` 20 | CAURL string `yaml:"ca_url"` 21 | } 22 | 23 | const defaultConfiguration = ` 24 | # This is the default configuration file for the Low-Stack Certify application. 25 | # You can change all values here to customize the application to your needs. 26 | 27 | # This is the runtime configuration for the application. 28 | runtime: 29 | # This is whether the application should run periodically or not. 30 | # If set to false, the application will only run once and exit. 31 | run_periodically: true 32 | 33 | # This is the number of minutes between each run of the application. 34 | period_in_minutes: 15 35 | 36 | # The path to the directory containing the zone configuration files. 37 | # This can be a relative or absolute path. 38 | zones_path: "/zones" 39 | 40 | # The path to the directory where certificates will be stored. 41 | # This can be a relative or absolute path. 42 | certificates_path: "/certificates" 43 | 44 | # This is the URL of the CA directory that will be used to sign certificates. 45 | #ca_url: "https://acme-staging-v02.api.letsencrypt.org/directory" # Use this for testing 46 | ca_url: "https://acme-v02.api.letsencrypt.org/directory" 47 | ` 48 | 49 | func GetConfiguration() *Configuration { 50 | configPath := GetConfigurationPath() 51 | if err := WriteDefaultConfigurationIfNotExists(configPath); err != nil { 52 | log.Fatal(fmt.Errorf("failed to write default configuration: %w", err)) 53 | } 54 | 55 | config, err := ReadConfiguration(configPath) 56 | if err != nil { 57 | log.Fatal(fmt.Errorf("failed to read configuration: %w", err)) 58 | } 59 | 60 | return config 61 | } 62 | 63 | func ReadConfiguration(path string) (*Configuration, error) { 64 | file, err := os.Open(path) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer file.Close() 69 | 70 | var config Configuration 71 | decoder := yaml.NewDecoder(file) 72 | if err := decoder.Decode(&config); err != nil { 73 | return nil, err 74 | } 75 | 76 | return &config, nil 77 | } 78 | 79 | func WriteDefaultConfigurationIfNotExists(path string) error { 80 | if _, err := os.Stat(path); os.IsNotExist(err) == false { 81 | return nil 82 | } 83 | 84 | // This will fail if the default configuration 85 | // does not match the expected structure 86 | ValidateDefaultConfiguration() 87 | 88 | file, err := os.Create(path) 89 | if err != nil { 90 | return fmt.Errorf("failed to create configuration file: %w", err) 91 | } 92 | defer file.Close() 93 | 94 | trimmedDefaultConfiguration := strings.TrimSpace(defaultConfiguration) 95 | if _, err := file.WriteString(trimmedDefaultConfiguration); err != nil { 96 | return fmt.Errorf("failed to write default configuration: %w", err) 97 | } 98 | 99 | return nil 100 | } 101 | 102 | func GetConfigurationPath() string { 103 | if path, ok := os.LookupEnv("CUSTOM_CONFIGURATION_PATH"); ok { 104 | return path 105 | } 106 | 107 | return "config/config.yaml" 108 | } 109 | 110 | func ValidateDefaultConfiguration() { 111 | if err := yaml.Unmarshal([]byte(defaultConfiguration), &Configuration{}); err != nil { 112 | log.Fatal(fmt.Errorf("failed to unmarshal default configuration: %w", err)) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /logs.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Low-Stack-Technologies/lowstack-certify/5760257096ddcab390c796d522efe6e7c4ec9ce4/logs.txt --------------------------------------------------------------------------------