-
-Built in Rust on top of Cloudflare's [Pingora](https://github.com/cloudflare/pingora), shipped as a hardened container (~few MB, non-root, read-only rootfs). Designed for self-hosting: one process in front of all your services, configured from a single file.
+
## Why
-I normally use `nginxproxy/nginx-proxy` for my self-hosted projects. It works, but every container has to live on the same Docker network with a `VIRTUAL_HOST` label. That couples the proxy to the apps in a way I don't love.
+`nginxproxy/nginx-proxy` was my go-to for a while, and it does the job. What I never liked is that every container needs a `VIRTUAL_HOST` label and has to share a network with the proxy. That always felt a bit wrong to me.
-So I wrote my own (by hand, old-school, no AI writing the code) to learn the internals and end up with something that:
+A lot has changed since then. Building your own proxy is doable now, though it comes with its own challenges, so I built this one on a proven stack. The core idea: keep it simple for self-hosted projects, and still get a legit Let's Encrypt certificate for a box the internet can't reach. Point `vault.example.com` at a LAN IP in your own DNS, and you get proper HTTPS on your home network.
-- Has **one place** to declare what gets proxied (a TOML file).
-- Doesn't care how upstreams are deployed: containers, host processes, anywhere reachable.
-- Boots fast, binds two ports, terminates TLS, forwards. That's it.
+The code is written by hand, no AI. I want to keep my Rust skills sharp, and I still enjoy writing code.
## Features
-| Status | Feature |
-| ------ | -------------------------------------------------------- |
-| done | HTTP & HTTPS listeners (Pingora + BoringSSL) |
-| done | Per-host routing from a single `config.toml` |
-| done | Self-signed certificate generation (`rcgen`) on boot |
-| done | Per-route TLS mode: `self_signed`, `acme`, `none` |
-| done | HTTP → HTTPS redirect (301) for routes with a cert |
-| done | Hardened container: non-root, read-only fs, dropped caps |
-| done | Reach apps on the host via `host.docker.internal` |
-| wip | Let's Encrypt / ACME issuance (`rustls-acme`) |
-| todo | Structured logs (errors today, access logs next) |
-| todo | Full test coverage for behavioural guarantees |
-| todo | Performance benchmarks |
+- HTTP and HTTPS listeners, both always on
+- Routing by `Host` header from a single TOML file
+- Self-signed certs generated on boot for `self_signed` routes
+- 301 redirect from HTTP → HTTPS for any route that has a cert
+- Full ACME / Let's Encrypt: a renewal loop (hourly, every 2 minutes while an order is pending) that orders a cert, writes the DNS TXT record via Cloudflare API, and hot-swaps it in without a restart
+- Hardened container image: non-root, read-only rootfs, all capabilities dropped, no shell
+- Multi-arch images (amd64, arm64) on Docker Hub and GHCR per release
## Quick start
-The image is published to Docker Hub as `maxvanderschee/reverse-proxy` (also available on GHCR as `ghcr.io/mvdschee/reverse-proxy`). Two things to provide:
+Two things: a `config.toml` and a compose file.
-1. A `config.toml` describing your routes.
-2. A `docker-compose.yml` that mounts it.
+The image is `maxvanderschee/reverse-proxy` on Docker Hub, `ghcr.io/mvdschee/reverse-proxy` on GHCR. Multi-arch per release tag, plus `latest`.
**`config.toml`**
@@ -54,6 +43,19 @@ cert_type = "none"
host = "api.example.com"
upstream = "host.docker.internal:8000"
cert_type = "self_signed"
+
+# real cert: the renewal loop orders from Let's Encrypt, writes the DNS
+# TXT record via Cloudflare, swaps the new cert in. no restart.
+[[routes]]
+host = "blog.example.com"
+upstream = "host.docker.internal:2000"
+cert_type = "acme"
+
+# DNS challenge. acme routes without a provider are skipped.
+# challenge prefix is always _acme-challenge., not configurable.
+[routes.dns_provider.cloudflare]
+zone_id = "your-cloudflare-zone-id"
+api_token = "your-cloudflare-api-token"
```
**`docker-compose.yml`**
@@ -61,8 +63,9 @@ cert_type = "self_signed"
```yaml
services:
proxy:
- image: maxvanderschee/reverse-proxy:latest # or ghcr.io/mvdschee/reverse-proxy:latest
+ image: maxvanderschee/reverse-proxy:latest
restart: unless-stopped
+ # runs as nonroot (uid 65532), can't bind below 1024, so 8080/8443
ports:
- "80:8080"
- "443:8443"
@@ -76,93 +79,117 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
- tmpfs:
- - /tmp
+ healthcheck:
+ test: ["CMD", "nc", "-z", "127.0.0.1", "8080"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
volumes:
proxy-certs:
```
-Then:
+`docker compose up -d`, point DNS at the box, done. Configured hosts get proxied; everything else gets a `421 Misdirected Request`.
-```sh
-docker compose up -d
-```
+## Config
-Point DNS at the host, and traffic on `:80`/`:443` for the configured hosts will be terminated and proxied to the upstreams.
+An `[acme]` table and a list of `[[routes]]`. Full annotated schema: [`example/example.toml`](example/example.toml).
-## Configuration
+| Field | Required | What it does |
+| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `acme.email` | yes | Contact email for Let's Encrypt. Parser requires it even if every route is `none`. Use a real one!!! |
+| `routes[].host` | yes | The `Host` header to match, e.g. `app.example.com`. |
+| `routes[].upstream` | yes | `host:port` to forward to, over plain HTTP. `host.docker.internal:` reaches apps on the Docker host. |
+| `routes[].cert_type` | no | `none` (HTTP only), `self_signed`, or `acme`. Defaults to `none`. |
+| `routes[].dns_provider` | no | Only for `acme` routes. Cloudflare only for now (`zone_id` + `api_token`, see [Cloudflare setup](#cloudflare-setup)). Routes without one are skipped by the renewal loop. |
-The full schema lives in [`example/example.toml`](example/example.toml). The fields:
+A couple of sharp edges:
-| Field | Required | Description |
-| -------------------- | -------- | ----------------------------------------------------------------------------------------------------- |
-| `acme.email` | yes | Contact email for Let's Encrypt (used once ACME lands; required today even if every route is `none`). |
-| `routes[].host` | yes | The `Host` header to match (e.g. `app.example.com`). |
-| `routes[].upstream` | yes | `host:port` to forward to. Use `host.docker.internal:` to reach the host machine from Docker. |
-| `routes[].cert_type` | no | `self_signed` (default works on boot), `acme` (WIP), or `none` (HTTP only). Defaults to `acme`. |
+- `host` is an exact match. A request for `app.example.com:443` will not match a route for `app.example.com`.
+- "TLS route" means `cert_type` ≠ `none`. Those get the 301 on the HTTP listener and are served on 443 with whatever's in `CERT_DIR` (`..pem` / `..key`, e.g. `app.example.com.acme.pem`). Each cert type has its own files, so switching a route's `cert_type` never reuses the other type's cert. Self-signed certs are regenerated on every boot; ACME certs come from the renewal loop (renews within 30 days of expiry).
+- The proxy boots even when cert files are missing. It logs a warning and serves what it can.
+- The cert is picked by SNI. The TLS handshake only succeeds when the client sends SNI and a cert for that host exists.
+- The first ACME cert takes a few minutes. Boot tick writes the TXT record, then a tick every 2 minutes finalizes once the record resolves. Until it lands the host is unreachable: HTTP still 301s to HTTPS, and HTTPS has nothing to serve yet.
+- It talks to Let's Encrypt production by default and agrees to their terms for you. Set `ACME_ENV=staging` while testing to stay clear of the production rate limits.
+- The ACME account is tied to the environment it was created in. Once `acme_account` exists, `ACME_ENV` is ignored. Delete `acme_account` to switch between staging and production.
+- No `X-Forwarded-For` / `X-Forwarded-Proto` headers are added. The upstream sees the proxy as the client.
-## How it works
+## Environment variables
-```
- ┌──────────────────────────────────────────┐
- │ Pingora process │
-client │ ┌────────────────┐ ┌──────────────┐ │ HTTP
- ───► │ │ TLS termination│ ──►│ Host router │ ─┼──────► upstream
- │ └────────────────┘ └──────────────┘ │
- │ (BoringSSL) (config.toml) │
- └──────────────────────────────────────────┘
-```
+| Variable | Binary default | In the image | Notes |
+| ------------- | -------------- | ------------------------ | ------------------------------------------------------------ |
+| `CONFIG_PATH` | _none, exits_ | `/etc/proxy/config.toml` | Path to the TOML config. |
+| `CERT_DIR` | `.certs/` | `/var/lib/proxy/certs` | Where certs and the ACME account file (`acme_account`) live. |
+| `HTTP_PORT` | `80` | `8080` | Port for the HTTP listener. |
+| `HTTPS_PORT` | `443` | `8443` | Port for the HTTPS listener. |
+| `ACME_ENV` | `production` | `production` | `production` or `staging`. Other values get rejected |
+
+The image bakes in the right-hand column (Dockerfile sets all four), so the compose file above doesn't repeat them. Listeners always bind `0.0.0.0`.
-On startup the binary:
+## Container image
-1. Reads `config.toml` from `CONFIG_PATH`.
-2. Ensures `CERT_DIR` exists and is writable.
-3. Generates self-signed certs via `rcgen` for any route configured as `self_signed`.
-4. Boots Pingora with one HTTPS listener (SNI-routed) and one HTTP listener, both reading the same per-host route table. The HTTP listener 301-redirects to HTTPS for any route that has a cert.
+Both stages build on [Docker Hardened Images](https://hub.docker.com/hardened-images/catalog) alpine. The runtime image has the binary, `libgcc_s.so.1`, musl, busybox (for the `nc` healthcheck), and CA certs. No shell, no package manager.
-Routing is purely `Host`-based: incoming `Host` header → route entry → forward to upstream over plain HTTP.
+One build quirk: we cross-compile to `*-unknown-linux-musl` with `-crt-static` off. Fully static musl binaries can't `dlopen`, which breaks bindgen's libclang loader at build time. The runtime image ships musl, so the resulting mostly-dynamic binary runs fine.
-## Under the hood
+Since this sits on the public internet:
-A few decisions worth knowing about if you want to dig in or contribute.
+- **`read_only: true`** — the only writes are certs, and those go to the named volume. A compromised process can't touch its own binary or config.
+- **`cap_drop: ALL`** + **`no-new-privileges`** — no capabilities, no setuid escalation. If it gets owned, the blast radius is the process.
-**Pingora + BoringSSL.** The proxy is built on Pingora (the engine behind a chunk of Cloudflare's edge), with the `boringssl` feature instead of the OpenSSL default. That keeps the binary self-contained and avoids dragging system OpenSSL in.
+## How it works
-**Why `8080`/`8443` inside the container.** The image runs as user `nonroot` (uid `65532`) with `cap_drop: ALL` and `no-new-privileges`. A process without `CAP_NET_BIND_SERVICE` can't bind to ports below 1024, so the binary listens on `8080`/`8443` and the compose file maps the standard ports onto them. The defaults of the binary itself (`HTTP_PORT`/`HTTPS_PORT` env vars) are `80`/`443`; the Dockerfile overrides them.
+Implementation notes, for the curious:
-**Hardened base image.** Built and runtime images are both [Docker Hardened Images](https://hub.docker.com/hardened-images/catalog) (alpine-base). Runtime image ships only the binary, `musl`, `ca-certs`, and `libgcc_s.so.1` (needed by the dynamically-linked binary's unwinder). No shell, no package manager, no extras.
+- `main()` is synchronous. Pingora has its own Tokio runtime; the binary loads config, sorts out certs, hands over to the server loop.
+- The cert store is a `HashMap` behind an `ArcSwap`. The renewal loop swaps new certs in atomically, no restart.
+- ACME account credentials persist in `CERT_DIR/acme_account`. A restart reuses the account, doesn't re-register.
+- The renewal loop runs in two stages per host: first tick creates the order and writes the DNS-01 TXT record; later ticks (every 2 minutes while pending) wait until the TXT record resolves, then mark the challenge ready, finalize, swap the cert in. Dead orders get dropped and retried later. Pending orders live in memory, so a restart before it finalizes just starts a fresh order.
-**Static-ish musl build.** Cross-compiled to `*-unknown-linux-musl`, but with `-crt-static` disabled so that build-script artifacts (notably `bindgen`'s `dlopen` of `libclang`) work. The runtime image ships `musl`, so the binary still runs cleanly.
+## Roadmap
-**No async runtime juggling.** `main()` is synchronous. Pingora owns its own Tokio runtime, so the binary just initializes config, certs, and hands control to Pingora's server loop.
+Roughly in the order I'm doing them:
-**Per-route TLS modes.** Each route picks its own cert strategy. `none` skips TLS entirely (handy for an internal-only host). `self_signed` writes a fresh cert on every restart. `rcgen`'s defaults give it a ~2000-year validity, so there's no in-process renewal loop; the restart is the rotation. `acme` is wired into the type system but not yet issuing.
+1. Access logs and a proper logging story. Errors go to stdout for now.
+2. A test suite for the behavioural bits. Routing, redirects, cert handling, error paths.
+3. Benchmarks. Baseline numbers against nginx so I can judge changes by measurement instead of vibes.
+4. Idiomatic Rust. V1 was correctness-first; the internals get tidied up once the shape is stable.
-## Environment variables
+## A note on AI
-| Var | Default | Purpose |
-| ------------- | ------------------------- | ------------------------------------------- |
-| `CONFIG_PATH` | _required_ | Path to `config.toml`. |
-| `CERT_DIR` | `.certs/` | Where generated/issued certs are persisted. |
-| `HTTP_PORT` | `80` (image sets `8080`) | Port the HTTP listener binds to. |
-| `HTTPS_PORT` | `443` (image sets `8443`) | Port the HTTPS listener binds to. |
+I'm upfront about where I use AI. This one, the Rust is hand-written. What AI helped with:
-## Roadmap
+- Researching trade-offs (Pingora vs. the alternatives, BoringSSL vs. rustls, musl vs. glibc)
+- Drafting and polishing text.
+- The Dockerfile. I have written enough of those. Didn't feel like doing it by hand again.
+
+The code is mine. If you find a bug, that's on me.
+
+## Contributing
+
+- **Open an issue first.** I'd rather talk through what you want and how before reviewing a PR that already fixes a problem.
+- **No AI-written code.** The Rust here is hand-written and I'd like it to stay that way. You can use AI to help you understand a change or clean up but I will be a bit sceptical if you have no Rust experience on your profile.
+
+## Cloudflare setup
+
+`acme` routes need two things from Cloudflare: the zone ID and an API token that can edit DNS in that zone.
-In rough priority order:
+**Zone ID.** Domains → pick your domain → Overview. It's in the API section towards the bottom. The Account ID sits right next to it and looks the same, don't mix them up.
-1. **ACME issuance.** Wire up `rustls-acme` so the `acme` cert_type flips from "wired" to "working".
-2. **Structured logging.** Error logs are in place; access logs and a sane structured format come next.
-3. **Full test coverage.** Behavioural guarantees (routing, TLS modes, redirect, error paths) backed by tests, not just manual checks.
-4. **Performance benchmarks.** Measure baseline throughput/latency vs. nginx, so future changes can be judged against numbers instead of vibes.
-5. **Make each module idiomatic.** V1 is correctness-first; polish the internals once the surface is stable.
+**API token.**
-## AI disclaimer
+1. My Profile → API Tokens → Create Token.
+2. Use the "Edit zone DNS" template.
+3. Permissions: `Zone` → `DNS` → `Edit`. That's the only one needed: the proxy creates and updates records, and it calls `/zones//dns_records` directly, so it needs `Edit` but not `Zone` → `Read`.
+4. Zone Resources: `Include` → `Specific zone` → your domain. Don't hand it all zones.
+5. Optional: Client IP Address Filtering to the box's public IP. If you set a TTL, renewals stop when it expires.
+6. Create, copy the token (you only see it once), put it in `api_token`.
-I'll be upfront on every public project about what was done with AI. For this one, I wanted to write the Rust myself to keep the skills sharp and have 100% understanding of every bit of code. AI was used for:
+What the proxy does with it:
-- Research and tradeoff discussions
-- Cleanup of the README and other prose
-- Talking through code-level solutions
-- Generating the Docker image scaffolding from a spec
+- Writes one TXT record, `_acme-challenge.`, TTL auto, not proxied, with a comment so you can spot it.
+- Keeps the record around. The next renewal updates it in place. Leave it alone.
+- The host has to live in that zone: `blog.example.com` → the `example.com` zone. No wildcards, one cert per host.
+- The token is plain text in `config.toml`. Keep that in mind.
+- DNS-01 means Let's Encrypt never connects to the box. The A record can point at a LAN IP, that's the whole point.
diff --git a/docker-compose.yml b/docker-compose.yml
index dec2d7b..469c2b5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,11 +1,12 @@
services:
proxy:
# Replace with the actual image
- # image: ghcr.io/mvdschee/proxy:latest
+ # image: ghcr.io/mvdschee/reverse-proxy:latest
build:
context: .
dockerfile: .docker/Dockerfile
ports:
+ # nonroot user can't bind to ports < 1024, so map to 8080/8443
- "80:8080"
- "443:8443"
# Lets upstream = "host.docker.internal:" reach apps running
@@ -23,8 +24,6 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
- tmpfs:
- - /tmp
healthcheck:
# Busybox `nc -z` confirms the HTTP listener is bound. It does
# not exercise the proxy path; the listener simply has to accept.
diff --git a/example/example.toml b/example/example.toml
index 69e6f8a..6066324 100644
--- a/example/example.toml
+++ b/example/example.toml
@@ -1,12 +1,28 @@
[acme]
-email = "max@example.com"
+email = "max@example.com" # use a real one, Let's Encrypt rejects example.com, even for local testing
[[routes]]
host = "git.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
-cert_type = "self_signed" # default is "acme"
+cert_type = "self_signed" # default is "none"
[[routes]]
host = "test.example.com"
upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
-cert_type = "none" # default is "acme"
+cert_type = "none" # default is "none"
+
+# acme gets a legit Let's Encrypt cert through the DNS-01 challenge, so the box
+# can stay off the internet: point blog.example.com at a LAN IP and it still works.
+# the renewal loop only picks up acme routes with a dns provider. it orders the
+# cert, writes the TXT record, and swaps the cert in once the record resolves.
+# no restart needed.
+[[routes]]
+host = "blog.example.com"
+upstream = "127.0.0.1:3000" # use "host.docker.internal:3000" when using docker
+cert_type = "acme"
+
+# the dns provider for the DNS-01 challenge. cloudflare is the only one for now.
+# the challenge record is always _acme-challenge..
+[routes.dns_provider.cloudflare]
+zone_id = "your-cloudflare-zone-id"
+api_token = "your-cloudflare-api-token"
diff --git a/makefile b/makefile
index 3cd59f6..0f06349 100644
--- a/makefile
+++ b/makefile
@@ -1,5 +1,5 @@
dev:
- CONFIG_PATH=./example/example.toml watchexec -q -c -w src --exts rs --restart "cargo run"
+ CONFIG_PATH=./example/local.toml ACME_ENV=staging HTTP_PORT=880 HTTPS_PORT=8443 watchexec -q -c -w src --exts rs --restart "cargo run"
scan:
foxguard --config .foxguard.yml
diff --git a/src/config.rs b/src/config.rs
index 6a6ddde..10f72e9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,49 +1,35 @@
use crate::{
Error, Result,
core::models::{
- certs::{CertDir, Email},
+ certs::{CertAccountPath, CertDir},
+ config::{AcmeEnv, Config, ConfigTomlFile},
proxy::{ProxyInputAddress, ProxyPort},
- routes::Route,
tasks::TaskInterval,
},
};
-use serde::Deserialize;
use std::{env, fs};
const CONFIG_PATH_ENV: &str = "CONFIG_PATH";
const CERT_DIR_ENV: &str = "CERT_DIR";
const HTTP_PORT_ENV: &str = "HTTP_PORT";
const HTTPS_PORT_ENV: &str = "HTTPS_PORT";
+const ACME_ENV: &str = "ACME_ENV";
+pub const ACME_CHALLENGE_PREFIX: &str = "_acme-challenge.";
const CERT_DIR_DEFAULT: &str = ".certs/";
+// this will be stored in the .certs/ or depending on where the user wants to store it
+const CERT_CREDENTIAL_FILE: &str = "acme_account";
+// set
+const ACME_ENV_DEFAULT: &str = "production";
const HTTP_PORT_DEFAULT: u16 = 80;
const HTTPS_PORT_DEFAULT: u16 = 443;
const INPUT_ADDRESS: &str = "0.0.0.0";
-// in seconds
-const CERT_BACKGROUND_TASK_INTERVAL: u64 = 120;
-
-#[derive(Debug, Clone)]
-pub struct Config {
- pub email: Email,
- pub cert_dir: CertDir,
- pub routes: Vec,
- pub task_interval: TaskInterval,
- pub http_port: ProxyPort,
- pub https_port: ProxyPort,
- pub input_address: ProxyInputAddress,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct ConfigTomlFile {
- pub acme: Acme,
- pub routes: Vec,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct Acme {
- pub email: Email,
-}
+/// in seconds
+const CERT_BACKGROUND_TASK_INTERVAL_DEFAULT: u64 = 3600; // 1 hour
+const CERT_BACKGROUND_TASK_INTERVAL_PENDING: u64 = 120; // 2 minutes
+pub const CERT_RENEWAL_TRESHOLD_DAYS: u32 = 30;
+pub const FILE_PERMISSION: u32 = 0o600;
impl Config {
pub fn init() -> Result {
@@ -51,6 +37,8 @@ impl Config {
let config_file = parse_toml_config(config_path)?;
let cert_dir = load_env(CERT_DIR_ENV).unwrap_or_else(|_| CERT_DIR_DEFAULT.to_string());
+ let cert_account_path = format!("{}/{}", cert_dir, CERT_CREDENTIAL_FILE);
+
let http_port =
load_env(HTTP_PORT_ENV).ok().and_then(|v| v.parse().ok()).unwrap_or(HTTP_PORT_DEFAULT);
let https_port = load_env(HTTPS_PORT_ENV)
@@ -58,11 +46,23 @@ impl Config {
.and_then(|v| v.parse().ok())
.unwrap_or(HTTPS_PORT_DEFAULT);
+ let acme_env = load_env(ACME_ENV).unwrap_or_else(|_| ACME_ENV_DEFAULT.to_string());
+ // a typo would silently fall back to staging and issue untrusted certs
+ if acme_env != "production" && acme_env != "staging" {
+ return Err(Error::Config(format!(
+ "{} must be \"production\" or \"staging\", got \"{}\"",
+ ACME_ENV, acme_env
+ )));
+ }
+
Ok(Config {
email: config_file.acme.email.clone(),
+ acme_env: AcmeEnv::from(acme_env),
cert_dir: CertDir::from(cert_dir),
+ cert_account_path: CertAccountPath::from(cert_account_path),
routes: config_file.routes.clone(),
- task_interval: TaskInterval::from(CERT_BACKGROUND_TASK_INTERVAL),
+ task_interval_default: TaskInterval::from(CERT_BACKGROUND_TASK_INTERVAL_DEFAULT),
+ task_interval_pending: TaskInterval::from(CERT_BACKGROUND_TASK_INTERVAL_PENDING),
http_port: ProxyPort::from(http_port),
https_port: ProxyPort::from(https_port),
input_address: ProxyInputAddress::from(INPUT_ADDRESS.to_string()),
diff --git a/src/core/handlers/certs.rs b/src/core/handlers/certs.rs
index f9d088d..c7361be 100644
--- a/src/core/handlers/certs.rs
+++ b/src/core/handlers/certs.rs
@@ -1,73 +1,592 @@
use crate::{
Error, Result,
+ config::CERT_RENEWAL_TRESHOLD_DAYS,
core::{
- handlers::filesystem::{safe_path, write_file},
+ handlers::filesystem::{check_file_exists, read_file, safe_path, write_file},
models::{
- certs::{CertificateConfig, CertificateType},
+ certs::{
+ CertAccountPath, CertPath, CertificateConfig, CertificateType, Email, KeyPath,
+ OrderOutcome, TlsMaterial, TlsStore,
+ },
+ config::AcmeEnv,
+ dns::{
+ Cloudflare, CloudflareProvider, DnsProvider, ProviderCredentail,
+ default_challenge_prefix,
+ },
+ routes::Host,
tasks::TaskInterval,
},
},
- info,
+ error, info,
+ services::{
+ certs::{
+ acme::{create_account, create_order, init_account, load_account},
+ self_signed::create_self_signed_certificate_files,
+ },
+ dns::resolver::DnsResolver,
+ http::create_client,
+ },
+ warn,
+};
+use arc_swap::ArcSwap;
+use async_trait::async_trait;
+use boring::asn1::Asn1Time;
+use instant_acme::{
+ Account, AccountCredentials, AuthorizationStatus, ChallengeType, Order, OrderStatus,
+ RetryPolicy,
};
-use rcgen::{CertifiedKey, generate_simple_self_signed};
-use std::time::Duration;
-use tokio::time;
-
-pub fn generate_certs(certificate_configs: Vec) -> Result<()> {
- for config in &certificate_configs {
- // self signed certificates are good until the year 4096
- // this will be replace every restart so it's safe to keep using the default setting
- match config.cert_type {
- CertificateType::SelfSigned => {
- info!("generating self-signed certificate for {}", config.host);
-
- let subject_alt_names = vec![config.host.to_string()];
- let pem_filename = format!("{}.pem", config.host);
- let key_filename = format!("{}.key", config.host);
-
- let key_path = safe_path(&config.cert_dir, &key_filename)?;
- let pem_path = safe_path(&config.cert_dir, &pem_filename)?;
-
- let CertifiedKey {
- cert,
- signing_key,
- } = generate_simple_self_signed(subject_alt_names)
- .map_err(|e| Error::Certificate(e.to_string()))?;
-
- let pem_serialized = cert.pem();
- let key_serialized = signing_key.serialize_pem();
-
- write_file(pem_path, pem_serialized.as_bytes())?;
- write_file(key_path, key_serialized.as_bytes())?;
+use pingora::{server::ShutdownWatch, services::background::BackgroundService, tls};
+use reqwest::Client;
+use std::{collections::HashMap, sync::Arc, time::Duration};
+
+/// ------------------------------
+/// Main background cert loop
+/// ------------------------------
+pub struct CertBackgroundRenewal {
+ pub certificate_configs: Vec,
+ pub cert_account_path: CertAccountPath,
+ pub task_interval_default: TaskInterval,
+ pub task_interval_pending: TaskInterval,
+ pub tls_store: TlsStore,
+ pub email: Email,
+ pub acme_env: AcmeEnv,
+}
+
+impl CertBackgroundRenewal {
+ pub fn new(
+ certificate_configs: Vec,
+ cert_account_path: CertAccountPath,
+ task_interval_default: TaskInterval,
+ task_interval_pending: TaskInterval,
+ tls_store: TlsStore,
+ email: Email,
+ acme_env: AcmeEnv,
+ ) -> Self {
+ Self {
+ certificate_configs,
+ cert_account_path,
+ task_interval_default,
+ task_interval_pending,
+ tls_store,
+ acme_env,
+ email,
+ }
+ }
+}
+
+#[async_trait]
+impl BackgroundService for CertBackgroundRenewal {
+ // start should never return this will stop the background task,
+ // this means we have to be a little more verbose with our error handeling.
+ // TLDR; just continue on any error :D, problem for the next loop :')
+ async fn start(&self, mut shutdown: ShutdownWatch) {
+ let configs: Vec = self
+ .certificate_configs
+ .iter()
+ .filter(|c| c.cert_type == CertificateType::Acme)
+ .cloned()
+ .collect();
+
+ if configs.iter().all(|config| config.provider_config.is_none()) {
+ warn!("No ACME certificates configured, skipping ACME renewal loop");
+ return;
+ }
+
+ let http_client = match create_client() {
+ Ok(client) => client,
+ Err(e) => {
+ error!("Can't start cert renewal loop: {}", e);
+ return;
+ },
+ };
+
+ let dns_resolver = match DnsResolver::new(default_challenge_prefix()) {
+ Ok(resolver) => resolver,
+ Err(err) => {
+ error!("Failed to create DNS resolver: {err:?}");
+ return;
},
- CertificateType::Acme => {
- info!("generating acme certificate for {}", config.host);
+ };
+
+ // mutated in the renew_host to keep track of the order state
+ let mut pending_order_urls: HashMap = HashMap::new();
+ // store account outside of loop
+ let mut account: Option = None;
+
+ loop {
+ info!("renewal loop: {} ACME hosts", configs.len());
+
+ if account.is_none() {
+ match resolve_acme_account(&self.cert_account_path, &self.email, &self.acme_env)
+ .await
+ {
+ Ok(acc) => account = Some(acc),
+ Err(err) => {
+ error!("Failed to resolve ACME account: {err:?}");
+ },
+ };
+ }
+
+ if let Some(account) = &account {
+ for config in &configs {
+ if let Err(err) = renew_host(
+ &self.tls_store,
+ &dns_resolver,
+ account,
+ &mut pending_order_urls,
+ &http_client,
+ config.clone(),
+ )
+ .await
+ {
+ error!("renewal failed for {}: {err:?}", config.host);
+ }
+ }
+ }
+
+ let interval = if account.is_some() && pending_order_urls.is_empty() {
+ *self.task_interval_default
+ } else {
+ *self.task_interval_pending
+ };
+
+ info!("background renewal loop sleeping for {} seconds...", interval);
+
+ tokio::select! {
+ _ = tokio::time::sleep(Duration::from_secs(interval)) => {}
+ _ = shutdown.changed() => break,
+ }
+ }
+ }
+}
+
+// ------------------------------
+// Cert functions used in our
+// background cert loop
+// ------------------------------
+
+pub fn swap_store(store: &TlsStore, host: Host, key_bytes: &[u8], cert_bytes: &[u8]) -> Result<()> {
+ let tls = parse_certificates(cert_bytes, key_bytes)?;
+
+ let mut new_certs = HashMap::clone(&store.load());
+
+ new_certs.insert(host, tls);
+ store.store(Arc::new(new_certs));
+
+ Ok(())
+}
+
+pub fn parse_certificates(cert_bytes: &[u8], key_bytes: &[u8]) -> Result {
+ // the PEM can be a full chain, the first cert is for the domain the rest are intermediates
+ let mut certs = tls::x509::X509::stack_from_pem(cert_bytes)
+ .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?
+ .into_iter();
+
+ let cert = certs
+ .next()
+ .ok_or_else(|| Error::Certificate("No certificate found in PEM".to_string()))?;
+ let chain = certs.collect();
+
+ let key = tls::pkey::PKey::private_key_from_pem(key_bytes)
+ .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?;
+
+ let pub_key = cert
+ .public_key()
+ .map_err(|e| Error::Certificate(format!("Failed to get public key: {}", e)))?;
+
+ if !pub_key.public_eq(&key) {
+ return Err(Error::Certificate("Public key does not match private key".to_string()));
+ }
+
+ Ok(TlsMaterial {
+ cert,
+ chain,
+ key,
+ })
+}
+
+// each cert get a suffix to prevent you from never getting a new certificate when switching
+// between self-signed and acme
+pub fn certificate_paths(config: &CertificateConfig) -> Result<(KeyPath, CertPath)> {
+ let suffix = match config.cert_type {
+ CertificateType::SelfSigned => "self_signed",
+ CertificateType::Acme => "acme",
+ CertificateType::None => {
+ return Err(Error::Certificate(format!("No certificate files for {}", config.host)));
+ },
+ };
+
+ let cert_filename = format!("{}.{}.pem", config.host, suffix);
+ let key_filename = format!("{}.{}.key", config.host, suffix);
+
+ let key_path = safe_path(&config.cert_dir, &key_filename)?;
+ let cert_path = safe_path(&config.cert_dir, &cert_filename)?;
+
+ Ok((key_path, cert_path))
+}
+
+async fn renew_host(
+ tls_store: &TlsStore,
+ dns_resolver: &DnsResolver,
+ account: &Account,
+ pending_order_urls: &mut HashMap,
+ http_client: &Client,
+ config: CertificateConfig,
+) -> Result<()> {
+ // --------------
+ // CHECK STAGE
+ // --------------
+ info!("[{}] checking", config.host);
+
+ // check if dns provider is configured
+ let dns_service_config = match config.provider_config.clone() {
+ Some(config) => config,
+ None => {
+ warn!("Skipping Cert...");
+ warn!("No DNS credentials provided for {}", config.host);
+ return Ok(());
+ },
+ };
+ // check if existing cert needs renewal
+ let certs = tls_store.load();
+
+ if let Some(TlsMaterial {
+ cert,
+ ..
+ }) = certs.get(config.host.as_str())
+ {
+ let threshold = Asn1Time::days_from_now(CERT_RENEWAL_TRESHOLD_DAYS)
+ .map_err(|e| Error::Certificate(format!("Failed to create threshold time: {}", e)))?;
+
+ let needs_renewal = cert.not_after() < threshold;
+
+ if !needs_renewal {
+ info!(
+ "[{}] skip: cert valid until {}, threshold {} days",
+ config.host,
+ cert.not_after(),
+ CERT_RENEWAL_TRESHOLD_DAYS
+ );
+ return Ok(());
+ }
+ };
+
+ // --------------
+ // CHOOSE STAGE
+ // --------------
+ let order_url = pending_order_urls.get(&config.host);
+ let mut order = create_order(account, &config.host, order_url).await?;
+
+ let ready_to_validate = order_url.is_some();
+
+ if ready_to_validate {
+ info!("[{}] stage 2: resuming order", config.host);
+ } else {
+ info!("[{}] stage 1: new order", config.host);
+ }
+
+ // --------------
+ // ACTION STAGE
+ // --------------
+ let order_status = order.state().status;
+ info!("[{}] order status {:?}", config.host, order_status);
+
+ let dns_service =
+ get_dns_services(dns_service_config, http_client.clone(), config.host.clone());
+
+ let outcome = match (order_status, ready_to_validate) {
+ (OrderStatus::Pending, false) => {
+ authorizations_dns(&mut order, &dns_service, &config.host).await?;
+ OrderOutcome::Waiting
+ },
+ (OrderStatus::Pending, true) => {
+ authorizations_ready(&mut order, &config.host, dns_resolver).await?;
+ finish_order(&mut order, tls_store, &config).await?
+ },
+ (OrderStatus::Ready, _) => finish_order(&mut order, tls_store, &config).await?,
+ (OrderStatus::Valid, _) => OrderOutcome::Dead,
+ (OrderStatus::Processing, _) => OrderOutcome::Dead,
+ (OrderStatus::Invalid, _) => OrderOutcome::Dead,
+ };
+
+ match outcome {
+ OrderOutcome::Issued => {
+ info!("[{}] renewed", config.host);
+ pending_order_urls.remove(&config.host)
+ },
+ OrderOutcome::Dead => {
+ warn!("[{}] order dropped ({:?}), new order next tick", config.host, order_status);
+ pending_order_urls.remove(&config.host)
+ },
+ OrderOutcome::Waiting => {
+ info!("[{}] holding order for next tick", config.host);
+ pending_order_urls.insert(config.host.clone(), order.url().to_string())
+ },
+ };
+
+ Ok(())
+}
+
+async fn finish_order(
+ order: &mut Order,
+ tls_store: &TlsStore,
+ config: &CertificateConfig,
+) -> Result {
+ // Exponentially back off until the order becomes ready or invalid.
+ let status = order
+ .poll_ready(&RetryPolicy::default())
+ .await
+ .map_err(|e| Error::Certificate(format!("Polling order failed: {}", e)))?;
+
+ if status != OrderStatus::Ready {
+ return Err(Error::Certificate(format!("Order is not ready: {:?}", status)));
+ }
+
+ info!("[{}] order ready, finalizing", config.host);
+
+ let private_key_pem = order
+ .finalize()
+ .await
+ .map_err(|e| Error::Certificate(format!("Finalizing order failed: {}", e)))?;
+
+ let cert_chain_pem = order
+ .poll_certificate(&RetryPolicy::default())
+ .await
+ .map_err(|e| Error::Certificate(format!("Polling certificate failed: {}", e)))?;
+
+ info!("[{}] certificate issued", config.host);
+
+ let (key_path, cert_path) = certificate_paths(config)?;
+
+ write_file(key_path, private_key_pem.as_bytes())?;
+ write_file(cert_path, cert_chain_pem.as_bytes())?;
+
+ info!("[{}] cert and key written to {}", config.host, config.cert_dir);
+
+ swap_store(
+ tls_store,
+ config.host.clone(),
+ private_key_pem.as_bytes(),
+ cert_chain_pem.as_bytes(),
+ )?;
+
+ info!("[{}] tls store updated", config.host);
+
+ Ok(OrderOutcome::Issued)
+}
+
+async fn authorizations_dns(
+ order: &mut Order,
+ dns_service: &impl DnsProvider,
+ host: &Host,
+) -> Result<()> {
+ info!("[{}] writing dns-01 challenge records", host);
+
+ let mut authorizations = order.authorizations();
+
+ while let Some(result) = authorizations.next().await {
+ let mut authz = result
+ .map_err(|e| Error::Certificate(format!("authorizations for this order: {}", e)))?;
+
+ match authz.status {
+ // all status should continue here, we are create a new entry
+ AuthorizationStatus::Pending
+ | AuthorizationStatus::Invalid
+ | AuthorizationStatus::Revoked
+ | AuthorizationStatus::Expired
+ | AuthorizationStatus::Deactivated => {},
+ AuthorizationStatus::Valid => {
+ info!("[{}] authorization already valid, no record needed", host);
+ continue;
},
- CertificateType::None => {},
+ // no catch all to prevent introducing new status
}
+
+ let challenge = match authz.challenge(ChallengeType::Dns01) {
+ Some(challenge) => challenge,
+ None => {
+ return Err(Error::Certificate("no dns01 challenge found".to_string()));
+ },
+ };
+
+ let dns_value = challenge.key_authorization().dns_value();
+ let record = dns_service.upsert_challenge_record(dns_value).await?;
+
+ info!("[{}] txt record {} = {}", host, record.name, record.value);
}
Ok(())
}
-pub async fn background_certs_task(
- certificates: Vec,
- task_interval: TaskInterval,
+async fn authorizations_ready(
+ order: &mut Order,
+ host: &Host,
+ dns_resolver: &DnsResolver,
) -> Result<()> {
- // only acme certificates need to be renewed
- let certificates = certificates
- .into_iter()
- .filter(|cert| cert.cert_type == CertificateType::Acme)
- .collect::>();
+ info!("[{}] marking dns-01 challenges ready", host);
+
+ let mut authorizations = order.authorizations();
+
+ while let Some(result) = authorizations.next().await {
+ let mut authz = result
+ .map_err(|e| Error::Certificate(format!("authorizations for this order: {}", e)))?;
+
+ match authz.status {
+ // this one should continue
+ AuthorizationStatus::Pending => {},
+ // others need to just skip and log
+ AuthorizationStatus::Valid
+ | AuthorizationStatus::Expired
+ | AuthorizationStatus::Invalid
+ | AuthorizationStatus::Revoked
+ | AuthorizationStatus::Deactivated => {
+ info!("[{}] authorization {:?}", host, authz.status);
+ continue;
+ },
+ // no catch all to prevent introducing new status
+ }
+
+ let records = dns_resolver.lookup_text(host).await?;
+
+ let mut challenge = match authz.challenge(ChallengeType::Dns01) {
+ Some(challenge) => challenge,
+ None => {
+ return Err(Error::Certificate("no dns01 challenge found".to_string()));
+ },
+ };
+
+ let dns_value = challenge.key_authorization().dns_value();
+
+ // check if dns_value is in records if so we are allowed to continue otherwise skip
+ if records.contains(&dns_value) {
+ info!("[{}] dns value present", host);
+ challenge
+ .set_ready()
+ .await
+ .map_err(|e| Error::Certificate(format!("set challenge ready: {}", e)))?;
- loop {
- info!("certificates: {}", certificates.len());
+ info!("[{}] challenge marked ready, polling", host);
- // for certificate in &certificates {
- // }
+ continue;
+ }
- time::sleep(Duration::from_secs(*task_interval)).await;
+ return Err(Error::Certificate("dns value not present".to_string()));
}
Ok(())
}
+
+fn get_dns_services(config: ProviderCredentail, client: Client, host: Host) -> impl DnsProvider {
+ match config {
+ ProviderCredentail::Cloudflare(config) => Cloudflare {
+ client,
+ host,
+ config: CloudflareProvider {
+ zone_id: config.zone_id,
+ api_token: config.api_token,
+ },
+ challenge_prefix: default_challenge_prefix(),
+ },
+ }
+}
+
+async fn resolve_acme_account(
+ cert_account_path: &CertAccountPath,
+ email: &Email,
+ acme_env: &AcmeEnv,
+) -> Result {
+ // set crypto lib to load/create the account
+ init_account();
+
+ if check_file_exists(cert_account_path) {
+ let credentials = get_acme_account(cert_account_path)?;
+ warn!("loading the previouse account, this skip the ACME_ENV value");
+ warn!("remove acme_account in your cert folder if this is not what you want");
+ return load_account(credentials).await;
+ }
+
+ let (account, credentials) = create_account(email, acme_env).await?;
+ let content = serde_json::to_vec(&credentials)
+ .map_err(|e| Error::Acme(format!("Failed to serialize ACME account: {}", e)))?;
+ write_file(cert_account_path.clone(), &content)?;
+
+ Ok(account)
+}
+
+fn get_acme_account(cert_account_path: &CertAccountPath) -> Result {
+ let raw_content = read_file(cert_account_path)?;
+
+ serde_json::from_slice(&raw_content).map_err(|e| {
+ Error::Acme(format!(
+ "Failed to parse ACME account: {}, delete {} to register a new one",
+ e, cert_account_path
+ ))
+ })
+}
+
+// ------------------------------
+// Self-signed cert functions
+// those are used in the setup stage
+// ------------------------------
+
+pub fn create_self_signed_certs(certificate_configs: &Vec) -> Result<()> {
+ for config in certificate_configs {
+ if config.cert_type == CertificateType::SelfSigned {
+ // self signed certificates are good until the year 4096
+ // this will be replace every restart so it's safe to keep using the default setting
+ // for selfsigned we will create the certs here right away
+ create_self_signed_certificate_files(config)?;
+ }
+ }
+
+ Ok(())
+}
+
+pub fn load_tls_store(certificate_configs: &Vec) -> Result {
+ let mut tls_certs = HashMap::new();
+
+ for config in certificate_configs {
+ if config.cert_type != CertificateType::None {
+ let (key_path, cert_path) = certificate_paths(config)?;
+
+ let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path);
+
+ // We only show a warning so its easier to debug once its running,
+ // but we are not stopping any traffic.
+ if !has_tls_files {
+ warn!("Certificate files not found for host '{}' but is expected", &config.host);
+ continue;
+ }
+
+ let cert_bytes = match read_file(&cert_path) {
+ Ok(cert_bytes) => cert_bytes,
+ Err(e) => {
+ error!("failed to read cert file for {}: {}", &config.host, e);
+ continue;
+ },
+ };
+
+ let key_bytes = match read_file(&key_path) {
+ Ok(key_bytes) => key_bytes,
+ Err(e) => {
+ error!("failed to read key file for {}: {}", &config.host, e);
+ continue;
+ },
+ };
+
+ let tls = match parse_certificates(&cert_bytes, &key_bytes) {
+ Ok(tls) => tls,
+ Err(e) => {
+ error!("failed to parse certs for {}: {}", &config.host, e);
+ continue;
+ },
+ };
+
+ tls_certs.insert(config.host.clone(), tls);
+ }
+ }
+
+ let tls_store: TlsStore = Arc::new(ArcSwap::from_pointee(tls_certs));
+
+ Ok(tls_store)
+}
diff --git a/src/core/handlers/filesystem.rs b/src/core/handlers/filesystem.rs
index f4648d8..f1398b8 100644
--- a/src/core/handlers/filesystem.rs
+++ b/src/core/handlers/filesystem.rs
@@ -1,6 +1,8 @@
-use crate::{Error, Result, core::models::filesystem::SafePath};
+use crate::{Error, Result, config::FILE_PERMISSION, core::models::filesystem::SafePath};
use std::{
fs,
+ io::Write,
+ os::unix::fs::{OpenOptionsExt, PermissionsExt},
path::{Component, Path, PathBuf},
};
@@ -14,7 +16,21 @@ pub fn write_file(file_path: SafePath, content: &[u8]) -> Result<()> {
return Err(Error::FileSystem(format!("Parent directory {:?} does not exist", parent)));
}
- fs::write(file_path.as_str(), content)
+ // written files are certificates and accounts in the certs directory
+ // for safty we use 0600 (owner only read/write) as read/write permission
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .mode(FILE_PERMISSION)
+ .open(file_path.as_str())
+ .map_err(|e| Error::FileSystem(format!("Failed to open {}: {}", file_path, e)))?;
+
+ file.set_permissions(fs::Permissions::from_mode(FILE_PERMISSION)).map_err(|e| {
+ Error::FileSystem(format!("Failed to set permissions {}: {}", file_path, e))
+ })?;
+
+ file.write_all(content)
.map_err(|e| Error::FileSystem(format!("Failed to write {}: {}", file_path, e)))?;
Ok(())
diff --git a/src/core/handlers/proxy.rs b/src/core/handlers/proxy.rs
index 62693f4..943e946 100644
--- a/src/core/handlers/proxy.rs
+++ b/src/core/handlers/proxy.rs
@@ -1,9 +1,9 @@
use crate::{
Error, Result,
core::{
- handlers::filesystem::read_file,
+ handlers::certs::CertBackgroundRenewal,
models::{
- certs::{TlsCerts, TlsMaterial},
+ certs::{TlsMaterial, TlsStore},
proxy::{ProxyConfig, ProxyRoute, ProxyRouteMap},
},
},
@@ -15,7 +15,7 @@ use pingora::{
ErrorType,
http::ResponseHeader,
listeners::{TlsAccept, tls::TlsSettings},
- prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult},
+ prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult, background_service},
proxy::{ProxyHttp, Session, http_proxy_service},
server::{Server, configuration::ServerConf},
services::Service,
@@ -23,7 +23,12 @@ use pingora::{
};
use std::{collections::HashMap, sync::Arc};
-pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<()> {
+pub fn run_proxy(
+ proxy_config: ProxyConfig,
+ routes: Vec,
+ tls_store: TlsStore,
+ renewal: CertBackgroundRenewal,
+) -> Result<()> {
let mut server = Server::new(None).map_err(|e| Error::Proxy(e.to_string()))?;
server.bootstrap();
@@ -31,34 +36,13 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<(
let server_conf = server.configuration.clone();
let http_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.http_port);
let https_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.https_port);
-
let mut routes_map = HashMap::new();
- let mut tls_certs = HashMap::new();
for route in routes {
- if *route.tls {
- let cert_bytes = read_file(&route.cert_path)?;
- let cert = tls::x509::X509::from_pem(&cert_bytes)
- .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?;
-
- let key_bytes = read_file(&route.key_path)?;
- let key = tls::pkey::PKey::private_key_from_pem(&key_bytes)
- .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?;
-
- tls_certs.insert(
- route.host.clone(),
- TlsMaterial {
- cert,
- key,
- },
- );
- }
-
routes_map.insert(route.host.clone(), route);
}
let routes_map = Arc::new(routes_map);
- let tls_certs = Arc::new(tls_certs);
// plain proxies with redirect
let plain_service =
@@ -66,10 +50,12 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<(
server.add_service(plain_service);
// tls proxies
- let tls_service =
- tls_routes_service(server_conf, https_addr.clone(), routes_map.clone(), tls_certs)?;
+ let tls_service = tls_routes_service(server_conf, https_addr, routes_map, tls_store)?;
server.add_service(tls_service);
+ // background cert services
+ server.add_service(background_service("cert-renewal", renewal));
+
server.run_forever();
}
@@ -77,14 +63,14 @@ pub fn tls_routes_service(
server_conf: Arc,
listen_addr: String,
routes_map: ProxyRouteMap,
- tls_certs: TlsCerts,
+ tls_store: TlsStore,
) -> Result {
let proxy_app = ProxyToUpstream::new(routes_map.clone(), false);
let mut service = http_proxy_service(&server_conf, proxy_app);
- let sni_resolver = SniResolver::new(tls_certs);
- let callback = Box::new(sni_resolver);
+ let cert_resolver = CertResolver::new(tls_store);
+ let callback = Box::new(cert_resolver);
let tls_settings =
TlsSettings::with_callbacks(callback).map_err(|e| Error::Proxy(e.to_string()))?;
service.add_tls_with_settings(&listen_addr, None, tls_settings);
@@ -186,20 +172,20 @@ fn host_from_session(session: &Session) -> Option<&str> {
session.get_header(header::HOST).and_then(|h| h.to_str().ok())
}
-struct SniResolver {
- certs: TlsCerts,
+struct CertResolver {
+ tls_store: TlsStore,
}
-impl SniResolver {
- fn new(tls_certs: TlsCerts) -> Self {
+impl CertResolver {
+ fn new(tls_store: TlsStore) -> Self {
Self {
- certs: tls_certs,
+ tls_store,
}
}
}
#[async_trait]
-impl TlsAccept for SniResolver {
+impl TlsAccept for CertResolver {
async fn certificate_callback(&self, ssl: &mut ssl::SslRef) -> () {
let sni_provided = ssl.servername(ssl::NameType::HOST_NAME).map(str::to_owned);
@@ -208,10 +194,13 @@ impl TlsAccept for SniResolver {
return;
};
+ let certs = self.tls_store.load();
+
let Some(TlsMaterial {
cert,
+ chain,
key,
- }) = self.certs.get(sni_provided.as_str())
+ }) = certs.get(sni_provided.as_str())
else {
error!("No certificate found for SNI: {}", sni_provided);
return;
@@ -221,6 +210,14 @@ impl TlsAccept for SniResolver {
error!("Failed to use certificate for SNI {}: {}", sni_provided, e);
return;
}
+
+ for intermediate in chain {
+ if let Err(e) = tls::ext::ssl_add_chain_cert(ssl, intermediate) {
+ error!("Failed to add chain certificate for SNI {}: {}", sni_provided, e);
+ return;
+ }
+ }
+
if let Err(e) = tls::ext::ssl_use_private_key(ssl, key) {
error!("Failed to use private key for SNI {}: {}", sni_provided, e);
return;
diff --git a/src/core/models/certs.rs b/src/core/models/certs.rs
index 818149a..c3063cd 100644
--- a/src/core/models/certs.rs
+++ b/src/core/models/certs.rs
@@ -1,20 +1,21 @@
-use crate::core::models::routes::Host;
+use crate::{
+ core::models::{dns::ProviderCredentail, filesystem::SafePath, routes::Host},
+ string_newtype,
+};
+use arc_swap::ArcSwap;
use pingora::tls::{
pkey::{PKey, Private},
x509::X509,
};
use serde::Deserialize;
-use std::collections::HashMap;
-use std::fmt;
-use std::ops::Deref;
-use std::sync::Arc;
+use std::{collections::HashMap, sync::Arc};
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CertificateType {
SelfSigned,
- #[default]
Acme,
+ #[default]
None,
}
@@ -22,61 +23,43 @@ pub enum CertificateType {
pub struct CertificateConfig {
pub host: Host,
pub cert_dir: CertDir,
- pub email: Email,
pub cert_type: CertificateType,
+ pub provider_config: Option,
+}
+
+pub enum OrderOutcome {
+ Issued, // clear pending
+ Waiting, // keep pending, try next loop
+ Dead, // clear pending, new order next loop
}
pub struct TlsMaterial {
pub cert: X509,
+ // intermediates certs for non browser based clients
+ pub chain: Vec,
pub key: PKey,
}
-pub type TlsCerts = Arc>;
-
-// --- EMAIL ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct Email(String);
-
-impl Deref for Email {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for Email {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
+impl Clone for TlsMaterial {
+ fn clone(&self) -> Self {
+ Self {
+ cert: self.cert.to_owned(),
+ chain: self.chain.to_owned(),
+ key: self.key.to_owned(),
+ }
}
}
-impl From for Email {
- fn from(s: String) -> Self {
- Email(s)
- }
-}
-
-// --- CERT_DIR ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct CertDir(String);
+pub type KeyPath = SafePath;
+pub type CertPath = SafePath;
-impl Deref for CertDir {
- type Target = String;
+pub type TlsStore = Arc>>;
- fn deref(&self) -> &String {
- &self.0
- }
-}
+// --- EMAIL ---
+string_newtype!(Email, derive(Deserialize));
-impl fmt::Display for CertDir {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
+// --- CERT_DIR ---
+string_newtype!(CertDir, derive(Deserialize));
-impl From for CertDir {
- fn from(s: String) -> Self {
- CertDir(s)
- }
-}
+// --- CERT_ACCOUNT_PATH ---
+pub type CertAccountPath = SafePath;
diff --git a/src/core/models/config.rs b/src/core/models/config.rs
new file mode 100644
index 0000000..f092327
--- /dev/null
+++ b/src/core/models/config.rs
@@ -0,0 +1,39 @@
+use crate::{
+ core::models::{
+ certs::{CertAccountPath, CertDir, Email},
+ proxy::{ProxyInputAddress, ProxyPort},
+ routes::Route,
+ tasks::TaskInterval,
+ },
+ string_newtype,
+};
+use serde::Deserialize;
+
+#[derive(Debug, Clone)]
+pub struct Config {
+ pub email: Email,
+ pub cert_dir: CertDir,
+ pub acme_env: AcmeEnv,
+ // opague string type as it can't be cloned when its in AccountCredentials type
+ pub cert_account_path: CertAccountPath,
+ pub routes: Vec,
+ pub task_interval_default: TaskInterval,
+ pub task_interval_pending: TaskInterval,
+ pub http_port: ProxyPort,
+ pub https_port: ProxyPort,
+ pub input_address: ProxyInputAddress,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct ConfigTomlFile {
+ pub acme: Acme,
+ pub routes: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct Acme {
+ pub email: Email,
+}
+
+// --- CERT_DIR ---
+string_newtype!(AcmeEnv);
diff --git a/src/core/models/dns.rs b/src/core/models/dns.rs
new file mode 100644
index 0000000..52784a4
--- /dev/null
+++ b/src/core/models/dns.rs
@@ -0,0 +1,66 @@
+use crate::{Result, config::ACME_CHALLENGE_PREFIX, core::models::routes::Host, string_newtype};
+use async_trait::async_trait;
+use reqwest::Client;
+use serde::Deserialize;
+
+#[async_trait]
+pub trait DnsProvider {
+ /// Single fuction to set and update
+ /// will return the set record (if returned otherwise fake it)
+ async fn upsert_challenge_record(&self, dns_value: String) -> Result;
+}
+
+pub fn default_challenge_prefix() -> ChallengePrefix {
+ ChallengePrefix::from(ACME_CHALLENGE_PREFIX.to_string())
+}
+
+// --- DNS Record ---
+pub struct Record {
+ #[expect(dead_code, reason = "used later to update/delete the record")]
+ pub provider_id: RecordId,
+ pub name: String,
+ pub value: String,
+}
+
+// --- Challenge Prefix (ex: _acme-challenge.) ---
+string_newtype!(ChallengePrefix, derive(Deserialize));
+
+// API token can be for any provider.
+// we can safely asume that this will always be a string
+string_newtype!(ApiToken, derive(Deserialize));
+
+// DNS Record ID, meant for any provider
+// the dns provider required to update a record
+string_newtype!(RecordId, derive(Deserialize));
+
+// --- DNS Provider credentials ---
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ProviderCredentail {
+ Cloudflare(CloudflareProvider),
+}
+
+// --- add here any other providers credentials or needed structs ---
+
+// --- CloudFlare ---
+// ASSIGNED TYPE PREFIX: CF
+
+// config struct, define here your values that is needed,
+// for the runtime struct
+#[derive(Debug, Clone, Deserialize)]
+pub struct CloudflareProvider {
+ pub api_token: ApiToken,
+ pub zone_id: CFZoneId,
+}
+
+// runtime struct that will actually do the DNS updating
+pub struct Cloudflare {
+ pub client: Client,
+ pub host: Host,
+ pub config: CloudflareProvider,
+ pub challenge_prefix: ChallengePrefix,
+}
+
+// Zone ID
+string_newtype!(CFZoneId, derive(Deserialize));
diff --git a/src/core/models/filesystem.rs b/src/core/models/filesystem.rs
index 53c4570..3a70b54 100644
--- a/src/core/models/filesystem.rs
+++ b/src/core/models/filesystem.rs
@@ -1,25 +1,4 @@
-use std::{fmt, ops::Deref};
+use crate::string_newtype;
// --- SAFEPATH ---
-#[derive(Debug, Clone)]
-pub struct SafePath(String);
-
-impl Deref for SafePath {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for SafePath {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for SafePath {
- fn from(s: String) -> Self {
- SafePath(s)
- }
-}
+string_newtype!(SafePath);
diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs
index 1cf9ecb..d53125f 100644
--- a/src/core/models/mod.rs
+++ b/src/core/models/mod.rs
@@ -1,4 +1,6 @@
pub mod certs;
+pub mod config;
+pub mod dns;
pub mod filesystem;
pub mod proxy;
pub mod routes;
diff --git a/src/core/models/proxy.rs b/src/core/models/proxy.rs
index dab8d40..678e682 100644
--- a/src/core/models/proxy.rs
+++ b/src/core/models/proxy.rs
@@ -1,10 +1,8 @@
-use crate::core::models::{
- certs::CertDir,
- filesystem::SafePath,
- routes::{Host, Upstream},
+use crate::{
+ core::models::routes::{Host, Upstream},
+ string_newtype,
};
-use http::{Response, StatusCode, header};
-use std::{collections::HashMap, fmt, ops::Deref, sync::Arc};
+use std::{collections::HashMap, ops::Deref, sync::Arc};
pub type ProxyRouteMap = Arc>;
@@ -13,13 +11,10 @@ pub struct ProxyRoute {
pub host: Host,
pub upstream: Upstream,
pub tls: ProxyTls,
- pub cert_path: SafePath,
- pub key_path: SafePath,
}
#[derive(Debug, Clone)]
pub struct ProxyConfig {
- pub cert_dir: CertDir,
pub http_port: ProxyPort,
pub https_port: ProxyPort,
pub input_address: ProxyInputAddress,
@@ -62,25 +57,4 @@ impl From for ProxyPort {
}
// --- PROXY INPUT ADDRESS ---
-#[derive(Debug, Clone)]
-pub struct ProxyInputAddress(String);
-
-impl Deref for ProxyInputAddress {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for ProxyInputAddress {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for ProxyInputAddress {
- fn from(s: String) -> Self {
- ProxyInputAddress(s)
- }
-}
+string_newtype!(ProxyInputAddress);
diff --git a/src/core/models/routes.rs b/src/core/models/routes.rs
index 17649ec..b44a2e3 100644
--- a/src/core/models/routes.rs
+++ b/src/core/models/routes.rs
@@ -1,6 +1,8 @@
-use crate::core::models::certs::CertificateType;
+use crate::{
+ core::models::{certs::CertificateType, dns::ProviderCredentail},
+ string_newtype,
+};
use serde::Deserialize;
-use std::{fmt, ops::Deref};
#[derive(Debug, Clone, Deserialize)]
pub struct Route {
@@ -8,19 +10,11 @@ pub struct Route {
pub upstream: Upstream,
#[serde(default)]
pub cert_type: CertificateType,
+ pub dns_provider: Option,
}
// --- HOST ---
-#[derive(Debug, Clone, Deserialize, Hash, Eq, PartialEq)]
-pub struct Host(String);
-
-impl Deref for Host {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
+string_newtype!(Host, derive(Deserialize, Hash, Eq, PartialEq));
impl std::borrow::Borrow for Host {
fn borrow(&self) -> &str {
@@ -28,38 +22,5 @@ impl std::borrow::Borrow for Host {
}
}
-impl fmt::Display for Host {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for Host {
- fn from(s: String) -> Self {
- Host(s)
- }
-}
-
// --- UPSTREAM ---
-#[derive(Debug, Clone, Deserialize)]
-pub struct Upstream(String);
-
-impl Deref for Upstream {
- type Target = String;
-
- fn deref(&self) -> &String {
- &self.0
- }
-}
-
-impl fmt::Display for Upstream {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From for Upstream {
- fn from(s: String) -> Self {
- Upstream(s)
- }
-}
+string_newtype!(Upstream, derive(Deserialize));
diff --git a/src/core/models/tasks.rs b/src/core/models/tasks.rs
index 1cc2b46..8722ed1 100644
--- a/src/core/models/tasks.rs
+++ b/src/core/models/tasks.rs
@@ -2,6 +2,7 @@ use serde::Deserialize;
use std::ops::Deref;
// --- TASK INTERVAL ---
+/// Interval for a task in seconds.
#[derive(Debug, Clone, Deserialize)]
pub struct TaskInterval(u64);
diff --git a/src/core/setup.rs b/src/core/setup.rs
index b133345..22c551b 100644
--- a/src/core/setup.rs
+++ b/src/core/setup.rs
@@ -2,18 +2,20 @@ use crate::{
Error, Result,
core::{
handlers::{
- certs::generate_certs,
- filesystem::{check_file_exists, safe_path},
+ certs::{CertBackgroundRenewal, create_self_signed_certs, load_tls_store},
proxy::run_proxy,
},
models::{
- certs::{CertDir, CertificateConfig, CertificateType, Email},
+ certs::{
+ CertAccountPath, CertDir, CertificateConfig, CertificateType, Email, TlsStore,
+ },
+ config::AcmeEnv,
proxy::{ProxyConfig, ProxyRoute, ProxyTls},
routes::Route,
tasks::TaskInterval,
},
},
- error, info, warn,
+ info,
};
use std::path::Path;
@@ -46,87 +48,95 @@ impl HandleFileSystem {
pub struct HandleCertificates {
certificate_configs: Vec,
- task_interval: TaskInterval,
+ cert_account_path: CertAccountPath,
+ task_interval_default: TaskInterval,
+ task_interval_pending: TaskInterval,
+ email: Email,
+ acme_env: AcmeEnv,
}
impl HandleCertificates {
pub fn new(
cert_dir: CertDir,
+ cert_account_path: CertAccountPath,
email: Email,
+ acme_env: AcmeEnv,
routes: Vec,
- task_interval: TaskInterval,
+ task_interval_default: TaskInterval,
+ task_interval_pending: TaskInterval,
) -> Self {
let certificate_configs = routes
.into_iter()
.map(|route| CertificateConfig {
host: route.host.clone(),
cert_dir: cert_dir.clone(),
- email: email.clone(),
cert_type: route.cert_type.clone(),
+ provider_config: route.dns_provider.clone(),
})
.collect::>();
Self {
certificate_configs,
- task_interval,
+ cert_account_path,
+ task_interval_default,
+ task_interval_pending,
+ email,
+ acme_env,
}
}
- pub fn run(self) -> Result<()> {
- generate_certs(self.certificate_configs.clone())?;
-
- // spawn(async move {
- // info!("starting certificates tasks...");
- // background_certs_task(self.certificates, self.task_interval).await;
- // });
-
- Ok(())
+ pub fn run(self) -> Result<(TlsStore, CertBackgroundRenewal)> {
+ create_self_signed_certs(&self.certificate_configs)?;
+
+ let store = load_tls_store(&self.certificate_configs)?;
+ let renewal = CertBackgroundRenewal::new(
+ self.certificate_configs.clone(),
+ self.cert_account_path.clone(),
+ self.task_interval_default.clone(),
+ self.task_interval_pending.clone(),
+ store.clone(),
+ self.email.clone(),
+ self.acme_env.clone(),
+ );
+
+ Ok((store, renewal))
}
}
pub struct HandleProxy {
proxy_config: ProxyConfig,
proxy_routes: Vec,
+ tls_store: TlsStore,
}
impl HandleProxy {
- pub fn new(proxy_config: ProxyConfig, routes: Vec) -> Result {
+ pub fn new(proxy_config: ProxyConfig, routes: Vec, tls_store: TlsStore) -> Result {
let mut proxy_routes = Vec::new();
for route in routes {
- let cert_filename = format!("{}.pem", route.host);
- let key_filename = format!("{}.key", route.host);
-
- let key_path = safe_path(&proxy_config.cert_dir, &key_filename)?;
- let cert_path = safe_path(&proxy_config.cert_dir, &cert_filename)?;
-
- let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path);
-
- // We only show a warning so its easier to debug once its running,
- // but we are not stopping any traffic.
- if !has_tls_files && route.cert_type != CertificateType::None {
- warn!("Certificate files not found for host `{}` but is expected", route.host);
- }
-
proxy_routes.push(ProxyRoute {
host: route.host.clone(),
upstream: route.upstream.clone(),
tls: ProxyTls::from(route.cert_type != CertificateType::None),
- cert_path,
- key_path,
});
}
Ok(Self {
proxy_config,
proxy_routes,
+ tls_store,
})
}
- pub fn run(&self) -> Result<()> {
+ pub fn run(&self, renewal: CertBackgroundRenewal) -> Result<()> {
info!("proxy running...");
- run_proxy(self.proxy_config.clone(), self.proxy_routes.clone())?;
+ run_proxy(
+ self.proxy_config.clone(),
+ self.proxy_routes.clone(),
+ self.tls_store.clone(),
+ renewal,
+ )?;
Err(Error::Proxy("proxy exited".to_string()))
}
diff --git a/src/error.rs b/src/error.rs
index a9f79ac..10da973 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -13,18 +13,33 @@ pub enum Error {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
+ #[error("request error: {0}")]
+ Request(reqwest::Error),
+
#[error("file system error: {0}")]
FileSystem(String),
#[error("certificate error: {0}")]
Certificate(String),
+ #[error("Acme error: {0}")]
+ Acme(String),
+
#[error("proxy error: {0}")]
Proxy(String),
+ #[error("dns error: {0}")]
+ Dns(String),
+
#[error("config error: {0}")]
Config(String),
#[error("unknown error")]
Unknown,
}
+
+impl From for Error {
+ fn from(err: reqwest::Error) -> Self {
+ Error::Request(err)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index ad61be0..314deb4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,15 +1,13 @@
-use crate::{
- config::Config,
- core::{
- models::proxy::ProxyConfig,
- setup::{HandleCertificates, HandleFileSystem, HandleProxy},
- },
+use crate::core::{
+ models::{config::Config, proxy::ProxyConfig},
+ setup::{HandleCertificates, HandleFileSystem, HandleProxy},
};
pub use error::{Error, Result};
mod config;
mod core;
pub mod error;
+mod services;
mod utils;
// entry needs to be synchronous as pingora has there own
@@ -25,21 +23,23 @@ fn main() -> Result<()> {
// and spin up background tasks to refresh certificates
let cert_handler = HandleCertificates::new(
config.cert_dir.clone(),
+ config.cert_account_path.clone(),
config.email,
+ config.acme_env,
config.routes.clone(),
- config.task_interval,
+ config.task_interval_default,
+ config.task_interval_pending,
);
- cert_handler.run()?;
+ let (store, renewal) = cert_handler.run()?;
// start the proxy
let proxy_config = ProxyConfig {
- cert_dir: config.cert_dir.clone(),
http_port: config.http_port,
https_port: config.https_port,
input_address: config.input_address,
};
- let proxy_handler = HandleProxy::new(proxy_config, config.routes)?;
- proxy_handler.run()?;
+ let proxy_handler = HandleProxy::new(proxy_config, config.routes, store)?;
+ proxy_handler.run(renewal)?;
Err(Error::MainLoopClosed)
}
diff --git a/src/services/certs/acme.rs b/src/services/certs/acme.rs
new file mode 100644
index 0000000..451296e
--- /dev/null
+++ b/src/services/certs/acme.rs
@@ -0,0 +1,82 @@
+use crate::{
+ Error, Result,
+ core::models::{certs::Email, config::AcmeEnv, routes::Host},
+ info,
+};
+use instant_acme::{
+ Account, AccountCredentials, Identifier, LetsEncrypt, NewAccount, NewOrder, Order,
+};
+use rustls::crypto::CryptoProvider;
+
+pub fn init_account() {
+ let _ = CryptoProvider::install_default(rustls::crypto::aws_lc_rs::default_provider());
+}
+
+pub async fn load_account(credentials: AccountCredentials) -> Result {
+ let account = Account::builder()
+ .map_err(|e| Error::Acme(e.to_string()))?
+ .from_credentials(credentials)
+ .await
+ .map_err(|e| Error::Acme(e.to_string()))?;
+
+ info!("Reusing ACME account");
+
+ Ok(account)
+}
+
+// this one returns credentials, because we are going to save it to the file
+// for later usage to start the process with the same credentials
+pub async fn create_account(
+ email: &Email,
+ acme_env: &AcmeEnv,
+) -> Result<(Account, AccountCredentials)> {
+ let url = if acme_env.as_str() == "production" {
+ info!("Using production ACME server");
+ LetsEncrypt::Production.url().to_owned()
+ } else {
+ info!("Using staging ACME server");
+ LetsEncrypt::Staging.url().to_owned()
+ };
+
+ let (account, credentials) = Account::builder()
+ .map_err(|e| Error::Acme(e.to_string()))?
+ .create(
+ &NewAccount {
+ contact: &[&format!("mailto:{}", email.as_str())],
+ terms_of_service_agreed: true,
+ only_return_existing: false,
+ },
+ url,
+ None,
+ )
+ .await
+ .map_err(|e| Error::Acme(e.to_string()))?;
+
+ info!("Created new ACME account");
+
+ Ok((account, credentials))
+}
+
+pub async fn create_order(
+ account: &Account,
+ host: &Host,
+ order_url: Option<&String>,
+) -> Result {
+ let identifier = Identifier::Dns(host.to_string());
+ // instant_acme support multiple host per order,
+ // but we need to know which dns needs to be updated
+ // which is why we split them up, this is maybe something we can refine in V2
+ let identifiers = vec![identifier];
+
+ if let Some(order_url) = order_url {
+ account
+ .order(order_url.to_owned())
+ .await
+ .map_err(|e| Error::Acme(format!("Failed with order_from_url: {}", e)))
+ } else {
+ account
+ .new_order(&NewOrder::new(&identifiers))
+ .await
+ .map_err(|e| Error::Acme(format!("Failed with new_order: {}", e)))
+ }
+}
diff --git a/src/services/certs/mod.rs b/src/services/certs/mod.rs
new file mode 100644
index 0000000..7c4495a
--- /dev/null
+++ b/src/services/certs/mod.rs
@@ -0,0 +1,2 @@
+pub mod acme;
+pub mod self_signed;
diff --git a/src/services/certs/self_signed.rs b/src/services/certs/self_signed.rs
new file mode 100644
index 0000000..a7c44a9
--- /dev/null
+++ b/src/services/certs/self_signed.rs
@@ -0,0 +1,31 @@
+use crate::{
+ Error, Result,
+ core::{
+ handlers::{certs::certificate_paths, filesystem::write_file},
+ models::certs::CertificateConfig,
+ },
+ info,
+};
+use rcgen::{CertifiedKey, generate_simple_self_signed};
+
+pub fn create_self_signed_certificate_files(config: &CertificateConfig) -> Result<()> {
+ info!("generating self-signed certificate for {}", config.host);
+
+ let subject_alt_names = vec![config.host.to_string()];
+
+ let (key_path, cert_path) = certificate_paths(config)?;
+
+ let CertifiedKey {
+ cert,
+ signing_key,
+ } = generate_simple_self_signed(subject_alt_names)
+ .map_err(|e| Error::Certificate(e.to_string()))?;
+
+ let cert_serialized = cert.pem();
+ let key_serialized = signing_key.serialize_pem();
+
+ write_file(cert_path, cert_serialized.as_bytes())?;
+ write_file(key_path, key_serialized.as_bytes())?;
+
+ Ok(())
+}
diff --git a/src/services/dns/cloudflare.rs b/src/services/dns/cloudflare.rs
new file mode 100644
index 0000000..d224b6d
--- /dev/null
+++ b/src/services/dns/cloudflare.rs
@@ -0,0 +1,215 @@
+use crate::{
+ Error, Result,
+ core::models::dns::{Cloudflare, DnsProvider, Record, RecordId},
+ info,
+ services::dns::utils::create_txt_key,
+};
+use async_trait::async_trait;
+use http::HeaderValue;
+use serde::Deserialize;
+
+const API_BASE: &str = "https://api.cloudflare.com/client/v4";
+const RECORD_COMMENT: &str = "managed by reverse-proxy (acme dns-01), do not edit";
+
+/// CloudflareResponse can be used for both Vec or DnsRecord
+#[derive(Debug, Deserialize)]
+pub struct CloudflareResponse {
+ // #[serde(default)]
+ // pub errors: Vec,
+ // #[serde(default)]
+ // pub messages: Vec,
+ // pub success: bool,
+ pub result: T,
+}
+
+// some fields are skipped that provides nothing
+// #[derive(Debug, Deserialize)]
+// pub struct CloudflareMessage {
+// pub code: u32,
+// pub message: String,
+// pub documentation_url: Option,
+// }
+
+// some fields are skipped that provides nothing
+#[derive(Debug, Deserialize)]
+pub struct DnsRecord {
+ pub id: String,
+ pub name: String,
+ // #[serde(rename = "type")]
+ // pub record_type: String,
+ pub content: String,
+ // pub ttl: u32,
+ // pub proxied: bool,
+ pub comment: Option,
+ // pub created_on: DateTime,
+ // pub modified_on: DateTime,
+}
+
+#[async_trait]
+impl DnsProvider for Cloudflare {
+ async fn upsert_challenge_record(&self, dns_value: String) -> Result {
+ // return on error so we are not
+ let record_id = self.get_record_id().await?;
+
+ let Some(record_id) = record_id else {
+ // add record
+ return self.create_record(dns_value).await;
+ };
+
+ // update record
+ return self.update_record(record_id, dns_value).await;
+ }
+}
+
+impl Cloudflare {
+ async fn get_record_id(self: &Cloudflare) -> Result